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:
parent
6c3b918311
commit
e7868f453c
@ -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))")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 사이드바에는 적용되지 않음, 기기 로컬 설정)
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user