// // TrackingViewModel.swift // HaruDanim // // Drives real-time tracking of Actions: starting/stopping time sessions // and recording count entries. // import Foundation import SwiftData import Observation @Observable final class TrackingViewModel { /// Currently running time sessions (those without an `endTime`). var activeSessions: [TimeSession] = [] // MARK: - Time tracking /// Starts a new `TimeSession` for the action, or ends the running one if it is /// already being tracked. func toggleTimeTracking(for action: Action, context: ModelContext) { if let running = runningSession(for: action, context: context) { running.endTime = .now } else { let session = TimeSession(startTime: .now, action: action) context.insert(session) } save(context) refreshActiveSessions(context: context) } /// Ends a specific running session (used by the stop button in the UI). func stopSession(_ session: TimeSession, context: ModelContext) { guard session.endTime == nil else { return } session.endTime = .now save(context) refreshActiveSessions(context: context) } // MARK: - Count tracking /// Records a `CountEntry` for the action. func incrementCount(for action: Action, amount: Int = 1, context: ModelContext) { let entry = CountEntry(timestamp: .now, amount: amount, action: action) context.insert(entry) 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`). func refreshActiveSessions(context: ModelContext) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.endTime == nil }, sortBy: [SortDescriptor(\.startTime, order: .forward)] ) activeSessions = (try? context.fetch(descriptor)) ?? [] } /// Returns the running session belonging to `action`, if any. func runningSession(for action: Action, context: ModelContext) -> TimeSession? { let actionID = action.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.endTime == nil && $0.action?.id == actionID } ) return try? context.fetch(descriptor).first } /// Whether the given action currently has a running session. func isTracking(_ action: Action) -> Bool { activeSessions.contains { $0.action?.id == action.id } } // MARK: - Persistence private func save(_ context: ModelContext) { do { try context.save() } catch { // Persistence failures are non-fatal for the in-flight UI; surface in logs. print("TrackingViewModel save failed: \(error)") } } }