mycode/myApp/HaruDanim/IOS/Core/ArchiveReport.swift
songyc macbook cf78a3ac7e 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
2026-08-11 17:11:51 +09:00

376 lines
18 KiB
Swift

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