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 c937f3d..b2c84c6 100644 Binary files a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ 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