feat(progress): 주기 몫 채운 주간·월간 다짐의 하루 진행률을 '이번 주/달 달성' 상태로 표시

- 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 채우면 하루 게이지가 0% 대신
  완료 상태(게이지 가득+초록 ✓+문구)로 표시 — 목표 탭 행·모음 카드·위젯 ②(체크)·
  ③(문구)·④(링 가득)·시리 하루 응답 공유
- 목표 하루 평균(combinedSpanRatio)에도 해당 다짐을 1.0으로 반영 —
  위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 헤더 자동 반영
- 판정은 QuestProgress.isPeriodFulfilled 단일 지점 — 기준일의 논리적 하루 끝
  클리핑으로 일기 과거 날짜 소급 차단. spanProgress 수치·판정(judgment)·연속(streak)
  불변(표시 계층 전용). 하루 다짐·특정 기간·atMost는 비대상, 잠금화면·워치
  컴플리케이션은 공간 제약으로 수치 유지
- 도움말 '진행률 읽는 법' 문구 갱신, l10n en/ja 완료(전 카탈로그 stale 0·missing 0)
- 검증: -progressSelfTest 37→49건 ALL PASS(기존 37건 수치 무변경), 신규
  -seedPeriodFulfilled 시드로 ko/en/ja·위젯 실렌더 확인, 시리 스모크 10건 OK,
  Debug/Store 빌드 성공(워치 타깃은 Shared 미포함·무변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
This commit is contained in:
songyc macbook 2026-08-01 06:45:01 +09:00
parent 65a82b7771
commit 0e6804b531
13 changed files with 9331 additions and 9009 deletions

View File

@ -16,7 +16,7 @@
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 4,400원) / `.yearly`(년 44,000원) / `.lifetime`(77,000원) — **ASC 등록·판매 중(실제 가격, 2026-07-28)**. 로컬 테스트 구성(HaruDanim.storekit)도 동일 가격. 가격은 코드에 없고 `product.displayPrice`로 동적 표기, 년 구독 절약률 배지도 동적 계산 |
| 현재 상태 (2026-07-30) | **버전 1.1 App Store 출시 완료(2026-07-30, 사용자 확인) — 유지보수 모드.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 |
| 현재 상태 (2026-07-30) | **버전 1.1 App Store 출시 완료(2026-07-30, 사용자 확인) — 유지보수 모드.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: **주기 몫 완료 상태 표기**(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 |
| 출시·운영 메모 (2026-07-28) | **ASC 등록·심사·출시 전부 완료** — 제출 1건에 앱 1.0+구독 그룹+상품 3종 동반(신형 ASC는 각 항목의 "심사에 추가"로 하나의 제출 초안에 담는 방식), '앱 개인정보 보호'="데이터 수집 안 함", 유료 앱 계약·대한민국 세금 양식 활성, 소규모 사업자 프로그램 가입. 개발자 본인 프리미엄은 **평생 이용권 특가 코드(무료 Offer Code) 교환**으로 스토어 버전에 적용됨(IAP용 프로모션 코드는 2026-03 폐지 — 이후 무료 지급은 특가 코드만 가능, 생성 후 교환 가능까지 최대 1시간). 상시 참고: `ITSAppUsesNonExemptEncryption=NO`, PrivacyInfo.xcprivacy 4개 번들(수집·트래킹 없음), 개인정보처리방침=`github.com/notalentprogrammer/HaruDanim` PRIVACY.md·이용약관=Apple 표준 EULA(결제 화면·앱 설명 모두 링크). **iCloud 프로덕션 동기화 확인 완료(2026-07-28)** — 아이폰·아이패드·맥 3기기 반나절 실사용에서 기존 데이터 포함 정상 왕복(개발→프로덕션 CloudKit 환경 전환에 따른 재업로드 우려 해소. 향후 동기화 문제 보고 시 이 전환 이력 참고). 예정: 정산 계좌 변경(시기 무관, 예금주 명의 일치·지급 회차 밀림 고지 확인). 절차 상세 기록은 Marketing/README.md 체크리스트 |
---
@ -126,13 +126,14 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능 — 단 **주기와 다른 span의 환산 목표(페이스 가이드)에서는 값을 목표에서 캡해 초과 표기 없음**(주 2회 다짐을 오늘 1회 한 것이 "하루 350%"로 표기되던 왜곡 수정 — `spanProgress``usesScaledTarget` 분기). 자기 주기 span(하루 다짐의 '하루' 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, atMost는 값 무캡(하루 게이지의 환산 한도 초과 0%는 페이스 경고로 유효). 수치 검증은 `-progressSelfTest`(§14)
- **하루 단위 다짐의 주간·월간 집계는 방향 무관 수행일 기록만 합산** — 목표량이 수행일 수 환산이라 비수행일 기록을 섞으면 분모·분자가 어긋남(월수금 '이하 유지'가 목요일 기록으로 초과 표시되던 비대칭 수정). '이상 달성'은 추가로 하루별 기여를 그날 목표량으로 캡 — 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- **수행일 아닌 날의 '하루' 게이지는 '수행일 아님' 상태로 표기**(`isScheduled` 기준 — 요일·날짜 다짐의 쉬는 날, 기간 밖 custom 포함): 목표 탭 행·모음 카드·위젯 ③(문구)·②(— 대시)·④(흐림)·시리 하루 응답이 공유. 쉬는 날의 기록은 주/월 집계에 안 잡히므로 퍼센트로 보여 주면 "오늘 채웠다"는 오해 유발. 워치 컴플리케이션·잠금화면 점은 공간 제약으로 수치(0%) 유지 — 의도된 단순화
- **주기 몫 완료 상태(2026-08-01, 사용자 요청 소기능)**: 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 이미 채웠으면(`isPeriodFulfilled(asOf:)` — 단일 판정 지점. 값은 기준일의 논리적 하루 끝까지로 클리핑해, 일기 과거 날짜 조회에서 그날 이후에 채운 기록이 소급해 '달성'으로 보이지 않음) 하루 게이지를 0% 대신 완료 상태로 표시 — "달성했는데 빈 게이지 0%"가 '안 했음'으로 오독되던 실사용 문제의 해소. 표기: 목표 탭 행·모음 카드는 게이지 가득+초록 ✓+'이번 주 달성'/'이번 달 달성'(`periodFulfilledLabel`), 위젯 ③은 퍼센트 자리에 문구(누적값 라벨은 그대로), ②는 34pt 퍼센트 칸이라 초록 체크 아이콘, ④는 링 가득(스냅숏 `QuestCellSnapshot.periodFulfilledLabel` + ratio·displayRatio 1 승격), 시리 하루 응답은 "이번 주(달) 몫은 이미 채웠어요"(value 100). `combinedSpanRatio` 하루 평균에도 해당 다짐을 1.0으로 카운트 — 위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 '이 날의 목표' 헤더에 자동 반영. **spanProgress 수치·판정(judgment)·연속(streak)은 불변**(표시 계층 전용 — 자가 검증 기존 37건 무변경 통과가 그 증거). 비대상: 하루 다짐(그날이 곧 주기 — 주/월 span 승격도 없음, 초과 표기 유지)·특정 기간(반복 주기 없음)·atMost(빈 날이 이미 100%, 하루 환산 한도 초과 0% 경고 유지). 잠금화면·워치 컴플리케이션은 공간 제약으로 수치 유지(수행일 아님과 동일한 의도된 단순화). 일기 다짐 줄은 기존 '그날까지 누적+✓'가 이미 표현하므로 무변경. 검증: `-progressSelfTest` 49건(O·P·Q 시리즈) + `-seedPeriodFulfilled` 시드(§14), ko/en/ja 렌더 확인 완료
- **의도로 확정한 트레이드오프**: ①다짐 생성(목표 시작) 이전 기록도 주기 범위 안이면 진행률에 포함(달력 주기 기준 측정 — 연속 달성만 목표 시작일 하한) ②월 경계에 걸친 주의 '주간' 게이지(월간 다짐)는 이웃 달 기록 혼입(페이스 표시 한정, 100% 캡으로 완화) ③적용일이 없는 달(예: 31일 지정 다짐의 6월)은 0% 표시 ④특정 기간 종료 후에도 주/월 게이지는 계속 계산(목표 종료일이 자연 해소, 하루는 '수행일 아님')
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
- **마감 시각(deadlineMinutes, 하루 단위 전용)**: 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(`dayMeasurementRange` — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). **의도된 트레이드오프**: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(`spanRawValue`). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요)
### 4.3 목표 진행률·판정 (Goal extension)
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유. **다짐이 하나도 없는 목표는 수치(0%) 대신 '다짐 없음' 상태로 표기** — 위젯 ②('다짐이 없어요' 문구, ④는 원래 처리돼 있었음)·일기 '이 날의 목표' 헤더('다짐 없음'+게이지 생략)·시리(기존 '아직 다짐이 없어요' 오류 응답). 모음 탭 카드는 다짐 없으면 게이지 블록 자체가 안 그려져 해당 없음. 잠금화면·워치 컴플리케이션은 공간 제약으로 수치(0%) 유지 — 수행일 아님과 동일한 의도된 단순화
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급)이고, **주기 몫을 채운 주/월 다짐은 1.0으로 카운트**(§4.2 주기 몫 완료). 모음 탭 카드·위젯·워치·시리가 공유. **다짐이 하나도 없는 목표는 수치(0%) 대신 '다짐 없음' 상태로 표기** — 위젯 ②('다짐이 없어요' 문구, ④는 원래 처리돼 있었음)·일기 '이 날의 목표' 헤더('다짐 없음'+게이지 생략)·시리(기존 '아직 다짐이 없어요' 오류 응답). 모음 탭 카드는 다짐 없으면 게이지 블록 자체가 안 그려져 해당 없음. 잠금화면·워치 컴플리케이션은 공간 제약으로 수치(0%) 유지 — 수행일 아님과 동일한 의도된 단순화
- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded` — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일**
- **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference` — 달력일을 키로 직접 찾아 '하루 시작 시간'이 오후여도 안전) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- **주기 중간 종료 판정**(`QuestProgress.judgment(asOf:)`): 판정용 진행률은 current()와 달리 ①값을 기준 시점까지의 누적으로 제한(종료일 이후 같은 주기의 기록 배제)하고 ②주간/월간/특정 기간 다짐의 목표량·한도를 **주기 내 경과일 비율로 축소** — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로 깎이지 않고, atMost 한도도 같은 비율이라 공정. 하루 단위 다짐은 목표량 축소 없음(그날 목표는 온전히 채워야 달성). 표시 연산(combinedSpanRatio·spanProgress)에는 영향 없음
@ -343,7 +344,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|---|---|
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) `-mainOthersCollapsed`('나머지 행동' 섹션 접힘 강제 — 표시 전용 오버라이드, 실 설정 무변경) |
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo <N>`(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |

View File

@ -61,6 +61,7 @@ struct ContentView: View {
#if DEBUG
DebugSeed.seedIfRequested(context: context)
DebugSeed.seedEmptyGoalIfRequested(context: context)
DebugSeed.seedPeriodFulfilledIfRequested(context: context)
DebugSeed.seedBulkIfRequested(context: context)
DebugSeed.autoStartIfRequested(context: context)
DebugSeed.pinGoalsIfRequested(context: context)

View File

@ -343,6 +343,51 @@ enum DebugSeed {
}
expect("N 기간 밖 수행일 판정",
QuestProgress(quest: nQuest, math: math).isScheduled(on: now) ? 1 : 0, 0)
// O. 3 ~ , () (§4.2)
// spanProgress ( 0%),
let (oAct, oQuest) = makeQuest("O 주3회 완료", type: .count, period: .weekly, targetCount: 3)
add(oAct, at(0, 9)); add(oAct, at(1, 9)); add(oAct, at(2, 9))
expect("O 하루 표기(수치 불변 0%)", progress(oQuest, .day).displayRatio, 0.0)
expect("O 주기 몫 완료 판정",
QuestProgress(quest: oQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1)
if let goal = oQuest.goal {
expect("O 목표 하루 평균(완료 반영 100%)", goal.combinedSpanRatio(.day, math: math, now: now), 1.0)
expect("O 목표 주간 평균(무영향)", goal.combinedSpanRatio(.week, math: math, now: now), 1.0)
}
// false ( · · · / true)
expect("O2 주간 미달 false",
QuestProgress(quest: aQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0)
expect("O3 하루 다짐 false",
QuestProgress(quest: dQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0)
expect("O4 이하 유지 false",
QuestProgress(quest: hQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0)
expect("O5 특정 기간 false",
QuestProgress(quest: iQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0)
expect("O6 월간 완료 true",
QuestProgress(quest: gQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1)
// P. ( 50%) + (0.5+1)/2
let (pAct, pQuest) = makeQuest("P 하루2회", type: .count, period: .daily, targetCount: 2)
add(pAct, at(3, 9))
if let goal = pQuest.goal {
let p2 = Quest(goal: goal)
p2.targetAction = oAct // O ~ 3 ( )
p2.measure = .count
p2.period = .weekly
p2.direction = .atLeast
p2.targetCount = 3
context.insert(p2)
expect("P 목표 하루 평균(혼합 75%)", goal.combinedSpanRatio(.day, math: math, now: now), 0.75)
}
// Q. ( ):
let (qAct, qQuest) = makeQuest("Q 주1회 목요일", type: .count, period: .weekly, targetCount: 1)
add(qAct, at(3, 10))
expect("Q 오늘(목) 기준 완료",
QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1)
expect("Q 화요일 기준 미완료(소급 차단)",
QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: at(1, 12)) ? 1 : 0, 0)
} catch {
failures += 1
lines.append("FAIL 컨테이너 생성: \(error)")
@ -467,6 +512,46 @@ enum DebugSeed {
)
}
/// `-seedPeriodFulfilled YES`: · 1
/// " / " ( , ).
/// , 09:00
/// .
static func seedPeriodFulfilledIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedPeriodFulfilled") else { return }
let title = "주기 몫 완료 검증"
let existing = (try? context.fetch(FetchDescriptor<Goal>()))?.contains { $0.title == title } ?? false
guard !existing else { return }
let math = DayMath()
let weekStart = math.weekRange(containing: .now).lowerBound
let monthStart = math.monthRange(containing: .now).lowerBound
let goal = Goal(title: title, symbolName: "checkmark.seal.fill", colorHex: "#4A7A9D",
startDate: min(weekStart, monthStart), endDate: nil)
context.insert(goal)
let weeklyAction = Action(name: "주간 완료 행동", symbolName: "w.circle.fill",
trackingType: .count, sortOrder: 80)
let monthlyAction = Action(name: "월간 완료 행동", symbolName: "m.circle.fill",
trackingType: .count, sortOrder: 81)
context.insert(weeklyAction)
context.insert(monthlyAction)
let weekly = Quest(goal: goal)
weekly.targetAction = weeklyAction
weekly.measure = .count
weekly.period = .weekly
weekly.targetCount = 3
context.insert(weekly)
let monthly = Quest(goal: goal)
monthly.targetAction = monthlyAction
monthly.measure = .count
monthly.period = .monthly
monthly.targetCount = 5
context.insert(monthly)
context.insert(CountEntry(action: weeklyAction,
timestamp: weekStart.addingTimeInterval(9 * 3600), amount: 3))
context.insert(CountEntry(action: monthlyAction,
timestamp: monthStart.addingTimeInterval(9 * 3600), amount: 5))
try? context.save()
}
/// `-seedEmptyGoal YES`: 1
/// · ' ' ( · )
static func seedEmptyGoalIfRequested(context: ModelContext) {

File diff suppressed because it is too large Load Diff

View File

@ -377,19 +377,31 @@ struct QuestRow: View {
// (· , )
// / " "
let restDay = span == .day && !progress.isScheduled(on: .now)
// / 0%
// " " (§4.2)
let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled()
let result = progress.spanProgress(span)
let over = !restDay && quest.direction == .atMost && result.value > result.target
return VStack(alignment: .leading, spacing: 2) {
Text(span.label)
.font(.caption2)
.foregroundStyle(.secondary)
ProgressView(value: restDay ? 0 : result.ratio)
ProgressView(value: restDay ? 0 : (fulfilled ? 1 : result.ratio))
.tint(over ? .red : quest.targetColor)
.opacity(restDay ? 0.4 : 1)
if restDay {
Text("수행일 아님")
.font(.caption2)
.foregroundStyle(.tertiary)
} else if fulfilled {
HStack(spacing: 3) {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(AppTheme.green)
Text(progress.periodFulfilledLabel)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
} else {
Text(Format.percent(result.displayRatio))
.font(.caption2.weight(.semibold).monospacedDigit())

View File

@ -221,7 +221,7 @@ struct HelpView: View {
HelpTopic(
symbol: "chart.bar.fill",
title: "진행률 읽는 법",
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산하고, 요일·날짜를 정한 다짐은 수행일의 기록만 집계해요 — 수행일이 아닌 날의 '하루' 진행률은 '수행일 아님'으로 표시돼요. 다짐 자신의 주기(하루 다짐의 '하루' 등)에서는 100%를 넘는 초과 달성이 그대로 표시되지만, 주간 다짐의 '하루'처럼 주기와 다른 기간은 목표를 그 기간 몫으로 나눈 페이스 기준이라 최대 100%까지만 표시돼요. '이하 유지' 다짐은 한도 안이면 100%, 넘으면 0%예요 — 주간·월간 한도 다짐의 '하루'는 하루 몫 한도 기준이라, 주간 한도가 남았어도 오늘 많이 쓰면 먼저 알려주는 경고 역할을 해요.",
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산하고, 요일·날짜를 정한 다짐은 수행일의 기록만 집계해요 — 수행일이 아닌 날의 '하루' 진행률은 '수행일 아님'으로 표시돼요. 주간·월간 다짐이 이번 주(이번 달) 몫을 이미 채웠다면 '하루' 진행률은 0% 대신 '이번 주 달성'·'이번 달 달성'으로 표시되고 목표의 하루 진행률에도 달성으로 반영돼요 — 오늘은 더 하지 않아도 된다는 뜻이에요. 다짐 자신의 주기(하루 다짐의 '하루' 등)에서는 100%를 넘는 초과 달성이 그대로 표시되지만, 주간 다짐의 '하루'처럼 주기와 다른 기간은 목표를 그 기간 몫으로 나눈 페이스 기준이라 최대 100%까지만 표시돼요. '이하 유지' 다짐은 한도 안이면 100%, 넘으면 0%예요 — 주간·월간 한도 다짐의 '하루'는 하루 몫 한도 기준이라, 주간 한도가 남았어도 오늘 많이 쓰면 먼저 알려주는 경고 역할을 해요.",
imageName: "help-goal"
),
HelpTopic(

View File

@ -821,14 +821,19 @@ struct GoalSummaryCard: View {
ForEach(StatSpan.allCases) { span in
// ( )
let restDay = span == .day && !progress.isScheduled(on: .now)
// / 0% (§4.2 )
let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled()
let result = progress.spanProgress(span)
let over = !restDay && quest.direction == .atMost && result.value > result.target
miniGauge(
label: span.label,
ratio: restDay ? 0 : result.ratio,
percentText: restDay ? String(localized: "수행일 아님") : Format.percent(result.displayRatio),
ratio: restDay ? 0 : (fulfilled ? 1 : result.ratio),
percentText: restDay
? String(localized: "수행일 아님")
: (fulfilled ? progress.periodFulfilledLabel : Format.percent(result.displayRatio)),
color: over ? .red : quest.targetColor,
emphasized: over
emphasized: over,
showsCheck: fulfilled
)
}
}
@ -861,7 +866,8 @@ struct GoalSummaryCard: View {
ratio: Double,
percentText: String,
color: Color,
emphasized: Bool
emphasized: Bool,
showsCheck: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
@ -869,9 +875,16 @@ struct GoalSummaryCard: View {
.foregroundStyle(.secondary)
ProgressView(value: min(max(ratio, 0), 1))
.tint(color)
Text(percentText)
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(emphasized ? .red : .secondary)
HStack(spacing: 3) {
if showsCheck {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(AppTheme.green)
}
Text(percentText)
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(emphasized ? .red : .secondary)
}
}
.frame(maxWidth: .infinity)
}

View File

@ -684,6 +684,15 @@ struct QuestProgressIntent: AppIntent {
dialog: "'\(quest.goalTitle) · \(model.targetName)' 오늘은 수행일이 아니에요."
)
}
// / (§4.2) " 0%"
if span.statSpan == .day, progress.isPeriodFulfilled() {
return .result(
value: 100,
dialog: model.period == .monthly
? "'\(quest.goalTitle) · \(model.targetName)' 이번 달 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요."
: "'\(quest.goalTitle) · \(model.targetName)' 이번 주 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요."
)
}
let result = progress.spanProgress(span.statSpan)
let percent = (result.displayRatio * 100).rounded()
let detail: String

View File

@ -291,6 +291,33 @@ struct QuestProgress {
return sum
}
// MARK: (§4.2)
/// / ' ' .
/// " / " ( · · ·)
/// (combinedSpanRatio) .
/// spanProgress ·(judgment)·(streak) .
///
/// '' (
/// . ,
/// "now " ).
/// ( )· ( )·' '( 100%) .
func isPeriodFulfilled(asOf now: Date = .now) -> Bool {
guard quest.direction == .atLeast, quest.targetValue > 0 else { return false }
switch quest.period {
case .daily, .custom: return false
case .weekly, .monthly: break
}
guard let range = currentPeriodRange(now: now) else { return false }
let clipped = range.lowerBound..<min(range.upperBound, math.dayRange(containing: now).upperBound)
return value(in: clipped, now: now) >= quest.targetValue
}
/// isPeriodFulfilled true
var periodFulfilledLabel: String {
quest.period == .monthly ? String(localized: "이번 달 달성") : String(localized: "이번 주 달성")
}
/// span
private func scaledTarget(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
let target = quest.targetValue
@ -496,8 +523,14 @@ extension Goal {
: quests
// 0% (100%)
guard !counted.isEmpty else { return 1 }
let sum = counted.reduce(0.0) {
$0 + QuestProgress(quest: $1, math: math).spanProgress(span, now: now).ratio
let sum = counted.reduce(0.0) { partial, quest in
let progress = QuestProgress(quest: quest, math: math)
// / (1.0)
// " / " (§4.2 )
if span == .day, progress.isPeriodFulfilled(asOf: now) {
return partial + 1
}
return partial + progress.spanProgress(span, now: now).ratio
}
return sum / Double(counted.count)
}

View File

@ -190,6 +190,12 @@ struct QuestBarRow: View {
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.tertiary)
.frame(width: 34, alignment: .trailing)
} else if quest.periodFulfilledLabel != nil {
// (§4.2) ,
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(AppTheme.green)
.frame(width: 34, alignment: .trailing)
} else if let streak = quest.streakLabel {
VStack(alignment: .trailing, spacing: 0) {
Text(Format.percent(quest.displayRatio))

View File

@ -550,6 +550,50 @@
}
}
},
"'%@ · %@' 이번 달 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요." : {
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "'%1$@ · %2$@' 이번 달 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "'%1$@ · %2$@' has already met this month's target. Nothing more needed today."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "「%1$@ · %2$@」は今月の分をすでに達成しています。今日はやらなくても大丈夫です。"
}
}
}
},
"'%@ · %@' 이번 주 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요." : {
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "'%1$@ · %2$@' 이번 주 몫은 이미 채웠어요. 오늘은 더 하지 않아도 돼요."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "'%1$@ · %2$@' has already met this week's target. Nothing more needed today."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "「%1$@ · %2$@」は今週の分をすでに達成しています。今日はやらなくても大丈夫です。"
}
}
}
},
"'%@' %@ 진행률은 %lld%%예요." : {
"localizations" : {
"en" : {
@ -2448,6 +2492,22 @@
}
}
},
"이번 달 달성" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done this month"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "今月分達成"
}
}
}
},
"이번 주" : {
"localizations" : {
"en" : {
@ -2480,6 +2540,22 @@
}
}
},
"이번 주 달성" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done this week"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "今週分達成"
}
}
}
},
"이상 달성" : {
"localizations" : {
"en" : {
@ -3589,4 +3665,4 @@
}
},
"version" : "1.0"
}
}

View File

@ -169,6 +169,8 @@ struct QuestRingActionCellView: View {
private var percentText: String {
// ( span )
if cell.isRestDay { return String(localized: "수행일 아님") }
// (§4.2) " " .
if let fulfilled = cell.periodFulfilledLabel { return fulfilled }
if cell.isAtMost {
return cell.isAchieved
? String(localized: "\(spanLabel) 한도 지킴")

View File

@ -93,6 +93,10 @@ struct QuestCellSnapshot: Identifiable {
/// span (· , )
/// · 0 ' ' ( , · )
let isRestDay: Bool
/// (§4.2) /
/// (" " ). span , nil. ratio·displayRatio
/// 1 · ( , ).
var periodFulfilledLabel: String? = nil
/// ( )
let runTarget: ActionEntity?
let isRunning: Bool
@ -112,6 +116,9 @@ struct QuestCellSnapshot: Identifiable {
// span
// " " (/ )
let isRestDay = span == .day && !progress.isScheduled(on: now)
// / 0% ·
// / (§4.2 , )
let fulfilled = span == .day && !isRestDay && progress.isPeriodFulfilled(asOf: now)
let result = isRestDay
? QuestProgressResult(value: 0, target: quest.targetValue, direction: quest.direction)
: progress.spanProgress(span, now: now)
@ -132,11 +139,12 @@ struct QuestCellSnapshot: Identifiable {
symbolName: quest.targetSymbol,
colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
ratio: isRestDay ? 0 : result.ratio,
displayRatio: isRestDay ? 0 : result.displayRatio,
ratio: isRestDay ? 0 : (fulfilled ? 1 : result.ratio),
displayRatio: isRestDay ? 0 : (fulfilled ? 1 : result.displayRatio),
isAtMost: quest.direction == .atMost,
isAchieved: isRestDay ? false : result.isAchieved,
isAchieved: isRestDay ? false : (fulfilled || result.isAchieved),
isRestDay: isRestDay,
periodFulfilledLabel: fulfilled ? progress.periodFulfilledLabel : nil,
runTarget: action.map(ActionEntity.init),
isRunning: action?.isRunning ?? false,
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil,