feat(export): CSV 내보내기 구간 지정 — 기록만 걸러내고 구조는 전체 유지, 코어를 CSVExport로 추출

- 기존 동작 검증: 전량 내보내기(무필터)였음을 확인 후 '전체(기본)/직접 지정' 세그먼트 추가
- 세션은 구간 겹침 포함(기록 탭 범위 문법), 구간 파일명 접미 -yyyyMMdd-yyyyMMdd
- 행동·목표·다짐 목록은 항상 전체(가져오기 연결·백업 온전성) — footer로 안내
- CSV 생성 코어 CSVExport 신설: 데이터 정리·초기화(예정)의 삭제 전 백업이 재사용
- -dataExportRangeDays 검증 인자, 전체(2031·8044건)↔최근 7일(4·1건) 실파일 대조 확인

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 16:48:01 +09:00
parent e7868f453c
commit 8c93da5ff3
2 changed files with 212 additions and 85 deletions

View File

@ -0,0 +1,131 @@
//
// CSVExport.swift
// Haru_Danim
//
// CSV (1.4 DataExportView ).
// ' ·'( ) .
// : ISO 8601 , UTF-8 BOM, · (§7),
// rawValue(time/count ) .
//
import Foundation
enum CSVExport {
/// (····) URL .
/// - range: nil = . **(·)**
/// ·· () ( · ).
/// ( ), .
/// - fileSuffix: (: "-20260713-20260811"). =
static func makeFiles(
actions: [Action],
sessions: [TimeSession],
entries: [CountEntry],
goals: [Goal],
range: Range<Date>? = nil,
fileSuffix: String = ""
) -> [URL] {
let filteredSessions: [TimeSession]
let filteredEntries: [CountEntry]
if let range {
let now = Date.now
filteredSessions = sessions.filter {
$0.startAt < range.upperBound && ($0.endAt ?? now) > range.lowerBound
}
filteredEntries = entries.filter { range.contains($0.timestamp) }
} else {
filteredSessions = sessions
filteredEntries = entries
}
var urls: [URL?] = []
urls.append(write(String(localized: "행동") + fileSuffix, header: [
"id", String(localized: "이름"), String(localized: "추적 방식"),
String(localized: "꼬리표"), String(localized: "즐겨찾기"), String(localized: "만든날"),
],
rows: actions.map { action in
[action.uuid.uuidString, action.name,
action.trackingType == .time ? "time" : "count",
action.sortedTags.map(\.name).joined(separator: "; "),
action.isFavorite ? "true" : "false",
iso(action.createdAt)]
}))
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)") + fileSuffix, header: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시작"),
String(localized: "종료"), String(localized: ""), String(localized: "메모"),
],
rows: filteredSessions.compactMap { session in
guard let action = session.action else { return nil }
return [action.uuid.uuidString, action.name,
iso(session.startAt),
session.endAt.map(iso) ?? "",
String(Int(session.duration())),
session.note]
}))
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)") + fileSuffix, header: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시각"),
String(localized: "수량"), String(localized: "메모"),
],
rows: filteredEntries.compactMap { entry in
guard let action = entry.action else { return nil }
return [action.uuid.uuidString, action.name,
iso(entry.timestamp), String(entry.amount), entry.note]
}))
urls.append(write(String(localized: "목표") + fileSuffix, header: [
"id", String(localized: "내용"), String(localized: "시작일"),
String(localized: "종료일"), String(localized: "상태"), String(localized: "달성기준%"),
],
rows: goals.map { goal in
[goal.uuid.uuidString, goal.title, iso(goal.startDate),
goal.endDate.map(iso) ?? "", goal.statusRaw,
String(goal.achieveThresholdPercent)]
}))
urls.append(write(String(localized: "다짐") + fileSuffix, header: [
"id", String(localized: "목표"), String(localized: "대상"),
String(localized: "주기"), String(localized: "목표량"), String(localized: "방향"),
],
rows: goals.flatMap(\.sortedQuests).map { quest in
[quest.uuid.uuidString, quest.goal?.title ?? "",
quest.targetName, quest.scheduleLabel,
quest.measure == .time ? String(Int(quest.targetSeconds)) + "s" : String(quest.targetCount),
quest.direction == .atLeast ? "atLeast" : "atMost"]
}))
return urls.compactMap(\.self)
}
/// ("-yyyyMMdd-yyyyMMdd") ,
static func fileSuffix(for range: Range<Date>, math: DayMath) -> String {
let keys = math.dayKeys(in: range)
guard let first = keys.first, let last = keys.last else { return "" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd"
return "-\(formatter.string(from: first))-\(formatter.string(from: last))"
}
private static func iso(_ date: Date) -> String {
date.formatted(.iso8601)
}
/// CSV (·· )
private static func escape(_ field: String) -> String {
if field.contains(",") || field.contains("\"") || field.contains("\n") {
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
return field
}
private static func write(_ name: String, header: [String], rows: [[String]]) -> URL? {
let lines = [header.map(escape).joined(separator: ",")]
+ rows.map { $0.map(escape).joined(separator: ",") }
// UTF-8 BOM
let content = "\u{FEFF}" + lines.joined(separator: "\n")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
do {
try content.write(to: url, atomically: true, encoding: .utf8)
return url
} catch {
return nil
}
}
}

View File

@ -6,6 +6,8 @@
// CSV ·· .
// (ExportImageView) .
// ( ).
// 1.4: (·) ··
// . CSV CSVExport( ) .
//
import SwiftUI
@ -17,15 +19,71 @@ struct DataExportView: View {
@Query(sort: \CountEntry.timestamp) private var entries: [CountEntry]
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
private enum RangeMode: String, CaseIterable, Identifiable {
case all, custom
var id: String { rawValue }
var label: String {
switch self {
case .all: return String(localized: "전체")
case .custom: return String(localized: "직접 지정")
}
}
}
@State private var rangeMode: RangeMode = .all
@State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -30, to: .now) ?? .now
@State private var endDate: Date = .now
@State private var isRendering = false
@State private var resultURLs: [URL] = []
private var math: DayMath { DayMath() }
/// (§4.1: startOfDay )
private var selectedRange: Range<Date>? {
guard rangeMode == .custom else { return nil }
let startKey = math.calendar.startOfDay(for: min(startDate, endDate))
let endKey = math.calendar.startOfDay(for: max(startDate, endDate))
return math.dayRange(forKey: startKey).lowerBound..<math.dayRange(forKey: endKey).upperBound
}
///
private var visibleSessionCount: Int {
guard let range = selectedRange else { return sessions.count }
let now = Date.now
return sessions.count {
$0.startAt < range.upperBound && ($0.endAt ?? now) > range.lowerBound
}
}
private var visibleEntryCount: Int {
guard let range = selectedRange else { return entries.count }
return entries.count { range.contains($0.timestamp) }
}
var body: some View {
List {
Section {
Picker("구간", selection: $rangeMode) {
ForEach(RangeMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
if rangeMode == .custom {
DatePicker("부터", selection: $startDate, in: ...endDate, displayedComponents: .date)
DatePicker("까지", selection: $endDate, in: startDate..., displayedComponents: .date)
}
} header: {
Text("내보낼 구간")
} footer: {
if rangeMode == .custom {
Text("시간·횟수 기록만 구간으로 걸러져요. 행동·목표·다짐 목록은 항상 전체가 담겨요 (가져오기에서 기록을 행동과 연결하는 데 필요해요).")
}
}
Section {
row("figure.walk", String(localized: "행동"), String(localized: "\(actions.count)"))
row("timer", String(localized: "시간 기록"), String(localized: "\(sessions.count)"))
row("number", String(localized: "횟수 기록"), String(localized: "\(entries.count)"))
row("timer", String(localized: "시간 기록"), String(localized: "\(visibleSessionCount)"))
row("number", String(localized: "횟수 기록"), String(localized: "\(visibleEntryCount)"))
row("flag.checkered", String(localized: "목표·다짐"), String(localized: "\(goals.count)"))
} header: {
Text("내보낼 데이터")
@ -64,8 +122,18 @@ struct DataExportView: View {
.navigationTitle("데이터 내보내기")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.onChange(of: rangeMode) { resultURLs = [] }
.onChange(of: startDate) { resultURLs = [] }
.onChange(of: endDate) { resultURLs = [] }
#if DEBUG
.onAppear {
// : -dataExportRangeDays <N> N
let rangeDays = UserDefaults.standard.integer(forKey: "dataExportRangeDays")
if rangeDays > 0 {
rangeMode = .custom
startDate = math.calendar.date(byAdding: .day, value: -(rangeDays - 1), to: .now) ?? .now
endDate = .now
}
// : -dataExportRun YES Documents
if UserDefaults.standard.bool(forKey: "dataExportRun") {
export()
@ -87,66 +155,21 @@ struct DataExportView: View {
}
}
// MARK: CSV
// MARK: CSV ( CSVExport )
private func export() {
isRendering = true
Task { @MainActor in
var urls: [URL?] = []
// ·( ) ,
// UI. rawValue(time/count ) .
urls.append(write(String(localized: "행동"), header: [
"id", String(localized: "이름"), String(localized: "추적 방식"),
String(localized: "꼬리표"), String(localized: "즐겨찾기"), String(localized: "만든날"),
],
rows: actions.map { action in
[action.uuid.uuidString, action.name,
action.trackingType == .time ? "time" : "count",
action.sortedTags.map(\.name).joined(separator: "; "),
action.isFavorite ? "true" : "false",
iso(action.createdAt)]
}))
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)"), header: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시작"),
String(localized: "종료"), String(localized: ""), String(localized: "메모"),
],
rows: sessions.compactMap { session in
guard let action = session.action else { return nil }
return [action.uuid.uuidString, action.name,
iso(session.startAt),
session.endAt.map(iso) ?? "",
String(Int(session.duration())),
session.note]
}))
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)"), header: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시각"),
String(localized: "수량"), String(localized: "메모"),
],
rows: entries.compactMap { entry in
guard let action = entry.action else { return nil }
return [action.uuid.uuidString, action.name,
iso(entry.timestamp), String(entry.amount), entry.note]
}))
urls.append(write(String(localized: "목표"), header: [
"id", String(localized: "내용"), String(localized: "시작일"),
String(localized: "종료일"), String(localized: "상태"), String(localized: "달성기준%"),
],
rows: goals.map { goal in
[goal.uuid.uuidString, goal.title, iso(goal.startDate),
goal.endDate.map(iso) ?? "", goal.statusRaw,
String(goal.achieveThresholdPercent)]
}))
urls.append(write(String(localized: "다짐"), header: [
"id", String(localized: "목표"), String(localized: "대상"),
String(localized: "주기"), String(localized: "목표량"), String(localized: "방향"),
],
rows: goals.flatMap(\.sortedQuests).map { quest in
[quest.uuid.uuidString, quest.goal?.title ?? "",
quest.targetName, quest.scheduleLabel,
quest.measure == .time ? String(Int(quest.targetSeconds)) + "s" : String(quest.targetCount),
quest.direction == .atLeast ? "atLeast" : "atMost"]
}))
resultURLs = urls.compactMap(\.self)
let range = selectedRange
let suffix = range.map { CSVExport.fileSuffix(for: $0, math: math) } ?? ""
resultURLs = CSVExport.makeFiles(
actions: actions,
sessions: sessions,
entries: entries,
goals: goals,
range: range,
fileSuffix: suffix
)
isRendering = false
#if DEBUG
if UserDefaults.standard.bool(forKey: "dataExportRun"),
@ -160,31 +183,4 @@ struct DataExportView: View {
#endif
}
}
private func iso(_ date: Date) -> String {
date.formatted(.iso8601)
}
/// CSV (·· )
private func escape(_ field: String) -> String {
if field.contains(",") || field.contains("\"") || field.contains("\n") {
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
return field
}
private func write(_ name: String, header: [String], rows: [[String]]) -> URL? {
let lines = [header.map(escape).joined(separator: ",")]
+ rows.map { $0.map(escape).joined(separator: ",") }
// UTF-8 BOM
let content = "\u{FEFF}" + lines.joined(separator: "\n")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
do {
try content.write(to: url, atomically: true, encoding: .utf8)
return url
} catch {
return nil
}
}
}