- 그룹=목표, 행=다짐(대상 아이콘·이름 + 주기 scheduleLabel[마감 시각 포함] + 목표량·방향 '3회 이상 달성'/'1시간 이하 유지') - 페이지 분할은 목표 그룹 단위(한 목표의 다짐이 페이지에 찢어지지 않음) - 신규 키 '목표별 다짐' en/ja 완역, 나머지는 기존 키 재사용 — 카탈로그 missing/stale 0 유지 - 벌크 1만 건(4페이지 스티치 1290×11042)·소량(iPad) 실물 검사 통과, Debug/Store 빌드 성공 - 검증 중 'Documents 산출물 0개' 소동은 macOS NFD 파일명 대 grep NFC 불일치 착시로 판명 — 코드 무결 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
419 lines
20 KiB
Swift
419 lines
20 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
|
|
))
|
|
}
|
|
|
|
// 목표별 다짐 표 — CSV에만 있던 다짐 구성(대상·주기·목표량)을 리포트에도 담는다
|
|
// (그룹 = 목표, 목표량 표기는 앱 용어 그대로 "3회 이상 달성"/"1시간 이하 유지")
|
|
var questGroups: [ExportTableData.Group] = []
|
|
for goal in goals {
|
|
let rows = goal.sortedQuests.map { quest in
|
|
ExportTableData.Row(
|
|
symbol: quest.targetSymbol, color: quest.targetColor, name: quest.targetName,
|
|
values: [quest.scheduleLabel,
|
|
"\(quest.targetValueLabel) \(quest.direction.label)"]
|
|
)
|
|
}
|
|
guard !rows.isEmpty else { continue }
|
|
questGroups.append(.init(label: goal.title, rows: rows))
|
|
}
|
|
// 페이지당 rowsPerPage행 기준으로 '그룹(목표) 단위' 분할 — 한 목표의 다짐들이
|
|
// 페이지에 걸쳐 찢어지지 않게 한다 (한 그룹이 자체로 넘치면 그 그룹만 한 페이지)
|
|
var questPageGroups: [[ExportTableData.Group]] = []
|
|
var currentGroups: [ExportTableData.Group] = []
|
|
var currentRows = 0
|
|
for group in questGroups {
|
|
if !currentGroups.isEmpty && currentRows + group.rows.count > rowsPerPage {
|
|
questPageGroups.append(currentGroups)
|
|
currentGroups = []
|
|
currentRows = 0
|
|
}
|
|
currentGroups.append(group)
|
|
currentRows += group.rows.count
|
|
}
|
|
if !currentGroups.isEmpty { questPageGroups.append(currentGroups) }
|
|
for (index, groups) in questPageGroups.enumerated() {
|
|
let suffix = index == 0 ? "" : String(localized: " (계속)")
|
|
let title = String(localized: "목표별 다짐") + suffix
|
|
pages.append(ExportSnapshot(
|
|
title: title, periodLabel: periodLabel, filterNote: nil,
|
|
hero: [],
|
|
sections: [.table(title: title, ExportTableData(
|
|
columns: [String(localized: "주기"), String(localized: "목표량")],
|
|
groups: groups
|
|
))],
|
|
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
|
|
}
|
|
}
|