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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-13 09:44:44 -05:00
parent 877e699c56
commit 473eb271cc
5 changed files with 170 additions and 3 deletions

View File

@@ -78,6 +78,7 @@
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -180,6 +181,7 @@
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -246,6 +248,7 @@
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
@@ -589,7 +592,8 @@
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */, 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
); DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
217A29BCEDD9D44B6DD85AF6 /* Sources */ = { 217A29BCEDD9D44B6DD85AF6 /* Sources */ = {

View File

@@ -38,6 +38,7 @@ struct ConjugaApp: App {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var isReady = false @State private var isReady = false
@State private var syncMonitor = SyncStatusMonitor() @State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -106,6 +107,7 @@ struct ConjugaApp: App {
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast) .animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
.environment(syncMonitor) .environment(syncMonitor)
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext }) .environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer)
.task { .task {
if let url = SharedStore.localStoreURL() { if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "before-bootstrap") StoreInspector.dump(at: url, label: "before-bootstrap")
@@ -130,6 +132,15 @@ struct ConjugaApp: App {
} }
} }
.onChange(of: scenePhase) { _, newPhase in .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 { if newPhase == .background {
WidgetDataService.update( WidgetDataService.update(
localContainer: localContainer, localContainer: localContainer,

View File

@@ -7,6 +7,7 @@ final class DailyLog {
var dateString: String = "" var dateString: String = ""
var reviewCount: Int = 0 var reviewCount: Int = 0
var correctCount: Int = 0 var correctCount: Int = 0
var studySeconds: Int = 0
var accuracy: Double { var accuracy: Double {
guard reviewCount > 0 else { return 0 } guard reviewCount > 0 else { return 0 }

View File

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

View File

@@ -4,6 +4,7 @@ import Charts
struct DashboardView: View { struct DashboardView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(StudyTimerService.self) private var studyTimer
@State private var userProgress: UserProgress? @State private var userProgress: UserProgress?
@State private var dailyLogs: [DailyLog] = [] @State private var dailyLogs: [DailyLog] = []
@State private var testResults: [TestResult] = [] @State private var testResults: [TestResult] = []
@@ -19,8 +20,11 @@ struct DashboardView: View {
// Summary stats // Summary stats
statsGrid statsGrid
// Streak calendar // Study time + Activity side by side
streakCalendar HStack(alignment: .top, spacing: 12) {
studyTimeCard
streakCalendar
}
// Accuracy chart // Accuracy chart
accuracyChart 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 // MARK: - Streak Calendar
private var streakCalendar: some View { private var streakCalendar: some View {
@@ -81,6 +136,7 @@ struct DashboardView: View {
StreakCalendarView(dailyLogs: dailyLogs) StreakCalendarView(dailyLogs: dailyLogs)
} }
.padding() .padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
} }
@@ -167,6 +223,48 @@ struct DashboardView: View {
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) .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 // MARK: - Computed
private var recentLogs: [DailyLog] { 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 { #Preview {
DashboardView() DashboardView()
.environment(StudyTimerService())
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true) .modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
} }