mycode/myApp/HaruDanim/IOS/Core/DataReset.swift
songyc macbook e04cdfdcb9 feat(1.5-p2): 건강 데이터 타일 — HealthCache 기반·모음 탭 3섹션 순서·안내/지표 시트
- HealthMetric 8종(표준 단위·표기) + HealthStore(HK 조회: 누적 통계·스탠드·마음챙김·
  수면 구간[합집합 병합, 깨어난 날 귀속 — plan §3.4 규칙]) + HealthCache(App Group,
  지표×dayKey, 400일 보존 — 5.1.3 준수: CloudKit 비저장)
- 모음 탭 건강 섹션(타일 줄+접기·연결 CTA[명시적 탭 권한]·안내 시트[사용자 요구 가이드]·
  지표 선택 시트[체크+드래그 순서]) — actionGrid·레이아웃 금지구역 무접촉, 편집 모드 숨김
- 3섹션(즐겨찾기·나머지·건강) 순서 렌더러 + 설정 탭 표시 토글·순서 화면(기기 로컬)
- HealthKit 엔타이틀먼트(iOS 앱만)·NSHealthShareUsageDescription, 전체 초기화에
  건강 키·캐시 청소 추가
- 검증: Debug/Store 빌드, 자가 검증 106건 ALL PASS(26.5+18.5), 시각 QA 7장
  (타일·순서·설정·안내·지표·CTA·18.5 빈 상태). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 05:38:50 +09:00

