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:
songyc macbook 2026-07-22 21:05:16 +09:00
parent 28a453485d
commit c18e3c7c6f
11 changed files with 242 additions and 129 deletions

View File

@ -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개가 세션 한도를 급격히 소모한 전례. 검토·수정·검증 등 모든 작업은 오래 걸리더라도 세션 본체가 직접 수행할 것

View File

@ -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)시간을 넘겼어요. 종료를 잊으셨다면 열어서 꺼 주세요.")

View File

@ -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" : "終了時刻が開始時刻より早いか同じです。"
}
}
}

View File

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

View File

@ -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
//

View File

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

View File

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

View File

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

View File

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

View File

@ -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))"
}
}
}

View File

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