new feature Quest(다짐) pogress and Fix an issue in 횟수측정 Action(행동) where count and time are recorded but not reflected in the icon

This commit is contained in:
songyc macbook 2026-07-07 14:29:11 +09:00
parent c35f50fa73
commit 7a53bb9ce4
5 changed files with 332 additions and 12 deletions

View File

@ -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
}
}

View File

@ -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`).

View File

@ -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 {

View File

@ -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