fix(review): 전체 코드 리뷰 1차 — 데이터 복구·통계 표시·기록 편집 결함 수정
'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.
[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
(이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
— 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
in: 은 표시 제약만) — 저장 시 정규화
+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)
검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
28a453485d
commit
c18e3c7c6f
@ -142,7 +142,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
## 5. 데이터 계층
|
||||
|
||||
### 5.1 저장소 (`Shared/DataStore.swift`)
|
||||
- 스토어 파일: App Group 컨테이너의 `HaruDanim.store` (위젯·인텐트가 같은 DB 사용). 구 샌드박스 `default.store`는 1회 이관
|
||||
- 스토어 파일: App Group 컨테이너의 `HaruDanim.store` (위젯·인텐트가 같은 DB 사용). 구 샌드박스 `default.store`는 1회 이관 — **"1회"는 App Group 플래그(`migration.legacyStoreImported`)로 보장**: 구 스토어가 copyItem이라 영구 잔존하므로, 스토어 손상 백업 복구 직후 재이관으로 옛 데이터가 부활(조용한 롤백)하는 것을 플래그가 막는다(2026-07-22 전체 리뷰에서 수정)
|
||||
- **CloudKit 미러링은 메인 앱 프로세스만**. 위젯 확장(.appex)은 같은 파일을 로컬 전용으로 열음. 확장의 쓰기는 메인 앱이 원격 변경 알림으로 받아 내보냄
|
||||
- iCloud 동기화 = 프리미엄 + `settings.cloudSync` 토글 (앱 재시작 시 적용). 실패 시 로컬 폴백
|
||||
- **원격 변경 가져오기(import)는 무음 푸시가 트리거** — `IOS/Info.plist`의 `UIBackgroundModes: remote-notification`(+`aps-environment` 엔타이틀먼트) 필수. 이 모드가 없으면 앱 실행/포그라운드 복귀 때만 가져와서, 다른 기기의 변경이 사용 중에는 절대 안 보이고 새로고침 버튼도 무용지물이 된다(수정된 버그). 가져오기를 코드로 강제하는 공식 API는 없음. iOS 타깃 Info.plist는 `GENERATE_INFOPLIST_FILE=YES` + `INFOPLIST_FILE=IOS/Info.plist` 병합 방식(파일에는 생성 설정에 없는 키만 넣음, IOS 동기화 그룹의 membershipException으로 리소스 복사 제외)
|
||||
@ -356,3 +356,4 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
8. 위젯에서 흰색 하드코딩 금지 — `widgetRenderingMode` 분기 (§10)
|
||||
9. 시뮬레이터 검증 스크린샷은 `xcrun simctl io <sim> screenshot`, 런치 인자는 콜드 스타트로
|
||||
10. **아이콘 전용 버튼에는 `accessibilityLabel` 필수** (보이스오버가 "버튼"으로만 읽는 것 방지). 여러 요소로 된 셀은 label+value로 한 덩어리 낭독(모음 탭 행동 셀 참고), 타임테이블 블록·점처럼 텍스트 없는 시각 요소는 verbatim 라벨(이름+시각)로
|
||||
11. **서브 에이전트(Agent/Workflow 병렬 분업) 사용 금지 — 사용자 지시(2026-07-22)**: 병렬 리뷰 에이전트 8개가 세션 한도를 급격히 소모한 전례. 검토·수정·검증 등 모든 작업은 오래 걸리더라도 세션 본체가 직접 수행할 것
|
||||
|
||||
@ -16,6 +16,11 @@ import UserNotifications
|
||||
enum SessionAlertManager {
|
||||
private static let idPrefix = "longSession-"
|
||||
|
||||
/// 연속 호출 경합 방어 — 시작 직후 종료(위젯 버튼 연타 등)로 sync가 겹치면,
|
||||
/// 앞선 호출의 '조회(await)→추가' 사이에 뒤 호출이 끼어들어 종료된 세션의
|
||||
/// 예약이 살아남을 수 있다. 항상 마지막 호출만 예약을 확정한다.
|
||||
private static var generation = 0
|
||||
|
||||
/// 설정값(시간). 0 = 사용 안 함
|
||||
static var alertHours: Int {
|
||||
if let group = AppGroup.defaults.object(forKey: SettingsKeys.longSessionAlertHours) as? Int {
|
||||
@ -69,6 +74,8 @@ enum SessionAlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
generation += 1
|
||||
let gen = generation
|
||||
Task {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
#if DEBUG
|
||||
@ -79,6 +86,8 @@ enum SessionAlertManager {
|
||||
var addErrors: [String] = []
|
||||
#endif
|
||||
let pending = await center.pendingNotificationRequests()
|
||||
// 조회하는 사이 더 새로운 sync가 시작됐으면 이 결과는 낡았다 — 그쪽에 맡긴다
|
||||
guard gen == generation else { return }
|
||||
let existing = Set(pending.map(\.identifier).filter { $0.hasPrefix(idPrefix) })
|
||||
let desired = Set(planned.map(\.id))
|
||||
let stale = existing.subtracting(desired)
|
||||
@ -86,6 +95,7 @@ enum SessionAlertManager {
|
||||
center.removePendingNotificationRequests(withIdentifiers: Array(stale))
|
||||
}
|
||||
for plan in planned where !existing.contains(plan.id) {
|
||||
guard gen == generation else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "측정이 계속되고 있어요")
|
||||
content.body = String(localized: "'\(plan.name)' 측정이 \(hours)시간을 넘겼어요. 종료를 잊으셨다면 열어서 꺼 주세요.")
|
||||
|
||||
@ -2281,24 +2281,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"기록이 있는 %@:00 ~ %@:00 구간만 표시" : {
|
||||
"기록이 있는 %@:%@ ~ %@:%@ 구간만 표시" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Showing only %@:00 – %@:00 with records"
|
||||
"value" : "Showing only %@:%@ ~ %@:%@ where records exist"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "記録のある%@:00〜%@:00のみ表示"
|
||||
"value" : "記録がある%@:%@〜%@:%@の区間のみ表示"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "기록이 있는 %1$@:00 ~ %2$@:00 구간만 표시"
|
||||
"value" : "기록이 있는 %1$@:%2$@ ~ %3$@:%4$@ 구간만 표시"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9437,18 +9437,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"종료 시각이 시작 시각보다 빨라요." : {
|
||||
"종료 시각이 시작 시각보다 빠르거나 같아요." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The end time is earlier than the start time."
|
||||
"value" : "The end time is earlier than or the same as the start time."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "終了時刻が開始時刻より前になっています。"
|
||||
"value" : "終了時刻が開始時刻より早いか同じです。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,6 +144,8 @@ struct ExportTimetableData {
|
||||
|
||||
/// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24
|
||||
var startHour: Int
|
||||
/// 하루 시작 시각의 분 성분 — 0이 아니면(정시 아닌 하루 시작) 라벨을 "HH:mm"으로
|
||||
var startMinute: Int = 0
|
||||
/// 기록이 없는 위·아래 구간을 잘라낸 표시 범위 (오프셋 시간)
|
||||
var hourLo: Int
|
||||
var hourHi: Int
|
||||
@ -430,11 +432,14 @@ enum ExportBuilder {
|
||||
))
|
||||
}
|
||||
|
||||
let startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound)
|
||||
let dayStart = math.dayRange(forKey: firstKey).lowerBound
|
||||
let startHour = math.calendar.component(.hour, from: dayStart)
|
||||
let startMinute = math.calendar.component(.minute, from: dayStart)
|
||||
|
||||
// 일기 요약처럼 하루 전체를 고정으로 보여줄 때: 기록이 없어도 24시간 그리드를 그대로 반환
|
||||
if !trimHours {
|
||||
return (ExportTimetableData(startHour: startHour, hourLo: 0, hourHi: 24, days: days), nil)
|
||||
return (ExportTimetableData(startHour: startHour, startMinute: startMinute,
|
||||
hourLo: 0, hourHi: 24, days: days), nil)
|
||||
}
|
||||
|
||||
guard minFrac.isFinite else { return nil }
|
||||
@ -446,11 +451,14 @@ enum ExportBuilder {
|
||||
hourHi = min(24, hourLo + 6)
|
||||
hourLo = max(0, hourHi - 6)
|
||||
}
|
||||
// 정시 아닌 하루 시작도 라벨이 실제 경계 시각을 말하도록 분 성분 포함 (화면 축과 동일 규칙)
|
||||
let mm = String(format: "%02d", startMinute)
|
||||
let subtitle: String? = (hourLo > 0 || hourHi < 24)
|
||||
? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):00 ~ \(String(format: "%02d", (startHour + hourHi) % 24)):00 구간만 표시")
|
||||
? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):\(mm) ~ \(String(format: "%02d", (startHour + hourHi) % 24)):\(mm) 구간만 표시")
|
||||
: nil
|
||||
return (
|
||||
ExportTimetableData(startHour: startHour, hourLo: hourLo, hourHi: hourHi, days: days),
|
||||
ExportTimetableData(startHour: startHour, startMinute: startMinute,
|
||||
hourLo: hourLo, hourHi: hourHi, days: days),
|
||||
subtitle
|
||||
)
|
||||
}
|
||||
@ -522,13 +530,15 @@ enum ExportBuilder {
|
||||
|
||||
func totalsBars(_ type: TrackingType, title: String) -> ExportSectionData? {
|
||||
let actions = type == .time ? timeActions : countActions
|
||||
let items = actions
|
||||
.map { action -> ExportBarItem in
|
||||
// 동명 행동 구분 표시 이름 (통계 탭·⑤ 위젯과 동일 규칙)
|
||||
let names = Format.disambiguated(actions.map(\.name))
|
||||
let items = zip(actions, names)
|
||||
.map { action, name -> ExportBarItem in
|
||||
let value: Double = type == .time
|
||||
? agg.seconds(for: action, in: range, now: now)
|
||||
: Double(agg.count(for: action, in: range))
|
||||
return ExportBarItem(
|
||||
name: action.name,
|
||||
name: name,
|
||||
value: type == .time ? value / 3600 : value,
|
||||
valueLabel: type == .time
|
||||
? Format.durationShort(value)
|
||||
@ -548,6 +558,7 @@ enum ExportBuilder {
|
||||
func dailyLines(_ type: TrackingType, title: String, weekdayCategory: Bool) -> ExportSectionData? {
|
||||
let actions = type == .time ? timeActions : countActions
|
||||
guard !actions.isEmpty else { return nil }
|
||||
let seriesNames = Format.disambiguated(actions.map(\.name))
|
||||
let keys = math.dayKeys(in: range)
|
||||
var points: [ExportLineChartData.Point] = []
|
||||
var categories: [String] = []
|
||||
@ -555,14 +566,14 @@ enum ExportBuilder {
|
||||
let dayRange = math.dayRange(forKey: key)
|
||||
let category = Format.weekdayShort(math.calendar.component(.weekday, from: key))
|
||||
if weekdayCategory { categories.append(category) }
|
||||
for action in actions {
|
||||
for (action, seriesName) in zip(actions, seriesNames) {
|
||||
let value: Double = type == .time
|
||||
? agg.seconds(for: action, in: dayRange, now: now) / 3600
|
||||
: Double(agg.count(for: action, in: dayRange))
|
||||
points.append(ExportLineChartData.Point(
|
||||
date: weekdayCategory ? nil : key,
|
||||
category: weekdayCategory ? category : nil,
|
||||
series: action.name,
|
||||
series: seriesName,
|
||||
value: value
|
||||
))
|
||||
}
|
||||
@ -570,7 +581,7 @@ enum ExportBuilder {
|
||||
return .lines(ExportLineChartData(
|
||||
title: title,
|
||||
yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
||||
seriesNames: actions.map(\.name),
|
||||
seriesNames: seriesNames,
|
||||
seriesColors: actions.map(\.color),
|
||||
points: points,
|
||||
xAxis: weekdayCategory ? .category(order: categories) : .days
|
||||
@ -580,71 +591,85 @@ enum ExportBuilder {
|
||||
func weeklyLines(_ type: TrackingType, title: String) -> ExportSectionData? {
|
||||
let actions = type == .time ? timeActions : countActions
|
||||
guard !actions.isEmpty, !weekRanges.isEmpty else { return nil }
|
||||
let seriesNames = Format.disambiguated(actions.map(\.name))
|
||||
var points: [ExportLineChartData.Point] = []
|
||||
var categories: [String] = []
|
||||
for (index, weekRange) in weekRanges.enumerated() {
|
||||
let label = String(localized: "\(index + 1)주차")
|
||||
categories.append(label)
|
||||
for action in actions {
|
||||
for (action, seriesName) in zip(actions, seriesNames) {
|
||||
let value: Double = type == .time
|
||||
? agg.seconds(for: action, in: weekRange, now: now) / 3600
|
||||
: Double(agg.count(for: action, in: weekRange))
|
||||
points.append(ExportLineChartData.Point(
|
||||
date: nil, category: label, series: action.name, value: value
|
||||
date: nil, category: label, series: seriesName, value: value
|
||||
))
|
||||
}
|
||||
}
|
||||
return .lines(ExportLineChartData(
|
||||
title: title,
|
||||
yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
||||
seriesNames: actions.map(\.name),
|
||||
seriesNames: seriesNames,
|
||||
seriesColors: actions.map(\.color),
|
||||
points: points,
|
||||
xAxis: .category(order: categories)
|
||||
))
|
||||
}
|
||||
|
||||
// 꼬리표별 집계 (통계 탭 tagStats와 동일 규칙: 꼬리표 없는 행동은 "꼬리표 없음")
|
||||
// 꼬리표별 집계 (통계 탭 tagStats와 동일 규칙: 꼬리표 없는 행동은 "꼬리표 없음").
|
||||
// 키는 꼬리표 identity(nil = '꼬리표 없음') — 동명 꼬리표가 이름 키로 합산되는 것 방지,
|
||||
// 표시 이름은 마지막에 Format.disambiguated로 구분한다 (통계 탭과 동일 규칙)
|
||||
struct TagTotal {
|
||||
var name: String
|
||||
var color: Color
|
||||
var seconds: TimeInterval = 0
|
||||
var count: Int = 0
|
||||
}
|
||||
var tagTotals: [String: TagTotal] = [:]
|
||||
func tagNames(for action: Action) -> [(String, Color)] {
|
||||
if action.tags.isEmpty { return [(String(localized: "꼬리표 없음"), Color.gray)] }
|
||||
return action.sortedTags.map { ($0.name, $0.color) }
|
||||
var tagAccs: [PersistentIdentifier?: TagTotal] = [:]
|
||||
var tagOrder: [PersistentIdentifier?] = []
|
||||
func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] {
|
||||
if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] }
|
||||
return action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
|
||||
}
|
||||
for item in segments {
|
||||
for (name, color) in tagNames(for: item.action) {
|
||||
tagTotals[name, default: TagTotal(color: color)].seconds += item.duration
|
||||
tagTotals[name]?.color = color
|
||||
func accumulate(_ action: Action, seconds: TimeInterval, count: Int) {
|
||||
for (key, name, color) in tagKeys(for: action) {
|
||||
if tagAccs[key] == nil {
|
||||
tagAccs[key] = TagTotal(name: name, color: color)
|
||||
tagOrder.append(key)
|
||||
}
|
||||
tagAccs[key]?.seconds += seconds
|
||||
tagAccs[key]?.count += count
|
||||
}
|
||||
}
|
||||
for item in counts {
|
||||
for (name, color) in tagNames(for: item.action) {
|
||||
tagTotals[name, default: TagTotal(color: color)].count += item.entry.amount
|
||||
tagTotals[name]?.color = color
|
||||
for item in segments { accumulate(item.action, seconds: item.duration, count: 0) }
|
||||
for item in counts { accumulate(item.action, seconds: 0, count: item.entry.amount) }
|
||||
let orderedTagTotals: [TagTotal] = {
|
||||
let ordered = tagOrder.compactMap { tagAccs[$0] }
|
||||
let names = Format.disambiguated(ordered.map(\.name))
|
||||
return zip(ordered, names).map { acc, name in
|
||||
var copy = acc
|
||||
copy.name = name
|
||||
return copy
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
func tagBars(_ type: TrackingType, title: String) -> ExportSectionData? {
|
||||
let items: [ExportBarItem]
|
||||
switch type {
|
||||
case .time:
|
||||
items = tagTotals
|
||||
.filter { $0.value.seconds > 0 }
|
||||
.sorted { $0.value.seconds > $1.value.seconds }
|
||||
.map { name, total in
|
||||
ExportBarItem(name: name, value: total.seconds / 3600,
|
||||
items = orderedTagTotals
|
||||
.filter { $0.seconds > 0 }
|
||||
.sorted { $0.seconds > $1.seconds }
|
||||
.map { total in
|
||||
ExportBarItem(name: total.name, value: total.seconds / 3600,
|
||||
valueLabel: Format.durationShort(total.seconds), color: total.color)
|
||||
}
|
||||
case .count:
|
||||
items = tagTotals
|
||||
.filter { $0.value.count > 0 }
|
||||
.sorted { $0.value.count > $1.value.count }
|
||||
.map { name, total in
|
||||
ExportBarItem(name: name, value: Double(total.count),
|
||||
items = orderedTagTotals
|
||||
.filter { $0.count > 0 }
|
||||
.sorted { $0.count > $1.count }
|
||||
.map { total in
|
||||
ExportBarItem(name: total.name, value: Double(total.count),
|
||||
valueLabel: String(localized: "\(total.count)회"), color: total.color)
|
||||
}
|
||||
}
|
||||
@ -680,27 +705,29 @@ enum ExportBuilder {
|
||||
var timeRows: [ExportTableData.Row] = []
|
||||
var countRows: [ExportTableData.Row] = []
|
||||
if byTag {
|
||||
timeRows = tagTotals
|
||||
.filter { $0.value.seconds > 0 }
|
||||
.sorted { $0.value.seconds > $1.value.seconds }
|
||||
.map { tableRow(name: $0.key, color: $0.value.color, symbol: "tag.fill",
|
||||
total: $0.value.seconds, type: .time) }
|
||||
countRows = tagTotals
|
||||
.filter { $0.value.count > 0 }
|
||||
.sorted { $0.value.count > $1.value.count }
|
||||
.map { tableRow(name: $0.key, color: $0.value.color, symbol: "tag.fill",
|
||||
total: Double($0.value.count), type: .count) }
|
||||
timeRows = orderedTagTotals
|
||||
.filter { $0.seconds > 0 }
|
||||
.sorted { $0.seconds > $1.seconds }
|
||||
.map { tableRow(name: $0.name, color: $0.color, symbol: "tag.fill",
|
||||
total: $0.seconds, type: .time) }
|
||||
countRows = orderedTagTotals
|
||||
.filter { $0.count > 0 }
|
||||
.sorted { $0.count > $1.count }
|
||||
.map { tableRow(name: $0.name, color: $0.color, symbol: "tag.fill",
|
||||
total: Double($0.count), type: .count) }
|
||||
} else {
|
||||
timeRows = timeActions
|
||||
.map { ($0, agg.seconds(for: $0, in: range, now: now)) }
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map { tableRow(name: $0.0.name, color: $0.0.color, symbol: $0.0.symbolName,
|
||||
total: $0.1, type: .time) }
|
||||
countRows = countActions
|
||||
.map { ($0, Double(agg.count(for: $0, in: range))) }
|
||||
.sorted { $0.1 > $1.1 }
|
||||
.map { tableRow(name: $0.0.name, color: $0.0.color, symbol: $0.0.symbolName,
|
||||
total: $0.1, type: .count) }
|
||||
let timeNames = Format.disambiguated(timeActions.map(\.name))
|
||||
timeRows = zip(timeActions, timeNames)
|
||||
.map { ($0, $1, agg.seconds(for: $0, in: range, now: now)) }
|
||||
.sorted { $0.2 > $1.2 }
|
||||
.map { tableRow(name: $0.1, color: $0.0.color, symbol: $0.0.symbolName,
|
||||
total: $0.2, type: .time) }
|
||||
let countNames = Format.disambiguated(countActions.map(\.name))
|
||||
countRows = zip(countActions, countNames)
|
||||
.map { ($0, $1, Double(agg.count(for: $0, in: range))) }
|
||||
.sorted { $0.2 > $1.2 }
|
||||
.map { tableRow(name: $0.1, color: $0.0.color, symbol: $0.0.symbolName,
|
||||
total: $0.2, type: .count) }
|
||||
}
|
||||
guard !timeRows.isEmpty || !countRows.isEmpty else { return nil }
|
||||
var columns = [String(localized: "합계"), String(localized: "하루 평균")]
|
||||
@ -1089,7 +1116,7 @@ private struct ExportTimetableView: View {
|
||||
private var weekly: Bool { data.days.count > 1 }
|
||||
private var hourHeight: CGFloat { weekly ? 18 : 26 }
|
||||
private var hours: Int { max(data.hourHi - data.hourLo, 1) }
|
||||
private let labelWidth: CGFloat = 24
|
||||
private var labelWidth: CGFloat { data.startMinute == 0 ? 24 : 34 }
|
||||
private let spacing: CGFloat = 4
|
||||
|
||||
var body: some View {
|
||||
@ -1119,7 +1146,9 @@ private struct ExportTimetableView: View {
|
||||
private var hourLabels: some View {
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
ForEach(0..<hours, id: \.self) { offset in
|
||||
Text(String(format: "%02d", (data.startHour + data.hourLo + offset) % 24))
|
||||
Text(data.startMinute == 0
|
||||
? String(format: "%02d", (data.startHour + data.hourLo + offset) % 24)
|
||||
: String(format: "%02d:%02d", (data.startHour + data.hourLo + offset) % 24, data.startMinute))
|
||||
.font(.system(size: 9).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: hourHeight, alignment: .top)
|
||||
|
||||
@ -737,7 +737,9 @@ struct GoalEditorView: View {
|
||||
goal.symbolName = symbolName
|
||||
goal.colorHex = color.hexString
|
||||
goal.startDate = startDate
|
||||
goal.endDate = hasEndDate ? endDate : nil
|
||||
// 같은 편집 세션에서 시작일을 종료일 뒤로 옮기면 DatePicker의 in: 제약은 표시만
|
||||
// 걸릴 뿐 endDate 상태값이 안 바뀐다 — 종료<시작으로 저장되지 않게 정규화
|
||||
goal.endDate = hasEndDate ? max(endDate, startDate) : nil
|
||||
goal.achieveThresholdPercent = achieveThreshold
|
||||
// 워치 노출은 기기 로컬 설정 — CloudKit 모델에 쓰지 않는다 (LocalPrefs 참고)
|
||||
LocalPrefs.setShowsOnWatch(goal.uuid, enabled: showsOnWatch)
|
||||
@ -757,7 +759,7 @@ struct GoalEditorView: View {
|
||||
symbolName: symbolName,
|
||||
colorHex: color.hexString,
|
||||
startDate: startDate,
|
||||
endDate: hasEndDate ? endDate : nil
|
||||
endDate: hasEndDate ? max(endDate, startDate) : nil
|
||||
)
|
||||
newGoal.achieveThresholdPercent = achieveThreshold
|
||||
// 새 목표는 리스트 맨 뒤에 배치
|
||||
|
||||
@ -197,7 +197,9 @@ struct HistoryView: View {
|
||||
// MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택)
|
||||
|
||||
private var isFiltering: Bool {
|
||||
!excludedActionIDs.isEmpty
|
||||
// 삭제된 행동의 잔존 제외 ID는 무시 — 실재 행동이 하나라도 제외됐을 때만 필터 활성
|
||||
// (안 그러면 제외했던 행동을 삭제한 뒤에도 칩·내보내기 필터 문구가 허위로 남는다)
|
||||
allActions.contains { !passesFilter($0) }
|
||||
}
|
||||
|
||||
private func passesFilter(_ action: Action) -> Bool {
|
||||
@ -579,15 +581,20 @@ struct TimetableView: View {
|
||||
}
|
||||
|
||||
private var hourLabels: some View {
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
// 하루 시작이 정시가 아니면(예 06:30) 행 경계도 06:30~07:30이라 시(hour)만 쓰면
|
||||
// 라벨과 실제 위치가 최대 59분 어긋난다 — 분 성분까지 표기 (내보내기와 동일 규칙)
|
||||
let startMinute = math.calendar.component(.minute, from: math.dayRange(forKey: selectedDayKey).lowerBound)
|
||||
return VStack(alignment: .trailing, spacing: 0) {
|
||||
ForEach(0..<24, id: \.self) { i in
|
||||
Text(String(format: "%02d", (startHour + i) % 24))
|
||||
Text(startMinute == 0
|
||||
? String(format: "%02d", (startHour + i) % 24)
|
||||
: String(format: "%02d:%02d", (startHour + i) % 24, startMinute))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: hourHeight, alignment: .top)
|
||||
}
|
||||
}
|
||||
.frame(width: 26)
|
||||
.frame(width: startMinute == 0 ? 26 : 36)
|
||||
}
|
||||
|
||||
private var weekColumns: some View {
|
||||
|
||||
@ -283,6 +283,24 @@ struct SessionEditorView: View {
|
||||
@State private var endAt: Date = .now
|
||||
@State private var note = ""
|
||||
|
||||
/// 다이얼을 움직이지 않은 필드는 원본 시각(초 포함)을 그대로 보존한다 — 편집 상태의
|
||||
/// 분 절사(§6.5)는 표시 정합용이지 데이터 파괴 의도가 아니라서, 메모만 고치고 저장해도
|
||||
/// 1분 미만 세션이 0초가 되거나 실측정 초 단위가 지워지면 안 된다.
|
||||
private var effectiveStart: Date {
|
||||
startAt == flooredToMinute(session.startAt) ? session.startAt : startAt
|
||||
}
|
||||
|
||||
private var effectiveEnd: Date {
|
||||
guard let end = session.endAt else { return endAt }
|
||||
return endAt == flooredToMinute(end) ? end : endAt
|
||||
}
|
||||
|
||||
/// 저장하면 0길이(또는 역전) 세션이 되는 상태 — 목록·타임테이블·내보내기 어디에도
|
||||
/// 안 보이는 유령 기록이 되므로 저장을 막는다
|
||||
private var invalidOrder: Bool {
|
||||
isFinished && effectiveEnd <= effectiveStart
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@ -293,8 +311,8 @@ struct SessionEditorView: View {
|
||||
Toggle("종료됨", isOn: $isFinished.animation())
|
||||
if isFinished {
|
||||
CollapsibleTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
|
||||
if endAt < startAt {
|
||||
Text("종료 시각이 시작 시각보다 빨라요.")
|
||||
if invalidOrder {
|
||||
Text("종료 시각이 시작 시각보다 빠르거나 같아요.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
@ -324,15 +342,16 @@ struct SessionEditorView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("저장") {
|
||||
// 화면의 다이얼 값(분 단위) 그대로 저장 — 초 찌꺼기를 남기면
|
||||
// 표시(14:32~15:32)와 실제 길이(59분 13초)가 어긋난다
|
||||
session.startAt = startAt
|
||||
session.endAt = isFinished ? max(endAt, startAt) : nil
|
||||
// 다이얼을 움직인 필드만 다이얼 값(분 단위) 그대로 저장 — 초 찌꺼기를 남기면
|
||||
// 표시(14:32~15:32)와 실제 길이(59분 13초)가 어긋난다. 안 움직인 필드는
|
||||
// 원본 시각 보존(effectiveStart/End 주석 참고)
|
||||
session.startAt = effectiveStart
|
||||
session.endAt = isFinished ? effectiveEnd : nil
|
||||
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
DataChange.commit(context: context)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(isFinished && endAt < startAt)
|
||||
.disabled(invalidOrder)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@ -369,8 +388,8 @@ struct SessionAddView: View {
|
||||
}
|
||||
Section("종료") {
|
||||
CollapsibleTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
|
||||
if endAt < startAt {
|
||||
Text("종료 시각이 시작 시각보다 빨라요.")
|
||||
if endAt <= startAt {
|
||||
Text("종료 시각이 시작 시각보다 빠르거나 같아요.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
@ -394,7 +413,7 @@ struct SessionAddView: View {
|
||||
DataChange.commit(context: context)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(endAt < startAt)
|
||||
.disabled(endAt <= startAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +220,9 @@ struct StatsTabView: View {
|
||||
// MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택)
|
||||
|
||||
private var isFiltering: Bool {
|
||||
!excludedActionIDs.isEmpty
|
||||
// 삭제된 행동의 잔존 제외 ID는 무시 — 실재 행동이 하나라도 제외됐을 때만 필터 활성
|
||||
// (안 그러면 제외했던 행동을 삭제한 뒤에도 칩·내보내기 필터 문구가 허위로 남는다)
|
||||
allActions.contains { !passesFilter($0) }
|
||||
}
|
||||
|
||||
/// 행동이 통계에 포함되는지: 행동 단위로만 판정 (꼬리표 일부 행동만 선택해도 정상 반영)
|
||||
@ -448,37 +450,38 @@ private struct StatsChartsView: View {
|
||||
tagCountBarCard(stats)
|
||||
}
|
||||
|
||||
/// 꼬리표별 합계 — 행동별 버킷 합계를 소속 꼬리표들에 배분 (기존 세그먼트 순회와 동일한 값)
|
||||
/// 꼬리표별 합계 — 행동별 버킷 합계를 소속 꼬리표들에 배분 (기존 세그먼트 순회와 동일한 값).
|
||||
/// 키는 꼬리표 identity('꼬리표 없음' 자리는 nil) — 동명 꼬리표가 이름 키로 합산되던 것 방지,
|
||||
/// 표시 이름은 마지막에 Format.disambiguated로 구분한다
|
||||
private func tagStats(_ data: StatsData) -> [TagStat] {
|
||||
var seconds: [String: TimeInterval] = [:]
|
||||
var countsByTag: [String: Int] = [:]
|
||||
var colors: [String: Color] = [:]
|
||||
struct Acc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 }
|
||||
var accs: [PersistentIdentifier?: Acc] = [:]
|
||||
var order: [PersistentIdentifier?] = [] // 첫 등장 순서 (구분 번호가 결정적이게)
|
||||
|
||||
func tagNames(for action: Action) -> [(String, Color)] {
|
||||
if action.tags.isEmpty { return [(String(localized: "꼬리표 없음"), Color.gray)] }
|
||||
return action.sortedTags.map { ($0.name, $0.color) }
|
||||
func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] {
|
||||
if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] }
|
||||
return action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
|
||||
}
|
||||
|
||||
for action in filteredActions {
|
||||
let totalSeconds = data.seconds(for: action.persistentModelID, days: data.dayKeys)
|
||||
let totalCount = data.count(for: action.persistentModelID, days: data.dayKeys)
|
||||
guard totalSeconds > 0 || totalCount > 0 else { continue }
|
||||
for (name, color) in tagNames(for: action) {
|
||||
seconds[name, default: 0] += totalSeconds
|
||||
countsByTag[name, default: 0] += totalCount
|
||||
colors[name] = color
|
||||
for (key, name, color) in tagKeys(for: action) {
|
||||
if accs[key] == nil {
|
||||
accs[key] = Acc(name: name, color: color)
|
||||
order.append(key)
|
||||
}
|
||||
accs[key]?.seconds += totalSeconds
|
||||
accs[key]?.count += totalCount
|
||||
}
|
||||
}
|
||||
|
||||
let names = Set(seconds.keys).union(countsByTag.keys)
|
||||
return names
|
||||
.map { name in
|
||||
TagStat(
|
||||
name: name,
|
||||
color: colors[name] ?? .gray,
|
||||
seconds: seconds[name] ?? 0,
|
||||
count: countsByTag[name] ?? 0
|
||||
)
|
||||
let ordered = order.compactMap { accs[$0] }
|
||||
let names = Format.disambiguated(ordered.map(\.name))
|
||||
return zip(ordered, names)
|
||||
.map { acc, name in
|
||||
TagStat(name: name, color: acc.color, seconds: acc.seconds, count: acc.count)
|
||||
}
|
||||
.sorted { $0.seconds > $1.seconds }
|
||||
}
|
||||
@ -610,13 +613,14 @@ private struct StatsChartsView: View {
|
||||
.filter { data.value(for: $0, type: type, days: data.dayKeys) > 0 }
|
||||
}
|
||||
|
||||
private func dailyPoints(for actions: [Action], type: TrackingType, data: StatsData) -> [ActionDayPoint] {
|
||||
private func dailyPoints(for actions: [Action], names: [String], type: TrackingType, data: StatsData) -> [ActionDayPoint] {
|
||||
var result: [ActionDayPoint] = []
|
||||
let series = Array(zip(actions, names))
|
||||
for key in data.dayKeys {
|
||||
for action in actions {
|
||||
for (action, name) in series {
|
||||
let raw = data.value(for: action, type: type, days: [key])
|
||||
result.append(ActionDayPoint(
|
||||
dayKey: key, actionName: action.name,
|
||||
dayKey: key, actionName: name,
|
||||
value: type == .time ? raw / 3600 : raw
|
||||
))
|
||||
}
|
||||
@ -637,16 +641,17 @@ private struct StatsChartsView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private func weeklyPoints(for actions: [Action], type: TrackingType, data: StatsData) -> [ActionWeekPoint] {
|
||||
private func weeklyPoints(for actions: [Action], names: [String], type: TrackingType, data: StatsData) -> [ActionWeekPoint] {
|
||||
var result: [ActionWeekPoint] = []
|
||||
let series = Array(zip(actions, names))
|
||||
for (index, weekRange) in weekRangesInMonth.enumerated() {
|
||||
// 주 경계는 하루 단위로 정렬되므로 그 주에 속한 하루 버킷 합 = 기존 구간 집계와 동일
|
||||
let weekDays = math.dayKeys(in: weekRange)
|
||||
for action in actions {
|
||||
for (action, name) in series {
|
||||
let raw = data.value(for: action, type: type, days: weekDays)
|
||||
// String(localized:)로 감싸야 en/ja에서도 지역화됨 (내보내기 ExportBuilder와 동일 키)
|
||||
result.append(ActionWeekPoint(weekLabel: String(localized: "\(index + 1)주차"),
|
||||
actionName: action.name,
|
||||
actionName: name,
|
||||
value: type == .time ? raw / 3600 : raw))
|
||||
}
|
||||
}
|
||||
@ -654,9 +659,11 @@ private struct StatsChartsView: View {
|
||||
}
|
||||
|
||||
private func totals(for type: TrackingType, data: StatsData) -> [ActionTotal] {
|
||||
activeActions(for: type, data: data)
|
||||
.map { action in
|
||||
ActionTotal(name: action.name, color: action.color,
|
||||
let actions = activeActions(for: type, data: data)
|
||||
let names = Format.disambiguated(actions.map(\.name))
|
||||
return zip(actions, names)
|
||||
.map { action, name in
|
||||
ActionTotal(name: name, color: action.color,
|
||||
value: data.value(for: action, type: type, days: data.dayKeys))
|
||||
}
|
||||
.sorted { $0.value > $1.value }
|
||||
@ -675,13 +682,14 @@ private struct StatsChartsView: View {
|
||||
private func dailyLineCard(_ title: LocalizedStringKey, type: TrackingType, weekdayAxis: Bool,
|
||||
data: StatsData) -> some View {
|
||||
let actions = activeActions(for: type, data: data)
|
||||
let names = actions.map(\.name)
|
||||
// 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙)
|
||||
let names = Format.disambiguated(actions.map(\.name))
|
||||
let colors = actions.map(\.color)
|
||||
chartCard(title) {
|
||||
if actions.isEmpty {
|
||||
emptyChartText
|
||||
} else {
|
||||
Chart(dailyPoints(for: actions, type: type, data: data)) { point in
|
||||
Chart(dailyPoints(for: actions, names: names, type: type, data: data)) { point in
|
||||
LineMark(
|
||||
x: .value("날짜", point.dayKey, unit: .day),
|
||||
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
|
||||
@ -718,13 +726,14 @@ private struct StatsChartsView: View {
|
||||
private func weeklyLineCard(_ title: LocalizedStringKey, type: TrackingType,
|
||||
data: StatsData) -> some View {
|
||||
let actions = activeActions(for: type, data: data)
|
||||
let names = actions.map(\.name)
|
||||
// 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙)
|
||||
let names = Format.disambiguated(actions.map(\.name))
|
||||
let colors = actions.map(\.color)
|
||||
chartCard(title) {
|
||||
if actions.isEmpty {
|
||||
emptyChartText
|
||||
} else {
|
||||
Chart(weeklyPoints(for: actions, type: type, data: data)) { point in
|
||||
Chart(weeklyPoints(for: actions, names: names, type: type, data: data)) { point in
|
||||
LineMark(
|
||||
x: .value("주차", point.weekLabel),
|
||||
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
|
||||
@ -788,9 +797,11 @@ private struct StatsChartsView: View {
|
||||
}
|
||||
|
||||
private func actionSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] {
|
||||
activeActions(for: type, data: data)
|
||||
.map { action in
|
||||
SummaryRowItem(name: action.name, color: action.color, symbol: action.symbolName,
|
||||
let actions = activeActions(for: type, data: data)
|
||||
let names = Format.disambiguated(actions.map(\.name))
|
||||
return zip(actions, names)
|
||||
.map { action, name in
|
||||
SummaryRowItem(name: name, color: action.color, symbol: action.symbolName,
|
||||
total: data.value(for: action, type: type, days: data.dayKeys),
|
||||
type: type)
|
||||
}
|
||||
|
||||
@ -157,14 +157,31 @@ nonisolated enum DataStore {
|
||||
if changed { try? context.save() }
|
||||
}
|
||||
|
||||
/// 기존 앱 샌드박스(Application Support/default.store)의 DB를 App Group으로 1회 복사
|
||||
/// 구 샌드박스 스토어 이관 완료 플래그 (App Group — 앱·확장 프로세스 공유)
|
||||
private static let legacyMigrationDoneKey = "migration.legacyStoreImported"
|
||||
|
||||
/// 기존 앱 샌드박스(Application Support/default.store)의 DB를 App Group으로 1회 복사.
|
||||
/// "1회"는 플래그로 보장한다 — 원본 이관이 copyItem이라 구 스토어가 영구 잔존하는데,
|
||||
/// 스토어 손상 복구(backupBrokenStore)로 타깃 파일이 사라진 직후 이 함수가 다시 돌면
|
||||
/// 수개월 전 데이터가 담긴 구 스토어가 되살아나 조용한 롤백이 되기 때문("타깃 없음"
|
||||
/// 가드만으로는 그 경우를 구분 못 한다).
|
||||
private static func migrateLegacyStoreIfNeeded() {
|
||||
let fm = FileManager.default
|
||||
let target = storeURL
|
||||
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path,
|
||||
!fm.fileExists(atPath: target.path) else { return }
|
||||
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path else { return }
|
||||
let defaults = AppGroup.defaults
|
||||
guard !defaults.bool(forKey: legacyMigrationDoneKey) else { return }
|
||||
if fm.fileExists(atPath: target.path) {
|
||||
// 이미 새 스토어 사용 중(과거에 이관됐거나 신규 생성) — 이후로는 이관 후보 아님
|
||||
defaults.set(true, forKey: legacyMigrationDoneKey)
|
||||
return
|
||||
}
|
||||
let legacy = URL.applicationSupportDirectory.appending(path: "default.store")
|
||||
guard fm.fileExists(atPath: legacy.path) else { return }
|
||||
guard fm.fileExists(atPath: legacy.path) else {
|
||||
// 이관할 구 스토어 자체가 없음(신규 설치)
|
||||
defaults.set(true, forKey: legacyMigrationDoneKey)
|
||||
return
|
||||
}
|
||||
// -wal/-shm 저널 파일까지 함께 복사해야 최신 데이터가 유지됨
|
||||
for suffix in ["", "-shm", "-wal"] {
|
||||
let from = URL(fileURLWithPath: legacy.path + suffix)
|
||||
@ -173,6 +190,7 @@ nonisolated enum DataStore {
|
||||
try? fm.copyItem(at: from, to: to)
|
||||
}
|
||||
}
|
||||
defaults.set(true, forKey: legacyMigrationDoneKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,4 +67,18 @@ enum Format {
|
||||
return String(localized: "\(rounded.formatted(.number.precision(.fractionLength(1))))회",
|
||||
comment: "횟수 평균 (소수 1자리)")
|
||||
}
|
||||
|
||||
/// 동명 항목 구분 표시 이름 — 이름이 곧 차트 시리즈 키·범례·Identifiable id가 되는 곳
|
||||
/// (통계 탭·통계 내보내기·⑤ 행동 통계 위젯)에서 같은 이름의 행동/꼬리표가 한 시리즈로
|
||||
/// 합쳐지거나 id가 충돌하는 것을 막는다. 첫 항목은 이름 그대로, 이후 중복만
|
||||
/// "이름 (2)", "이름 (3)"… (입력 순서 기준 — 세 표면 모두 같은 배치 순서를 넘기므로 라벨 일치.
|
||||
/// 숫자·괄호는 언어 무관이라 별도 번역 불필요)
|
||||
static func disambiguated(_ names: [String]) -> [String] {
|
||||
var counts: [String: Int] = [:]
|
||||
return names.map { name in
|
||||
let n = (counts[name] ?? 0) + 1
|
||||
counts[name] = n
|
||||
return n == 1 ? name : "\(name) (\(n))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,6 +98,8 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
let actions = Array(WidgetStore.selectedActions(configuration.actions,
|
||||
defaultCount: Self.maxActions(family))
|
||||
.prefix(Self.maxActions(family)))
|
||||
// 동명 행동 구분 표시 이름 — 이름이 시리즈 키·범례라 같으면 한 선으로 합쳐진다 (앱 통계 탭과 동일 규칙)
|
||||
let seriesNames = Format.disambiguated(actions.map(\.name))
|
||||
let isTimeType = !actions.contains { $0.trackingType == .count }
|
||||
|
||||
let math = DayMath()
|
||||
@ -117,8 +119,8 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
let range = span == .week ? math.weekRange(containing: now) : math.monthRange(containing: now)
|
||||
for key in math.dayKeys(in: range) {
|
||||
let dayRange = math.dayRange(forKey: key)
|
||||
for action in actions {
|
||||
points.append(StatsPoint(day: key, weekLabel: nil, actionName: action.name,
|
||||
for (action, seriesName) in zip(actions, seriesNames) {
|
||||
points.append(StatsPoint(day: key, weekLabel: nil, actionName: seriesName,
|
||||
value: value(action, in: dayRange)))
|
||||
}
|
||||
}
|
||||
@ -129,10 +131,10 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
while cursor < month.upperBound {
|
||||
let week = math.weekRange(containing: cursor)
|
||||
let clipped = max(week.lowerBound, month.lowerBound)..<min(week.upperBound, month.upperBound)
|
||||
for action in actions {
|
||||
for (action, seriesName) in zip(actions, seriesNames) {
|
||||
// 앱 통계 탭과 같은 표기("N주차") + String(localized:)로 en/ja 지역화
|
||||
points.append(StatsPoint(day: nil, weekLabel: String(localized: "\(index)주차"),
|
||||
actionName: action.name,
|
||||
actionName: seriesName,
|
||||
value: value(action, in: clipped)))
|
||||
}
|
||||
cursor = week.upperBound
|
||||
@ -147,7 +149,7 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
title: span.title,
|
||||
isWeekAxis: span == .monthByWeek,
|
||||
isTimeType: isTimeType,
|
||||
names: actions.map(\.name),
|
||||
names: seriesNames,
|
||||
colorHexes: actions.map { $0.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F" },
|
||||
points: points
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user