fix(quest): count scheduled days only for daily-quest spans + rest-day state

진행률 검증 보고에서 확정한 결정 반영 (사용자 위임):

- [계산 수정] 하루 단위 다짐의 주/월 집계를 **방향 무관 수행일 기록만** 합산 — 목표량(scaledTarget)이 수행일 수 환산인데 '이하 유지'만 비수행일 기록까지 값에 섞여 분모·분자가 어긋나던 비대칭 해소 (월수금 1시간 이하 다짐이 목요일 시청으로 한도 초과 표시되던 문제). atLeast의 하루 기여 캡·마감 창 클립은 기존 유지
- [표시 수정] 수행일 아닌 날(요일·날짜 다짐의 쉬는 날, 기간 밖 custom)의 '하루' 게이지를 '수행일 아님' 상태로 — 쉬는 날 기록은 주/월 집계에 안 잡히는 값이라 퍼센트(예 133%)로 보여주면 "오늘 채웠다"는 오해 유발. 목표 탭 행·모음 카드(문구+빈 바), 위젯 ③(문구·누적값 숨김)·②(— 대시)·④(링 흐림, 스냅숏 isRestDay), 시리 '오늘' 조회("오늘은 수행일이 아니에요") 공유. 워치·잠금화면 점은 공간 제약으로 수치 유지(문서화)
- [의도 확정·유지] atMost 하루 게이지의 환산 한도 페이스 경고 / 다짐 생성 이전 기록 인정 / 월 경계 주 혼입 / 적용일 없는 달 0% / custom 종료 후 게이지 — CLAUDE.md §4.2에 트레이드오프로 명시
- 검증: -progressSelfTest 37건 ALL PASS(E 비수행일 제외·M 비대칭 수정·N 기간 밖 판정 신규), 목표 탭 스크린샷(달리기 월수금: 하루 '수행일 아님'/주간 0%/월간 7%), 위젯 미리보기 렌더 무결, Debug·Store·워치 3스킴 빌드 성공
- 도움말 '진행률 읽는 법' 전면 보강(수행일 집계·페이스 캡·이하 유지 이분법+하루 경고) en/ja 완비, 신규 키 2종('수행일 아님', 시리 응답) IOS·위젯 카탈로그 클린(763/213키)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-16 22:59:15 +09:00
parent 3ae49faad7
commit 2a176fa8a2
12 changed files with 7907 additions and 7763 deletions

View File

