From 05b2d392c81746d8db210804886fc3a04d4b6867 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Wed, 15 Jul 2026 16:07:05 +0900 Subject: [PATCH] fix(diary): calendar month drift + export date picks off-by-one at custom day start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '하루 시작 시간'이 자정보다 늦게 설정된 경우의 논리적 하루 경계 버그 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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/CLAUDE.md | 1 + myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift | 12 ++++++++---- myApp/HaruDanim/IOS/Views/DiaryView.swift | 7 +++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 4437598..13934d3 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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) diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index fc01299..0241b81 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -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 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 diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index c46cdea..3b0e64f 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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)