- 보고된 버그: 주 2회 다짐을 목요일에 1회 수행 → 목표 탭 하루 진행률 350% (1 ÷ 주목표 2/7). 주기와 다른 span의 환산 목표는 페이스 가이드일 뿐인데 초과분까지 퍼센트로 표기되던 왜곡 - 수정: spanProgress에서 '이상 달성'이면서 환산 목표 span일 때 값을 목표에서 캡 (usesScaledTarget 분기) — 자기 주기 span(하루 다짐의 '하루' 200% 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, '이하 유지'는 값 무캡(UI 초과 판정·페이스 경고 유지). 게이지(ratio)·달성 판정·연속·목표 종료 판정은 원래 캡·별도 경로라 무영향 — 바뀌는 것은 퍼센트 문구뿐 - 검증 도구 신설: -progressSelfTest (DEBUG) — 인메모리 컨테이너에 12시나리오·33검증(보고 케이스, 주간/월간/요일 스케줄/마감/atMost/특정 기간/잔존 deadline/무기록 + 목표 합산 회귀) 구성해 손계산 기대값과 대조, ALL PASS. 설정·날짜 고정이라 언제 실행해도 동일 판정 - 화면 확인: 목표 탭 '꾸준한 달리기'(주 2시간) 하루 233%→100%, 주간 67%·월간 30% 유지, 매일 다짐 117%/133%(자기 주기)·atMost·마감 다짐 표기 불변 - 도움말 '진행률 읽는 법'에 페이스 기준 설명 추가(en/ja 완비), CLAUDE.md §4.2 규칙·§14 인자 기재 - §2.3 l10n 루틴 강화: stringsdata를 Objects-normal/arm64로 한정 — x86_64·Release 등 낡은 구성 파일이 수정 전 문구를 계속 추출시키는 오염 발견·수정 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
684 lines
36 KiB
Swift
684 lines
36 KiB
Swift
//
|
||
// DebugSeed.swift
|
||
// Haru_Danim
|
||
//
|
||
// 개발 검증용 데모 데이터. 런치 인자 `-seedDemo YES`가 있을 때만 동작. (DEBUG 전용)
|
||
//
|
||
|
||
#if DEBUG
|
||
import Foundation
|
||
import SwiftData
|
||
|
||
private extension Error {
|
||
/// 인텐트 오류를 사람이 읽을 문구로 (인텐트 오류 타입의 localizedStringResource 우선)
|
||
var asIntentMessage: LocalizedStringResource {
|
||
if let convertible = self as? any CustomLocalizedStringResourceConvertible {
|
||
return convertible.localizedStringResource
|
||
}
|
||
return "\(localizedDescription)"
|
||
}
|
||
}
|
||
|
||
enum DebugSeed {
|
||
/// `-autoStart <행동이름>` 런치 인자가 있으면 해당 행동의 시간 측정을 시작 (Live Activity 검증용)
|
||
static func autoStartIfRequested(context: ModelContext) {
|
||
guard let name = UserDefaults.standard.string(forKey: "autoStart") else { return }
|
||
let descriptor = FetchDescriptor<Action>(predicate: #Predicate { $0.name == name })
|
||
guard let action = try? context.fetch(descriptor).first else { return }
|
||
if action.runningSession == nil {
|
||
context.insert(TimeSession(action: action, startAt: .now))
|
||
}
|
||
LiveActivityManager.sync(context: context)
|
||
}
|
||
|
||
/// `-intentSmokeTest YES` 런치 인자: 시리 단축어 인텐트 6종의 perform()을 앱 안에서
|
||
/// 직접 실행해 보는 스모크 테스트. 결과는 [IntentTest] 프리픽스로 콘솔에 출력된다
|
||
/// (`xcrun simctl launch --console`로 확인). 데모 데이터(-seedDemo) 위에서 실행할 것.
|
||
@MainActor
|
||
static func runIntentSmokeTestIfRequested() async {
|
||
guard UserDefaults.standard.bool(forKey: "intentSmokeTest") else { return }
|
||
var lines: [String] = []
|
||
func log(_ name: String, _ result: String) {
|
||
print("[IntentTest] \(name): \(result)")
|
||
lines.append("\(name): \(result)")
|
||
}
|
||
defer {
|
||
// CLI에서 읽을 수 있게 결과를 Documents에도 남긴다
|
||
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||
try? lines.joined(separator: "\n")
|
||
.write(to: docs.appendingPathComponent("intent-smoke-test.txt"),
|
||
atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
func entity(_ name: String) -> ActionEntity? {
|
||
IntentStore.actions().first { $0.name == name }.map(ActionEntity.init)
|
||
}
|
||
guard let running = entity("달리기"), let water = entity("물 마시기") else {
|
||
log("준비", "실패 — 데모 행동 없음 (-seedDemo 필요)")
|
||
return
|
||
}
|
||
let math = DayMath()
|
||
let agg = Aggregator(math: math)
|
||
|
||
// ① 시간 측정 시작 → 진행 세션 생김
|
||
do {
|
||
let intent = StartTimeActionIntent()
|
||
intent.action = running
|
||
_ = try await intent.perform()
|
||
let model = try IntentStore.action(running.id)
|
||
log("측정 시작", model.isRunning ? "OK — 진행 중 세션 생성" : "실패 — 세션 없음")
|
||
} catch { log("측정 시작", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ② 이미 측정 중일 때 다시 시작 → 중복 세션이 생기면 안 됨
|
||
do {
|
||
let intent = StartTimeActionIntent()
|
||
intent.action = running
|
||
_ = try await intent.perform()
|
||
let count = try IntentStore.action(running.id).sessions.filter { $0.endAt == nil }.count
|
||
log("중복 시작 방지", count == 1 ? "OK — 진행 세션 1개 유지" : "실패 — 진행 세션 \(count)개")
|
||
} catch { log("중복 시작 방지", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ③ 측정 종료 → 진행 세션 없어짐
|
||
do {
|
||
let intent = StopTimeActionIntent()
|
||
intent.action = running
|
||
_ = try await intent.perform()
|
||
let model = try IntentStore.action(running.id)
|
||
log("측정 종료", model.isRunning ? "실패 — 아직 진행 중" : "OK — 종료됨")
|
||
} catch { log("측정 종료", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ④ 횟수 추가 → 오늘 누적 +3
|
||
do {
|
||
let before = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now))
|
||
let intent = AddCountIntent()
|
||
intent.action = water
|
||
intent.amount = 3
|
||
_ = try await intent.perform()
|
||
let after = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now))
|
||
log("횟수 추가", after == before + 3 ? "OK — \(before) → \(after)" : "실패 — \(before) → \(after)")
|
||
} catch { log("횟수 추가", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ⑤ 타입 불일치 방어: 시간형 행동에 횟수 추가 → IntentTargetError
|
||
do {
|
||
let intent = AddCountIntent()
|
||
intent.action = running
|
||
intent.amount = 1
|
||
_ = try await intent.perform()
|
||
log("타입 방어", "실패 — 시간형에 횟수 추가가 통과됨")
|
||
} catch { log("타입 방어", "OK — 거부: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ⑤-2 위젯 실행 버튼(RunActionIntent): 시간형 시작 → 다시 실행하면 종료 토글
|
||
do {
|
||
_ = try await RunActionIntent(actionID: running.id.uuidString).perform()
|
||
let startedOK = try IntentStore.action(running.id).isRunning
|
||
_ = try await RunActionIntent(actionID: running.id.uuidString).perform()
|
||
let stoppedOK = try !IntentStore.action(running.id).isRunning
|
||
log("위젯 실행 토글", startedOK && stoppedOK ? "OK — 시작 후 토글 종료" : "실패 — 시작=\(startedOK) 종료=\(stoppedOK)")
|
||
} catch { log("위젯 실행 토글", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ⑥ 조회 인텐트 3종 — throw 없이 완료되는지
|
||
do {
|
||
let total = ActionTotalIntent()
|
||
total.action = running
|
||
total.span = .day
|
||
_ = try await total.perform()
|
||
log("누적값 조회", "OK — 하루 \(Format.durationShort(agg.seconds(for: try IntentStore.action(running.id), in: math.dayRange(containing: .now))))")
|
||
} catch { log("누적값 조회", "throw: \(String(localized: error.asIntentMessage))") }
|
||
do {
|
||
guard let goal = IntentStore.goals().first(where: { !$0.quests.isEmpty }) else {
|
||
log("목표 진행률", "건너뜀 — 다짐 있는 목표 없음"); return
|
||
}
|
||
let intent = GoalProgressIntent()
|
||
intent.goal = GoalEntity(goal)
|
||
intent.span = .day
|
||
_ = try await intent.perform()
|
||
log("목표 진행률", "OK — '\(goal.title)' 하루 \(Int((goal.combinedSpanRatio(.day) * 100).rounded()))%")
|
||
if let quest = goal.sortedQuests.first {
|
||
let questIntent = QuestProgressIntent()
|
||
questIntent.quest = QuestEntity(quest)
|
||
questIntent.span = .week
|
||
_ = try await questIntent.perform()
|
||
log("다짐 진행률", "OK — '\(quest.targetName)' 주간 \(Format.percent(QuestProgress(quest: quest).spanProgress(.week).displayRatio))")
|
||
}
|
||
} catch { log("목표/다짐 진행률", "throw: \(String(localized: error.asIntentMessage))") }
|
||
|
||
// ⑦ 프리미엄 게이트: 잠금 상태에서 실행 → PremiumRequiredError
|
||
let wasPremium = PremiumGate.isPremium
|
||
PremiumGate.cache(false)
|
||
do {
|
||
let intent = StartTimeActionIntent()
|
||
intent.action = running
|
||
_ = try await intent.perform()
|
||
log("프리미엄 게이트", "실패 — 잠금 상태에서 실행됨")
|
||
} catch { log("프리미엄 게이트", "OK — 거부: \(String(localized: error.asIntentMessage))") }
|
||
PremiumGate.cache(wasPremium)
|
||
log("완료", "스모크 테스트 끝")
|
||
}
|
||
|
||
/// `-progressSelfTest YES` 런치 인자: 다짐 진행률(spanProgress) 수치 검증.
|
||
/// 인메모리 컨테이너에 시나리오들을 구성해 하루/주간/월간의 퍼센트 문구(displayRatio)·
|
||
/// 게이지(ratio)·값을 손계산 기대값과 대조하고 결과를 Documents/progress-self-test.txt로 남긴다.
|
||
/// 설정은 주 시작 월요일·하루 시작 00:00으로 고정하고 기준 시각은 이번 주 목요일 12:00 —
|
||
/// 기기 설정·실행 날짜와 무관하게 항상 같은 판정이 나온다 (실데이터 무영향).
|
||
/// 핵심 규칙: 주기와 다른 span의 환산 목표는 '이상 달성' 초과 표기를 100%에서 캡,
|
||
/// 자기 주기 span·특정 기간은 초과 표기 유지, '이하 유지'는 값 무캡 (§4.2).
|
||
@MainActor
|
||
static func runProgressSelfTestIfRequested() {
|
||
guard UserDefaults.standard.bool(forKey: "progressSelfTest") else { return }
|
||
var lines: [String] = []
|
||
var failures = 0
|
||
|
||
func expect(_ label: String, _ actual: Double, _ expected: Double) {
|
||
let pass = abs(actual - expected) < 0.0005
|
||
if !pass { failures += 1 }
|
||
lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(String(format: "%.4f", actual)) / expected \(String(format: "%.4f", expected))")
|
||
}
|
||
|
||
do {
|
||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||
let container = try ModelContainer(for: DataStore.schema, configurations: [config])
|
||
let context = container.mainContext
|
||
|
||
let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0))
|
||
let cal = math.calendar
|
||
let weekStart = math.weekRange(containing: .now).lowerBound
|
||
/// 주 시작(월) + offset일의 hour:minute 시각
|
||
func at(_ dayOffset: Int, _ hour: Int, _ minute: Int = 0) -> Date {
|
||
cal.date(byAdding: .minute, value: hour * 60 + minute,
|
||
to: cal.date(byAdding: .day, value: dayOffset, to: weekStart)!)!
|
||
}
|
||
let now = at(3, 12) // 이번 주 목요일 12:00
|
||
let monthDays = Double(math.dayKeys(in: math.monthRange(containing: now)).count)
|
||
lines.append("기준: 목요일 12:00 (\(now)), 이번 달 일수 \(Int(monthDays))")
|
||
|
||
/// 시나리오 하나 = 전용 목표 + 전용 행동 + 다짐 (교차 오염 방지)
|
||
func makeQuest(_ name: String, type: TrackingType, period: QuestPeriod,
|
||
direction: QuestDirection = .atLeast,
|
||
targetSeconds: Double = 3600, targetCount: Int = 1,
|
||
configure: (Quest) -> Void = { _ in }) -> (Action, Quest) {
|
||
let goal = Goal(title: name, symbolName: "flag.fill", colorHex: "#2F6B4F",
|
||
startDate: cal.date(byAdding: .day, value: -30, to: now)!, endDate: nil)
|
||
context.insert(goal)
|
||
let action = Action(name: name, symbolName: "star.fill", trackingType: type, sortOrder: 0)
|
||
context.insert(action)
|
||
let quest = Quest(goal: goal)
|
||
quest.targetAction = action
|
||
quest.measure = type
|
||
quest.period = period
|
||
quest.direction = direction
|
||
quest.targetSeconds = targetSeconds
|
||
quest.targetCount = targetCount
|
||
configure(quest)
|
||
context.insert(quest)
|
||
return (action, quest)
|
||
}
|
||
func add(_ action: Action, _ when: Date, amount: Int = 1) {
|
||
context.insert(CountEntry(action: action, timestamp: when, amount: amount))
|
||
}
|
||
func addSession(_ action: Action, _ start: Date, _ end: Date) {
|
||
context.insert(TimeSession(action: action, startAt: start, endAt: end))
|
||
}
|
||
func progress(_ quest: Quest, _ span: StatSpan) -> QuestProgressResult {
|
||
QuestProgress(quest: quest, math: math).spanProgress(span, now: now)
|
||
}
|
||
|
||
// ── A. 주 2회 다짐, 오늘 1회 (보고된 버그: 하루 350% → 100%)
|
||
let (aAct, aQuest) = makeQuest("A 주2회", type: .count, period: .weekly, targetCount: 2)
|
||
add(aAct, at(3, 10))
|
||
expect("A 하루 표기(버그 수정)", progress(aQuest, .day).displayRatio, 1.0)
|
||
expect("A 하루 게이지", progress(aQuest, .day).ratio, 1.0)
|
||
expect("A 주간 표기", progress(aQuest, .week).displayRatio, 0.5)
|
||
expect("A 월간 표기(환산 캡 이하)", progress(aQuest, .month).displayRatio, 7.0 / (2.0 * monthDays))
|
||
if let goal = aQuest.goal {
|
||
expect("A 목표 하루 진행률(회귀 무영향)", goal.combinedSpanRatio(.day, math: math, now: now), 1.0)
|
||
expect("A 목표 주간 진행률", goal.combinedSpanRatio(.week, math: math, now: now), 0.5)
|
||
}
|
||
|
||
// ── B. 주 2회 다짐, 월·화·목 3회 (자기 주기 초과 표기 유지)
|
||
let (bAct, bQuest) = makeQuest("B 주2회 3번", type: .count, period: .weekly, targetCount: 2)
|
||
add(bAct, at(0, 10)); add(bAct, at(1, 10)); add(bAct, at(3, 10))
|
||
expect("B 주간 표기(자기 주기 150% 유지)", progress(bQuest, .week).displayRatio, 1.5)
|
||
expect("B 주간 게이지", progress(bQuest, .week).ratio, 1.0)
|
||
expect("B 하루 표기(캡)", progress(bQuest, .day).displayRatio, 1.0)
|
||
|
||
// ── C. 주 7시간 다짐, 오늘 2시간 (시간형도 동일 규칙: 하루 200% → 100%)
|
||
let (cAct, cQuest) = makeQuest("C 주7시간", type: .time, period: .weekly, targetSeconds: 7 * 3600)
|
||
addSession(cAct, at(3, 9), at(3, 11))
|
||
expect("C 하루 표기(캡)", progress(cQuest, .day).displayRatio, 1.0)
|
||
expect("C 주간 표기", progress(cQuest, .week).displayRatio, 2.0 / 7.0)
|
||
|
||
// ── D. 하루 5회 다짐, 오늘 10회 (자기 주기 200% 표기 유지 + 주간 하루 기여 캡)
|
||
let (dAct, dQuest) = makeQuest("D 하루5회", type: .count, period: .daily, targetCount: 5)
|
||
add(dAct, at(3, 9), amount: 10)
|
||
expect("D 하루 표기(자기 주기 200% 유지)", progress(dQuest, .day).displayRatio, 2.0)
|
||
expect("D 하루 게이지", progress(dQuest, .day).ratio, 1.0)
|
||
expect("D 주간 표기(하루 기여 캡)", progress(dQuest, .week).displayRatio, 5.0 / 35.0)
|
||
|
||
// ── E. 월수금 하루 5회 다짐: 월 5회, 수 10회 (수는 5로 캡, 목표 = 5×적용일 3)
|
||
let (eAct, eQuest) = makeQuest("E 월수금5회", type: .count, period: .daily, targetCount: 5) {
|
||
$0.scheduleMode = .weekdays
|
||
$0.weekdays = [2, 4, 6]
|
||
}
|
||
add(eAct, at(0, 9), amount: 5); add(eAct, at(2, 9), amount: 10)
|
||
expect("E 주간 표기", progress(eQuest, .week).displayRatio, 10.0 / 15.0)
|
||
expect("E 하루 표기(목요일 0회)", progress(eQuest, .day).displayRatio, 0.0)
|
||
|
||
// ── F. 월 10회 다짐, 오늘 1회 (하루 환산 캡 + 주간 환산 정확도)
|
||
let (fAct, fQuest) = makeQuest("F 월10회", type: .count, period: .monthly, targetCount: 10)
|
||
add(fAct, at(3, 10))
|
||
expect("F 하루 표기(캡)", progress(fQuest, .day).displayRatio, 1.0)
|
||
expect("F 주간 표기", progress(fQuest, .week).displayRatio, monthDays / 70.0)
|
||
expect("F 월간 표기", progress(fQuest, .month).displayRatio, 0.1)
|
||
|
||
// ── G. 월 10회 다짐, 오늘 12회 (자기 주기 120% 유지, 하루·주간은 캡)
|
||
let (gAct, gQuest) = makeQuest("G 월10회 12번", type: .count, period: .monthly, targetCount: 10)
|
||
add(gAct, at(3, 10), amount: 12)
|
||
expect("G 월간 표기(자기 주기 120% 유지)", progress(gQuest, .month).displayRatio, 1.2)
|
||
expect("G 하루 표기(캡)", progress(gQuest, .day).displayRatio, 1.0)
|
||
expect("G 주간 표기(캡)", progress(gQuest, .week).displayRatio, 1.0)
|
||
|
||
// ── H. '이하 유지' 주 7시간, 오늘 3시간 (값 무캡 — 하루 페이스 경고 0%, 주간 100%)
|
||
let (hAct, hQuest) = makeQuest("H 이하7시간", type: .time, period: .weekly,
|
||
direction: .atMost, targetSeconds: 7 * 3600)
|
||
addSession(hAct, at(3, 9), at(3, 12))
|
||
expect("H 하루 게이지(환산 한도 초과 경고)", progress(hQuest, .day).ratio, 0.0)
|
||
expect("H 하루 값(무캡 — UI 초과 판정용)", progress(hQuest, .day).value, 3 * 3600)
|
||
expect("H 주간 게이지(한도 안)", progress(hQuest, .week).ratio, 1.0)
|
||
|
||
// ── I. 특정 기간(10일) 10회 다짐, 오늘 12회 (환산 없음 — 전 span 초과 표기 유지)
|
||
let (iAct, iQuest) = makeQuest("I 기간10회", type: .count, period: .custom, targetCount: 10) {
|
||
$0.customStart = at(0, 0)
|
||
$0.customEnd = at(9, 0)
|
||
}
|
||
add(iAct, at(3, 10), amount: 12)
|
||
expect("I 하루 표기(기간 다짐 무캡)", progress(iQuest, .day).displayRatio, 1.2)
|
||
expect("I 주간 표기(기간 다짐 무캡)", progress(iQuest, .week).displayRatio, 1.2)
|
||
|
||
// ── J. 주간 다짐에 잔존 deadlineMinutes (하루 단위 전용 — 무시돼 A와 동일해야 함)
|
||
let (jAct, jQuest) = makeQuest("J 주2회+잔존마감", type: .count, period: .weekly, targetCount: 2) {
|
||
$0.deadlineMinutes = 480
|
||
}
|
||
add(jAct, at(3, 10))
|
||
expect("J 하루 표기(마감 무시)", progress(jQuest, .day).displayRatio, 1.0)
|
||
expect("J 주간 표기(마감 무시)", progress(jQuest, .week).displayRatio, 0.5)
|
||
|
||
// ── K. 하루 1회 + 마감 08:00 (마감 회귀 확인: 07:30은 인정, 08:10은 제외)
|
||
let (kAct, kQuest) = makeQuest("K 마감내", type: .count, period: .daily, targetCount: 1) {
|
||
$0.deadlineMinutes = 480
|
||
}
|
||
add(kAct, at(3, 7, 30))
|
||
expect("K 하루 표기(마감 안 1회)", progress(kQuest, .day).displayRatio, 1.0)
|
||
let (k2Act, k2Quest) = makeQuest("K2 마감밖", type: .count, period: .daily, targetCount: 1) {
|
||
$0.deadlineMinutes = 480
|
||
}
|
||
add(k2Act, at(3, 8, 10))
|
||
expect("K2 하루 표기(마감 뒤 제외)", progress(k2Quest, .day).displayRatio, 0.0)
|
||
|
||
// ── L. 기록 없는 주간 다짐 (0% 기본)
|
||
let (_, lQuest) = makeQuest("L 무기록", type: .count, period: .weekly, targetCount: 2)
|
||
expect("L 하루 표기", progress(lQuest, .day).displayRatio, 0.0)
|
||
expect("L 주간 표기", progress(lQuest, .week).displayRatio, 0.0)
|
||
} catch {
|
||
failures += 1
|
||
lines.append("FAIL 컨테이너 생성: \(error)")
|
||
}
|
||
|
||
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
|
||
for line in lines { print("[ProgressTest] \(line)") }
|
||
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||
try? lines.joined(separator: "\n")
|
||
.write(to: docs.appendingPathComponent("progress-self-test.txt"),
|
||
atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
|
||
/// `-seedBulk YES` 런치 인자: 성능 검증용 대량 데이터 생성 —
|
||
/// 과거 500일에 걸쳐 시간 행동 2개(하루 2세션) + 횟수 행동 2개(하루 8건),
|
||
/// 총 약 1만 건. 범위 조회 전환 후에도 기록·통계 탭이 쾌적한지 확인하는 용도.
|
||
static func seedBulkIfRequested(context: ModelContext) {
|
||
guard UserDefaults.standard.bool(forKey: "seedBulk") else { return }
|
||
let marker = "벌크 시간 1"
|
||
let existing = (try? context.fetchCount(
|
||
FetchDescriptor<Action>(predicate: #Predicate { $0.name == marker })
|
||
)) ?? 0
|
||
guard existing == 0 else { return }
|
||
|
||
let tag = Tag(name: "벌크", colorHex: "#4F8A8B")
|
||
tag.sortOrder = 90
|
||
context.insert(tag)
|
||
var timeActions: [Action] = []
|
||
for (index, name) in ["벌크 시간 1", "벌크 시간 2"].enumerated() {
|
||
let action = Action(name: name, symbolName: "clock.fill", trackingType: .time,
|
||
sortOrder: 90 + index)
|
||
action.tags = [tag]
|
||
context.insert(action)
|
||
timeActions.append(action)
|
||
}
|
||
var countActions: [Action] = []
|
||
for (index, name) in ["벌크 횟수 1", "벌크 횟수 2"].enumerated() {
|
||
let action = Action(name: name, symbolName: "number", trackingType: .count,
|
||
sortOrder: 92 + index)
|
||
action.tags = [tag]
|
||
context.insert(action)
|
||
countActions.append(action)
|
||
}
|
||
|
||
let cal = Calendar.current
|
||
let today = cal.startOfDay(for: .now)
|
||
for dayOffset in 1...500 {
|
||
guard let day = cal.date(byAdding: .day, value: -dayOffset, to: today) else { continue }
|
||
for (slot, action) in timeActions.enumerated() {
|
||
for hour in [9 + slot * 2, 19 + slot] {
|
||
let start = day.addingTimeInterval(TimeInterval(hour) * 3600)
|
||
context.insert(TimeSession(action: action, startAt: start,
|
||
endAt: start.addingTimeInterval(35 * 60)))
|
||
}
|
||
}
|
||
for action in countActions {
|
||
for slot in 0..<8 {
|
||
context.insert(CountEntry(
|
||
action: action,
|
||
timestamp: day.addingTimeInterval(TimeInterval(8 + slot) * 3600)
|
||
))
|
||
}
|
||
}
|
||
}
|
||
try? context.save()
|
||
}
|
||
|
||
/// `-endGoalYesterday <목표 제목>` 런치 인자: 해당 목표의 종료일을 어제로 변경
|
||
/// (목표 탭을 열지 않아도 실행 시점에 자동 판정되는지 검증용)
|
||
static func endGoalYesterdayIfRequested(context: ModelContext) {
|
||
guard let title = UserDefaults.standard.string(forKey: "endGoalYesterday") else { return }
|
||
let descriptor = FetchDescriptor<Goal>(predicate: #Predicate { $0.title == title })
|
||
guard let goal = try? context.fetch(descriptor).first else { return }
|
||
goal.endDate = Calendar.current.date(byAdding: .day, value: -1, to: .now)
|
||
try? context.save()
|
||
}
|
||
|
||
/// `-streakDump` 런치 인자: 모든 다짐의 연속 달성 계산 결과를
|
||
/// Documents/streak-dump.txt로 기록 (CLI에서 수치 검증용)
|
||
static func dumpStreaksIfRequested(context: ModelContext) {
|
||
guard UserDefaults.standard.bool(forKey: "streakDump") else { return }
|
||
let quests = (try? context.fetch(FetchDescriptor<Quest>())) ?? []
|
||
var lines: [String] = []
|
||
for quest in quests {
|
||
let goal = quest.goal
|
||
let streak = QuestProgress(quest: quest).streak()
|
||
lines.append(
|
||
"goal=\(goal?.title ?? "-") start=\(goal?.startDate.formatted(date: .numeric, time: .omitted) ?? "-") "
|
||
+ "quest=\(quest.targetName) period=\(quest.period) direction=\(quest.direction) "
|
||
+ "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil") deadline=\(quest.deadlineMinutes)"
|
||
)
|
||
}
|
||
let url = URL.documentsDirectory.appending(path: "streak-dump.txt")
|
||
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
||
}
|
||
|
||
/// `-pinGoals <N>` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용)
|
||
static func pinGoalsIfRequested(context: ModelContext) {
|
||
let n = UserDefaults.standard.integer(forKey: "pinGoals")
|
||
guard n > 0 else { return }
|
||
let descriptor = FetchDescriptor<Goal>(sortBy: [SortDescriptor(\.createdAt)])
|
||
guard let goals = try? context.fetch(descriptor) else { return }
|
||
// 노출 목표는 기기별 로컬 설정 (LocalPrefs 참고)
|
||
LocalPrefs.defaults.set(
|
||
LocalPrefs.rawValue(goals.prefix(n).map(\.uuid)),
|
||
forKey: LocalPrefsKeys.pinnedGoals
|
||
)
|
||
}
|
||
|
||
static func seedIfRequested(context: ModelContext) {
|
||
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
|
||
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
|
||
guard existing == 0 else { return }
|
||
|
||
let study = Tag(name: "공부", colorHex: "#4A7A9D")
|
||
let workout = Tag(name: "운동", colorHex: "#9D5B4A")
|
||
let life = Tag(name: "생활", colorHex: "#2F6B4F")
|
||
context.insert(study)
|
||
context.insert(workout)
|
||
context.insert(life)
|
||
|
||
let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
||
reading.tags = [study]
|
||
reading.isFavorite = true
|
||
reading.promptsForNote = true
|
||
let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1)
|
||
english.tags = [study]
|
||
let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2)
|
||
running.tags = [workout]
|
||
let pushup = Action(name: "팔굽혀펴기", symbolName: "dumbbell.fill", trackingType: .count, sortOrder: 3)
|
||
pushup.tags = [workout]
|
||
let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4)
|
||
water.tags = [life]
|
||
water.promptsForNote = true
|
||
// '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함)
|
||
let video = Action(name: "영상 시청", symbolName: "play.rectangle.fill", trackingType: .time, sortOrder: 5)
|
||
video.tags = [life]
|
||
for action in [reading, english, running, pushup, water, video] {
|
||
context.insert(action)
|
||
}
|
||
|
||
let now = Date.now
|
||
let cal = Calendar.current
|
||
func at(daysAgo: Int, hour: Int, minute: Int = 0) -> Date {
|
||
let day = cal.date(byAdding: .day, value: -daysAgo, to: now)!
|
||
return cal.date(bySettingHour: hour, minute: minute, second: 0, of: day)!
|
||
}
|
||
|
||
// 시간 기록 (며칠치)
|
||
for daysAgo in 0...6 {
|
||
context.insert(TimeSession(action: english, startAt: at(daysAgo: daysAgo, hour: 8), endAt: at(daysAgo: daysAgo, hour: 9, minute: 10)))
|
||
if daysAgo % 2 == 0 {
|
||
context.insert(TimeSession(action: running, startAt: at(daysAgo: daysAgo, hour: 7), endAt: at(daysAgo: daysAgo, hour: 7, minute: 40)))
|
||
}
|
||
if daysAgo % 3 != 0 {
|
||
context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15)))
|
||
}
|
||
// 영상 시청: 짝수 날은 40분(1시간 이하 유지), 홀수 날은 1시간 30분(초과)
|
||
if daysAgo % 2 == 0 {
|
||
context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 13, minute: 10)))
|
||
} else {
|
||
context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 14, minute: 0)))
|
||
}
|
||
}
|
||
// 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용
|
||
let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40))
|
||
crossing.note = "자기 전에 소설 읽음. 재밌어서 늦게 잠"
|
||
context.insert(crossing)
|
||
// 진행 중 세션
|
||
context.insert(TimeSession(action: reading, startAt: now.addingTimeInterval(-25 * 60)))
|
||
|
||
// 횟수 기록
|
||
for daysAgo in 0...6 {
|
||
for i in 0..<(3 + daysAgo % 4) {
|
||
context.insert(CountEntry(action: water, timestamp: at(daysAgo: daysAgo, hour: 9 + i * 2)))
|
||
}
|
||
if daysAgo % 2 == 0 {
|
||
context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20))
|
||
}
|
||
}
|
||
// 메모가 달린 횟수 기록 (기록 탭 메모 표시 확인용)
|
||
let notedWater = CountEntry(action: water, timestamp: at(daysAgo: 0, hour: 20))
|
||
notedWater.note = "자기 전 물 한 컵"
|
||
context.insert(notedWater)
|
||
|
||
// 목표 + 다짐
|
||
let toeic = Goal(
|
||
title: "토익 700점 이상 받기",
|
||
symbolName: "graduationcap.fill",
|
||
colorHex: "#4A7A9D",
|
||
startDate: cal.date(byAdding: .day, value: -10, to: now)!,
|
||
endDate: cal.date(byAdding: .day, value: 30, to: now)!
|
||
)
|
||
context.insert(toeic)
|
||
let englishQuest = Quest(goal: toeic)
|
||
englishQuest.targetAction = english
|
||
englishQuest.measure = .time
|
||
englishQuest.period = .daily
|
||
englishQuest.targetSeconds = 3600
|
||
englishQuest.direction = .atLeast
|
||
context.insert(englishQuest)
|
||
|
||
let health = Goal(
|
||
title: "건강한 생활 습관 만들기",
|
||
symbolName: "heart.fill",
|
||
colorHex: "#2F6B4F",
|
||
startDate: cal.date(byAdding: .day, value: -20, to: now)!,
|
||
endDate: nil
|
||
)
|
||
// 다짐 3개(이하 유지 포함)라 모음 탭 목표 카드 검증에 적합
|
||
health.showsOnMain = true
|
||
context.insert(health)
|
||
let waterQuest = Quest(goal: health)
|
||
waterQuest.targetAction = water
|
||
waterQuest.measure = .count
|
||
waterQuest.period = .daily
|
||
waterQuest.targetCount = 8
|
||
waterQuest.direction = .atLeast
|
||
context.insert(waterQuest)
|
||
let runQuest = Quest(goal: health)
|
||
runQuest.targetAction = running
|
||
runQuest.measure = .time
|
||
runQuest.period = .daily
|
||
runQuest.scheduleMode = .weekdays
|
||
runQuest.weekdays = [2, 4, 6]
|
||
runQuest.targetSeconds = 30 * 60
|
||
runQuest.direction = .atLeast
|
||
context.insert(runQuest)
|
||
// 다짐 4개째: 모음 탭 목표 카드의 '더 보기' 접힘/펼침 검증용
|
||
let pushupQuest = Quest(goal: health)
|
||
pushupQuest.targetAction = pushup
|
||
pushupQuest.measure = .count
|
||
pushupQuest.period = .daily
|
||
pushupQuest.targetCount = 20
|
||
pushupQuest.direction = .atLeast
|
||
context.insert(pushupQuest)
|
||
// '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용)
|
||
let videoQuest = Quest(goal: health)
|
||
videoQuest.targetAction = video
|
||
videoQuest.measure = .time
|
||
videoQuest.period = .daily
|
||
videoQuest.targetSeconds = 3600
|
||
videoQuest.direction = .atMost
|
||
context.insert(videoQuest)
|
||
|
||
// 세 번째 목표 (모음 탭 다중 목표 카드 검증용)
|
||
let habit = Goal(
|
||
title: "매일 밤 독서 습관",
|
||
symbolName: "book.fill",
|
||
colorHex: "#9D5B4A",
|
||
startDate: cal.date(byAdding: .day, value: -5, to: now)!,
|
||
endDate: cal.date(byAdding: .day, value: 60, to: now)!
|
||
)
|
||
context.insert(habit)
|
||
let readingQuest = Quest(goal: habit)
|
||
readingQuest.targetAction = reading
|
||
readingQuest.measure = .time
|
||
readingQuest.period = .daily
|
||
readingQuest.targetSeconds = 45 * 60
|
||
readingQuest.direction = .atLeast
|
||
context.insert(readingQuest)
|
||
|
||
// 네 번째 목표 (모음 탭 목표 카드 개수 제한 해제 검증용)
|
||
let run = Goal(
|
||
title: "꾸준한 달리기",
|
||
symbolName: "figure.run",
|
||
colorHex: "#4A7A9D",
|
||
startDate: cal.date(byAdding: .day, value: -14, to: now)!,
|
||
endDate: cal.date(byAdding: .day, value: 45, to: now)!
|
||
)
|
||
context.insert(run)
|
||
let weeklyRunQuest = Quest(goal: run)
|
||
weeklyRunQuest.targetAction = running
|
||
weeklyRunQuest.measure = .time
|
||
weeklyRunQuest.period = .weekly
|
||
weeklyRunQuest.targetSeconds = 2 * 3600
|
||
weeklyRunQuest.direction = .atLeast
|
||
context.insert(weeklyRunQuest)
|
||
|
||
// 다섯 번째 목표: 최근 시작 + 초과한 날 없는 '이하 유지' 다짐
|
||
// (연속 계산이 목표 시작일 이전으로 새지 않는지 검증용 — 기대값 연속 4일, 버그면 401일)
|
||
let screen = Goal(
|
||
title: "스크린 타임 관리",
|
||
symbolName: "iphone",
|
||
colorHex: "#4A7A9D",
|
||
startDate: cal.date(byAdding: .day, value: -3, to: now)!,
|
||
endDate: nil
|
||
)
|
||
context.insert(screen)
|
||
let screenQuest = Quest(goal: screen)
|
||
screenQuest.targetAction = video
|
||
screenQuest.measure = .time
|
||
screenQuest.period = .daily
|
||
screenQuest.targetSeconds = 3 * 3600 // 기록 최대 90분이라 절대 초과하지 않음
|
||
screenQuest.direction = .atMost
|
||
context.insert(screenQuest)
|
||
|
||
// 여섯 번째 목표: 마감 시각 다짐 검증용 — "오전 8시까지 일어나기 1회 이상"
|
||
// 기록: 오늘 07:30(창 안)+08:10(창 밖 — 집계 제외돼 하루 100%가 200%로 뻥튀기되면 버그),
|
||
// 어제 08:30(창 밖 → 그날 실패), 그저께 07:00(창 안).
|
||
// 기대값: 하루 진행률 100%, 연속 1일(어제 실패로 끊김), streak-dump에 deadline=480.
|
||
let wake = Action(name: "일찍 일어나기", symbolName: "sunrise.fill", trackingType: .count, sortOrder: 6)
|
||
wake.tags = [life]
|
||
context.insert(wake)
|
||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 7, minute: 30)))
|
||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 8, minute: 10)))
|
||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 1, hour: 8, minute: 30)))
|
||
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 2, hour: 7, minute: 0)))
|
||
let morning = Goal(
|
||
title: "아침 루틴 지키기",
|
||
symbolName: "sunrise.fill",
|
||
colorHex: "#D9A621",
|
||
startDate: cal.date(byAdding: .day, value: -7, to: now)!,
|
||
endDate: nil
|
||
)
|
||
context.insert(morning)
|
||
let wakeQuest = Quest(goal: morning)
|
||
wakeQuest.targetAction = wake
|
||
wakeQuest.measure = .count
|
||
wakeQuest.period = .daily
|
||
wakeQuest.targetCount = 1
|
||
wakeQuest.direction = .atLeast
|
||
wakeQuest.deadlineMinutes = 8 * 60
|
||
context.insert(wakeQuest)
|
||
|
||
// 목표 탭 정렬 순서 부여
|
||
for (index, goal) in [toeic, health, habit, run, screen, morning].enumerated() {
|
||
goal.sortOrder = index
|
||
}
|
||
|
||
// 완료된 목표 (완료 목표 분리 화면 검증용 — 달성 1개 + 미달성 1개)
|
||
let sleep = Goal(
|
||
title: "일찍 자기 챌린지",
|
||
symbolName: "moon.zzz.fill",
|
||
colorHex: "#2F6B4F",
|
||
startDate: cal.date(byAdding: .day, value: -60, to: now)!,
|
||
endDate: cal.date(byAdding: .day, value: -30, to: now)!
|
||
)
|
||
sleep.status = .achieved
|
||
sleep.sortOrder = 6
|
||
context.insert(sleep)
|
||
let diet = Goal(
|
||
title: "한 달 다이어트",
|
||
symbolName: "fork.knife",
|
||
colorHex: "#9D5B4A",
|
||
startDate: cal.date(byAdding: .day, value: -50, to: now)!,
|
||
endDate: cal.date(byAdding: .day, value: -20, to: now)!
|
||
)
|
||
diet.status = .notAchieved
|
||
diet.sortOrder = 7
|
||
context.insert(diet)
|
||
|
||
// 기기별 로컬 UI 설정 시드: 메모 창 행동, 모음 탭 노출 목표 (LocalPrefs 참고)
|
||
LocalPrefs.setPromptsForNote(reading.uuid, enabled: true)
|
||
LocalPrefs.setPromptsForNote(water.uuid, enabled: true)
|
||
LocalPrefs.defaults.set(
|
||
LocalPrefs.rawValue([health.uuid]),
|
||
forKey: LocalPrefsKeys.pinnedGoals
|
||
)
|
||
}
|
||
}
|
||
#endif
|