@ -116,7 +116,9 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 4.2 다짐 진행률 (QuestProgress)
- `ratio`(게이지용, 0...1): atLeast = min(value/target, 1) / **atMost = 한도 안이면 1, 넘으면 0**
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능 — 단 **주기와 다른 span의 환산 목표(페이스 가이드)에서는 값을 목표에서 캡해 초과 표기 없음**(주 2회 다짐을 오늘 1회 한 것이 "하루 350%"로 표기되던 왜곡 수정 — `spanProgress``usesScaledTarget` 분기). 자기 주기 span(하루 다짐의 '하루' 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, atMost는 값 무캡(하루 게이지의 환산 한도 초과 0%는 페이스 경고로 유효). 수치 검증은 `-progressSelfTest`(§14)
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- **하루 단위 다짐의 주간·월간 집계는 방향 무관 수행일 기록만 합산** — 목표량이 수행일 수 환산이라 비수행일 기록을 섞으면 분모·분자가 어긋남(월수금 '이하 유지'가 목요일 기록으로 초과 표시되던 비대칭 수정). '이상 달성'은 추가로 하루별 기여를 그날 목표량으로 캡 — 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- **수행일 아닌 날의 '하루' 게이지는 '수행일 아님' 상태로 표기**(`isScheduled` 기준 — 요일·날짜 다짐의 쉬는 날, 기간 밖 custom 포함): 목표 탭 행·모음 카드·위젯 ③(문구)·②(— 대시)·④(흐림)·시리 하루 응답이 공유. 쉬는 날의 기록은 주/월 집계에 안 잡히므로 퍼센트로 보여 주면 "오늘 채웠다"는 오해 유발. 워치 컴플리케이션·잠금화면 점은 공간 제약으로 수치(0%) 유지 — 의도된 단순화
- **의도로 확정한 트레이드오프**: ①다짐 생성(목표 시작) 이전 기록도 주기 범위 안이면 진행률에 포함(달력 주기 기준 측정 — 연속 달성만 목표 시작일 하한) ②월 경계에 걸친 주의 '주간' 게이지(월간 다짐)는 이웃 달 기록 혼입(페이스 표시 한정, 100% 캡으로 완화) ③적용일이 없는 달(예: 31일 지정 다짐의 6월)은 0% 표시 ④특정 기간 종료 후에도 주/월 게이지는 계속 계산(목표 종료일이 자연 해소, 하루는 '수행일 아님')
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
- **마감 시각(deadlineMinutes, 하루 단위 전용)**: 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(`dayMeasurementRange` — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). **의도된 트레이드오프**: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(`spanRawValue`). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요)
@ -294,7 +296,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 12. 시리 단축어 (`Shared/HaruDanimIntents.swift`, 프리미엄 게이트)
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). 시작/종료와 `RunActionIntent`(위젯 버튼)는 **LiveActivityIntent 채택** — 앱 프로세스에서 실행되어 시리·위젯 탭의 백그라운드 실행에서도 다이나믹 아일랜드가 바로 뜬다 (위젯 버튼 응답성 회귀 시 RunActionIntent의 채택만 되돌릴 것). `RunActionIntent`·`ToggleActionControlIntent`(제어 센터용 SetValueIntent)는 `isDiscoverable=false`로 단축어 앱에서 숨김.
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구, '오늘' 조회는 수행일이 아니면 '수행일 아님' 응답). 시작/종료와 `RunActionIntent`(위젯 버튼)는 **LiveActivityIntent 채택** — 앱 프로세스에서 실행되어 시리·위젯 탭의 백그라운드 실행에서도 다이나믹 아일랜드가 바로 뜬다 (위젯 버튼 응답성 회귀 시 RunActionIntent의 채택만 되돌릴 것). `RunActionIntent`·`ToggleActionControlIntent`(제어 센터용 SetValueIntent)는 `isDiscoverable=false`로 단축어 앱에서 숨김.
- **시리 문구**: `AppShortcutsProvider`에 기본 + 행동 이름 파라미터 문구("하루 다님에서 \(\.$action) 측정 시작" — 되묻지 않고 한 번에 실행). 번역은 **`IOS/AppShortcuts.xcstrings`**(일반 카탈로그와 별개, ko/en/ja). 행동 어휘는 앱 시작 시 `updateAppShortcutParameters()`로 갱신(새 행동은 다음 실행부터 음성 인식).
- 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목(위젯 설정용 센티널이 단축어 앱 선택지에도 노출됨 — 알려진 트레이드오프). 도움말 프리미엄 그룹에 사용법 항목, 결제 화면 기능 목록에도 표기. 로직 검증은 `-intentSmokeTest` (§14) — perform() 10개 시나리오를 앱 안에서 실행하고 결과를 Documents/intent-smoke-test.txt로 남긴다.
@ -323,7 +325,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|---|---|
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 33케이스 자가 검증 → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |

View File

@ -254,14 +254,15 @@ enum DebugSeed {
expect("D 하루 게이지", progress(dQuest, .day).ratio, 1.0)
expect("D 주간 표기(하루 기여 캡)", progress(dQuest, .week).displayRatio, 5.0 / 35.0)
// E. 5 : 5, 10 ( 5 , = 5× 3)
// E. 5 : 5, 10(5 ), 5( )
let (eAct, eQuest) = makeQuest("E 월수금5회", type: .count, period: .daily, targetCount: 5) {
$0.scheduleMode = .weekdays
$0.weekdays = [2, 4, 6]
}
add(eAct, at(0, 9), amount: 5); add(eAct, at(2, 9), amount: 10)
expect("E 주간 표기", progress(eQuest, .week).displayRatio, 10.0 / 15.0)
expect("E 하루 표기(목요일 0회)", progress(eQuest, .day).displayRatio, 0.0)
add(eAct, at(0, 9), amount: 5); add(eAct, at(2, 9), amount: 10); add(eAct, at(3, 9), amount: 5)
expect("E 주간 표기(목요일 기록 제외)", progress(eQuest, .week).displayRatio, 10.0 / 15.0)
expect("E 오늘 수행일 아님 판정",
QuestProgress(quest: eQuest, math: math).isScheduled(on: now) ? 1 : 0, 0)
// F. 10 , 1 ( + )
let (fAct, fQuest) = makeQuest("F 월10회", type: .count, period: .monthly, targetCount: 10)
@ -314,10 +315,32 @@ enum DebugSeed {
add(k2Act, at(3, 8, 10))
expect("K2 하루 표기(마감 뒤 제외)", progress(k2Quest, .day).displayRatio, 0.0)
// L. (0% )
// L. (0% + / )
let (_, lQuest) = makeQuest("L 무기록", type: .count, period: .weekly, targetCount: 2)
expect("L 하루 표기", progress(lQuest, .day).displayRatio, 0.0)
expect("L 주간 표기", progress(lQuest, .week).displayRatio, 0.0)
expect("L 주간 다짐 오늘 수행일 판정",
QuestProgress(quest: lQuest, math: math).isScheduled(on: now) ? 1 : 0, 1)
// M. ' ' 1: 30( ), 3( )
// ( )
let (mAct, mQuest) = makeQuest("M 월수금 이하1시간", type: .time, period: .daily,
direction: .atMost, targetSeconds: 3600) {
$0.scheduleMode = .weekdays
$0.weekdays = [2, 4, 6]
}
addSession(mAct, at(0, 9), at(0, 9, 30))
addSession(mAct, at(3, 13), at(3, 16))
expect("M 주간 값(목요일 시청 제외)", progress(mQuest, .week).value, 30 * 60)
expect("M 주간 게이지(한도 안 100%)", progress(mQuest, .week).ratio, 1.0)
// N. ( )
let (_, nQuest) = makeQuest("N 지난기간", type: .count, period: .custom, targetCount: 5) {
$0.customStart = at(-10, 0)
$0.customEnd = at(-3, 0)
}
expect("N 기간 밖 수행일 판정",
QuestProgress(quest: nQuest, math: math).isScheduled(on: now) ? 1 : 0, 0)
} catch {
failures += 1
lines.append("FAIL 컨테이너 생성: \(error)")

File diff suppressed because it is too large Load Diff

View File

@ -374,17 +374,27 @@ struct QuestRow: View {
private func spanGauge(_ span: StatSpan) -> some View {
let progress = QuestProgress(quest: quest)
// (· , )
// / " "
let restDay = span == .day && !progress.isScheduled(on: .now)
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
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: result.ratio)
ProgressView(value: restDay ? 0 : result.ratio)
.tint(over ? .red : quest.targetColor)
Text(Format.percent(result.displayRatio))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .primary)
.opacity(restDay ? 0.4 : 1)
if restDay {
Text("수행일 아님")
.font(.caption2)
.foregroundStyle(.tertiary)
} else {
Text(Format.percent(result.displayRatio))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .primary)
}
}
.frame(maxWidth: .infinity)
}

