feat(quest): daily quest deadline time — clip the day's measurement window
하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계): - 모델: Quest.deadlineMinutes(-1=없음) 필드 1개 — CloudKit 안전(기본값), 주기가 하루 단위가 아니면 값이 남아도 무시(hasDeadline이 주기·범위를 함께 검사) - 의미론: 마감 = 그날 집계 창의 끝. dayMeasurementRange(forKey:) 단일 구현을 주기 범위(current/judgment)·주/월 span 하루 루프(spanValue)·연속 버킷팅 (perDayValues)·위젯 누적 라벨(spanRawValue)·일기 카드가 공유한다. 원본 기록은 불변 — 마감 뒤 기록은 남되 그 다짐에만 미집계. 시간형은 기존 겹침 클리핑이 마감을 걸친 세션을 부분 인정(추가 코드 0). '이하 유지'는 마감까지 한도 안이면 그날 달성으로 고정. - 경계 규칙: 마감은 그 논리적 하루(24h) 안에서 그 시계 시각의 등장 지점 — 하루 시작보다 이른 시각은 다음 달력일 새벽, 시작과 같은 시각은 +24h(온전한 하루)로 0길이 창이 수학적으로 불가능(에디터 검증 불필요, 오류 상태 없음) - 의도된 최소화: '마감 지남' 전용 표시 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계 불필요 — 위젯·워치·시리는 코드 변경 0으로 자동 일관), 연속 달성은 값 클리핑만(오늘 놓친 끊김 반영은 내일부터 — 유예 규칙 유지), 주간/월간/기간 마감은 미지원 - UI: 다짐 에디터 토글+시간 선택(하루 단위만, footer 설명), 주기 문구에 "· 오전 8:00까지" 표기(목표 탭·시리 요약·CSV 자동 반영), 일기 카드 "하루 목표 · 오전 8:00까지", 도움말 항목 추가, ko/en/ja 번역 - 검증: seedDemo에 마감 다짐 시드(창 안/밖/어제 실패 케이스) — 하루 100% (창 밖 기록 포함 시 200%가 되는 함정 통과), 주간 29%(=2/7)·월간 6%(=2/31) 수기 계산과 정확 일치, streak-dump 연속 1일(어제 실패로 끊김) 확인, 마감 없는 다짐 전부 기존 수치 유지(경로가 hasDeadline 가드로 완전 동일), 일기 카드 1회/1회(원본 합계는 그대로), 영어 로케일 "By 8:00 AM" 렌더, Debug·Store 빌드 성공, 카탈로그 9종 missing/stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
b399ee02fe
commit
6a486a4fbd
@ -91,7 +91,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
| `TimeSession` | startAt, endAt(nil=측정 중), note | 삭제 규칙: Action cascade |
|
||||
| `CountEntry` | timestamp, amount, note | |
|
||||
| `Goal` | uuid, title, symbolName, colorHex, startDate, endDate?, statusRaw(inProgress/achieved/notAchieved), **achieveThresholdPercent(기본 100)**, sortOrder | isCollapsed·showsOnMain은 레거시 |
|
||||
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
|
||||
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), **deadlineMinutes(-1=없음 — 하루 단위 전용 마감 시각, §4.2)**, sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
|
||||
| `DiaryEntry` | dayKey(논리적 하루 키), moodEmoji, moodImageData(externalStorage), **calendarEventIDs[]**(그날 타임테이블에 넣을 EKEvent id), **hiddenActionIDs[]**(그날 타임테이블에서 숨길 Action.uuid 문자열), **hiddenGoalIDs[]**(그날 '이 날의 목표' 카드에서 숨길 Goal.uuid 문자열) | 날짜당 1개. 날짜별 선택은 uuid 문자열로 저장(기기 간 동기화 안전) |
|
||||
| `DiaryTodo` | text, isDone, sortOrder | 요약 페이지 체크리스트 |
|
||||
| `DiaryPage` | index, lined, lineSpacing, drawingData(PKDrawing, externalStorage), **templateID**(DiaryTemplate.uuid 문자열, 빈 값=없음), **templatePageIndex**(PDF 쪽, 0부터) | 768×1024 논리 좌표. 양식만 깔린 페이지도 hasContent=true(작성함 취급·내보내기 포함) |
|
||||
@ -114,6 +114,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
|
||||
- `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% 취급). 모음 탭 카드·위젯·워치·시리가 공유
|
||||
@ -180,7 +181,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
- 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택
|
||||
- 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)**
|
||||
- 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100%면 안내 문구에 기준 표기), 삭제
|
||||
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
|
||||
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기. 하루 단위는 '마감 시각까지만 집계' 토글+시간 선택 — §4.2 마감 규칙) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
|
||||
- **연속 달성**(`QuestProgress.streak` → `QuestStreakInfo`): 하루 단위=적용일만 연속 계산("연속 N일" — 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. **하한 = 목표 시작일** — 행동이 아니라 "목표 안 다짐"의 연속이므로 목표 시작 전 날짜/주기는 세지 않음(주·월은 시작일이 걸친 부분 주기까지 포함). 특히 atMost는 기록 없는 날이 전부 달성이라 이 하한이 없으면 과거 전체가 연속으로 잡힘. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 표기: 목표 탭 다짐 행 + 위젯 ②다짐 줄·③링 셀·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다
|
||||
- 목록 진입 시 `evaluateIfEnded` 일괄 실행
|
||||
|
||||
@ -309,7 +310,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
|
||||
## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)
|
||||
|
||||
공통: `-seedDemo YES`(데모 데이터: 태그3·행동6·기록 7일치·목표7·자정 걸친 세션), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
|
||||
공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일]), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
|
||||
|
||||
| 영역 | 인자 |
|
||||
|---|---|
|
||||
|
||||
@ -231,7 +231,7 @@ enum DebugSeed {
|
||||
lines.append(
|
||||
"goal=\(goal?.title ?? "-") start=\(goal?.startDate.formatted(date: .numeric, time: .omitted) ?? "-") "
|
||||
+ "quest=\(quest.targetName) period=\(quest.period) direction=\(quest.direction) "
|
||||
+ "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil")"
|
||||
+ "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil") deadline=\(quest.deadlineMinutes)"
|
||||
)
|
||||
}
|
||||
let url = URL.documentsDirectory.appending(path: "streak-dump.txt")
|
||||
@ -439,8 +439,36 @@ enum DebugSeed {
|
||||
screenQuest.direction = .atMost
|
||||
context.insert(screenQuest)
|
||||
|
||||
// 여섯 번째 목표: 마감 시각 다짐 검증용 — "오전 8시까지 일어나기 1회 이상"
|
||||
// 기록: 오늘 07:30(창 안)+08:10(창 밖 — 집계 제외돼 하루 100%가 200%로 뻥튀기되면 버그),
|
||||
// 어제 08:30(창 밖 → 그날 실패), 그저께 07:00(창 안).
|
||||
// 기대값: 하루 진행률 100%, 연속 1일(어제 실패로 끊김), streak-dump에 deadline=480.
|
||||
let wake = Action(name: "일찍 일어나기", symbolName: "sunrise.fill", trackingType: .count, sortOrder: 6)
|
||||
wake.tags = [life]
|
||||
context.insert(wake)
|
||||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 7, minute: 30)))
|
||||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 8, minute: 10)))
|
||||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 1, hour: 8, minute: 30)))
|
||||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 2, hour: 7, minute: 0)))
|
||||
let morning = Goal(
|
||||
title: "아침 루틴 지키기",
|
||||
symbolName: "sunrise.fill",
|
||||
colorHex: "#D9A621",
|
||||
startDate: cal.date(byAdding: .day, value: -7, to: now)!,
|
||||
endDate: nil
|
||||
)
|
||||
context.insert(morning)
|
||||
let wakeQuest = Quest(goal: morning)
|
||||
wakeQuest.targetAction = wake
|
||||
wakeQuest.measure = .count
|
||||
wakeQuest.period = .daily
|
||||
wakeQuest.targetCount = 1
|
||||
wakeQuest.direction = .atLeast
|
||||
wakeQuest.deadlineMinutes = 8 * 60
|
||||
context.insert(wakeQuest)
|
||||
|
||||
// 목표 탭 정렬 순서 부여
|
||||
for (index, goal) in [toeic, health, habit, run, screen].enumerated() {
|
||||
for (index, goal) in [toeic, health, habit, run, screen, morning].enumerated() {
|
||||
goal.sortOrder = index
|
||||
}
|
||||
|
||||
@ -453,7 +481,7 @@ enum DebugSeed {
|
||||
endDate: cal.date(byAdding: .day, value: -30, to: now)!
|
||||
)
|
||||
sleep.status = .achieved
|
||||
sleep.sortOrder = 5
|
||||
sleep.sortOrder = 6
|
||||
context.insert(sleep)
|
||||
let diet = Goal(
|
||||
title: "한 달 다이어트",
|
||||
@ -463,7 +491,7 @@ enum DebugSeed {
|
||||
endDate: cal.date(byAdding: .day, value: -20, to: now)!
|
||||
)
|
||||
diet.status = .notAchieved
|
||||
diet.sortOrder = 6
|
||||
diet.sortOrder = 7
|
||||
context.insert(diet)
|
||||
|
||||
// 기기별 로컬 UI 설정 시드: 메모 창 행동, 모음 탭 노출 목표 (LocalPrefs 참고)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1509,8 +1509,13 @@ private struct DiaryGoalsCard: View {
|
||||
switch quest.period {
|
||||
case .daily:
|
||||
if progress.isActiveDay(dayKey) {
|
||||
measured = dayRange
|
||||
context = String(localized: "하루 목표")
|
||||
// 마감 시각 다짐은 그날의 집계 창(하루 시작~마감)만 잰다 — 목표 탭과 동일 규칙
|
||||
measured = progress.dayMeasurementRange(forKey: dayKey)
|
||||
var label = String(localized: "하루 목표")
|
||||
if let deadline = quest.deadlineLabel {
|
||||
label += " · " + deadline
|
||||
}
|
||||
context = label
|
||||
} else {
|
||||
measured = nil
|
||||
context = String(localized: "이 날은 수행일이 아니에요")
|
||||
|
||||
@ -127,6 +127,11 @@ struct HelpView: View {
|
||||
title: "진행률 읽는 법",
|
||||
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산해요. '이하 유지' 다짐은 한도를 넘지 않는 동안 100%로 표시돼요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "clock.badge.checkmark",
|
||||
title: "마감 시각까지 달성하기",
|
||||
body: "하루 단위 다짐을 만들 때 '마감 시각까지만 집계'를 켜면 하루 시작부터 그 시각까지의 기록만 다짐에 집계돼요. 예를 들어 '일어나기 1회 · 오전 8:00까지'로 만들면 8시 이후에 누른 기록은 남긴 하지만 그날 달성으로는 인정되지 않아요. '이하 유지' 다짐은 마감 시각까지 한도를 지키면 그날 달성이에요. 시간 측정 행동이 마감을 걸치면 마감 전까지의 시간만 인정돼요. 하루 시작 시간보다 이른 시각을 고르면 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "flame.fill",
|
||||
title: "연속 달성 표시",
|
||||
|
||||
@ -45,6 +45,12 @@ struct QuestEditorView: View {
|
||||
@State private var customStart: Date = .now
|
||||
@State private var customEnd: Date = .now
|
||||
|
||||
// ② 마감 시각 (하루 단위 전용 — 그날의 집계 창을 [하루 시작, 마감]으로 줄인다)
|
||||
@State private var hasDeadline = false
|
||||
@State private var deadlineTime: Date = Calendar.current.date(
|
||||
bySettingHour: 21, minute: 0, second: 0, of: .now
|
||||
) ?? .now
|
||||
|
||||
// ③ 목표량 + 방향
|
||||
@State private var targetHours = 1
|
||||
@State private var targetMinutes = 0
|
||||
@ -121,7 +127,7 @@ struct QuestEditorView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var periodSection: some View {
|
||||
Section("② 주기 선택") {
|
||||
Section {
|
||||
Picker("반복 주기", selection: $period.animation()) {
|
||||
ForEach(QuestPeriod.allCases) { p in
|
||||
Text(p.label).tag(p)
|
||||
@ -154,12 +160,23 @@ struct QuestEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 마감 시각: 그날의 집계 창을 [하루 시작, 마감]으로 줄인다 (하루 단위 전용)
|
||||
Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation())
|
||||
if hasDeadline {
|
||||
DatePicker("마감 시각", selection: $deadlineTime, displayedComponents: .hourAndMinute)
|
||||
}
|
||||
}
|
||||
|
||||
if period == .custom {
|
||||
DatePicker("시작", selection: $customStart, displayedComponents: .date)
|
||||
DatePicker("끝", selection: $customEnd, in: customStart..., displayedComponents: .date)
|
||||
}
|
||||
} header: {
|
||||
Text("② 주기 선택")
|
||||
} footer: {
|
||||
if period == .daily && hasDeadline {
|
||||
Text("하루 시작부터 마감 시각까지의 기록만 이 다짐에 집계돼요. 마감 이후의 기록은 기록 탭에 남지만 이 다짐에는 반영되지 않아요. 하루 시작 시간보다 이른 시각은 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,6 +304,12 @@ struct QuestEditorView: View {
|
||||
ordinalWeekday = quest.ordinalWeekday
|
||||
customStart = quest.customStart ?? .now
|
||||
customEnd = quest.customEnd ?? .now
|
||||
if quest.deadlineMinutes >= 0 {
|
||||
hasDeadline = true
|
||||
let cal = Calendar.current
|
||||
deadlineTime = cal.date(byAdding: .minute, value: quest.deadlineMinutes,
|
||||
to: cal.startOfDay(for: .now)) ?? deadlineTime
|
||||
}
|
||||
targetHours = Int(quest.targetSeconds) / 3600
|
||||
targetMinutes = (Int(quest.targetSeconds) % 3600) / 60
|
||||
targetCount = quest.targetCount
|
||||
@ -307,6 +330,13 @@ struct QuestEditorView: View {
|
||||
}
|
||||
target.period = period
|
||||
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
|
||||
// 마감 시각은 하루 단위 전용 — 주기를 바꾸거나 토글을 끄면 함께 해제한다
|
||||
if period == .daily && hasDeadline {
|
||||
let comps = Calendar.current.dateComponents([.hour, .minute], from: deadlineTime)
|
||||
target.deadlineMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||
} else {
|
||||
target.deadlineMinutes = -1
|
||||
}
|
||||
target.weekdays = Array(weekdays).sorted()
|
||||
target.monthDays = Array(monthDays).sorted()
|
||||
target.ordinalWeek = ordinalWeek
|
||||
|
||||
@ -384,6 +384,9 @@ final class Quest {
|
||||
/// 횟수 목표 — measure == .count 일 때 사용
|
||||
var targetCount: Int = 1
|
||||
var directionRaw: String = QuestDirection.atLeast.rawValue
|
||||
/// 하루 단위 다짐의 마감 시각 (자정 기준 분, -1 = 마감 없음 — CloudKit 요건상 기본값).
|
||||
/// 지정되면 그날의 집계 창이 [하루 시작, 마감]으로 줄어든다 (QuestProgress.dayMeasurementRange)
|
||||
var deadlineMinutes: Int = -1
|
||||
|
||||
/// 목표 안에서의 표시 순서
|
||||
var sortOrder: Int = 0
|
||||
@ -449,8 +452,28 @@ extension Quest {
|
||||
measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)회")
|
||||
}
|
||||
|
||||
/// 주기 요약 문구 (예: "매주 월·수·금")
|
||||
/// 하루 단위 다짐에 마감 시각이 지정돼 있는지.
|
||||
/// 주기를 바꿔 값이 남아 있어도(편집 잔존·동기화) 하루 단위가 아니면 무시하고,
|
||||
/// 범위 밖 값(손상 데이터)도 없음으로 취급한다.
|
||||
var hasDeadline: Bool {
|
||||
period == .daily && (0...1439).contains(deadlineMinutes)
|
||||
}
|
||||
|
||||
/// 마감 시각 표시 문구 ("오전 8:00까지"). 마감이 없으면 nil
|
||||
var deadlineLabel: String? {
|
||||
guard hasDeadline else { return nil }
|
||||
let cal = Calendar.current
|
||||
let time = cal.date(byAdding: .minute, value: deadlineMinutes, to: cal.startOfDay(for: .now)) ?? .now
|
||||
return String(localized: "\(Format.time(time))까지")
|
||||
}
|
||||
|
||||
/// 주기 요약 문구 (예: "매주 월·수·금", 마감이 있으면 "매일 · 오전 8:00까지")
|
||||
var scheduleLabel: String {
|
||||
guard let deadlineLabel else { return baseScheduleLabel }
|
||||
return "\(baseScheduleLabel) · \(deadlineLabel)"
|
||||
}
|
||||
|
||||
private var baseScheduleLabel: String {
|
||||
switch period {
|
||||
case .daily:
|
||||
switch scheduleMode {
|
||||
|
||||
@ -90,6 +90,22 @@ struct QuestProgress {
|
||||
math.dayKeys(in: range).filter(isActiveDay).count
|
||||
}
|
||||
|
||||
/// 하루 단위 다짐의 "그날 집계 창". 마감 시각이 있으면 [하루 시작, 마감)으로 줄어든다.
|
||||
/// 마감은 그 논리적 하루(24시간) 안에서 그 시계 시각이 등장하는 지점이다 —
|
||||
/// 하루 시작보다 이른 시각은 다음 달력일 새벽을 뜻하고, 시작과 같은 시각은
|
||||
/// +24시간(온전한 하루)으로 해석해 0길이 창이 생기지 않는다.
|
||||
/// 마감이 없으면(또는 하루 단위가 아니면) 하루 전체 범위 그대로.
|
||||
func dayMeasurementRange(forKey key: Date) -> Range<Date> {
|
||||
let full = math.dayRange(forKey: key)
|
||||
guard quest.hasDeadline else { return full }
|
||||
let cal = math.calendar
|
||||
var deadline = cal.date(byAdding: .minute, value: quest.deadlineMinutes, to: key) ?? full.upperBound
|
||||
if deadline <= full.lowerBound {
|
||||
deadline = cal.date(byAdding: .day, value: 1, to: deadline) ?? full.upperBound
|
||||
}
|
||||
return full.lowerBound..<min(deadline, full.upperBound)
|
||||
}
|
||||
|
||||
/// 해당 시점의 논리적 하루가 이 다짐의 수행일인지 (목표 하루 진행률의 모수 판정용).
|
||||
/// 주/월 단위 다짐은 매일이 수행일이고, 특정 기간 다짐은 기간 안에 있을 때만 수행일.
|
||||
func isScheduled(on now: Date) -> Bool {
|
||||
@ -111,7 +127,8 @@ struct QuestProgress {
|
||||
case .daily:
|
||||
let key = math.dayKey(for: now)
|
||||
guard isActiveDay(key) else { return nil }
|
||||
return math.dayRange(forKey: key)
|
||||
// 마감 시각이 있으면 주기 자체가 [하루 시작, 마감)까지 — current()·judgment가 자동 반영
|
||||
return dayMeasurementRange(forKey: key)
|
||||
case .weekly:
|
||||
return math.weekRange(containing: now)
|
||||
case .monthly:
|
||||
@ -203,25 +220,52 @@ struct QuestProgress {
|
||||
/// - 하루 span: 그날의 실제 누적을 그대로 써서 200% 같은 초과 달성 표기를 유지한다.
|
||||
/// - '이하 유지'(atMost): 값이 아니라 하루 초과 여부(100%/0%)로 판정되므로 캡 대상이 아니다(기존 로직 유지).
|
||||
/// - 주/월/특정기간 단위 다짐: 기간 내 자유 누적이 정상 동작이라 캡 없이 전체 누적값을 쓴다.
|
||||
/// - 마감 시각(deadlineMinutes)이 있는 하루 단위 다짐: 모든 span에서 각 날의 값을
|
||||
/// 그날의 집계 창(하루 시작~마감)으로 제한해 잰다 — 마감 뒤 기록은 집계에서 제외.
|
||||
private func spanValue(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
|
||||
guard span != .day,
|
||||
quest.period == .daily,
|
||||
quest.direction == .atLeast,
|
||||
quest.targetValue > 0
|
||||
else {
|
||||
// 하루 span: 그날의 실제 누적. 마감 시각 다짐은 그날의 집계 창(마감까지)만 잰다.
|
||||
if span == .day {
|
||||
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 {
|
||||
return value(in: range, now: now)
|
||||
}
|
||||
let dailyTarget = quest.targetValue
|
||||
let agg = Aggregator(math: math)
|
||||
var sum = 0.0
|
||||
for key in math.dayKeys(in: range) where isActiveDay(key) {
|
||||
let dayRange = math.dayRange(forKey: key)
|
||||
let dayRange = dayMeasurementRange(forKey: key)
|
||||
let dayValue: Double
|
||||
switch quest.measure {
|
||||
case .time: dayValue = agg.seconds(for: quest.targetActions, in: dayRange, now: now)
|
||||
case .count: dayValue = Double(agg.count(for: quest.targetActions, in: dayRange))
|
||||
}
|
||||
sum += min(dayValue, dailyTarget)
|
||||
sum += caps ? min(dayValue, dailyTarget) : dayValue
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/// span 구간의 실측 누적값 — 위젯 ③ 누적값 라벨용.
|
||||
/// 진행률 캡(하루 기여 제한)·적용일 여부와 무관한 "원본" 성격의 값이지만,
|
||||
/// 마감 시각 다짐은 각 날의 집계 창(마감까지)만 센다 — 마감 뒤 기록으로
|
||||
/// 게이지는 0%인데 라벨 숫자만 늘어나는 모순을 막는다. 마감이 없으면 기존과 동일.
|
||||
func spanRawValue(_ span: StatSpan, now: Date = .now) -> Double {
|
||||
let range = spanRange(span, now: now)
|
||||
guard quest.hasDeadline else { return value(in: range, now: now) }
|
||||
let agg = Aggregator(math: math)
|
||||
var sum = 0.0
|
||||
for key in math.dayKeys(in: range) {
|
||||
let dayRange = dayMeasurementRange(forKey: key)
|
||||
switch quest.measure {
|
||||
case .time: sum += agg.seconds(for: quest.targetActions, in: dayRange, now: now)
|
||||
case .count: sum += Double(agg.count(for: quest.targetActions, in: dayRange))
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
@ -306,6 +350,16 @@ extension QuestProgress {
|
||||
/// 연속 계산 전체가 O(기록수 + 일수)가 되게 하는 것이 핵심.
|
||||
private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] {
|
||||
var values: [Date: Double] = [:]
|
||||
// 마감 시각 다짐은 하루별 집계 창(마감까지)만 센다. 창 끝은 날짜별로 캐시해
|
||||
// 기록마다 달력 연산이 반복되지 않게 한다. 마감이 없으면 분기 자체를 건너뜀(기존과 동일).
|
||||
let clips = quest.hasDeadline
|
||||
var windowEnds: [Date: Date] = [:]
|
||||
func windowEnd(_ key: Date) -> Date {
|
||||
if let cached = windowEnds[key] { return cached }
|
||||
let end = dayMeasurementRange(forKey: key).upperBound
|
||||
windowEnds[key] = end
|
||||
return end
|
||||
}
|
||||
for action in quest.targetActions {
|
||||
switch quest.measure {
|
||||
case .time:
|
||||
@ -314,14 +368,22 @@ extension QuestProgress {
|
||||
let start = max(session.startAt, lowerBound)
|
||||
guard start < end else { continue }
|
||||
for segment in math.splitByDay(start: start, end: end) {
|
||||
var upper = segment.range.upperBound
|
||||
if clips {
|
||||
// 조각의 시작은 항상 그날 창의 시작(하루 시작) 이후이므로 끝만 자르면 된다
|
||||
upper = min(upper, windowEnd(segment.dayKey))
|
||||
guard segment.range.lowerBound < upper else { continue }
|
||||
}
|
||||
values[segment.dayKey, default: 0]
|
||||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||||
+= upper.timeIntervalSince(segment.range.lowerBound)
|
||||
}
|
||||
}
|
||||
case .count:
|
||||
for entry in action.countEntries
|
||||
where entry.timestamp >= lowerBound && entry.timestamp <= now {
|
||||
values[math.dayKey(for: entry.timestamp), default: 0] += Double(entry.amount)
|
||||
let key = math.dayKey(for: entry.timestamp)
|
||||
if clips, entry.timestamp >= windowEnd(key) { continue }
|
||||
values[key, default: 0] += Double(entry.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -251,6 +251,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@까지": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "By %@"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@まで"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@은(는) 이미 측정 중이에요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -3437,4 +3453,4 @@
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,7 @@ struct QuestCellSnapshot: Identifiable {
|
||||
/// span 구간의 실제 누적값 문구 ("1시간 20분" / "3회") — ③ 다짐 진행률 위젯에서
|
||||
/// 퍼센트(또는 '한도 지킴') 문구 아래에 표시. 특히 '이하 유지'는 퍼센트가 없어
|
||||
/// 이 값이 없으면 지금 얼마나 했는지 알 수 없다. 진행률 캡(주간·월간 하루 기여 제한)과
|
||||
/// 무관한 원본 누적값.
|
||||
/// 무관한 원본 누적값 (단, 마감 시각 다짐은 그날 집계 창까지의 값 — spanRawValue).
|
||||
let valueLabel: String
|
||||
|
||||
var color: Color { Color(hex: colorHex) }
|
||||
@ -109,7 +109,8 @@ struct QuestCellSnapshot: Identifiable {
|
||||
let result = progress.spanProgress(span, now: now)
|
||||
let streak = progress.streak(now: now)
|
||||
let action = quest.targetAction
|
||||
let rawValue = progress.value(in: progress.spanRange(span, now: now), now: now)
|
||||
// 마감 시각 다짐은 집계 창(마감까지) 안의 값만 — 게이지와 라벨이 모순되지 않게
|
||||
let rawValue = progress.spanRawValue(span, now: now)
|
||||
let valueLabel: String
|
||||
switch quest.measure {
|
||||
case .time:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user