mycode/myApp/HaruDanim/IOS/Views/QuestEditorView.swift
songyc macbook 6a486a4fbd feat(quest): daily quest deadline time — clip the day's measurement window
하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계):

- 모델: Quest.deadlineMinutes(-1=없음) 필드 1개 — CloudKit 안전(기본값), 주기가
  하루 단위가 아니면 값이 남아도 무시(hasDeadline이 주기·범위를 함께 검사)
- 의미론: 마감 = 그날 집계 창의 끝. dayMeasurementRange(forKey:) 단일 구현을
  주기 범위(current/judgment)·주/월 span 하루 루프(spanValue)·연속 버킷팅
  (perDayValues)·위젯 누적 라벨(spanRawValue)·일기 카드가 공유한다.
  원본 기록은 불변 — 마감 뒤 기록은 남되 그 다짐에만 미집계. 시간형은 기존
  겹침 클리핑이 마감을 걸친 세션을 부분 인정(추가 코드 0). '이하 유지'는
  마감까지 한도 안이면 그날 달성으로 고정.
- 경계 규칙: 마감은 그 논리적 하루(24h) 안에서 그 시계 시각의 등장 지점 —
  하루 시작보다 이른 시각은 다음 달력일 새벽, 시작과 같은 시각은 +24h(온전한
  하루)로 0길이 창이 수학적으로 불가능(에디터 검증 불필요, 오류 상태 없음)
- 의도된 최소화: '마감 지남' 전용 표시 없음(게이지 값이 마감 시점에 연속이라
  위젯 타임라인 마감 경계 불필요 — 위젯·워치·시리는 코드 변경 0으로 자동 일관),
  연속 달성은 값 클리핑만(오늘 놓친 끊김 반영은 내일부터 — 유예 규칙 유지),
  주간/월간/기간 마감은 미지원
- UI: 다짐 에디터 토글+시간 선택(하루 단위만, footer 설명), 주기 문구에
  "· 오전 8:00까지" 표기(목표 탭·시리 요약·CSV 자동 반영), 일기 카드
  "하루 목표 · 오전 8:00까지", 도움말 항목 추가, ko/en/ja 번역
- 검증: seedDemo에 마감 다짐 시드(창 안/밖/어제 실패 케이스) — 하루 100%
  (창 밖 기록 포함 시 200%가 되는 함정 통과), 주간 29%(=2/7)·월간 6%(=2/31)
  수기 계산과 정확 일치, streak-dump 연속 1일(어제 실패로 끊김) 확인,
  마감 없는 다짐 전부 기존 수치 유지(경로가 hasDeadline 가드로 완전 동일),
  일기 카드 1회/1회(원본 합계는 그대로), 영어 로케일 "By 8:00 AM" 렌더,
  Debug·Store 빌드 성공, 카탈로그 9종 missing/stale 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-15 17:52:18 +09:00

357 lines
14 KiB
Swift