View File

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

View File

@ -586,12 +586,14 @@ struct GoalSummaryCard: View {
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
// ( )
let restDay = span == .day && !progress.isScheduled(on: .now)
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
let over = !restDay && quest.direction == .atMost && result.value > result.target
miniGauge(
label: span.label,
ratio: result.ratio,
percentText: Format.percent(result.displayRatio),
ratio: restDay ? 0 : result.ratio,
percentText: restDay ? String(localized: "수행일 아님") : Format.percent(result.displayRatio),
color: over ? .red : quest.targetColor,
emphasized: over
)

View File

@ -675,7 +675,15 @@ struct QuestProgressIntent: AppIntent {
try IntentStore.requirePremium()
IntentStore.refresh()
let model = try IntentStore.quest(quest.id)
let result = QuestProgress(quest: model).spanProgress(span.statSpan)
let progress = QuestProgress(quest: model)
// (· , )
if span.statSpan == .day, !progress.isScheduled(on: .now) {
return .result(
value: 0,
dialog: "'\(quest.goalTitle) · \(model.targetName)' 오늘은 수행일이 아니에요."
)
}
let result = progress.spanProgress(span.statSpan)
let percent = (result.displayRatio * 100).rounded()
let detail: String
switch model.direction {

View File

@ -238,7 +238,10 @@ struct QuestProgress {
/// (: 5 10 5 )
/// · .
/// - span: 200% .
/// - ' '(atMost): (100%/0%) ( ).
/// - / ** **
/// (scaledTarget) ·
/// ( ' ' ).
/// ' ' ( ).
/// - // : .
/// - (deadlineMinutes) : span
/// ( ~) .
@ -248,14 +251,12 @@ struct QuestProgress {
guard quest.hasDeadline else { return value(in: range, now: now) }
return value(in: dayMeasurementRange(forKey: math.dayKey(for: range.lowerBound)), now: now)
}
// / span :
// - ' ' ( )
// - ()
// (// , ' ') .
let caps = quest.period == .daily && quest.direction == .atLeast && quest.targetValue > 0
guard caps || (quest.period == .daily && quest.hasDeadline) else {
// / span .
// // .
guard quest.period == .daily else {
return value(in: range, now: now)
}
let caps = quest.direction == .atLeast && quest.targetValue > 0
let dailyTarget = quest.targetValue
let agg = Aggregator(math: math)
var sum = 0.0

View File

@ -173,8 +173,14 @@ struct QuestBarRow: View {
.frame(width: 52, alignment: .leading)
ProgressView(value: min(max(quest.ratio, 0), 1))
.progressViewStyle(.linear)
.tint(quest.isAtMost && !quest.isAchieved ? Color.red : quest.color)
if let streak = quest.streakLabel {
.tint(quest.isAtMost && !quest.isAchieved && !quest.isRestDay ? Color.red : quest.color)
if quest.isRestDay {
// ( )
Text(verbatim: "")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.tertiary)
.frame(width: 34, alignment: .trailing)
} else if let streak = quest.streakLabel {
VStack(alignment: .trailing, spacing: 0) {
Text(Format.percent(quest.displayRatio))
.font(.system(size: 9, weight: .semibold).monospacedDigit())
@ -484,6 +490,8 @@ struct QuestRingCellView: View {
VStack(spacing: 5) {
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
.frame(width: ringSize, height: ringSize)
// ( )
.opacity(quest.isRestDay ? 0.4 : 1)
VStack(spacing: 1) {
Text(quest.targetName)
.font(.system(size: 8, weight: .medium))

View File

@ -488,6 +488,28 @@
}
}
},
"'%@ · %@' 오늘은 수행일이 아니에요.": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "'%1$@ · %2$@' 오늘은 수행일이 아니에요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "'%1$@ · %2$@' isn't scheduled for today."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%1$@ · %2$@」は今日は実行日ではありません。"
}
}
}
},
"'%@' %@ 진행률은 %lld%%예요.": {
"localizations": {
"en": {
@ -1847,6 +1869,22 @@
}
}
},
"수행일 아님": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Not scheduled"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "実行日ではありません"
}
}
}
},
"숫자만": {
"localizations": {
"en": {
@ -3453,4 +3491,4 @@
}
},
"version": "1.0"
}
}

