feat(data): 데이터 정리·초기화 — 전체/처음~날짜까지 + 삭제 전 백업(CSV·일기 PDF·정리 리포트)

- DataReset 코어: 범위 삭제(완전 포함 세션만·경계 걸침/진행 중 보존·일기 dayKey≤키·구조/양식 보존), 전체 초기화(cascade+고아 정리+LocalPrefs 6키), 컨텍스트 경유 개별 삭제(CloudKit 전파 안전)
- ArchiveReport: 삭제 범위 총정리 다페이지 포스터(타이틀+기간(첫~마지막 기록일)+히어로+행동별 누적 표+꼬리표 합계+월별 표+목표 이력, 24행 청크) → PDF/스티치 PNG, ExportPosterView 재사용
- DataResetView(설정→데이터, 무료): 범위 세그먼트+삭제 대상 fetchCount+백업 토글 3종+2단계 확인(다이얼로그→얼럿)+iCloud·연속 끊김 경고+완료 요약
- 검증: -dataResetTest 인메모리 18건 ALL PASS, -dataResetBackupRun로 CSV 5종+3페이지 리포트 PDF 실물 확인(기간 라벨 2025-03-23~오늘), 화면 UI 확인
- 신규 인자: -dataResetPreview·-dataResetBackupRun pdf|image·-dataResetRangeDays·-dataResetTest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
songyc macbook 2026-08-11 17:11:51 +09:00
parent a09d4b3da4
commit cf78a3ac7e
5 changed files with 1043 additions and 1 deletions

View File

