fix(diary): calendar month drift + export date picks off-by-one at custom day start

'하루 시작 시간'이 자정보다 늦게 설정된 경우의 논리적 하루 경계 버그 2건:

- 달력: monthAnchor(자정 키)를 monthRange(containing:)에 그대로 넣어
  매달 1일에 이전 달 그리드가 그려지고 헤더(현재 달)와 불일치했다.
  키를 dayRange(forKey:).lowerBound로 실제 시각으로 변환해 전달
  (기록·통계 탭이 쓰는 기존 패턴). monthSummary 카운트도 동일 수정.
- 내보내기: '날짜 지정'(MultiDatePicker)의 선택일(자정 Date)을 dayKey(for:)에
  넣어 전날 일기가 내보내졌다. DiaryEntry.dayKey가 "달력일의 자정"이므로
  선택 달력일의 startOfDay를 키로 직접 사용 — 하루 시작 시간이 몇 시든 안전.
  '구간 지정'도 같은 원리로 통일(표시된 달력일 = 내보내는 일기 날짜 보장).

CLAUDE.md §4.1에 재발 방지 규칙 추가: 자정 키를 dayKey(for:)/~Range(containing:)에
재투입 금지, 달력 UI 선택일은 startOfDay를 키로 직접 사용.

검증: 하루 시작 00:00/06:00/18:00 전부 수치 검증(수정 전 06:00에서 이전 달·전날
재현 확인), Debug·Store 빌드 성공, iPad 시뮬레이터에 하루 시작 06:00 주입 후
달력(7월 헤더·그리드 일치, 마커 2건)과 내보내기 시트("일기를 쓴 날 2일") 확인.

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-15 16:07:05 +09:00
parent 3f518aa91b
commit 05b2d392c8
3 changed files with 14 additions and 6 deletions

View File

@ -105,6 +105,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 4.1 논리적 하루 (DayMath)
- 설정 "하루 시작 시간"(`dayStartMinutes`)·"주 시작 요일"(`weekStartWeekday`, 기본 월요일)이 모든 집계의 기준
- `dayKey(for:)`: 시각 → 그 시각이 속한 논리적 하루의 달력일 자정. 경계 이전 시각은 전날 귀속
- ⚠️ **자정 Date(=키)를 dayKey(for:)나 ~Range(containing:)에 다시 넣지 말 것** — 하루 시작 시간이 자정보다 늦으면 전날로 밀린다(일기 달력이 매달 1일에 이전 달을 그리고, 내보내기 '날짜 지정'이 전날 일기를 담던 버그). 달력 UI에서 고른 달력일은 `calendar.startOfDay`를 키로 직접 쓰고, 키로 시각 연산이 필요하면 `dayRange(forKey:).lowerBound`로 변환해 넘긴다(기록·통계 탭 패턴)
- 세션은 실제 시각 그대로 하나로 저장하고, **집계는 구간 겹침(overlap)으로 자동 분할** — 하루 경계를 걸친 세션이 날짜별로 나뉘어 계산됨. `Aggregator.seconds/count`가 이 규칙의 단일 구현이며 화면·위젯·내보내기 모두 이를 공유
### 4.2 다짐 진행률 (QuestProgress)

View File

@ -59,17 +59,21 @@ struct DiaryExportSheet: View {
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
}
/// ()
/// ().
/// UI DiaryEntry.dayKey
/// " " (startOfDay) .
/// Date dayKey(for:)
/// , .
private var targetEntries: [DiaryEntry] {
let keys: Set<Date>
switch mode {
case .range:
let startKey = math.dayKey(for: min(startDate, endDate))
let endKey = math.dayKey(for: max(startDate, endDate))
let startKey = math.calendar.startOfDay(for: min(startDate, endDate))
let endKey = math.calendar.startOfDay(for: max(startDate, endDate))
keys = Set(diaryEntries.map(\.dayKey).filter { $0 >= startKey && $0 <= endKey })
case .picks:
keys = Set(picks.compactMap { components in
math.calendar.date(from: components).map { math.dayKey(for: $0) }
math.calendar.date(from: components).map { math.calendar.startOfDay(for: $0) }
})
}
return diaryEntries

View File

@ -243,7 +243,9 @@ struct DiaryRootView: View {
}
private var monthSummary: some View {
let range = math.monthRange(containing: monthAnchor)
// monthAnchor '' monthRange(containing:)
// (= 1 )
let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound)
let count = entriesByDay.keys.filter { range.contains(math.dayRange(forKey: $0).lowerBound) }.count
return HStack(spacing: 6) {
Image(systemName: "pencil.and.scribble")
@ -419,7 +421,8 @@ private struct DiaryCalendarGrid: View {
/// ( nil)
private var cells: [Date?] {
let range = math.monthRange(containing: monthAnchor)
// monthAnchor( ) monthSummary
let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound)
let keys = math.dayKeys(in: range)
guard let first = keys.first else { return [] }
let firstWeekday = calendar.component(.weekday, from: first)