feat(stats): '오늘 기준' 롤링 보기 — 주간→지난 7일·월간→지난 30일 (탭·⑤ 위젯·내보내기)

- 통계 탭: 주간/월간 세그먼트 아래 '지난 7일/30일 단위로 보기' 토글(기기 로컬 stats.rollingWindow)
- StatSpan enum 불변(다짐 진행률 공유) — statsRange 헬퍼·rolling 플래그로 통계 표면만 처리
- 비교 카드 '이전 7일/30일과 비교', 화살표 7/30일 점프, 롤링 월간은 주차별 카드·주 평균 열 제외
- 위젯 ⑤: StatsChartSpan에 last7/last30 추가(소형은 last30→last7 강등), DayMath.rollingRange 공유
- 통계 내보내기: rolling 반영(제목·기간·합계 문구·last7/last30 슬러그)
- -statRolling 검증 인자, 주간·월간 롤링 화면 시뮬 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
songyc macbook 2026-08-11 16:44:51 +09:00
parent 6c3b918311
commit e7868f453c
5 changed files with 153 additions and 55 deletions

View File

@ -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<Date>
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<Date>] = []
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))")
)
}
}

View File

@ -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<Date> {
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<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
/// span
private var isRolling: Bool { rollingWindow && span != .day }
private var spanRange: Range<Date> {
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<PersistentIdentifier>
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<PersistentIdentifier>, orderedActions: [Action],
showingExport: Binding<Bool>) {
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<Date>
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<Date> {
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<Date> {
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)

View File

@ -54,6 +54,13 @@ struct DayMath {
return dayRange(forKey: key).lowerBound..<dayRange(forKey: lastKey).upperBound
}
/// ' ' (1.4): `days` .
/// (· · )
func rollingRange(endingAtKey key: Date, days: Int) -> Range<Date> {
let startKey = calendar.date(byAdding: .day, value: -(days - 1), to: key) ?? key
return dayRange(forKey: startKey).lowerBound..<dayRange(forKey: key).upperBound
}
/// `date`
func monthRange(containing date: Date) -> Range<Date> {
let key = dayKey(for: date)

View File

@ -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 , )

View File

@ -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<Date>
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) {