@ -34,6 +34,8 @@ struct ContentView: View {
NavigationStack { DataExportView() }
} else if UserDefaults.standard.bool(forKey: "dataImportPreview") {
NavigationStack { DataImportView() }
} else if UserDefaults.standard.bool(forKey: "dataResetPreview") {
NavigationStack { DataResetView() }
} else {
RootNavigationView()
}
@ -71,6 +73,7 @@ struct ContentView: View {
DebugSeed.dumpMissingSymbolsIfRequested()
DebugSeed.runProgressSelfTestIfRequested()
CSVImport.selfTestIfRequested()
DataReset.selfTestIfRequested()
await DebugSeed.runIntentSmokeTestIfRequested()
// : -themeAutoToggle YES 3 (App Group defaults)
// ' '

View File

@ -0,0 +1,375 @@
//
// ArchiveReport.swift
// Haru_Danim
//
// (1.4) ' ·' .
// (ExportPosterView )
// PDF 1 PNG 1 .
// - 1: + ( ~ ) + +
// - : ( ) ·
// ( = DayMath.splitByDay), .
//
import SwiftUI
import SwiftData
import UIKit
@MainActor
enum ArchiveReport {
/// ( Format Format )
enum OutputFormat { case pdf, image }
/// ( '' )
private static let rowsPerPage = 24
// MARK:
/// . range = (nil = ).
/// diaryDayKeys ( ).
static func makeURL(
format: OutputFormat,
actions: [Action],
sessions: [TimeSession],
entries: [CountEntry],
goals: [Goal],
diaryDayKeys: [Date],
range: Range<Date>?,
math: DayMath
) -> URL? {
let pages = makePages(actions: actions, sessions: sessions, entries: entries,
goals: goals, diaryDayKeys: diaryDayKeys, range: range, math: math)
guard !pages.isEmpty else { return nil }
let images = pages.compactMap { render($0) }
guard !images.isEmpty else { return nil }
switch format {
case .pdf: return writePDF(images, slug: fileSlug(pages))
case .image: return writeStitchedPNG(images, slug: fileSlug(pages))
}
}
// MARK:
private static func fileSlug(_ pages: [ExportSnapshot]) -> String {
pages.first?.fileSlug ?? String(localized: "정리")
}
private static func makePages(
actions: [Action],
sessions: [TimeSession],
entries: [CountEntry],
goals: [Goal],
diaryDayKeys: [Date],
range: Range<Date>?,
math: DayMath
) -> [ExportSnapshot] {
let now = Date.now
// ---- 1 : + + ----
var actionSeconds: [PersistentIdentifier: TimeInterval] = [:]
var actionCounts: [PersistentIdentifier: Int] = [:]
var recordedDays = Set<Date>()
var monthSeconds: [Date: TimeInterval] = [:]
var monthCounts: [Date: Int] = [:]
func monthKey(forDayKey key: Date) -> Date {
let comps = math.calendar.dateComponents([.year, .month], from: key)
return math.calendar.date(from: comps) ?? key
}
for session in sessions {
guard let action = session.action else { continue }
var start = session.startAt
var end = session.endAt ?? now
if let range {
start = max(start, range.lowerBound)
end = min(end, range.upperBound)
}
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end) {
let seconds = segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
actionSeconds[action.persistentModelID, default: 0] += seconds
recordedDays.insert(segment.dayKey)
monthSeconds[monthKey(forDayKey: segment.dayKey), default: 0] += seconds
}
}
for entry in entries {
guard let action = entry.action else { continue }
if let range, !range.contains(entry.timestamp) { continue }
let dayKey = math.dayKey(for: entry.timestamp)
actionCounts[action.persistentModelID, default: 0] += entry.amount
recordedDays.insert(dayKey)
monthCounts[monthKey(forDayKey: dayKey), default: 0] += entry.amount
}
let totalSeconds = actionSeconds.values.reduce(0, +)
let totalCount = actionCounts.values.reduce(0, +)
guard totalSeconds > 0 || totalCount > 0 || !diaryDayKeys.isEmpty || !goals.isEmpty else {
return []
}
// ---- : ~ ( ) ----
let allDays = recordedDays.union(diaryDayKeys)
let firstDay = allDays.min() ?? math.dayKey(for: now)
let lastDay = allDays.max() ?? math.dayKey(for: now)
let periodLabel = "\(Format.fullDate(firstDay)) ~ \(Format.fullDate(lastDay))"
let slugFormatter = DateFormatter()
slugFormatter.locale = Locale(identifier: "en_US_POSIX")
slugFormatter.dateFormat = "yyyyMMdd"
let slug = String(localized: "정리-\(slugFormatter.string(from: firstDay))-\(slugFormatter.string(from: lastDay))")
// ---- ----
let hero = [
ExportHeroStat(title: String(localized: "총 측정 시간"), value: Format.durationShort(totalSeconds)),
ExportHeroStat(title: String(localized: "총 횟수"), value: String(localized: "\(totalCount)")),
ExportHeroStat(title: String(localized: "기록한 날"), value: String(localized: "\(recordedDays.count)")),
ExportHeroStat(title: String(localized: "일기 쓴 날"), value: String(localized: "\(diaryDayKeys.count)")),
]
// ---- ( , / ) ----
let elapsedDays = max(recordedDays.count, 1)
func averageLabel(_ total: Double, type: TrackingType) -> String {
type == .time ? Format.durationShort(total / Double(elapsedDays))
: Format.countAverage(total / Double(elapsedDays))
}
let timeActions = actions.filter { $0.trackingType == .time && (actionSeconds[$0.persistentModelID] ?? 0) > 0 }
let countActions = actions.filter { $0.trackingType == .count && (actionCounts[$0.persistentModelID] ?? 0) > 0 }
let timeNames = Format.disambiguated(timeActions.map(\.name))
let countNames = Format.disambiguated(countActions.map(\.name))
var timeTotals: [(action: Action, name: String, total: TimeInterval)] = zip(timeActions, timeNames)
.map { ($0, $1, actionSeconds[$0.persistentModelID] ?? 0) }
timeTotals.sort { $0.total > $1.total }
let timeRows: [ExportTableData.Row] = timeTotals.map { item in
ExportTableData.Row(
symbol: item.action.symbolName, color: item.action.color, name: item.name,
values: [Format.durationShort(item.total),
averageLabel(item.total, type: .time)]
)
}
var countTotals: [(action: Action, name: String, total: Int)] = zip(countActions, countNames)
.map { ($0, $1, actionCounts[$0.persistentModelID] ?? 0) }
countTotals.sort { $0.total > $1.total }
let countRows: [ExportTableData.Row] = countTotals.map { item in
ExportTableData.Row(
symbol: item.action.symbolName, color: item.action.color, name: item.name,
values: [String(localized: "\(item.total)"),
averageLabel(Double(item.total), type: .count)]
)
}
let actionColumns = [String(localized: "합계"), String(localized: "하루 평균")]
// ---- (identity + ) ----
struct TagAcc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 }
var tagAccs: [PersistentIdentifier?: TagAcc] = [:]
var tagOrder: [PersistentIdentifier?] = []
for action in actions {
let seconds = actionSeconds[action.persistentModelID] ?? 0
let count = actionCounts[action.persistentModelID] ?? 0
guard seconds > 0 || count > 0 else { continue }
let keys: [(PersistentIdentifier?, String, Color)] = action.tags.isEmpty
? [(nil, String(localized: "꼬리표 없음"), Color.gray)]
: action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
for (key, name, color) in keys {
if tagAccs[key] == nil {
tagAccs[key] = TagAcc(name: name, color: color)
tagOrder.append(key)
}
tagAccs[key]?.seconds += seconds
tagAccs[key]?.count += count
}
}
let orderedTags: [TagAcc] = {
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
}
}()
let tagTimeBars: ExportSectionData? = {
let items = orderedTags.filter { $0.seconds > 0 }.sorted { $0.seconds > $1.seconds }
.map { ExportBarItem(name: $0.name, value: $0.seconds / 3600,
valueLabel: Format.durationShort($0.seconds), color: $0.color) }
guard !items.isEmpty else { return nil }
return .bars(title: String(localized: "꼬리표별 시간 합계"),
xLabel: String(localized: "시간(h)"), items: items)
}()
let tagCountBars: ExportSectionData? = {
let items = orderedTags.filter { $0.count > 0 }.sorted { $0.count > $1.count }
.map { ExportBarItem(name: $0.name, value: Double($0.count),
valueLabel: String(localized: "\($0.count)"), color: $0.color) }
guard !items.isEmpty else { return nil }
return .bars(title: String(localized: "꼬리표별 횟수 합계"),
xLabel: String(localized: "횟수"), items: items)
}()
// ---- () ----
let monthKeys = Set(monthSeconds.keys).union(monthCounts.keys).sorted()
let monthRows = monthKeys.map { key in
ExportTableData.Row(
symbol: "calendar", color: AppTheme.green,
name: key.formatted(.dateTime.year().month()),
values: [Format.durationShort(monthSeconds[key] ?? 0),
String(localized: "\(monthCounts[key] ?? 0)")]
)
}
// ---- ----
let goalRows = goals.map { goal in
let period = goal.endDate.map {
"\(Format.shortDate(goal.startDate)) ~ \(Format.shortDate($0))"
} ?? String(localized: "\(Format.shortDate(goal.startDate)) 시작")
return ExportTableData.Row(
symbol: goal.symbolName, color: goal.color, name: goal.title,
values: [period, goal.status.label]
)
}
// ---- ( rowsPerPage '' ) ----
var pages: [ExportSnapshot] = []
let title = String(localized: "하루 다님 기록 정리")
func chunkedTablePages(
rows: [ExportTableData.Row], columns: [String], groupLabel: String,
sectionTitle: String, pageTitle: String
) -> [(title: String, section: ExportSectionData)] {
guard !rows.isEmpty else { return [] }
return stride(from: 0, to: rows.count, by: rowsPerPage).map { start in
let chunk = Array(rows[start..<min(start + rowsPerPage, rows.count)])
let suffix = start == 0 ? "" : String(localized: " (계속)")
return (
title: start == 0 ? pageTitle : pageTitle + suffix,
section: .table(title: sectionTitle + suffix,
ExportTableData(columns: columns,
groups: [.init(label: groupLabel, rows: chunk)]))
)
}
}
// 1: + ()
var firstSections: [ExportSectionData] = []
var overflowPages: [(title: String, section: ExportSectionData)] = []
let timeTable = chunkedTablePages(rows: timeRows, columns: actionColumns,
groupLabel: String(localized: "시간"),
sectionTitle: String(localized: "행동별 누적 — 시간"),
pageTitle: title)
let countTable = chunkedTablePages(rows: countRows, columns: actionColumns,
groupLabel: String(localized: "횟수"),
sectionTitle: String(localized: "행동별 누적 — 횟수"),
pageTitle: title)
if let first = timeTable.first {
firstSections.append(first.section)
overflowPages += timeTable.dropFirst()
}
if let firstCount = countTable.first, timeTable.count <= 1 {
firstSections.append(firstCount.section)
overflowPages += countTable.dropFirst()
} else {
overflowPages += countTable
}
pages.append(ExportSnapshot(
title: title, periodLabel: periodLabel, filterNote: nil,
hero: hero, sections: firstSections, fileSlug: slug
))
for page in overflowPages {
pages.append(ExportSnapshot(
title: page.title, periodLabel: periodLabel, filterNote: nil,
hero: [], sections: [page.section], fileSlug: slug
))
}
// +
var tagSections: [ExportSectionData] = [tagTimeBars, tagCountBars].compactMap(\.self)
let monthPages = chunkedTablePages(rows: monthRows,
columns: [String(localized: "시간"), String(localized: "횟수")],
groupLabel: String(localized: ""),
sectionTitle: String(localized: "월별 합계"),
pageTitle: String(localized: "꼬리표·월별 정리"))
if let firstMonth = monthPages.first {
tagSections.append(firstMonth.section)
}
if !tagSections.isEmpty {
pages.append(ExportSnapshot(
title: String(localized: "꼬리표·월별 정리"), periodLabel: periodLabel, filterNote: nil,
hero: [], sections: tagSections, fileSlug: slug
))
}
for page in monthPages.dropFirst() {
pages.append(ExportSnapshot(
title: page.title, periodLabel: periodLabel, filterNote: nil,
hero: [], sections: [page.section], fileSlug: slug
))
}
//
for page in chunkedTablePages(rows: goalRows,
columns: [String(localized: "기간"), String(localized: "상태")],
groupLabel: String(localized: "목표"),
sectionTitle: String(localized: "목표 이력"),
pageTitle: String(localized: "목표 이력")) {
pages.append(ExportSnapshot(
title: page.title, periodLabel: periodLabel, filterNote: nil,
hero: [], sections: [page.section], fileSlug: slug
))
}
return pages
}
// MARK: (ExportPosterView , )
private static func render(_ snapshot: ExportSnapshot) -> UIImage? {
let renderer = ImageRenderer(
content: ExportPosterView(snapshot: snapshot).environment(\.colorScheme, .light)
)
renderer.scale = 3
renderer.isOpaque = true
return renderer.uiImage
}
private static func writePDF(_ images: [UIImage], slug: String) -> URL? {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(String(localized: "하루다님-\(slug).pdf"))
let width = ExportPosterView.width
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: width))
do {
try renderer.writePDF(to: url) { pdf in
for image in images {
let height = image.size.height * width / image.size.width
let bounds = CGRect(x: 0, y: 0, width: width, height: height)
pdf.beginPage(withBounds: bounds, pageInfo: [:])
image.draw(in: bounds)
}
}
return url
} catch {
return nil
}
}
private static func writeStitchedPNG(_ images: [UIImage], slug: String) -> URL? {
let width = ExportPosterView.width
let gap: CGFloat = 16
let heights = images.map { $0.size.height * width / $0.size.width }
let totalHeight = heights.reduce(0, +) + gap * CGFloat(max(images.count - 1, 0))
let format = UIGraphicsImageRendererFormat()
format.scale = 3
let renderer = UIGraphicsImageRenderer(
size: CGSize(width: width, height: totalHeight), format: format
)
let stitched = renderer.image { ctx in
UIColor(AppTheme.background).setFill()
ctx.fill(CGRect(x: 0, y: 0, width: width, height: totalHeight))
var y: CGFloat = 0
for (image, height) in zip(images, heights) {
image.draw(in: CGRect(x: 0, y: y, width: width, height: height))
y += height + gap
}
}
guard let data = stitched.pngData() else { return nil }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(String(localized: "하루다님-\(slug).png"))
try? data.write(to: url, options: .atomic)
return url
}
}