//
// QuestEditorView.swift
// Haru_Danim
//
// /: (/) + (CLAUDE.md §6.4)
//
import SwiftUI
import SwiftData
struct QuestEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
// (LocalPrefs )
private var allActions: [Action] {
LocalPrefs.orderedActions(allActionsQuery)
}
let goal: Goal
/// nil
let quest: Quest?
enum TargetKind: String, CaseIterable, Identifiable {
case action, tag
var id: String { rawValue }
var label: String { self == .action ? String(localized: "행동") : String(localized: "꼬리표") }
}
//
@State private var targetKind: TargetKind = .action
@State private var selectedAction: Action?
@State private var selectedTag: Tag?
@State private var measure: TrackingType = .time
//
@State private var period: QuestPeriod = .daily
@State private var scheduleMode: QuestScheduleMode = .everyDay
@State private var weekdays: Set<Int> = []
@State private var monthDays: Set<Int> = []
@State private var ordinalWeek = 1
@State private var ordinalWeekday = 2
@State private var customStart: Date = .now
@State private var customEnd: Date = .now
// ( [ , ] )
@State private var hasDeadline = false
@State private var deadlineTime: Date = Calendar.current.date(
bySettingHour: 21, minute: 0, second: 0, of: .now
) ?? .now
// +
@State private var targetHours = 1
@State private var targetMinutes = 0
@State private var targetCount = 1
@State private var direction: QuestDirection = .atLeast
var body: some View {
NavigationStack {
Form {
targetSection
periodSection
amountSection
}
.navigationTitle(quest == nil ? String(localized: "다짐 추가") : String(localized: "다짐 수정"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(!isValid)
}
}
.onAppear(perform: load)
}
}
// MARK:
private var targetSection: some View {
Section("① 대상 선택") {
Picker("대상 종류", selection: $targetKind.animation()) {
ForEach(TargetKind.allCases) { kind in
Text(kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
switch targetKind {
case .action:
if allActions.isEmpty {
Text("등록된 행동이 없어요").foregroundStyle(.secondary)
} else {
Picker("행동", selection: $selectedAction) {
Text("선택").tag(Action?.none)
ForEach(allActions) { action in
Label(action.name, systemImage: action.symbolName)
.tag(Optional(action))
}
}
}
case .tag:
if allTags.isEmpty {
Text("등록된 꼬리표가 없어요").foregroundStyle(.secondary)
} else {
Picker("꼬리표", selection: $selectedTag) {
Text("선택").tag(Tag?.none)
ForEach(allTags) { tag in
Text(tag.name).tag(Optional(tag))
}
}
Picker("측정 기준", selection: $measure) {
ForEach(TrackingType.allCases) { type in
Text(type.label).tag(type)
}
}
}
}
}
}
// MARK:
@ViewBuilder
private var periodSection: some View {
Section {
Picker("반복 주기", selection: $period.animation()) {
ForEach(QuestPeriod.allCases) { p in
Text(p.label).tag(p)
}
}
if period == .daily {
Picker("적용일", selection: $scheduleMode.animation()) {
ForEach(QuestScheduleMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
switch scheduleMode {
case .everyDay:
EmptyView()
case .weekdays:
weekdayPicker
case .monthDays:
monthDayPicker
case .ordinalWeekday:
// "n " (weekdayOrdinal) 'n '
Picker("몇 번째", selection: $ordinalWeek) {
ForEach(1...5, id: \.self) { n in
Text("\(n)번째").tag(n)
}
}
Picker("요일", selection: $ordinalWeekday) {
ForEach(1...7, id: \.self) { w in
Text("\(Format.weekdayShort(w))요일").tag(w)
}
}
}
// : [ , ] ( )
Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation())
if hasDeadline {
DatePicker("마감 시각", selection: $deadlineTime, displayedComponents: .hourAndMinute)
}
}
if period == .custom {
DatePicker("시작", selection: $customStart, displayedComponents: .date)
DatePicker("", selection: $customEnd, in: customStart..., displayedComponents: .date)
}
} header: {
Text("② 주기 선택")
} footer: {
if period == .daily && hasDeadline {
Text("하루 시작부터 마감 시각까지의 기록만 이 다짐에 집계돼요. 마감 이후의 기록은 기록 탭에 남지만 이 다짐에는 반영되지 않아요. 하루 시작 시간보다 이른 시각은 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요.")
}
}
}
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(1...7, id: \.self) { weekday in
let on = weekdays.contains(weekday)
Button {
if on { weekdays.remove(weekday) } else { weekdays.insert(weekday) }
} label: {
Text(Format.weekdayShort(weekday))
.font(.subheadline.weight(.semibold))
.frame(width: 36, height: 36)
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity)
}
private var monthDayPicker: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
ForEach(1...31, id: \.self) { day in
let on = monthDays.contains(day)
Button {
if on { monthDays.remove(day) } else { monthDays.insert(day) }
} label: {
Text("\(day)")
.font(.footnote.weight(.medium))
.frame(width: 34, height: 34)
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: +
private var effectiveMeasure: TrackingType {
targetKind == .action ? (selectedAction?.trackingType ?? .time) : measure
}
@ViewBuilder
private var amountSection: some View {
Section {
if effectiveMeasure == .time {
HStack {
Picker("시간", selection: $targetHours) {
ForEach(0...23, id: \.self) { h in
Text("\(h)시간").tag(h)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("", selection: $targetMinutes) {
ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { m in
Text("\(m)").tag(m)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
}
.frame(height: 110)
} else {
Stepper(value: $targetCount, in: 1...9999) {
HStack {
Text("목표 횟수")
Spacer()
Text("\(targetCount)").foregroundStyle(.secondary)
}
}
}
Picker("방향", selection: $direction) {
ForEach(QuestDirection.allCases) { dir in
Text(dir.label).tag(dir)
}
}
.pickerStyle(.segmented)
} header: {
Text("③ 목표량과 방향")
} footer: {
Text(direction == .atLeast
? "주기마다 목표량 이상을 달성하는 것이 목표예요."
: "주기마다 목표량을 넘지 않는 것이 목표예요.")
}
}
// MARK:
private var isValid: Bool {
let hasTarget = targetKind == .action ? selectedAction != nil : selectedTag != nil
guard hasTarget else { return false }
if period == .daily {
switch scheduleMode {
case .weekdays: if weekdays.isEmpty { return false }
case .monthDays: if monthDays.isEmpty { return false }
default: break
}
}
if period == .custom && customEnd < customStart { return false }
if effectiveMeasure == .time && targetHours == 0 && targetMinutes == 0 { return false }
return true
}
private func load() {
guard let quest else { return }
if let action = quest.targetAction {
targetKind = .action
selectedAction = action
} else if let tag = quest.targetTag {
targetKind = .tag
selectedTag = tag
}
measure = quest.measure
period = quest.period
scheduleMode = quest.scheduleMode
weekdays = Set(quest.weekdays)
monthDays = Set(quest.monthDays)
ordinalWeek = quest.ordinalWeek
ordinalWeekday = quest.ordinalWeekday
customStart = quest.customStart ?? .now
customEnd = quest.customEnd ?? .now
if quest.deadlineMinutes >= 0 {
hasDeadline = true
let cal = Calendar.current
deadlineTime = cal.date(byAdding: .minute, value: quest.deadlineMinutes,
to: cal.startOfDay(for: .now)) ?? deadlineTime
}
targetHours = Int(quest.targetSeconds) / 3600
targetMinutes = (Int(quest.targetSeconds) % 3600) / 60
targetCount = quest.targetCount
direction = quest.direction
}
private func save() {
let target = quest ?? Quest(goal: goal)
switch targetKind {
case .action:
target.targetAction = selectedAction
target.targetTag = nil
target.measure = selectedAction?.trackingType ?? .time
case .tag:
target.targetTag = selectedTag
target.targetAction = nil
target.measure = measure
}
target.period = period
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
//
if period == .daily && hasDeadline {
let comps = Calendar.current.dateComponents([.hour, .minute], from: deadlineTime)
target.deadlineMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
} else {
target.deadlineMinutes = -1
}
target.weekdays = Array(weekdays).sorted()
target.monthDays = Array(monthDays).sorted()
target.ordinalWeek = ordinalWeek
target.ordinalWeekday = ordinalWeekday
target.customStart = period == .custom ? customStart : nil
target.customEnd = period == .custom ? customEnd : nil
target.targetSeconds = Double(targetHours * 3600 + targetMinutes * 60)
target.targetCount = targetCount
target.direction = direction
if quest == nil {
target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1
context.insert(target)
}
DataChange.commit(context: context)
dismiss()
}
}