From 473eb271ccf0a0fb7afc5813b0309d6e5b9429fc Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 13 Apr 2026 09:44:44 -0500 Subject: [PATCH] Add background study timer tracking foreground time per day Track how long users spend studying by timing foreground sessions. StudyTimerService starts on app active, stops on background, and accumulates seconds into DailyLog.studySeconds (CloudKit-synced). Dashboard shows today/total study time with a 7-day bar chart. Closes #1 Co-Authored-By: Claude Opus 4.6 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 6 +- Conjuga/Conjuga/ConjugaApp.swift | 11 ++ Conjuga/Conjuga/Models/DailyLog.swift | 1 + .../Conjuga/Services/StudyTimerService.swift | 46 ++++++++ .../Views/Dashboard/DashboardView.swift | 109 +++++++++++++++++- 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 Conjuga/Conjuga/Services/StudyTimerService.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 604d7b8..7cbe7fe 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; + DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -180,6 +181,7 @@ E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = ""; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; + 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -246,6 +248,7 @@ 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, + 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */, @@ -589,7 +592,8 @@ 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, - ); + DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */, +); runOnlyForDeploymentPostprocessing = 0; }; 217A29BCEDD9D44B6DD85AF6 /* Sources */ = { diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index ff8883e..78e61cd 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -38,6 +38,7 @@ struct ConjugaApp: App { @Environment(\.scenePhase) private var scenePhase @State private var isReady = false @State private var syncMonitor = SyncStatusMonitor() + @State private var studyTimer = StudyTimerService() let localContainer: ModelContainer let cloudContainer: ModelContainer @@ -106,6 +107,7 @@ struct ConjugaApp: App { .animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast) .environment(syncMonitor) .environment(\.cloudModelContextProvider, { cloudContainer.mainContext }) + .environment(studyTimer) .task { if let url = SharedStore.localStoreURL() { StoreInspector.dump(at: url, label: "before-bootstrap") @@ -130,6 +132,15 @@ struct ConjugaApp: App { } } .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + studyTimer.start() + case .inactive, .background: + studyTimer.stop(context: cloudContainer.mainContext) + @unknown default: + break + } + if newPhase == .background { WidgetDataService.update( localContainer: localContainer, diff --git a/Conjuga/Conjuga/Models/DailyLog.swift b/Conjuga/Conjuga/Models/DailyLog.swift index 4554d41..c5e2d02 100644 --- a/Conjuga/Conjuga/Models/DailyLog.swift +++ b/Conjuga/Conjuga/Models/DailyLog.swift @@ -7,6 +7,7 @@ final class DailyLog { var dateString: String = "" var reviewCount: Int = 0 var correctCount: Int = 0 + var studySeconds: Int = 0 var accuracy: Double { guard reviewCount > 0 else { return 0 } diff --git a/Conjuga/Conjuga/Services/StudyTimerService.swift b/Conjuga/Conjuga/Services/StudyTimerService.swift new file mode 100644 index 0000000..00583cd --- /dev/null +++ b/Conjuga/Conjuga/Services/StudyTimerService.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData +import Observation + +@MainActor +@Observable +final class StudyTimerService { + private(set) var sessionStart: Date? + private(set) var tick: Int = 0 + private var timer: Timer? + + var isRunning: Bool { sessionStart != nil } + + /// Seconds elapsed in the current live session. + var currentSessionSeconds: Int { + // Access `tick` so SwiftUI re-evaluates each second. + _ = tick + guard let start = sessionStart else { return 0 } + return max(0, Int(Date().timeIntervalSince(start))) + } + + func start() { + guard sessionStart == nil else { return } + sessionStart = Date() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.tick += 1 + } + } + } + + func stop(context: ModelContext) { + guard let start = sessionStart else { return } + let elapsed = max(0, Int(Date().timeIntervalSince(start))) + sessionStart = nil + timer?.invalidate() + timer = nil + + guard elapsed > 0 else { return } + + let todayString = DailyLog.todayString() + let log = ReviewStore.fetchOrCreateDailyLog(dateString: todayString, context: context) + log.studySeconds += elapsed + try? context.save() + } +} diff --git a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift index 2d810c4..5f5a525 100644 --- a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift +++ b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift @@ -4,6 +4,7 @@ import Charts struct DashboardView: View { @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + @Environment(StudyTimerService.self) private var studyTimer @State private var userProgress: UserProgress? @State private var dailyLogs: [DailyLog] = [] @State private var testResults: [TestResult] = [] @@ -19,8 +20,11 @@ struct DashboardView: View { // Summary stats statsGrid - // Streak calendar - streakCalendar + // Study time + Activity side by side + HStack(alignment: .top, spacing: 12) { + studyTimeCard + streakCalendar + } // Accuracy chart accuracyChart @@ -71,6 +75,57 @@ struct DashboardView: View { } } + // MARK: - Study Time Card + + private var studyTimeCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Study Time") + .font(.headline) + + let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds + HStack(spacing: 0) { + VStack(spacing: 4) { + Text(formatStudyTime(todaySeconds)) + .font(.title3.bold().monospacedDigit()) + Text("Today") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + VStack(spacing: 4) { + Text(formatStudyTime(totalStudySeconds)) + .font(.title3.bold().monospacedDigit()) + Text("Total") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) { + Text("Start studying to see your time") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } else { + Chart(weeklyStudyData) { day in + BarMark( + x: .value("Day", day.label), + y: .value("Minutes", day.minutes) + ) + .foregroundStyle(.mint.gradient) + .cornerRadius(4) + } + .chartYAxis(.hidden) + .frame(height: 80) + } + } + .padding() + .frame(maxWidth: .infinity) + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) + } + // MARK: - Streak Calendar private var streakCalendar: some View { @@ -81,6 +136,7 @@ struct DashboardView: View { StreakCalendarView(dailyLogs: dailyLogs) } .padding() + .frame(maxWidth: .infinity) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) } @@ -167,6 +223,48 @@ struct DashboardView: View { .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) } + // MARK: - Study Time Computed + + private var todayStudySeconds: Int { + let today = DailyLog.todayString() + return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0 + } + + private var totalStudySeconds: Int { + dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds + } + + private var weeklyStudySeconds: Int { + weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60 + } + + private var weeklyStudyData: [StudyDay] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + return (0..<7).reversed().map { daysAgo in + let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)! + let dateStr = DailyLog.dateString(from: date) + let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0 + let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date) + return StudyDay(label: dayLabel, minutes: seconds / 60) + } + } + + private static let shortDayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE" + return f + }() + + private func formatStudyTime(_ totalSeconds: Int) -> String { + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } + // MARK: - Computed private var recentLogs: [DailyLog] { @@ -222,7 +320,14 @@ private struct StatCard: View { } } +private struct StudyDay: Identifiable { + let label: String + let minutes: Int + var id: String { label } +} + #Preview { DashboardView() + .environment(StudyTimerService()) .modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true) } -- 2.49.1