mycode/myApp/HaruDanim/IOS/Core/ArchiveReport.swift
songyc macbook 62fa1a2d68 feat(report): 정리 리포트에 '목표별 다짐' 표 추가 — 검수 발견 C 해소
- 그룹=목표, 행=다짐(대상 아이콘·이름 + 주기 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
2026-08-11 18:36:44 +09:00

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