diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 11690c5..584f6d7 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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 screenshot`, 런치 인자는 콜드 스타트로 10. **아이콘 전용 버튼에는 `accessibilityLabel` 필수** (보이스오버가 "버튼"으로만 읽는 것 방지). 여러 요소로 된 셀은 label+value로 한 덩어리 낭독(모음 탭 행동 셀 참고), 타임테이블 블록·점처럼 텍스트 없는 시각 요소는 verbatim 라벨(이름+시각)로 +11. **서브 에이전트(Agent/Workflow 병렬 분업) 사용 금지 — 사용자 지시(2026-07-22)**: 병렬 리뷰 에이전트 8개가 세션 한도를 급격히 소모한 전례. 검토·수정·검증 등 모든 작업은 오래 걸리더라도 세션 본체가 직접 수행할 것 diff --git a/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift b/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift index 9d2b258..fce51d0 100644 --- a/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift +++ b/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift @@ -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)시간을 넘겼어요. 종료를 잊으셨다면 열어서 꺼 주세요.") diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index b6d7868..337a49c 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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" : "終了時刻が開始時刻より早いか同じです。" } } } diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index 07641c4..221771e 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -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.. 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 { diff --git a/myApp/HaruDanim/IOS/Views/RecordEditors.swift b/myApp/HaruDanim/IOS/Views/RecordEditors.swift index d7679fe..dfeea56 100644 --- a/myApp/HaruDanim/IOS/Views/RecordEditors.swift +++ b/myApp/HaruDanim/IOS/Views/RecordEditors.swift @@ -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) } } } diff --git a/myApp/HaruDanim/IOS/Views/StatsTabView.swift b/myApp/HaruDanim/IOS/Views/StatsTabView.swift index 20b8551..2067b0a 100644 --- a/myApp/HaruDanim/IOS/Views/StatsTabView.swift +++ b/myApp/HaruDanim/IOS/Views/StatsTabView.swift @@ -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) } diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift index 08133ef..47d3afb 100644 --- a/myApp/HaruDanim/Shared/DataStore.swift +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -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) } } diff --git a/myApp/HaruDanim/Shared/Formatters.swift b/myApp/HaruDanim/Shared/Formatters.swift index 218ada1..fb62707 100644 --- a/myApp/HaruDanim/Shared/Formatters.swift +++ b/myApp/HaruDanim/Shared/Formatters.swift @@ -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))" + } + } } diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index 6ab88c0..b13f54c 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -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)..