mycode/myApp/HaruDanim/Shared/Models.swift
songyc macbook 42b95c5d7f feat(quest): consecutive-achievement display on quests, plus wording fixes
다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo):
- 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수,
  '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일)
- 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달
- 특정 기간 다짐: 반복이 없어 표시하지 않음
- 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고
  건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김
- 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수),
  소급 상한 400일

표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김):
- 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑)
- 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄
- 위젯 ④ 다짐 현황: 이름 아래 작은 글씨
- 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만
  행 높이 14→19 (적응 채움이 자동 반영)
- 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続)

문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은
달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와
주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함.

검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전
기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치),
위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인,
missing: [], Debug·Store 스킴 빌드 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 23:49:45 +09:00

479 lines
15 KiB
Swift

//
// Models.swift
// Haru_Danim
//
// SwiftData (CLAUDE.md §10 )
//
import Foundation
import SwiftData
import SwiftUI
// MARK: -
enum TrackingType: String, CaseIterable, Identifiable {
case time
case count
var id: String { rawValue }
var label: String {
switch self {
case .time: return String(localized: "시간 측정")
case .count: return String(localized: "횟수 기록")
}
}
var symbol: String {
switch self {
case .time: return "timer"
case .count: return "number"
}
}
}
// MARK: - (Tag)
@Model
final class Tag {
/// ID
var uuid: UUID = UUID()
var name: String = ""
var colorHex: String = "#2F6B4F"
/// /
var sortOrder: Int = 0
var createdAt: Date = Date()
// CloudKit : optional optional,
// non-optional (originalName )
@Relationship(originalName: "actions")
var actionsStorage: [Action]? = []
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetTag)
var questsStorage: [Quest]? = []
var actions: [Action] {
get { actionsStorage ?? [] }
set { actionsStorage = newValue }
}
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(name: String, colorHex: String) {
self.name = name
self.colorHex = colorHex
self.createdAt = .now
}
}
extension Tag {
var color: Color { Color(hex: colorHex) }
var sortedActions: [Action] {
actions.sorted { $0.sortOrder < $1.sortOrder }
}
}
// MARK: - (Action)
@Model
final class Action {
/// · ID
var uuid: UUID = UUID()
var name: String = ""
var symbolName: String = "star.fill"
var trackingTypeRaw: String = TrackingType.time.rawValue
/// [] (LocalPrefs.actionOrder) .
/// CloudKit , .
var sortOrder: Int = 0
/// ""
var isFavorite: Bool = false
/// [] (LocalPrefs.notePromptActions) .
/// CloudKit , 1 .
var promptsForNote: Bool = false
var createdAt: Date = Date()
// CloudKit optional + non-optional (originalName )
@Relationship(originalName: "tags", inverse: \Tag.actionsStorage)
var tagsStorage: [Tag]? = []
@Relationship(deleteRule: .cascade, originalName: "sessions", inverse: \TimeSession.action)
var sessionsStorage: [TimeSession]? = []
@Relationship(deleteRule: .cascade, originalName: "countEntries", inverse: \CountEntry.action)
var countEntriesStorage: [CountEntry]? = []
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetAction)
var questsStorage: [Quest]? = []
var tags: [Tag] {
get { tagsStorage ?? [] }
set { tagsStorage = newValue }
}
var sessions: [TimeSession] {
get { sessionsStorage ?? [] }
set { sessionsStorage = newValue }
}
var countEntries: [CountEntry] {
get { countEntriesStorage ?? [] }
set { countEntriesStorage = newValue }
}
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(name: String, symbolName: String, trackingType: TrackingType, sortOrder: Int) {
self.name = name
self.symbolName = symbolName
self.trackingTypeRaw = trackingType.rawValue
self.sortOrder = sortOrder
self.createdAt = .now
}
}
extension Action {
var trackingType: TrackingType {
get { TrackingType(rawValue: trackingTypeRaw) ?? .time }
set { trackingTypeRaw = newValue.rawValue }
}
/// : ( ) .
var color: Color {
tags.sorted { $0.createdAt < $1.createdAt }.first?.color ?? AppTheme.green
}
var sortedTags: [Tag] {
tags.sorted { $0.createdAt < $1.createdAt }
}
/// ( )
var runningSession: TimeSession? {
sessions.first { $0.endAt == nil }
}
var isRunning: Bool { runningSession != nil }
}
// MARK: - (TimeSession)
@Model
final class TimeSession {
var startAt: Date = Date()
var endAt: Date?
/// ()
var note: String = ""
var action: Action?
init(action: Action, startAt: Date, endAt: Date? = nil) {
self.action = action
self.startAt = startAt
self.endAt = endAt
}
}
extension TimeSession {
func duration(asOf now: Date = .now) -> TimeInterval {
max(0, (endAt ?? now).timeIntervalSince(startAt))
}
}
// MARK: - (CountEntry)
@Model
final class CountEntry {
var timestamp: Date = Date()
var amount: Int = 1
/// ()
var note: String = ""
var action: Action?
init(action: Action, timestamp: Date, amount: Int = 1) {
self.action = action
self.timestamp = timestamp
self.amount = amount
}
}
// MARK: - (Goal)
enum GoalStatus: String, CaseIterable {
case inProgress
case achieved
case notAchieved
var label: String {
switch self {
case .inProgress: return String(localized: "진행 중")
case .achieved: return String(localized: "달성 완료")
case .notAchieved: return String(localized: "미달성 종료")
}
}
}
@Model
final class Goal {
/// · ID
var uuid: UUID = UUID()
var title: String = ""
var symbolName: String = "flag.fill"
var colorHex: String = "#2F6B4F"
var startDate: Date = Date()
var endDate: Date?
var statusRaw: String = GoalStatus.inProgress.rawValue
/// (%). ' '.
/// 100 ( CloudKit ).
var achieveThresholdPercent: Int = 100
/// [] (LocalPrefs.collapsedGoals) .
/// CloudKit , 1 .
var isCollapsed: Bool = false
/// [] (LocalPrefs.pinnedGoals) .
/// CloudKit , 1 .
var showsOnMain: Bool = false
/// ( , createdAt )
var sortOrder: Int = 0
var createdAt: Date = Date()
// CloudKit optional + non-optional (originalName )
@Relationship(deleteRule: .cascade, originalName: "quests", inverse: \Quest.goal)
var questsStorage: [Quest]? = []
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(title: String, symbolName: String, colorHex: String, startDate: Date, endDate: Date?) {
self.title = title
self.symbolName = symbolName
self.colorHex = colorHex
self.startDate = startDate
self.endDate = endDate
self.statusRaw = GoalStatus.inProgress.rawValue
self.createdAt = .now
}
}
extension Goal {
var status: GoalStatus {
get { GoalStatus(rawValue: statusRaw) ?? .inProgress }
set { statusRaw = newValue.rawValue }
}
var color: Color { Color(hex: colorHex) }
var sortedQuests: [Quest] {
quests.sorted {
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
return $0.createdAt < $1.createdAt
}
}
/// ( )
func isPastEndDate(asOf now: Date = .now, calendar: Calendar = .current) -> Bool {
guard let endDate else { return false }
let endOfDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDate))!
return now >= endOfDay
}
/// " " (CLAUDE.md §6.4-4)
func needsConfirmation(asOf now: Date = .now) -> Bool {
status == .inProgress && quests.isEmpty && isPastEndDate(asOf: now)
}
/// (0...1)
func dateProgress(asOf now: Date = .now) -> Double? {
guard let endDate, endDate > startDate else { return nil }
let total = endDate.timeIntervalSince(startDate)
let elapsed = now.timeIntervalSince(startDate)
return min(max(elapsed / total, 0), 1)
}
}
// MARK: - (Quest)
enum QuestPeriod: String, CaseIterable, Identifiable {
case daily
case weekly
case monthly
case custom
var id: String { rawValue }
var label: String {
switch self {
case .daily: return String(localized: "하루 단위")
case .weekly: return String(localized: "일주일 단위")
case .monthly: return String(localized: "한 달 단위")
case .custom: return String(localized: "특정 기간")
}
}
}
///
enum QuestScheduleMode: String, CaseIterable, Identifiable {
case everyDay
case weekdays
case monthDays
case ordinalWeekday
var id: String { rawValue }
var label: String {
switch self {
case .everyDay: return String(localized: "매일")
case .weekdays: return String(localized: "요일 지정")
case .monthDays: return String(localized: "날짜 지정")
// weekdayOrdinal( n ) ' '
case .ordinalWeekday: return String(localized: "몇 번째 요일")
}
}
}
enum QuestDirection: String, CaseIterable, Identifiable {
case atLeast
case atMost
var id: String { rawValue }
var label: String {
switch self {
case .atLeast: return String(localized: "이상 달성")
case .atMost: return String(localized: "이하 유지")
}
}
}
@Model
final class Quest {
/// · ID
var uuid: UUID = UUID()
var goal: Goal?
/// :
var targetAction: Action?
var targetTag: Tag?
/// ( , )
var measureRaw: String = TrackingType.time.rawValue
var periodRaw: String = QuestPeriod.daily.rawValue
var scheduleModeRaw: String = QuestScheduleMode.everyDay.rawValue
/// Calendar.weekday (1= ... 7=)
var weekdays: [Int] = []
/// 1~31
var monthDays: [Int] = []
/// (1~5)
var ordinalWeek: Int = 1
/// Calendar.weekday
var ordinalWeekday: Int = 2
var customStart: Date?
var customEnd: Date?
/// () measure == .time
var targetSeconds: Double = 3600
/// measure == .count
var targetCount: Int = 1
var directionRaw: String = QuestDirection.atLeast.rawValue
///
var sortOrder: Int = 0
var createdAt: Date = Date()
init(goal: Goal) {
self.goal = goal
self.createdAt = .now
}
}
extension Quest {
var measure: TrackingType {
get { TrackingType(rawValue: measureRaw) ?? .time }
set { measureRaw = newValue.rawValue }
}
var period: QuestPeriod {
get { QuestPeriod(rawValue: periodRaw) ?? .daily }
set { periodRaw = newValue.rawValue }
}
var scheduleMode: QuestScheduleMode {
get { QuestScheduleMode(rawValue: scheduleModeRaw) ?? .everyDay }
set { scheduleModeRaw = newValue.rawValue }
}
var direction: QuestDirection {
get { QuestDirection(rawValue: directionRaw) ?? .atLeast }
set { directionRaw = newValue.rawValue }
}
var targetName: String {
if let targetAction { return targetAction.name }
if let targetTag { return "#\(targetTag.name)" }
return String(localized: "대상 없음")
}
var targetSymbol: String {
if let targetAction { return targetAction.symbolName }
return "tag.fill"
}
var targetColor: Color {
if let targetAction { return targetAction.color }
if let targetTag { return targetTag.color }
return AppTheme.green
}
///
var targetActions: [Action] {
if let targetAction { return [targetAction] }
if let targetTag { return targetTag.actions.filter { $0.trackingType == measure } }
return []
}
/// ( , )
var targetValue: Double {
measure == .time ? targetSeconds : Double(targetCount)
}
var targetValueLabel: String {
measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)")
}
/// (: " ··")
var scheduleLabel: String {
switch period {
case .daily:
switch scheduleMode {
case .everyDay:
return String(localized: "매일")
case .weekdays:
let names = weekdays.sorted().map { Format.weekdayShort($0) }
return String(localized: "매주 \(names.joined(separator: "·"))")
case .monthDays:
let names = monthDays.sorted().map { String(localized: "\($0)") }
return String(localized: "매달 \(names.joined(separator: ", "))")
case .ordinalWeekday:
// weekdayOrdinal "n " "n " (: 3 )
return String(localized: "매달 \(ordinalWeek)번째 \(Format.weekdayShort(ordinalWeekday))요일")
}
case .weekly:
return String(localized: "매주")
case .monthly:
return String(localized: "매달")
case .custom:
guard let customStart, let customEnd else { return String(localized: "특정 기간") }
return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))"
}
}
}