// // 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?, 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?, math: DayMath ) -> [ExportSnapshot] { let now = Date.now // ---- 1회 순회 집계: 행동별 합계 + 하루 키 집합 + 월별 합계 ---- var actionSeconds: [PersistentIdentifier: TimeInterval] = [:] var actionCounts: [PersistentIdentifier: Int] = [:] var recordedDays = Set() 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.. 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 } }