diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index 91fc0b5..7cd3219 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -468,6 +468,7 @@ enum ExportBuilder { static func stats( span: StatSpan, anchorDayKey: Date, + rolling: Bool = false, sessions: [TimeSession], entries: [CountEntry], orderedActions: [Action], @@ -481,13 +482,8 @@ enum ExportBuilder { let now = Date.now let agg = Aggregator(math: math) - let anchor = math.dayRange(forKey: anchorDayKey).lowerBound - let range: Range - switch span { - case .day: range = math.dayRange(containing: anchor) - case .week: range = math.weekRange(containing: anchor) - case .month: range = math.monthRange(containing: anchor) - } + // 통계 탭과 동일한 기간 계산 (rolling = '오늘 기준' 지난 7일/30일 — statsRange 참고) + let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math) let segments = collectSegments(sessions, allowed: allowed, range: range, now: now) let counts = collectCounts(entries, allowed: allowed, range: range) @@ -517,9 +513,10 @@ enum ExportBuilder { value: String(localized: "\(timeActions.count + countActions.count)개")), ] - // 월간 주 단위 범위 (주 시작 요일 설정 기준, 월 경계에서 잘림) + // 월간 주 단위 범위 (주 시작 요일 설정 기준, 월 경계에서 잘림). + // 롤링(지난 30일)은 창이 달력 월과 어긋나 "N주차"가 성립하지 않아 만들지 않는다 var weekRanges: [Range] = [] - if span == .month { + if span == .month && !rolling { var cursor = range.lowerBound while cursor < range.upperBound { let week = math.weekRange(containing: cursor) @@ -695,7 +692,7 @@ enum ExportBuilder { } func tableRow(name: String, color: Color, symbol: String, total: Double, type: TrackingType) -> ExportTableData.Row { var values = [valueLabel(total, type: type), averageLabel(total / Double(elapsedDays), type: type)] - if span == .month { + if span == .month && !rolling { values.append(averageLabel(total / Double(elapsedWeeks), type: type)) } return ExportTableData.Row(symbol: symbol, color: color, name: name, values: values) @@ -731,7 +728,7 @@ enum ExportBuilder { } guard !timeRows.isEmpty || !countRows.isEmpty else { return nil } var columns = [String(localized: "합계"), String(localized: "하루 평균")] - if span == .month { columns.append(String(localized: "주 평균")) } + if span == .month && !rolling { columns.append(String(localized: "주 평균")) } var groups: [ExportTableData.Group] = [] if !timeRows.isEmpty { groups.append(.init(label: String(localized: "시간"), rows: timeRows)) } if !countRows.isEmpty { groups.append(.init(label: String(localized: "횟수"), rows: countRows)) } @@ -751,8 +748,10 @@ enum ExportBuilder { sections += [ dailyLines(.time, title: String(localized: "행동별 시간 (일별)"), weekdayCategory: true), dailyLines(.count, title: String(localized: "행동별 횟수 (일별)"), weekdayCategory: true), - totalsBars(.time, title: String(localized: "주간 시간 합계")), - totalsBars(.count, title: String(localized: "주간 횟수 합계")), + totalsBars(.time, title: rolling + ? String(localized: "지난 7일 시간 합계") : String(localized: "주간 시간 합계")), + totalsBars(.count, title: rolling + ? String(localized: "지난 7일 횟수 합계") : String(localized: "주간 횟수 합계")), summaryTable(byTag: false, title: String(localized: "행동별 합계·평균")), summaryTable(byTag: true, title: String(localized: "꼬리표별 합계·평균")), ].compactMap(\.self) @@ -762,8 +761,10 @@ enum ExportBuilder { weeklyLines(.count, title: String(localized: "행동별 횟수 (주차별)")), dailyLines(.time, title: String(localized: "행동별 시간 (일별)"), weekdayCategory: false), dailyLines(.count, title: String(localized: "행동별 횟수 (일별)"), weekdayCategory: false), - totalsBars(.time, title: String(localized: "월간 시간 합계")), - totalsBars(.count, title: String(localized: "월간 횟수 합계")), + totalsBars(.time, title: rolling + ? String(localized: "지난 30일 시간 합계") : String(localized: "월간 시간 합계")), + totalsBars(.count, title: rolling + ? String(localized: "지난 30일 횟수 합계") : String(localized: "월간 횟수 합계")), summaryTable(byTag: false, title: String(localized: "행동별 합계·평균")), summaryTable(byTag: true, title: String(localized: "꼬리표별 합계·평균")), ].compactMap(\.self) @@ -776,21 +777,28 @@ enum ExportBuilder { title = String(localized: "하루 통계") periodLabel = Format.fullDate(anchorDayKey) case .week: - title = String(localized: "주간 통계") + title = rolling ? String(localized: "지난 7일 통계") : String(localized: "주간 통계") let keys = math.dayKeys(in: range) periodLabel = keys.isEmpty ? "" : "\(Format.shortDate(keys.first!)) ~ \(Format.shortDate(keys.last!))" case .month: - title = String(localized: "월간 통계") - periodLabel = anchorDayKey.formatted(.dateTime.year().month()) + title = rolling ? String(localized: "지난 30일 통계") : String(localized: "월간 통계") + if rolling { + let keys = math.dayKeys(in: range) + periodLabel = keys.isEmpty ? "" : "\(Format.shortDate(keys.first!)) ~ \(Format.shortDate(keys.last!))" + } else { + periodLabel = anchorDayKey.formatted(.dateTime.year().month()) + } } + // 파일명 구분: 롤링은 span 대신 last7/last30 슬러그 (달력 주기 내보내기와 헷갈리지 않게) + let slugKind = rolling ? (span == .week ? "last7" : "last30") : span.rawValue return ExportSnapshot( title: title, periodLabel: periodLabel, filterNote: note, hero: hero, sections: sections, - fileSlug: String(localized: "통계-\(span.rawValue)-\(slugDate(anchorDayKey))") + fileSlug: String(localized: "통계-\(slugKind)-\(slugDate(anchorDayKey))") ) } } diff --git a/myApp/HaruDanim/IOS/Views/StatsTabView.swift b/myApp/HaruDanim/IOS/Views/StatsTabView.swift index 4c946a3..6897306 100644 --- a/myApp/HaruDanim/IOS/Views/StatsTabView.swift +++ b/myApp/HaruDanim/IOS/Views/StatsTabView.swift @@ -12,6 +12,25 @@ import SwiftUI import SwiftData import Charts +/// 통계 탭 전용 기간 계산 (1.4 '오늘 기준' 롤링). +/// ⚠️ StatSpan enum 자체는 다짐 진행률(위젯 ②③④·워치·시리)과 공유되므로 케이스를 +/// 추가하지 말 것 — 롤링은 통계 표면에서만 이 함수·rolling 플래그로 처리한다. +func statsRange(span: StatSpan, anchorDayKey: Date, rolling: Bool, math: DayMath) -> Range { + let anchor = math.dayRange(forKey: anchorDayKey).lowerBound + switch span { + case .day: + return math.dayRange(containing: anchor) + case .week: + return rolling + ? math.rollingRange(endingAtKey: anchorDayKey, days: 7) + : math.weekRange(containing: anchor) + case .month: + return rolling + ? math.rollingRange(endingAtKey: anchorDayKey, days: 30) + : math.monthRange(containing: anchor) + } +} + struct StatsTabView: View { @Environment(AppRouter.self) private var router @Query(sort: \Tag.sortOrder) private var tags: [Tag] @@ -36,19 +55,19 @@ struct StatsTabView: View { @State private var anchorDayKey: Date = DayMath().dayKey(for: .now) @State private var showingFilter = false @State private var showingExport = false + /// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일). 표시 설정이라 기기 로컬 + @AppStorage(SettingsKeys.statsRollingWindow) private var rollingWindow = false /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동도 자동 포함됨. /// 꼬리표는 시트에서 소속 행동 일괄 토글용일 뿐, 판정은 행동 단위로만 한다. @State private var excludedActionIDs: Set = [] private var math: DayMath { DayMath() } + /// 하루 span에는 롤링 개념이 없다 + private var isRolling: Bool { rollingWindow && span != .day } + private var spanRange: Range { - let anchor = math.dayRange(forKey: anchorDayKey).lowerBound - switch span { - case .day: return math.dayRange(containing: anchor) - case .week: return math.weekRange(containing: anchor) - case .month: return math.monthRange(containing: anchor) - } + statsRange(span: span, anchorDayKey: anchorDayKey, rolling: isRolling, math: math) } var body: some View { @@ -63,6 +82,20 @@ struct StatsTabView: View { .padding(.horizontal) .padding(.bottom, 8) + // 주간/월간의 '오늘 기준' 롤링 토글 (1.4) — 월초·주초에 달력 주기가 거의 + // 비어 보이는 허전함의 해소. 하루 세그먼트에서는 의미가 없어 숨긴다 + if span != .day { + Toggle(isOn: $rollingWindow.animation()) { + Text(span == .week ? "지난 7일 단위로 보기" : "지난 30일 단위로 보기") + .font(.caption.weight(.semibold)) + } + .toggleStyle(.switch) + .controlSize(.mini) + .tint(AppTheme.green) + .padding(.horizontal) + .padding(.bottom, 8) + } + if isFiltering { filterChip } @@ -72,6 +105,7 @@ struct StatsTabView: View { StatsChartsView( span: span, anchorDayKey: anchorDayKey, + rolling: isRolling, excludedActionIDs: excludedActionIDs, orderedActions: allActions, showingExport: $showingExport @@ -121,6 +155,10 @@ struct StatsTabView: View { if UserDefaults.standard.bool(forKey: "statShowFilter") { showingFilter = true } + // 검증용: -statRolling YES → '오늘 기준' 롤링 토글 켠 상태로 시작 + if UserDefaults.standard.bool(forKey: "statRolling") { + rollingWindow = true + } // 검증용: -showExport YES → 내보내기 시트 표시 if UserDefaults.standard.bool(forKey: "showExport") { showingExport = true @@ -187,8 +225,8 @@ struct StatsTabView: View { private var currentPeriodLabel: String { switch span { case .day: return String(localized: "오늘") - case .week: return String(localized: "이번 주") - case .month: return String(localized: "이번 달") + case .week: return isRolling ? String(localized: "최근 7일") : String(localized: "이번 주") + case .month: return isRolling ? String(localized: "최근 30일") : String(localized: "이번 달") } } @@ -201,6 +239,10 @@ struct StatsTabView: View { guard let first = keys.first, let last = keys.last else { return "" } return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))" case .month: + if isRolling { + guard let first = keys.first, let last = keys.last else { return "" } + return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))" + } return anchorDayKey.formatted(.dateTime.year().month()) } } @@ -213,7 +255,12 @@ struct StatsTabView: View { case .week: anchorDayKey = cal.date(byAdding: .day, value: 7 * delta, to: anchorDayKey)! case .month: - anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)! + // 롤링(지난 30일)은 30일씩, 달력 월은 한 달씩 점프 + if isRolling { + anchorDayKey = cal.date(byAdding: .day, value: 30 * delta, to: anchorDayKey)! + } else { + anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)! + } } } @@ -279,6 +326,8 @@ struct StatsTabView: View { private struct StatsChartsView: View { let span: StatSpan let anchorDayKey: Date + /// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일 — 하루 span에서는 항상 false) + let rolling: Bool let excludedActionIDs: Set let orderedActions: [Action] @Binding var showingExport: Bool @@ -288,23 +337,18 @@ private struct StatsChartsView: View { private let math = DayMath() - init(span: StatSpan, anchorDayKey: Date, + init(span: StatSpan, anchorDayKey: Date, rolling: Bool, excludedActionIDs: Set, orderedActions: [Action], showingExport: Binding) { self.span = span self.anchorDayKey = anchorDayKey + self.rolling = rolling self.excludedActionIDs = excludedActionIDs self.orderedActions = orderedActions self._showingExport = showingExport let math = DayMath() - let anchor = math.dayRange(forKey: anchorDayKey).lowerBound - let range: Range - switch span { - case .day: range = math.dayRange(containing: anchor) - case .week: range = math.weekRange(containing: anchor) - case .month: range = math.monthRange(containing: anchor) - } + let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math) let lower = range.lowerBound let upper = range.upperBound let farFuture = Date.distantFuture @@ -317,18 +361,19 @@ private struct StatsChartsView: View { } private var spanRange: Range { - let anchor = math.dayRange(forKey: anchorDayKey).lowerBound - switch span { - case .day: return math.dayRange(containing: anchor) - case .week: return math.weekRange(containing: anchor) - case .month: return math.monthRange(containing: anchor) - } + statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math) } // MARK: 이전 기간 비교 (화면 전용 — 내보내기 리포트에는 넣지 않는다, §6.6) - /// 현재 기간 바로 앞의 같은 단위 기간 (경계 직전 시각이 속한 하루/주/달) + /// 현재 기간 바로 앞의 같은 단위 기간 (롤링은 바로 앞 7일/30일 창) private var previousRange: Range { + if rolling { + let days = span == .week ? 7 : 30 + let prevEndKey = math.calendar.date(byAdding: .day, value: -days, to: anchorDayKey) + ?? anchorDayKey + return math.rollingRange(endingAtKey: prevEndKey, days: days) + } let justBefore = spanRange.lowerBound.addingTimeInterval(-1) switch span { case .day: return math.dayRange(containing: justBefore) @@ -353,6 +398,9 @@ private struct StatsChartsView: View { } private var previousPeriodTitle: LocalizedStringKey { + if rolling { + return span == .week ? "이전 7일과 비교" : "이전 30일과 비교" + } switch span { case .day: return "어제와 비교" case .week: return "지난주와 비교" @@ -483,6 +531,7 @@ private struct StatsChartsView: View { ExportImageSheet(snapshot: ExportBuilder.stats( span: span, anchorDayKey: anchorDayKey, + rolling: rolling, sessions: sessions, entries: entries, orderedActions: orderedActions, @@ -663,12 +712,23 @@ private struct StatsChartsView: View { // MARK: 주간 — 행동별 일별 꺾은선 + 주간 합계 막대 + /// 합계 막대 제목 — 롤링이면 "지난 7일/30일", 달력 주기면 "주간/월간" + private var totalsTimeTitle: LocalizedStringKey { + if rolling { return span == .week ? "지난 7일 시간 합계" : "지난 30일 시간 합계" } + return span == .week ? "주간 시간 합계" : "월간 시간 합계" + } + + private var totalsCountTitle: LocalizedStringKey { + if rolling { return span == .week ? "지난 7일 횟수 합계" : "지난 30일 횟수 합계" } + return span == .week ? "주간 횟수 합계" : "월간 횟수 합계" + } + @ViewBuilder private func weekCharts(_ data: StatsData) -> some View { dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true, data: data) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true, data: data) - totalsBarCard("주간 시간 합계", type: .time, data: data) - totalsBarCard("주간 횟수 합계", type: .count, data: data) + totalsBarCard(totalsTimeTitle, type: .time, data: data) + totalsBarCard(totalsCountTitle, type: .count, data: data) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time, data: data), @@ -685,12 +745,16 @@ private struct StatsChartsView: View { @ViewBuilder private func monthCharts(_ data: StatsData) -> some View { - weeklyLineCard("행동별 시간 (주차별)", type: .time, data: data) - weeklyLineCard("행동별 횟수 (주차별)", type: .count, data: data) + // 롤링(지난 30일)은 창이 달력 월과 어긋나 "N주차" 표기가 성립하지 않는다 — + // 주차별 카드 없이 일별 선 + 합계 + 표 구성 (확장된 주간 보기와 동일한 문법) + if !rolling { + weeklyLineCard("행동별 시간 (주차별)", type: .time, data: data) + weeklyLineCard("행동별 횟수 (주차별)", type: .count, data: data) + } dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false, data: data) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false, data: data) - totalsBarCard("월간 시간 합계", type: .time, data: data) - totalsBarCard("월간 횟수 합계", type: .count, data: data) + totalsBarCard(totalsTimeTitle, type: .time, data: data) + totalsBarCard(totalsCountTitle, type: .count, data: data) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time, data: data), @@ -981,7 +1045,7 @@ private struct StatsChartsView: View { .gridColumnAlignment(.trailing) Text("하루 평균") .gridColumnAlignment(.trailing) - if span == .month { + if span == .month && !rolling { Text("주 평균") .gridColumnAlignment(.trailing) } @@ -991,7 +1055,7 @@ private struct StatsChartsView: View { ForEach(rows) { row in GridRow { HStack(spacing: 8) { - Image(systemName: row.symbol) + Image(safeSymbol: row.symbol) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) .frame(width: 24, height: 24) @@ -1007,7 +1071,7 @@ private struct StatsChartsView: View { Text(averageLabel(row.total / Double(elapsedDayCount), type: row.type)) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) - if span == .month { + if span == .month && !rolling { Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type)) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) diff --git a/myApp/HaruDanim/Shared/DayMath.swift b/myApp/HaruDanim/Shared/DayMath.swift index cefb771..c8f55f2 100644 --- a/myApp/HaruDanim/Shared/DayMath.swift +++ b/myApp/HaruDanim/Shared/DayMath.swift @@ -54,6 +54,13 @@ struct DayMath { return dayRange(forKey: key).lowerBound.. Range { + let startKey = calendar.date(byAdding: .day, value: -(days - 1), to: key) ?? key + return dayRange(forKey: startKey).lowerBound.. Range { let key = dayKey(for: date) diff --git a/myApp/HaruDanim/Shared/Settings.swift b/myApp/HaruDanim/Shared/Settings.swift index b7f2370..173949d 100644 --- a/myApp/HaruDanim/Shared/Settings.swift +++ b/myApp/HaruDanim/Shared/Settings.swift @@ -35,6 +35,9 @@ enum SettingsKeys { static let radialTabOrder = "settings.radialTabOrder" /// 글래스 버블 FAB '두 번 눌러 바로 이동' 대상 탭. AppTab rawValue, 빈 값 = 꺼짐. 기기 로컬(비동기화) static let radialDoubleTapTab = "settings.radialDoubleTapTab" + /// 통계 탭 주간/월간을 달력 주기 대신 '오늘 기준 지난 7일/30일'로 보기 (1.4). + /// 표시 설정이라 기기 로컬(standard defaults) — 위젯 ⑤는 자체 파라미터를 쓴다 + static let statsRollingWindow = "stats.rollingWindow" } /// iPhone 전용 내비게이션 방식 (iPad·Mac 사이드바에는 적용되지 않음, 기기 로컬 설정) diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index b13f54c..ee54dbe 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -19,12 +19,18 @@ enum StatsChartSpan: String, AppEnum { case week case monthByDay case monthByWeek + // 1.4 '오늘 기준' 롤링 — 달력 주기(주초·월초에 그래프가 거의 빈다) 대신 + // 오늘로 끝나는 지난 7일/30일 창. 기존 배치된 위젯의 raw 값은 그대로라 안전 + case last7 + case last30 static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "통계 기간") static let caseDisplayRepresentations: [StatsChartSpan: DisplayRepresentation] = [ .week: "일주일 (하루 단위)", .monthByDay: "한 달 (하루 단위, 중형 이상)", .monthByWeek: "한 달 (주 단위)", + .last7: "지난 7일 (오늘 기준)", + .last30: "지난 30일 (오늘 기준, 중형 이상)", ] var title: String { @@ -32,6 +38,8 @@ enum StatsChartSpan: String, AppEnum { case .week: return String(localized: "일주일 통계") case .monthByDay: return String(localized: "한 달 통계 (일별)") case .monthByWeek: return String(localized: "한 달 통계 (주별)") + case .last7: return String(localized: "지난 7일 통계") + case .last30: return String(localized: "지난 30일 통계") } } } @@ -91,9 +99,11 @@ struct StatsChartProvider: AppIntentTimelineProvider { return StatsChartEntry(date: .now, locked: true, theme: configuration.theme, title: "", isWeekAxis: false, isTimeType: true, names: [], colorHexes: [], points: []) } - // 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체 + // 소형은 하루 단위 30점이 과밀 — "한 달 (하루 단위)"는 주 단위로, + // "지난 30일"은 지난 7일로 대체 (설정 문구에 '중형 이상' 명시) var span = configuration.span if family == .systemSmall && span == .monthByDay { span = .monthByWeek } + if family == .systemSmall && span == .last30 { span = .last7 } let actions = Array(WidgetStore.selectedActions(configuration.actions, defaultCount: Self.maxActions(family)) @@ -115,8 +125,14 @@ struct StatsChartProvider: AppIntentTimelineProvider { } switch span { - case .week, .monthByDay: - let range = span == .week ? math.weekRange(containing: now) : math.monthRange(containing: now) + case .week, .monthByDay, .last7, .last30: + let range: Range + switch span { + case .week: range = math.weekRange(containing: now) + case .monthByDay: range = math.monthRange(containing: now) + case .last7: range = math.rollingRange(endingAtKey: math.dayKey(for: now), days: 7) + default: range = math.rollingRange(endingAtKey: math.dayKey(for: now), days: 30) + } for key in math.dayKeys(in: range) { let dayRange = math.dayRange(forKey: key) for (action, seriesName) in zip(actions, seriesNames) {