View File

@ -138,12 +138,15 @@ struct QuestRingActionCellView: View {
.foregroundStyle(.secondary)
// ' ' ( )
.invalidatableContent()
// span ' '
Text(cell.valueLabel)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.invalidatableContent()
// span ' ' .
// ( )
if !cell.isRestDay {
Text(cell.valueLabel)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.invalidatableContent()
}
if let streak = cell.streakLabel {
HStack(spacing: 2) {
Image(systemName: "flame.fill")
@ -164,6 +167,8 @@ struct QuestRingActionCellView: View {
}
private var percentText: String {
// ( span )
if cell.isRestDay { return String(localized: "수행일 아님") }
if cell.isAtMost {
return cell.isAchieved
? String(localized: "\(spanLabel) 한도 지킴")

View File

@ -90,6 +90,9 @@ struct QuestCellSnapshot: Identifiable {
let displayRatio: Double
let isAtMost: Bool
let isAchieved: Bool
/// span (· , )
/// · 0 ' ' ( , · )
let isRestDay: Bool
/// ( )
let runTarget: ActionEntity?
let isRunning: Bool
@ -106,7 +109,12 @@ struct QuestCellSnapshot: Identifiable {
@MainActor
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
let progress = QuestProgress(quest: quest)
let result = progress.spanProgress(span, now: now)
// span
// " " (/ )
let isRestDay = span == .day && !progress.isScheduled(on: now)
let result = isRestDay
? QuestProgressResult(value: 0, target: quest.targetValue, direction: quest.direction)
: progress.spanProgress(span, now: now)
let streak = progress.streak(now: now)
let action = quest.targetAction
// ()
@ -124,10 +132,11 @@ struct QuestCellSnapshot: Identifiable {
symbolName: quest.targetSymbol,
colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
ratio: result.ratio,
displayRatio: result.displayRatio,
ratio: isRestDay ? 0 : result.ratio,
displayRatio: isRestDay ? 0 : result.displayRatio,
isAtMost: quest.direction == .atMost,
isAchieved: result.isAchieved,
isAchieved: isRestDay ? false : result.isAchieved,
isRestDay: isRestDay,
runTarget: action.map(ActionEntity.init),
isRunning: action?.isRunning ?? false,
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil,
@ -194,22 +203,22 @@ extension QuestCellSnapshot {
static let samples: [QuestCellSnapshot] = [
QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true,
isAtMost: false, isAchieved: false, isRestDay: false, runTarget: nil, isRunning: true,
streakLabel: QuestStreakInfo(count: 5, unit: .day).label,
valueLabel: Format.durationShort(45 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false,
isAtMost: false, isAchieved: false, isRestDay: false, runTarget: nil, isRunning: false,
streakLabel: nil,
valueLabel: Format.durationShort(12 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1,
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false,
isAtMost: false, isAchieved: true, isRestDay: false, runTarget: nil, isRunning: false,
streakLabel: QuestStreakInfo(count: 12, unit: .day).label,
valueLabel: String(localized: "\(9)")),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill",
colorHex: "#7A5C9D", ratio: 1, displayRatio: 1,
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false,
isAtMost: true, isAchieved: true, isRestDay: false, runTarget: nil, isRunning: false,
streakLabel: nil,
valueLabel: Format.durationShort(40 * 60)),
]