285 lines
15 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DataReset.swift
// Haru_Danim
//
// ·(1.4) .
// CloudKit .
// (§5.1). ·
// DataChange.commit .
//
// ( , 1.4):
// - " " · , (··
// ·)
// - ( )
// ,
// - ( )
//
import Foundation
import SwiftData
enum DataReset {
struct Counts {
var sessions = 0
var entries = 0
var diaryEntries = 0
var actions = 0
var tags = 0
var goals = 0
var templates = 0
}
// MARK: ( ~ endDayKey)
/// endDayKey( , ) · . .
@MainActor
static func deleteRecords(upToDayKey endDayKey: Date, context: ModelContext,
math: DayMath) throws -> Counts {
var counts = Counts()
let upper = math.dayRange(forKey: endDayKey).upperBound
let farFuture = Date.distantFuture
// ( nil farFuture )
let sessions = try context.fetch(FetchDescriptor<TimeSession>(
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
))
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>(
predicate: #Predicate { $0.timestamp < upper }
))
for entry in entries { context.delete(entry) }
counts.entries = entries.count
// (§4.1)
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.dayKey <= endDayKey }
))
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
return counts
}
// MARK: (1.4(2) ·· )
/// . endDayKey nil , ().
/// cascade ·· · , .
@MainActor
static func deleteDiaries(upToDayKey endDayKey: Date?, context: ModelContext) throws -> Counts {
var counts = Counts()
let descriptor: FetchDescriptor<DiaryEntry>
if let endDayKey {
descriptor = FetchDescriptor<DiaryEntry>(predicate: #Predicate { $0.dayKey <= endDayKey })
} else {
descriptor = FetchDescriptor<DiaryEntry>()
}
let entries = try context.fetch(descriptor)
for entry in entries { context.delete(entry) }
counts.diaryEntries = entries.count
return counts
}
// MARK:
/// (cascade: , , ·· ).
/// includeLocalPrefs=true · App Group
/// ( false defaults ).
@MainActor
static func deleteAll(context: ModelContext, includeLocalPrefs: Bool = true) throws -> Counts {
var counts = Counts()
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>())
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
let templates = try context.fetch(FetchDescriptor<DiaryTemplate>())
for template in templates { context.delete(template) }
counts.templates = templates.count
let goals = try context.fetch(FetchDescriptor<Goal>())
for goal in goals { context.delete(goal) }
counts.goals = goals.count
let actions = try context.fetch(FetchDescriptor<Action>())
for action in actions { context.delete(action) }
counts.actions = actions.count
let tags = try context.fetch(FetchDescriptor<Tag>())
for tag in tags { context.delete(tag) }
counts.tags = tags.count
// cascade ( )
let sessions = try context.fetch(FetchDescriptor<TimeSession>())
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>())
for entry in entries { context.delete(entry) }
counts.entries = entries.count
if includeLocalPrefs {
let keys = [
LocalPrefsKeys.actionOrder, LocalPrefsKeys.pinnedGoals,
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
LocalPrefsKeys.watchActions,
// 1.5 ·
LocalPrefsKeys.healthTilesEnabled, LocalPrefsKeys.mainSectionOrder,
LocalPrefsKeys.healthCollapsed, LocalPrefsKeys.healthMetrics,
]
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
HealthCache.clearAll()
}
return counts
}
// MARK: (-dataResetTest · , )
#if DEBUG
@MainActor
static func selfTestIfRequested() {
guard UserDefaults.standard.bool(forKey: "dataResetTest") else { return }
var lines: [String] = []
var failures = 0
func expect(_ label: String, _ actual: Int, _ expected: Int) {
let pass = actual == expected
if !pass { failures += 1 }
lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(actual) / expected \(expected)")
}
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: DataStore.schema, configurations: [config])
let context = container.mainContext
let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0))
// : (cut) · ·· + + +
let cal = math.calendar
let today = cal.startOfDay(for: Date(timeIntervalSince1970: 1_754_000_000)) //
let cutKey = cal.date(byAdding: .day, value: -7, to: today)! // 7
let cutUpper = math.dayRange(forKey: cutKey).upperBound
let tag = Tag(name: "테스트", colorHex: "#2F6B4F")
context.insert(tag)
let action = Action(name: "검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
context.insert(action)
let countAction = Action(name: "검증횟수", symbolName: "number", trackingType: .count, sortOrder: 1)
context.insert(countAction)
let goal = Goal(title: "검증목표", symbolName: "flag.fill", colorHex: "#2F6B4F",
startDate: cutUpper.addingTimeInterval(-86400 * 30), endDate: nil)
context.insert(goal)
// ( )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-7200),
endAt: cutUpper.addingTimeInterval(-3600)))
// ( , )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-1800),
endAt: cutUpper.addingTimeInterval(1800)))
// ()
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(3600),
endAt: cutUpper.addingTimeInterval(7200)))
//
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-600), endAt: nil))
// : 2 + 1
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-100), amount: 1))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-200), amount: 2))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(100), amount: 3))
// : ()· = , = . cascade 1
let diaryOld = DiaryEntry(dayKey: cal.date(byAdding: .day, value: -1, to: cutKey)!)
context.insert(diaryOld)
let page = DiaryPage(index: 1)
page.entry = diaryOld
context.insert(page)
let diaryCut = DiaryEntry(dayKey: cutKey)
context.insert(diaryCut)
let diaryKeep = DiaryEntry(dayKey: cal.date(byAdding: .day, value: 1, to: cutKey)!)
context.insert(diaryKeep)
let template = DiaryTemplate(name: "양식", kind: .image)
context.insert(template)
try context.save()
// ---- ----
let rangeCounts = try DataReset.deleteRecords(upToDayKey: cutKey, context: context, math: math)
try context.save()
expect("범위: 삭제된 세션 수(완전 포함만)", rangeCounts.sessions, 1)
expect("범위: 삭제된 횟수 수", rangeCounts.entries, 2)
expect("범위: 삭제된 일기 수(기준일 포함)", rangeCounts.diaryEntries, 2)
expect("범위: 남은 세션(걸침+밖+진행 중)",
(try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 3)
expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 1)
expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 0)
expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 2)
expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 1)
expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 1)
expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
// ---- ( LocalPrefs ) ----
_ = try DataReset.deleteAll(context: context, includeLocalPrefs: false)
try context.save()
expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 0)
expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 0)
expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 0)
expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 0)
expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 0)
expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 0)
// ---- 2: ( · ) ----
let container2 = try ModelContainer(
for: DataStore.schema,
configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]
)
let context2 = container2.mainContext
let action2 = Action(name: "일기검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
context2.insert(action2)
context2.insert(TimeSession(action: action2,
startAt: cutUpper.addingTimeInterval(-7200),
endAt: cutUpper.addingTimeInterval(-3600)))
let template2 = DiaryTemplate(name: "양식2", kind: .image)
context2.insert(template2)
for offset in [-1, 0, 1] {
let entry = DiaryEntry(dayKey: cal.date(byAdding: .day, value: offset, to: cutKey)!)
context2.insert(entry)
let page = DiaryPage(index: 1)
page.entry = entry
context2.insert(page)
}
try context2.save()
let diaryRange = try DataReset.deleteDiaries(upToDayKey: cutKey, context: context2)
try context2.save()
expect("일기만(날짜까지): 삭제 수(기준일 포함)", diaryRange.diaryEntries, 2)
expect("일기만(날짜까지): 남은 일기", (try? context2.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
expect("일기만(날짜까지): 페이지 cascade", (try? context2.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 1)
expect("일기만(날짜까지): 기록 보존", (try? context2.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 1)
let diaryAll = try DataReset.deleteDiaries(upToDayKey: nil, context: context2)
try context2.save()
expect("일기만(전체): 삭제 수", diaryAll.diaryEntries, 1)
expect("일기만(전체): 일기 0", (try? context2.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
expect("일기만(전체): 기록 보존", (try? context2.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 1)
expect("일기만(전체): 행동 보존", (try? context2.fetchCount(FetchDescriptor<Action>())) ?? -1, 1)
expect("일기만(전체): 양식 보존", (try? context2.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
} catch {
failures += 1
lines.append("FAIL 예외: \(error)")
}
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
let url = URL.documentsDirectory.appending(path: "data-reset-test.txt")
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
#endif
}