From 7a53bb9ce434d3a9c273a554e5c52ca8842f1275 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 7 Jul 2026 14:29:11 +0900 Subject: [PATCH] =?UTF-8?q?new=20feature=20Quest(=EB=8B=A4=EC=A7=90)=20pog?= =?UTF-8?q?ress=20and=20Fix=20an=20issue=20in=20=ED=9A=9F=EC=88=98?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=20Action(=ED=96=89=EB=8F=99)=20where=20count?= =?UTF-8?q?=20and=20time=20are=20recorded=20but=20not=20reflected=20in=20t?= =?UTF-8?q?he=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserInterfaceState.xcuserstate | Bin 30553 -> 30547 bytes .../IOS/ViewModels/GoalViewModel.swift | 171 ++++++++++++++++++ .../IOS/ViewModels/TrackingViewModel.swift | 28 +++ .../HaruDanim/IOS/Views/GoalDetailView.swift | 115 ++++++++++-- .../IOS/Views/MainDashboardView.swift | 30 ++- 5 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index c937f3dd1eb07e5470a7f24b06752d1d06b4f35c..b2c84c6505d510c9e9d19b6c361b97982e606d0a 100644 GIT binary patch delta 13901 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 Mzw`X#r~b(Q0pm8OI{*Lx delta 13856 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Ⓢ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 diff --git a/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift b/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift new file mode 100644 index 0000000..83541fa --- /dev/null +++ b/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift @@ -0,0 +1,171 @@ +// +// GoalViewModel.swift +// HaruDanim +// +// Computes how much progress a `Quest` has accumulated within its current +// period, and evaluates whether it is being met given its direction. +// + +import Foundation +import SwiftData +import Observation + +/// The evaluated outcome of a quest for its current period. +enum QuestEvaluation { + /// `above` quest whose target has been reached. + case achieved + /// `above` quest still short of its target (period ongoing). + case inProgress + /// `below` quest still within its allowed limit. + case onTrack + /// `below` quest that has exceeded its limit. + case failed + /// Period type not yet supported (monthly / custom). + case unsupported +} + +/// A snapshot of a quest's accumulated value against its target for the +/// current period. `current` and `target` share units: seconds when +/// `usesTime`, discrete units otherwise. +struct QuestProgress { + let current: Double + let target: Double + let usesTime: Bool + let direction: QuestDirection + /// Whether the quest's period could actually be computed. + let isPeriodSupported: Bool + + /// Completion ratio clamped to `0...1`, suitable for a `ProgressView`. + /// For `below` quests this represents how much of the budget is used, so + /// a full bar means the limit has been reached. + var fraction: Double { + guard target > 0 else { return 0 } + return max(0, min(current / target, 1)) + } + + var evaluation: QuestEvaluation { + guard isPeriodSupported else { return .unsupported } + switch direction { + case .above: + return current >= target ? .achieved : .inProgress + case .below: + return current > target ? .failed : .onTrack + } + } +} + +@Observable +final class GoalViewModel { + + // MARK: - Public API + + /// Computes the current-period progress for `quest`, honoring the app's + /// logical day boundary (`dayStartHour`). Only `daily` and `weekly` + /// periods are supported; others return an unsupported, zero progress. + func progress(for quest: Quest, dayStartHour: Int = 0, now: Date = .now) -> QuestProgress { + guard let bounds = periodBounds(for: quest.period, dayStartHour: dayStartHour, now: now) else { + return QuestProgress( + current: 0, + target: quest.targetValue, + usesTime: quest.usesTime, + direction: quest.direction, + isPeriodSupported: false + ) + } + + let current: Double = quest.usesTime + ? accumulatedTime(for: quest, start: bounds.start, end: bounds.end, now: now) + : accumulatedCount(for: quest, start: bounds.start, end: bounds.end) + + return QuestProgress( + current: current, + target: quest.targetValue, + usesTime: quest.usesTime, + direction: quest.direction, + isPeriodSupported: true + ) + } + + // MARK: - Data sources + + /// Time sessions relevant to the quest: a single action's, or the union of + /// all actions carrying the target tag. + private func timeSessions(for quest: Quest) -> [TimeSession] { + if let action = quest.targetAction { return action.timeSessions } + if let tag = quest.targetTag { return tag.actions.flatMap { $0.timeSessions } } + return [] + } + + /// Count entries relevant to the quest: a single action's, or the union of + /// all actions carrying the target tag. + private func countEntries(for quest: Quest) -> [CountEntry] { + if let action = quest.targetAction { return action.countEntries } + if let tag = quest.targetTag { return tag.actions.flatMap { $0.countEntries } } + return [] + } + + // MARK: - Accumulation + + /// Total tracked duration (seconds) within `[start, end)`. Each session is + /// clipped to the window so boundary-crossing sessions only count their + /// overlapping portion; running sessions count up to `now`. + private func accumulatedTime(for quest: Quest, start: Date, end: Date, now: Date) -> TimeInterval { + timeSessions(for: quest).reduce(0) { total, session in + let sessionEnd = session.endTime ?? now + let overlapStart = max(session.startTime, start) + let overlapEnd = min(sessionEnd, end) + guard overlapEnd > overlapStart else { return total } + return total + overlapEnd.timeIntervalSince(overlapStart) + } + } + + /// Sum of count entry amounts whose timestamp falls within `[start, end)`. + private func accumulatedCount(for quest: Quest, start: Date, end: Date) -> Double { + let entries = countEntries(for: quest) + var total = 0 + for entry in entries where entry.timestamp >= start && entry.timestamp < end { + total += entry.amount + } + return Double(total) + } + + // MARK: - Period bounds + + /// The half-open `[start, end)` window for the current instance of `period`, + /// aligned to the logical day boundary. Returns `nil` for unsupported periods. + private func periodBounds(for period: QuestPeriod, dayStartHour: Int, now: Date) -> (start: Date, end: Date)? { + let calendar = Calendar.current + let dayStart = logicalDayStart(for: now, dayStartHour: dayStartHour, calendar: calendar) + + switch period { + case .daily: + guard let end = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return nil } + return (dayStart, end) + + case .weekly: + // Anchor the week on the calendar day the logical day belongs to, + // then shift by the day-start offset so the week honors the boundary. + let weekComponents = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: dayStart) + guard let weekMidnight = calendar.date(from: weekComponents), + let start = calendar.date(byAdding: .hour, value: dayStartHour, to: weekMidnight), + let end = calendar.date(byAdding: .day, value: 7, to: start) + else { return nil } + return (start, end) + + case .monthly, .custom: + return nil + } + } + + /// Start of the logical day containing `now`: the most recent occurrence of + /// `dayStartHour:00`. If `now` is before today's boundary, the logical day + /// began the previous calendar day. + private func logicalDayStart(for now: Date, dayStartHour: Int, calendar: Calendar) -> Date { + let midnight = calendar.startOfDay(for: now) + let boundary = calendar.date(byAdding: .hour, value: dayStartHour, to: midnight) ?? midnight + if now < boundary { + return calendar.date(byAdding: .day, value: -1, to: boundary) ?? boundary + } + return boundary + } +} diff --git a/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift b/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift index d03e71b..b3345dd 100644 --- a/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift +++ b/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift @@ -47,6 +47,34 @@ final class TrackingViewModel { save(context) } + // MARK: - Today's progress + + /// Sum of today's `CountEntry` amounts for the action (simple calendar day). + func getTodayCount(for action: Action) -> Int { + let calendar = Calendar.current + return action.countEntries + .filter { calendar.isDateInToday($0.timestamp) } + .reduce(0) { $0 + $1.amount } + } + + /// Total tracked duration today for the action, in seconds. Each session is + /// clipped to today's bounds so boundary-crossing sessions count only their + /// portion within the current calendar day. Running sessions count up to now. + func getTodayDuration(for action: Action) -> TimeInterval { + let calendar = Calendar.current + let now = Date.now + let dayStart = calendar.startOfDay(for: now) + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now + + return action.timeSessions.reduce(0) { total, session in + let sessionEnd = session.endTime ?? now + let overlapStart = max(session.startTime, dayStart) + let overlapEnd = min(sessionEnd, dayEnd) + guard overlapEnd > overlapStart else { return total } + return total + overlapEnd.timeIntervalSince(overlapStart) + } + } + // MARK: - Active session loading /// Reloads `activeSessions` from the store (sessions with no `endTime`). diff --git a/myApp/HaruDanim/IOS/Views/GoalDetailView.swift b/myApp/HaruDanim/IOS/Views/GoalDetailView.swift index 7a17a93..63605df 100644 --- a/myApp/HaruDanim/IOS/Views/GoalDetailView.swift +++ b/myApp/HaruDanim/IOS/Views/GoalDetailView.swift @@ -13,8 +13,17 @@ struct GoalDetailView: View { @Environment(\.modelContext) private var modelContext @Bindable var goal: Goal + /// App settings drive the logical day boundary used for progress windows. + @Query private var appSettings: [AppSettings] + + @State private var viewModel = GoalViewModel() @State private var isAddQuestPresented = false + /// Hour at which a logical day begins (defaults to midnight). + private var dayStartHour: Int { + appSettings.first?.dayStartHour ?? 0 + } + private var dateFormatter: Date.FormatStyle { .dateTime.year().month().day() } @@ -44,7 +53,7 @@ struct GoalDetailView: View { .foregroundStyle(.secondary) } else { ForEach(sortedQuests) { quest in - QuestRow(quest: quest) + QuestRow(quest: quest, viewModel: viewModel, dayStartHour: dayStartHour) } .onDelete(perform: deleteQuests) } @@ -78,6 +87,12 @@ struct GoalDetailView: View { private struct QuestRow: View { let quest: Quest + let viewModel: GoalViewModel + let dayStartHour: Int + + private var progress: QuestProgress { + viewModel.progress(for: quest, dayStartHour: dayStartHour) + } private var targetName: String { quest.targetAction?.name ?? quest.targetTag?.name ?? "대상 없음" @@ -90,20 +105,50 @@ private struct QuestRow: View { } var body: some View { - HStack(spacing: 12) { - Image(systemName: targetIcon) - .frame(width: 24) - .foregroundStyle(Color.brandPrimary) + let progress = self.progress + let evaluation = progress.evaluation - VStack(alignment: .leading, spacing: 4) { - Text(quest.title.isEmpty ? targetName : quest.title) - Text("\(quest.period.displayName) · \(targetName) \(QuestFormatting.value(quest.targetValue, usesTime: quest.usesTime)) \(quest.direction.displayName)") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(systemName: targetIcon) + .frame(width: 24) + .foregroundStyle(Color.brandPrimary) + + VStack(alignment: .leading, spacing: 4) { + Text(quest.title.isEmpty ? targetName : quest.title) + Text("\(quest.period.displayName) · \(targetName) \(QuestFormatting.value(quest.targetValue, usesTime: quest.usesTime)) \(quest.direction.displayName)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: evaluation.iconName) + .foregroundStyle(evaluation.tint) + .imageScale(.large) } - Spacer() + if evaluation == .unsupported { + Text("이 주기는 아직 진행률을 계산하지 않아요.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ProgressView(value: progress.fraction) + .tint(evaluation.tint) + + HStack { + Text("\(QuestFormatting.amount(progress.current, usesTime: progress.usesTime)) / \(QuestFormatting.amount(progress.target, usesTime: progress.usesTime))") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer() + Text(evaluation.label) + .font(.caption.weight(.medium)) + .foregroundStyle(evaluation.tint) + } + } } + .padding(.vertical, 4) } } @@ -278,6 +323,54 @@ private enum QuestFormatting { } return "\(Int(value.rounded()))회" } + + /// Formats an accumulated amount for progress display. Time is shown as + /// hours + minutes once it reaches an hour, otherwise minutes; counts as 회. + static func amount(_ value: Double, usesTime: Bool) -> String { + guard usesTime else { return "\(Int(value.rounded()))회" } + + let totalMinutes = Int((value / 60).rounded()) + if totalMinutes >= 60 { + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + return minutes == 0 ? "\(hours)시간" : "\(hours)시간 \(minutes)분" + } + return "\(totalMinutes)분" + } +} + +private extension QuestEvaluation { + /// Accent yellow (`#D9A621`) used for in-progress states. + private static let accent = Color(hex: "#D9A621") + + var label: String { + switch self { + case .achieved: return "달성" + case .inProgress: return "진행 중" + case .onTrack: return "유지 중" + case .failed: return "초과" + case .unsupported: return "" + } + } + + var iconName: String { + switch self { + case .achieved: return "checkmark.circle.fill" + case .inProgress: return "hourglass" + case .onTrack: return "checkmark.shield.fill" + case .failed: return "exclamationmark.triangle.fill" + case .unsupported: return "questionmark.circle" + } + } + + var tint: Color { + switch self { + case .achieved, .onTrack: return .brandPrimary + case .inProgress: return Self.accent + case .failed: return .red + case .unsupported: return .secondary + } + } } private extension QuestPeriod { diff --git a/myApp/HaruDanim/IOS/Views/MainDashboardView.swift b/myApp/HaruDanim/IOS/Views/MainDashboardView.swift index db9c3ea..c03c06c 100644 --- a/myApp/HaruDanim/IOS/Views/MainDashboardView.swift +++ b/myApp/HaruDanim/IOS/Views/MainDashboardView.swift @@ -66,7 +66,9 @@ struct MainDashboardView: View { ForEach(actions) { action in ActionTile( action: action, - isTracking: viewModel.isTracking(action) + isTracking: viewModel.isTracking(action), + todayCount: viewModel.getTodayCount(for: action), + todayDuration: viewModel.getTodayDuration(for: action) ) { tapped(action) } @@ -136,12 +138,24 @@ private struct ActiveSessionRow: View { private struct ActionTile: View { let action: Action let isTracking: Bool + let todayCount: Int + let todayDuration: TimeInterval let onTap: () -> Void private var tileColor: Color { Color(hex: action.tags.first?.colorHex ?? "#2F6B4F") } + /// Today's progress line, formatted per action type. + private var progressText: String { + switch action.type { + case .count: + return "\(todayCount)회" + case .time: + return Self.durationString(from: todayDuration) + } + } + var body: some View { Button(action: onTap) { VStack(spacing: 10) { @@ -150,6 +164,9 @@ private struct ActionTile: View { Text(action.name) .font(.subheadline.weight(.semibold)) .lineLimit(1) + Text(progressText) + .font(.caption) + .foregroundStyle(.white.opacity(0.85)) if action.type == .time && isTracking { Text("진행 중") .font(.caption2.weight(.bold)) @@ -168,6 +185,17 @@ private struct ActionTile: View { } .buttonStyle(.plain) } + + /// Formats a duration concisely, e.g. `1h 20m`, `45m`, or `0m`. + private static func durationString(from interval: TimeInterval) -> String { + let totalMinutes = Int(interval) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } } // MARK: - Color helpers