114 lines
4.0 KiB
Swift
114 lines
4.0 KiB
Swift
//
|
|
// 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<TimeSession>(
|
|
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<TimeSession>(
|
|
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)")
|
|
}
|
|
}
|
|
}
|