View File

@ -0,0 +1,223 @@
//
// 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:
/// (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,
]
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
}
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)
} 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
}

View File

@ -0,0 +1,436 @@
//
// DataResetView.swift
// Haru_Danim
//
// · (1.4, ). .
// : / (·, DataReset ).
// (CSV· PDF· ) ,
// 2 ( ) . DataReset,
// CSVExport·DiaryExportRenderer·ArchiveReport .
//
import SwiftUI
import SwiftData
struct DataResetView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
private enum RangeMode: String, CaseIterable, Identifiable {
case all, upTo
var id: String { rawValue }
var label: String {
switch self {
case .all: return String(localized: "전체")
case .upTo: return String(localized: "날짜 지정")
}
}
}
private enum ReportFormat: String, CaseIterable, Identifiable {
case pdf, image
var id: String { rawValue }
var label: String {
switch self {
case .pdf: return "PDF"
case .image: return String(localized: "이미지")
}
}
}
@State private var rangeMode: RangeMode = .all
@State private var endDate: Date = Calendar.current.date(byAdding: .month, value: -3, to: .now) ?? .now
@State private var includeCSV = true
@State private var includeDiary = true
@State private var includeReport = true
@State private var reportFormat: ReportFormat = .pdf
@State private var isGenerating = false
@State private var backupURLs: [URL] = []
@State private var confirmingDelete = false
@State private var finalConfirm = false
@State private var isDeleting = false
@State private var deletedCounts: DataReset.Counts?
private var math: DayMath { DayMath() }
/// () DatePicker (§4.1)
private var endKey: Date { math.calendar.startOfDay(for: endDate) }
private var upperBound: Date { math.dayRange(forKey: endKey).upperBound }
/// (CSV·)
private var backupRange: Range<Date>? {
rangeMode == .all ? nil : Date.distantPast..<upperBound
}
/// ,
private var canDelete: Bool {
let wantsBackup = includeCSV || includeDiary || includeReport
return (!wantsBackup || !backupURLs.isEmpty) && !isGenerating && !isDeleting
&& deletedCounts == nil
}
// MARK: (fetchCount )
private var targetSessionCount: Int {
let farFuture = Date.distantFuture
let descriptor: FetchDescriptor<TimeSession>
if rangeMode == .all {
descriptor = FetchDescriptor<TimeSession>()
} else {
let upper = upperBound
descriptor = FetchDescriptor<TimeSession>(
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
)
}
return (try? context.fetchCount(descriptor)) ?? 0
}
private var targetEntryCount: Int {
let descriptor: FetchDescriptor<CountEntry>
if rangeMode == .all {
descriptor = FetchDescriptor<CountEntry>()
} else {
let upper = upperBound
descriptor = FetchDescriptor<CountEntry>(predicate: #Predicate { $0.timestamp < upper })
}
return (try? context.fetchCount(descriptor)) ?? 0
}
private var targetDiaryCount: Int {
let descriptor: FetchDescriptor<DiaryEntry>
if rangeMode == .all {
descriptor = FetchDescriptor<DiaryEntry>()
} else {
let key = endKey
descriptor = FetchDescriptor<DiaryEntry>(predicate: #Predicate { $0.dayKey <= key })
}
return (try? context.fetchCount(descriptor)) ?? 0
}
private var targetActionCount: Int {
(try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
}
private var targetGoalCount: Int {
(try? context.fetchCount(FetchDescriptor<Goal>())) ?? 0
}
var body: some View {
List {
if let counts = deletedCounts {
doneSection(counts)
} else {
rangeSection
targetSection
backupSection
deleteSection
}
}
.tint(AppTheme.green)
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("데이터 정리·초기화")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.onChange(of: rangeMode) { backupURLs = [] }
.onChange(of: endDate) { backupURLs = [] }
.onChange(of: includeCSV) { backupURLs = [] }
.onChange(of: includeDiary) { backupURLs = [] }
.onChange(of: includeReport) { backupURLs = [] }
.onChange(of: reportFormat) { backupURLs = [] }
.confirmationDialog(
rangeMode == .all
? String(localized: "모든 데이터를 삭제할까요?")
: String(localized: "\(Format.fullDate(endKey))까지의 기록·일기를 삭제할까요?"),
isPresented: $confirmingDelete,
titleVisibility: .visible
) {
Button("삭제 진행", role: .destructive) { finalConfirm = true }
} message: {
Text(rangeMode == .all
? "행동·꼬리표·목표·다짐·기록·일기·양식이 모두 삭제돼요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요."
: "지정한 날짜까지의 시간·횟수 기록과 일기가 삭제돼요. 과거 기록이 사라지면 다짐의 연속 달성 기록이 끊겨요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.")
}
.alert("정말 삭제할까요?", isPresented: $finalConfirm) {
Button("취소", role: .cancel) {}
Button("삭제", role: .destructive) { performDelete() }
} message: {
Text("삭제는 되돌릴 수 없어요. 백업 파일을 저장했는지 다시 확인해 주세요.")
}
#if DEBUG
.onAppear {
// : -dataResetRangeDays <N> ' '(-N)
let days = UserDefaults.standard.integer(forKey: "dataResetRangeDays")
if days > 0 {
rangeMode = .upTo
endDate = math.calendar.date(byAdding: .day, value: -days, to: .now) ?? .now
}
// : -dataResetBackupRun pdf|image 3 Documents
if let raw = UserDefaults.standard.string(forKey: "dataResetBackupRun") {
includeCSV = true
includeDiary = true
includeReport = true
reportFormat = raw == "image" ? .image : .pdf
makeBackups(copyToDocuments: true)
}
}
#endif
}
// MARK:
private var rangeSection: some View {
Section {
Picker("범위", selection: $rangeMode) {
ForEach(RangeMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
if rangeMode == .upTo {
DatePicker("까지", selection: $endDate, in: ...Date.now, displayedComponents: .date)
}
} header: {
Text("정리할 범위")
} footer: {
Text(rangeMode == .all
? "모든 행동·꼬리표·목표·다짐·기록·일기·양식을 삭제하고 처음 상태로 돌아가요. 앱 설정(테마·하루 시작 시간 등)과 프리미엄은 그대로예요."
: "처음부터 지정한 날짜(포함)까지의 시간·횟수 기록과 일기만 삭제해요. 행동·꼬리표·목표·다짐과 양식 라이브러리는 남아요. 지정한 날짜에 걸쳐 있는 측정과 진행 중인 측정은 남아요.")
}
}
private var targetSection: some View {
Section("삭제 대상") {
countRow("timer", String(localized: "시간 기록"), String(localized: "\(targetSessionCount)"))
countRow("number", String(localized: "횟수 기록"), String(localized: "\(targetEntryCount)"))
countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(targetDiaryCount)"))
if rangeMode == .all {
countRow("figure.walk", String(localized: "행동"), String(localized: "\(targetActionCount)"))
countRow("flag.checkered", String(localized: "목표"), String(localized: "\(targetGoalCount)"))
}
}
}
private var backupSection: some View {
Section {
Toggle(isOn: $includeCSV) {
Label("CSV 내보내기", systemImage: "tablecells")
}
Toggle(isOn: $includeDiary) {
Label("일기 PDF", systemImage: "book.closed")
}
Toggle(isOn: $includeReport) {
Label("정리 리포트", systemImage: "doc.richtext")
}
if includeReport {
Picker("리포트 형식", selection: $reportFormat) {
ForEach(ReportFormat.allCases) { format in
Text(format.label).tag(format)
}
}
.pickerStyle(.segmented)
}
if isGenerating {
HStack {
ProgressView()
Text("백업 만드는 중…")
.foregroundStyle(.secondary)
}
} else if backupURLs.isEmpty {
if includeCSV || includeDiary || includeReport {
Button {
makeBackups()
} label: {
Label("백업 파일 만들기", systemImage: "square.and.arrow.down.on.square")
.frame(maxWidth: .infinity)
}
}
} else {
ShareLink(items: backupURLs) {
Label("백업 파일 \(backupURLs.count)개 공유", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
Button("다시 만들기") {
backupURLs = []
}
.frame(maxWidth: .infinity)
}
} header: {
Text("삭제 전 백업")
} footer: {
Text("CSV는 '데이터 가져오기'로 복원할 수 있는 원본 백업이에요. 정리 리포트는 삭제할 기록을 한눈에 정리한 문서예요(기간·행동별 누적·꼬리표·월별·목표 이력). 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요.")
}
}
private var deleteSection: some View {
Section {
if isDeleting {
HStack {
ProgressView()
Text("삭제하는 중…")
.foregroundStyle(.secondary)
}
} else {
Button(role: .destructive) {
confirmingDelete = true
} label: {
Label(rangeMode == .all ? "전체 데이터 삭제…" : "지정한 날짜까지 삭제…",
systemImage: "trash")
.frame(maxWidth: .infinity)
}
.disabled(!canDelete)
}
} footer: {
if !canDelete && !isDeleting && deletedCounts == nil {
Text("먼저 위에서 백업 파일을 만들어 주세요. 백업 없이 삭제하려면 백업 항목을 모두 꺼요.")
}
}
}
private func doneSection(_ counts: DataReset.Counts) -> some View {
Section {
Label {
Text("삭제를 마쳤어요")
.font(.headline)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppTheme.green)
}
countRow("timer", String(localized: "시간 기록"), String(localized: "\(counts.sessions)"))
countRow("number", String(localized: "횟수 기록"), String(localized: "\(counts.entries)"))
countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(counts.diaryEntries)"))
if counts.actions > 0 || counts.goals > 0 {
countRow("figure.walk", String(localized: "행동"), String(localized: "\(counts.actions)"))
countRow("flag.checkered", String(localized: "목표"), String(localized: "\(counts.goals)"))
}
Button("완료") { dismiss() }
.frame(maxWidth: .infinity)
} footer: {
Text("iCloud 동기화를 켰다면 다른 기기에는 잠시 뒤에 반영돼요.")
}
}
private func countRow(_ symbol: String, _ title: String, _ value: String) -> some View {
HStack {
Label {
Text(title)
} icon: {
Image(systemName: symbol)
.foregroundStyle(AppTheme.green)
}
Spacer()
Text(value).foregroundStyle(.secondary)
}
}
// MARK: ( CSVExport·DiaryExportRenderer·ArchiveReport)
private func makeBackups(copyToDocuments: Bool = false) {
isGenerating = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(60))
var urls: [URL] = []
do {
let actions = try context.fetch(
FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)])
)
let sessions = try context.fetch(
FetchDescriptor<TimeSession>(sortBy: [SortDescriptor(\.startAt)])
)
let entries = try context.fetch(
FetchDescriptor<CountEntry>(sortBy: [SortDescriptor(\.timestamp)])
)
let goals = try context.fetch(FetchDescriptor<Goal>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))
let diaryDescriptor: FetchDescriptor<DiaryEntry> = {
if rangeMode == .all {
return FetchDescriptor<DiaryEntry>(sortBy: [SortDescriptor(\.dayKey)])
}
let key = endKey
return FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.dayKey <= key },
sortBy: [SortDescriptor(\.dayKey)]
)
}()
let diaryEntries = try context.fetch(diaryDescriptor).filter(\.hasContent)
if includeCSV {
// CSVExport.fileSuffix(for:) dayKeys distantPast
// '~'
var suffix = ""
if rangeMode == .upTo {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd"
suffix = "-upto-\(formatter.string(from: endKey))"
}
urls += CSVExport.makeFiles(
actions: actions, sessions: sessions, entries: entries, goals: goals,
range: backupRange, fileSuffix: suffix
)
}
if includeDiary, !diaryEntries.isEmpty {
let orderedActions = LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
let templates = try context.fetch(FetchDescriptor<DiaryTemplate>())
let renderer = DiaryExportRenderer(
context: context, orderedActions: orderedActions,
templates: templates, math: math
)
let slug = String(localized: "일기-정리-\(DiaryExportRenderer.slugDate(diaryEntries.first!.dayKey))~\(DiaryExportRenderer.slugDate(diaryEntries.last!.dayKey))")
if let url = renderer.pdfURL(entries: diaryEntries, fileSlug: slug) {
urls.append(url)
}
}
if includeReport {
if let url = ArchiveReport.makeURL(
format: reportFormat == .pdf ? .pdf : .image,
actions: actions, sessions: sessions, entries: entries, goals: goals,
diaryDayKeys: diaryEntries.map(\.dayKey),
range: backupRange, math: math
) {
urls.append(url)
}
}
} catch {
urls = []
}
backupURLs = urls
isGenerating = false
#if DEBUG
if copyToDocuments,
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
for url in urls {
let dest = docs.appendingPathComponent(url.lastPathComponent)
try? FileManager.default.removeItem(at: dest)
try? FileManager.default.copyItem(at: url, to: dest)
}
}
#endif
}
}
// MARK:
private func performDelete() {
isDeleting = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(60))
do {
let counts: DataReset.Counts
if rangeMode == .all {
counts = try DataReset.deleteAll(context: context)
} else {
counts = try DataReset.deleteRecords(upToDayKey: endKey, context: context, math: math)
}
// + ··LiveActivity ( )
DataChange.commit(context: context)
deletedCounts = counts
} catch {
deletedCounts = nil
}
isDeleting = false
}
}
}

View File

@ -265,10 +265,15 @@ struct SettingsView: View {
} label: {
Label("데이터 가져오기", systemImage: "square.and.arrow.down")
}
NavigationLink {
DataResetView()
} label: {
Label("데이터 정리·초기화", systemImage: "trash")
}
} header: {
Text("데이터")
} footer: {
Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요. '데이터 가져오기'(프리미엄)로는 내보냈던 CSV의 행동·기록을 다시 불러올 수 있어요.")
Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요. '데이터 가져오기'(프리미엄)로는 내보냈던 CSV의 행동·기록을 다시 불러올 수 있어요. '데이터 정리·초기화'는 오래 쌓인 기록을 백업해 두고 비울 때 써요.")
}
Section("지원") {
NavigationLink {