From b406d374b323bb8810bde8792b025bf87135d711 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:02:58 +0000 Subject: [PATCH 1/6] feat(learning): add the study timer and statistics to the domain StudyTimer is a Pomodoro state machine driven by explicit dates (focus, short and long breaks, pause, skip, reset); StudyStatistics computes the last seven days, the current week, minutes per module and the streak of study days. Completed focus phases become StudySessions via RecordStudySessionUseCase. The notification plan sync keeps the timer's own notification. Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Domain/Entities/StudyNotification.swift | 2 + Domain/Entities/StudySession.swift | 42 +++++ .../Repositories/StudySessionRepository.swift | 9 ++ Domain/UseCases/NotificationUseCases.swift | 1 + Domain/UseCases/StudyStatistics.swift | 66 ++++++++ Domain/UseCases/StudyTimer.swift | 109 +++++++++++++ StudyAppTests/StudyTimerTests.swift | 144 ++++++++++++++++++ 7 files changed, 373 insertions(+) create mode 100644 Domain/Entities/StudySession.swift create mode 100644 Domain/Repositories/StudySessionRepository.swift create mode 100644 Domain/UseCases/StudyStatistics.swift create mode 100644 Domain/UseCases/StudyTimer.swift create mode 100644 StudyAppTests/StudyTimerTests.swift diff --git a/Domain/Entities/StudyNotification.swift b/Domain/Entities/StudyNotification.swift index ddb85a9..4a292bb 100644 --- a/Domain/Entities/StudyNotification.swift +++ b/Domain/Entities/StudyNotification.swift @@ -29,6 +29,8 @@ struct StudyNotification: Equatable, Sendable { /// Every identifier StudyApp schedules starts with this prefix, so foreign requests are never touched. static let identifierPrefix = "studyapp." + /// The study timer's „phase is over“ notification. Not part of the plan, so the plan sync keeps it. + static let studyTimerIdentifier = "studyapp.timer" } /// Which notifications the user wants. Stored as one value so new options do not need new storage keys. diff --git a/Domain/Entities/StudySession.swift b/Domain/Entities/StudySession.swift new file mode 100644 index 0000000..41fcbd8 --- /dev/null +++ b/Domain/Entities/StudySession.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A completed focus phase of the study timer. +struct StudySession: Identifiable, Hashable, Codable, Sendable { + let id: UUID + /// `nil` when the timer ran without a module. + var moduleID: UUID? + var startedAt: Date + var duration: TimeInterval + + var endedAt: Date { startedAt.addingTimeInterval(duration) } +} + +/// Lengths of the study timer's phases (Pomodoro technique). +struct StudyTimerSettings: Codable, Equatable, Sendable { + var focusMinutes = 25 + var shortBreakMinutes = 5 + var longBreakMinutes = 15 + /// A long break follows every this many focus phases. + var focusPhasesPerLongBreak = 4 + + static let minuteRange = 1...120 +} + +extension StudyTimerSettings { + private enum CodingKeys: String, CodingKey { + case focusMinutes, shortBreakMinutes, longBreakMinutes, focusPhasesPerLongBreak + } + + /// Missing keys fall back to the defaults, so stored settings survive new options. + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = StudyTimerSettings() + self.init( + focusMinutes: try container.decodeIfPresent(Int.self, forKey: .focusMinutes) ?? defaults.focusMinutes, + shortBreakMinutes: try container.decodeIfPresent(Int.self, forKey: .shortBreakMinutes) ?? defaults.shortBreakMinutes, + longBreakMinutes: try container.decodeIfPresent(Int.self, forKey: .longBreakMinutes) ?? defaults.longBreakMinutes, + focusPhasesPerLongBreak: try container.decodeIfPresent(Int.self, forKey: .focusPhasesPerLongBreak) + ?? defaults.focusPhasesPerLongBreak + ) + } +} diff --git a/Domain/Repositories/StudySessionRepository.swift b/Domain/Repositories/StudySessionRepository.swift new file mode 100644 index 0000000..cbfd27d --- /dev/null +++ b/Domain/Repositories/StudySessionRepository.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Stores the study timer's completed focus phases. +@MainActor +protocol StudySessionRepository { + func all() throws -> [StudySession] + func add(_ session: StudySession) throws + func deleteSessions(forModule moduleID: UUID) throws +} diff --git a/Domain/UseCases/NotificationUseCases.swift b/Domain/UseCases/NotificationUseCases.swift index dd75847..1665a0d 100644 --- a/Domain/UseCases/NotificationUseCases.swift +++ b/Domain/UseCases/NotificationUseCases.swift @@ -142,6 +142,7 @@ struct SyncNotificationsUseCase { let wanted = Set(planned.map(\.id)) let stale = await scheduler.pendingIdentifiers().filter { $0.hasPrefix(StudyNotification.identifierPrefix) && !wanted.contains($0) + && $0 != StudyNotification.studyTimerIdentifier } scheduler.cancel(identifiers: stale) await scheduler.schedule(planned) diff --git a/Domain/UseCases/StudyStatistics.swift b/Domain/UseCases/StudyStatistics.swift new file mode 100644 index 0000000..9cac311 --- /dev/null +++ b/Domain/UseCases/StudyStatistics.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Study time figures for the statistics view, computed from recorded sessions. +struct StudyStatistics: Equatable, Sendable { + struct Day: Equatable, Sendable { + let date: Date + let minutes: Int + } + + struct ModuleTime: Equatable, Sendable { + let moduleID: UUID? + let minutes: Int + } + + /// The last seven days, oldest first, including today. + let lastSevenDays: [Day] + /// Minutes in the current calendar week. + let minutesThisWeek: Int + /// Minutes per module in the current calendar week, most first. + let modulesThisWeek: [ModuleTime] + /// Consecutive days with at least one session, ending today (or yesterday, if today has none yet). + let streakDays: Int + + init(sessions: [StudySession], now: Date, calendar: Calendar = .current) { + let today = calendar.startOfDay(for: now) + var minutesPerDay: [Date: TimeInterval] = [:] + for session in sessions { + minutesPerDay[calendar.startOfDay(for: session.startedAt), default: 0] += session.duration + } + func minutes(_ seconds: TimeInterval) -> Int { Int((seconds / 60).rounded()) } + + lastSevenDays = (0..<7).reversed().compactMap { offset in + calendar.date(byAdding: .day, value: -offset, to: today).map { Day(date: $0, minutes: minutes(minutesPerDay[$0] ?? 0)) } + } + + let week = calendar.dateInterval(of: .weekOfYear, for: now) + let thisWeek = sessions.filter { week?.contains($0.startedAt) ?? false } + minutesThisWeek = minutes(thisWeek.reduce(0) { $0 + $1.duration }) + var perModule: [UUID?: TimeInterval] = [:] + for session in thisWeek { perModule[session.moduleID, default: 0] += session.duration } + modulesThisWeek = perModule + .map { ModuleTime(moduleID: $0.key, minutes: minutes($0.value)) } + .sorted { $0.minutes > $1.minutes } + + var streak = 0 + var day = minutesPerDay[today] == nil ? calendar.date(byAdding: .day, value: -1, to: today) : today + while let current = day, minutesPerDay[current] != nil { + streak += 1 + day = calendar.date(byAdding: .day, value: -1, to: current) + } + streakDays = streak + } +} + +/// Stores a completed focus phase. Phases shorter than a minute are not worth recording. +@MainActor +struct RecordStudySessionUseCase { + static let minimumDuration: TimeInterval = 60 + + let repository: any StudySessionRepository + + func callAsFunction(moduleID: UUID?, startedAt: Date, duration: TimeInterval) throws { + guard duration >= Self.minimumDuration else { return } + try repository.add(StudySession(id: UUID(), moduleID: moduleID, startedAt: startedAt, duration: duration)) + } +} diff --git a/Domain/UseCases/StudyTimer.swift b/Domain/UseCases/StudyTimer.swift new file mode 100644 index 0000000..a39ec92 --- /dev/null +++ b/Domain/UseCases/StudyTimer.swift @@ -0,0 +1,109 @@ +import Foundation + +/// The Pomodoro state machine: focus, short break, long break. A value type driven by explicit dates, +/// so it is deterministic and testable; the view model only feeds it the current time. +struct StudyTimer: Equatable, Sendable { + enum Phase: Equatable, Sendable { + case focus + case shortBreak + case longBreak + + var isBreak: Bool { self != .focus } + + var title: String { + switch self { + case .focus: "Fokus" + case .shortBreak: "Kurze Pause" + case .longBreak: "Lange Pause" + } + } + } + + /// What `tick` reports when a phase ended. + struct Completion: Equatable, Sendable { + let phase: Phase + let startedAt: Date + let duration: TimeInterval + } + + private(set) var phase: Phase = .focus + private(set) var completedFocusPhases = 0 + /// End of the running phase; `nil` while paused or not started. + private(set) var phaseEnd: Date? + /// Start of the running phase's time on the clock, for the recorded session. + private(set) var phaseStartedAt: Date? + private var pausedRemaining: TimeInterval? + var settings: StudyTimerSettings + + init(settings: StudyTimerSettings = StudyTimerSettings()) { + self.settings = settings + } + + var isRunning: Bool { phaseEnd != nil } + var isPaused: Bool { phaseEnd == nil && pausedRemaining != nil } + + var phaseLength: TimeInterval { + let minutes = switch phase { + case .focus: settings.focusMinutes + case .shortBreak: settings.shortBreakMinutes + case .longBreak: settings.longBreakMinutes + } + return TimeInterval(max(1, minutes)) * 60 + } + + func remaining(at now: Date) -> TimeInterval { + if let phaseEnd { return max(0, phaseEnd.timeIntervalSince(now)) } + return pausedRemaining ?? phaseLength + } + + /// Progress of the current phase from 0 to 1. + func progress(at now: Date) -> Double { + 1 - remaining(at: now) / phaseLength + } + + mutating func start(at now: Date) { + guard !isRunning else { return } + let remaining = pausedRemaining ?? phaseLength + if pausedRemaining == nil { phaseStartedAt = now } + pausedRemaining = nil + phaseEnd = now.addingTimeInterval(remaining) + } + + mutating func pause(at now: Date) { + guard let phaseEnd else { return } + pausedRemaining = max(0, phaseEnd.timeIntervalSince(now)) + self.phaseEnd = nil + } + + /// Ends the phase when its time is up and moves to the next one (not started). + mutating func tick(at now: Date) -> Completion? { + guard let phaseEnd, now >= phaseEnd else { return nil } + let completion = Completion(phase: phase, startedAt: phaseStartedAt ?? now, duration: phaseLength) + advance(countsFocus: true) + return completion + } + + /// Skips to the next phase. A skipped focus phase is not counted. + mutating func skip() { + advance(countsFocus: false) + } + + /// Back to a fresh focus phase. + mutating func reset() { + self = StudyTimer(settings: settings) + } + + private mutating func advance(countsFocus: Bool) { + if phase == .focus { + if countsFocus { completedFocusPhases += 1 } + let perLongBreak = max(1, settings.focusPhasesPerLongBreak) + let isLongBreakDue = countsFocus && completedFocusPhases.isMultiple(of: perLongBreak) + phase = isLongBreakDue ? .longBreak : .shortBreak + } else { + phase = .focus + } + phaseEnd = nil + phaseStartedAt = nil + pausedRemaining = nil + } +} diff --git a/StudyAppTests/StudyTimerTests.swift b/StudyAppTests/StudyTimerTests.swift new file mode 100644 index 0000000..3bf83e6 --- /dev/null +++ b/StudyAppTests/StudyTimerTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import StudyApp + +struct StudyTimerTests { + let start = Date(timeIntervalSince1970: 1_760_000_000) + let settings = StudyTimerSettings(focusMinutes: 25, shortBreakMinutes: 5, longBreakMinutes: 15, focusPhasesPerLongBreak: 2) + + @Test func runsAFocusPhaseAndReportsItWhenTimeIsUp() { + var timer = StudyTimer(settings: settings) + timer.start(at: start) + #expect(timer.isRunning) + #expect(timer.remaining(at: start.addingTimeInterval(60)) == 24 * 60) + #expect(timer.tick(at: start.addingTimeInterval(24 * 60)) == nil) + + let completion = timer.tick(at: start.addingTimeInterval(25 * 60)) + #expect(completion == StudyTimer.Completion(phase: .focus, startedAt: start, duration: 25 * 60)) + #expect(timer.phase == .shortBreak) + #expect(!timer.isRunning) + } + + @Test func pauseKeepsTheRemainingTime() { + var timer = StudyTimer(settings: settings) + timer.start(at: start) + timer.pause(at: start.addingTimeInterval(10 * 60)) + #expect(timer.isPaused) + #expect(timer.remaining(at: start.addingTimeInterval(60 * 60)) == 15 * 60) + + let resumed = start.addingTimeInterval(60 * 60) + timer.start(at: resumed) + // The recorded start stays the original one. + #expect(timer.tick(at: resumed.addingTimeInterval(15 * 60))?.startedAt == start) + } + + @Test func longBreakFollowsEveryNthFocusPhase() { + var timer = StudyTimer(settings: settings) + var now = start + func finishPhase() { + timer.start(at: now) + now = now.addingTimeInterval(timer.phaseLength) + _ = timer.tick(at: now) + } + finishPhase() + #expect(timer.phase == .shortBreak) + finishPhase() + #expect(timer.phase == .focus) + finishPhase() + #expect(timer.phase == .longBreak) + #expect(timer.completedFocusPhases == 2) + } + + @Test func skippingAFocusPhaseDoesNotCountIt() { + var timer = StudyTimer(settings: settings) + timer.skip() + #expect(timer.phase == .shortBreak) + #expect(timer.completedFocusPhases == 0) + timer.skip() + #expect(timer.phase == .focus) + } + + @Test func storedSettingsWithoutNewKeysStayReadable() throws { + let settings = try JSONDecoder().decode(StudyTimerSettings.self, from: Data(#"{"focusMinutes":50}"#.utf8)) + #expect(settings.focusMinutes == 50) + #expect(settings.shortBreakMinutes == 5) + } +} + +struct StudyStatisticsTests { + let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Europe/Berlin")! + calendar.firstWeekday = 2 + return calendar + }() + + private func date(_ day: Int, _ hour: Int = 10) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))! + } + + private func session(_ day: Int, minutes: Double, module: UUID? = nil) -> StudySession { + StudySession(id: UUID(), moduleID: module, startedAt: date(day), duration: minutes * 60) + } + + @Test func sumsDaysWeekAndModules() { + let analysis = UUID() + let sessions = [ + session(28, minutes: 25, module: analysis), + session(28, minutes: 25, module: analysis), + session(27, minutes: 25), + session(20, minutes: 25, module: analysis), // previous week + ] + let statistics = StudyStatistics(sessions: sessions, now: date(28, 18), calendar: calendar) + + #expect(statistics.lastSevenDays.count == 7) + #expect(statistics.lastSevenDays.last == .init(date: date(28, 0), minutes: 50)) + #expect(statistics.minutesThisWeek == 75) + #expect(statistics.modulesThisWeek.first == .init(moduleID: analysis, minutes: 50)) + } + + @Test func streakCountsConsecutiveDaysUpToTodayOrYesterday() { + let sessions = [session(25, minutes: 25), session(26, minutes: 25), session(27, minutes: 25), session(23, minutes: 25)] + #expect(StudyStatistics(sessions: sessions, now: date(28, 9), calendar: calendar).streakDays == 3) + #expect(StudyStatistics(sessions: sessions + [session(28, minutes: 25)], now: date(28, 20), calendar: calendar).streakDays == 4) + #expect(StudyStatistics(sessions: sessions, now: date(30, 9), calendar: calendar).streakDays == 0) + } +} + +@MainActor +final class InMemoryStudySessionRepository: StudySessionRepository { + var sessions: [StudySession] = [] + func all() throws -> [StudySession] { sessions } + func add(_ session: StudySession) throws { sessions.append(session) } + func deleteSessions(forModule moduleID: UUID) throws { sessions.removeAll { $0.moduleID == moduleID } } +} + +@MainActor +struct RecordStudySessionTests { + @Test func ignoresPhasesShorterThanAMinute() throws { + let repository = InMemoryStudySessionRepository() + let record = RecordStudySessionUseCase(repository: repository) + try record(moduleID: nil, startedAt: .now, duration: 30) + try record(moduleID: nil, startedAt: .now, duration: 25 * 60) + #expect(repository.sessions.count == 1) + } +} + +@MainActor +struct StudyTimerNotificationTests { + @Test func planSyncKeepsTheTimerNotification() async throws { + let scheduler = FakeNotificationScheduler() + await scheduler.schedule([ + StudyNotification(id: StudyNotification.studyTimerIdentifier, title: "Pause", body: "", fireDate: .now, target: .today), + StudyNotification(id: "studyapp.lecture.alt", title: "Alt", body: "", fireDate: .now, target: .today), + ]) + let sync = SyncNotificationsUseCase( + scheduler: scheduler, modules: FakeModuleRepository(), calendar: InMemoryCalendarRepository(), + assignments: FakeAssignmentRepository() + ) + + try await sync(settings: NotificationSettings(), now: .now) + + #expect(Array(scheduler.scheduled.keys) == [StudyNotification.studyTimerIdentifier]) + } +} From 6eece537fca902a6fddac4d7d8d7ff7d64dbd06d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:05:14 +0000 Subject: [PATCH 2/6] feat(learning): store study sessions and timer settings StudySessionRecord and its repository keep completed focus phases; timer lengths are stored as JSON that tolerates missing keys. Sessions are part of backups (older backups without them still restore). Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 3 ++ .../UserDefaultsStudyPreferences.swift | 10 +++++++ .../SwiftDataStudySessionRepository.swift | 30 +++++++++++++++++++ Data/SwiftData/Persistence.swift | 1 + Data/SwiftData/StudySessionRecord.swift | 22 ++++++++++++++ Domain/Entities/Backup.swift | 2 ++ Domain/Repositories/BackupArchiving.swift | 1 + Domain/Repositories/StudyPreferences.swift | 2 ++ .../Repositories/StudySessionRepository.swift | 1 + Domain/UseCases/BackupUseCases.swift | 3 ++ StudyAppTests/BackupTests.swift | 6 +++- StudyAppTests/StudyTimerTests.swift | 1 + docs/BACKUP.md | 4 +-- 13 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 Data/Repositories/SwiftDataStudySessionRepository.swift create mode 100644 Data/SwiftData/StudySessionRecord.swift diff --git a/App/AppContainer.swift b/App/AppContainer.swift index 9d944fc..a2d75fc 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -23,6 +23,7 @@ final class AppContainer { let backupFolder: any BackupFolder let notificationScheduler: any NotificationScheduling let examRepository: any ExamRepository + let studySessionRepository: any StudySessionRepository /// Einstellungen und Lesefortschritt; im Speicher für Tests und Previews, sonst in `UserDefaults`. let preferences: any StudySettingsStore & ReadingProgressStore @@ -41,6 +42,7 @@ final class AppContainer { courseFileRepository = SwiftDataCourseFileRepository(context: context) notificationScheduler = UserNotificationScheduler() examRepository = SwiftDataExamRepository(context: context) + studySessionRepository = SwiftDataStudySessionRepository(context: context) calendarFeedLoader = URLSessionCalendarFeedLoader() calendarEventParser = ICalendarFeedParser() pdfInspector = PDFKitInspector() @@ -94,6 +96,7 @@ final class AppContainer { calendar: studyCalendarRepository, courseFiles: courseFileRepository, exams: examRepository, + studySessions: studySessionRepository, files: fileStorage ) } diff --git a/Data/Preferences/UserDefaultsStudyPreferences.swift b/Data/Preferences/UserDefaultsStudyPreferences.swift index cf5e41f..2a9ad0c 100644 --- a/Data/Preferences/UserDefaultsStudyPreferences.swift +++ b/Data/Preferences/UserDefaultsStudyPreferences.swift @@ -13,6 +13,7 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto static let lastAutoOpenedLecture = "autopilot.lastHandledLecture" static let automaticBackups = "backup.automatic" static let notificationSettings = "notifications.settings" + static let studyTimerSettings = "studyTimer.settings" static func lastPage(_ documentID: UUID) -> String { "reading.page.\(documentID.uuidString)" } static func lastDocument(_ moduleID: UUID) -> String { "reading.document.\(moduleID.uuidString)" } } @@ -69,6 +70,15 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto set { defaults.set(try? JSONEncoder().encode(newValue), forKey: Key.notificationSettings) } } + /// Stored as JSON; unreadable or missing data falls back to the defaults. + var studyTimerSettings: StudyTimerSettings { + get { + defaults.data(forKey: Key.studyTimerSettings) + .flatMap { try? JSONDecoder().decode(StudyTimerSettings.self, from: $0) } ?? StudyTimerSettings() + } + set { defaults.set(try? JSONEncoder().encode(newValue), forKey: Key.studyTimerSettings) } + } + func lastPage(for documentID: UUID) -> Int? { let page = defaults.integer(forKey: Key.lastPage(documentID)) return page > 0 ? page : nil diff --git a/Data/Repositories/SwiftDataStudySessionRepository.swift b/Data/Repositories/SwiftDataStudySessionRepository.swift new file mode 100644 index 0000000..60d9d68 --- /dev/null +++ b/Data/Repositories/SwiftDataStudySessionRepository.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftData + +@MainActor +final class SwiftDataStudySessionRepository: StudySessionRepository { + private let context: ModelContext + init(context: ModelContext) { self.context = context } + + func all() throws -> [StudySession] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.startedAt)]) + return try context.fetch(descriptor).map { $0.toDomain() } + } + + func add(_ session: StudySession) throws { + context.insert(StudySessionRecord(session)) + try context.save() + } + + func deleteSessions(forModule moduleID: UUID) throws { + let records = try context.fetch(FetchDescriptor()).filter { $0.moduleID == moduleID } + guard !records.isEmpty else { return } + for record in records { context.delete(record) } + try context.save() + } + + func deleteAll() throws { + try context.delete(model: StudySessionRecord.self) + try context.save() + } +} diff --git a/Data/SwiftData/Persistence.swift b/Data/SwiftData/Persistence.swift index d386e39..8453ffd 100644 --- a/Data/SwiftData/Persistence.swift +++ b/Data/SwiftData/Persistence.swift @@ -15,6 +15,7 @@ enum Persistence { AssignmentRecord.self, CourseFileRecord.self, ExamRecord.self, + StudySessionRecord.self, ]) static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { diff --git a/Data/SwiftData/StudySessionRecord.swift b/Data/SwiftData/StudySessionRecord.swift new file mode 100644 index 0000000..2d125c4 --- /dev/null +++ b/Data/SwiftData/StudySessionRecord.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftData + +/// A completed focus phase of the study timer. +@Model +final class StudySessionRecord { + @Attribute(.unique) var id: UUID + var moduleID: UUID? + var startedAt: Date + var duration: TimeInterval + + init(_ session: StudySession) { + id = session.id + moduleID = session.moduleID + startedAt = session.startedAt + duration = session.duration + } + + func toDomain() -> StudySession { + StudySession(id: id, moduleID: moduleID, startedAt: startedAt, duration: duration) + } +} diff --git a/Domain/Entities/Backup.swift b/Domain/Entities/Backup.swift index 4be55d3..c7052e9 100644 --- a/Domain/Entities/Backup.swift +++ b/Domain/Entities/Backup.swift @@ -17,6 +17,8 @@ struct BackupSnapshot: Codable, Equatable, Sendable { var courseFiles: [CourseFile] = [] /// Manual exams plus the stored state (hidden, study plan progress) of detected ones. var exams: [Exam] = [] + /// Completed focus phases of the study timer. + var studySessions: [StudySession] = [] /// Names of the stored files (`FileStorage`) the snapshot refers to: slide PDFs and downloaded course files. var referencedFiles: [String] { diff --git a/Domain/Repositories/BackupArchiving.swift b/Domain/Repositories/BackupArchiving.swift index 59979d2..a7207ea 100644 --- a/Domain/Repositories/BackupArchiving.swift +++ b/Domain/Repositories/BackupArchiving.swift @@ -49,5 +49,6 @@ struct StudyDataRepositories { let calendar: any StudyCalendarRepository let courseFiles: any CourseFileRepository let exams: any ExamRepository + let studySessions: any StudySessionRepository let files: any FileStorage } diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift index daf86ff..258efdc 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -19,6 +19,7 @@ protocol StudySettingsStore: AnyObject { /// Create a weekly backup in the app's backup folder. var automaticBackups: Bool { get set } var notificationSettings: NotificationSettings { get set } + var studyTimerSettings: StudyTimerSettings { get set } } extension StudySettingsStore { @@ -45,6 +46,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore { var lastAutoOpenedLecture: String? var automaticBackups = true var notificationSettings = NotificationSettings() + var studyTimerSettings = StudyTimerSettings() private var pages: [UUID: Int] = [:] private var documents: [UUID: UUID] = [:] diff --git a/Domain/Repositories/StudySessionRepository.swift b/Domain/Repositories/StudySessionRepository.swift index cbfd27d..88a8191 100644 --- a/Domain/Repositories/StudySessionRepository.swift +++ b/Domain/Repositories/StudySessionRepository.swift @@ -6,4 +6,5 @@ protocol StudySessionRepository { func all() throws -> [StudySession] func add(_ session: StudySession) throws func deleteSessions(forModule moduleID: UUID) throws + func deleteAll() throws } diff --git a/Domain/UseCases/BackupUseCases.swift b/Domain/UseCases/BackupUseCases.swift index 1cfd483..caea8ca 100644 --- a/Domain/UseCases/BackupUseCases.swift +++ b/Domain/UseCases/BackupUseCases.swift @@ -23,6 +23,7 @@ struct MakeBackupSnapshotUseCase { snapshot.calendarSources = try repositories.calendar.sources() snapshot.calendarEvents = try repositories.calendar.events() snapshot.exams = try repositories.exams.all() + snapshot.studySessions = try repositories.studySessions.all() return snapshot } } @@ -138,6 +139,7 @@ struct RestoreBackupUseCase { for exam in try repositories.exams.all() { try repositories.exams.delete(id: exam.id) } + try repositories.studySessions.deleteAll() } private func insert(_ snapshot: BackupSnapshot) throws { @@ -155,6 +157,7 @@ struct RestoreBackupUseCase { ) } for exam in snapshot.exams { try repositories.exams.save(exam) } + for session in snapshot.studySessions { try repositories.studySessions.add(session) } for (moduleID, files) in Dictionary(grouping: snapshot.courseFiles, by: \.moduleID) { try repositories.courseFiles.replaceCatalog(forModule: moduleID, with: files) } diff --git a/StudyAppTests/BackupTests.swift b/StudyAppTests/BackupTests.swift index 9839026..028c4af 100644 --- a/StudyAppTests/BackupTests.swift +++ b/StudyAppTests/BackupTests.swift @@ -59,12 +59,14 @@ private struct DataStore { let calendar = InMemoryCalendarRepository() let courseFiles = FakeCourseFileRepository() let exams = InMemoryExamRepository() + let studySessions = InMemoryStudySessionRepository() let files = FakeFileStorage() var repositories: StudyDataRepositories { StudyDataRepositories( modules: modules, slides: slides, notes: notes, flashcards: flashcards, studySheets: studySheets, - assignments: assignments, calendar: calendar, courseFiles: courseFiles, exams: exams, files: files + assignments: assignments, calendar: calendar, courseFiles: courseFiles, exams: exams, + studySessions: studySessions, files: files ) } @@ -112,6 +114,7 @@ private struct DataStore { id: UUID(), moduleID: module.id, title: "Klausur Analysis I", startDate: now.addingTimeInterval(14 * 86_400), location: "Audimax", calendarEventKey: nil, completedPlanItems: ["review.final"] )) + try studySessions.add(StudySession(id: UUID(), moduleID: module.id, startedAt: now, duration: 25 * 60)) return module } } @@ -139,6 +142,7 @@ struct BackupTests { #expect(snapshot.calendarEvents.count == 1) #expect(snapshot.courseFiles.count == 1) #expect(snapshot.exams.map(\.title) == ["Klausur Analysis I"]) + #expect(snapshot.studySessions.map(\.duration) == [TimeInterval(25 * 60)]) #expect(snapshot.referencedFiles == ["StudIP/\(module.id.uuidString)/z1/Blatt1.zip", "doc.pdf"]) } diff --git a/StudyAppTests/StudyTimerTests.swift b/StudyAppTests/StudyTimerTests.swift index 3bf83e6..56c6811 100644 --- a/StudyAppTests/StudyTimerTests.swift +++ b/StudyAppTests/StudyTimerTests.swift @@ -111,6 +111,7 @@ final class InMemoryStudySessionRepository: StudySessionRepository { func all() throws -> [StudySession] { sessions } func add(_ session: StudySession) throws { sessions.append(session) } func deleteSessions(forModule moduleID: UUID) throws { sessions.removeAll { $0.moduleID == moduleID } } + func deleteAll() throws { sessions.removeAll() } } @MainActor diff --git a/docs/BACKUP.md b/docs/BACKUP.md index ace6390..9f4b63a 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -4,7 +4,7 @@ ## What is included -Modules (with semester, grade, ECTS and Stud.IP link), slide documents and their PDFs including annotations, notes, flashcards with their review schedule, study sheets, assignments, exams (manual ones and the study plan progress of detected ones), calendar sources and events, the Stud.IP file catalog and downloaded course files. +Modules (with semester, grade, ECTS and Stud.IP link), slide documents and their PDFs including annotations, notes, flashcards with their review schedule, study sheets, assignments, exams (manual ones and the study plan progress of detected ones), study timer sessions, calendar sources and events, the Stud.IP file catalog and downloaded course files. Not included: the Stud.IP password or session (keychain), app settings (`UserDefaults`) and reading positions. After restoring, sign in to Stud.IP again. @@ -15,7 +15,7 @@ A `.studyappbackup` file is an [Apple Archive](https://developer.apple.com/docum ```text manifest.json formatVersion, appVersion, createdAt, moduleCount, fileCount data.json BackupSnapshot: modules, slideDocuments, notes, flashcards, studySheets, - assignments, calendarSources, calendarEvents, courseFiles, exams + assignments, calendarSources, calendarEvents, courseFiles, exams, studySessions files/… stored files with the same relative paths as in the app's storage ``` From 4e65d5ab373106bcb5df4a031b0a5a5e0c191452 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:07:42 +0000 Subject: [PATCH 3/6] feat(learning): add the study timer view model One app-wide StudyTimerViewModel drives the timer every second while it runs, records finished focus phases for the chosen module, schedules a notification for the end of the phase (cancelled on pause, skip and reset) and provides the statistics. Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- .../StudyTimer/StudyTimerViewModel.swift | 134 ++++++++++++++++++ StudyAppTests/StudyTimerTests.swift | 47 ++++++ 2 files changed, 181 insertions(+) create mode 100644 Presentation/StudyTimer/StudyTimerViewModel.swift diff --git a/Presentation/StudyTimer/StudyTimerViewModel.swift b/Presentation/StudyTimer/StudyTimerViewModel.swift new file mode 100644 index 0000000..4a1c282 --- /dev/null +++ b/Presentation/StudyTimer/StudyTimerViewModel.swift @@ -0,0 +1,134 @@ +import Foundation +import Observation + +/// The app-wide study timer: one timer, shown in the toolbar (and the menu bar), plus the statistics. +@MainActor +@Observable +final class StudyTimerViewModel { + private(set) var timer: StudyTimer + /// Module the focus time is counted for; `nil` counts it without a module. + var moduleID: UUID? + private(set) var sessions: [StudySession] = [] + /// Updated every second while the timer runs, so the remaining time redraws. + private(set) var now: Date + var errorMessage: String? + + private let repository: any StudySessionRepository + private let record: RecordStudySessionUseCase + private let settingsStore: any StudySettingsStore + private let scheduler: (any NotificationScheduling)? + private let clock: () -> Date + @ObservationIgnored private var tickTask: Task? + + init( + repository: any StudySessionRepository, + settings: any StudySettingsStore, + scheduler: (any NotificationScheduling)? = nil, + clock: @escaping () -> Date = { .now } + ) { + self.repository = repository + record = RecordStudySessionUseCase(repository: repository) + settingsStore = settings + self.scheduler = scheduler + self.clock = clock + timer = StudyTimer(settings: settings.studyTimerSettings) + now = clock() + } + + var settings: StudyTimerSettings { + get { timer.settings } + set { + timer.settings = newValue + settingsStore.studyTimerSettings = newValue + } + } + + var remaining: TimeInterval { timer.remaining(at: now) } + var progress: Double { timer.progress(at: now) } + var isActive: Bool { timer.isRunning || timer.isPaused } + + /// „24:59“. + var remainingText: String { Self.clockText(remaining) } + + var statistics: StudyStatistics { StudyStatistics(sessions: sessions, now: now) } + + func load() { + do { + sessions = try repository.all() + } catch { + errorMessage = error.localizedDescription + } + } + + func start() async { + now = clock() + timer.start(at: now) + startTicking() + await scheduleEndNotification() + } + + func pause() { + now = clock() + timer.pause(at: now) + cancelEndNotification() + } + + func skip() { + timer.skip() + cancelEndNotification() + } + + func reset() { + timer.reset() + cancelEndNotification() + } + + /// Advances the timer; records a finished focus phase. + func tick() { + now = clock() + guard let completion = timer.tick(at: now) else { return } + if completion.phase == .focus { + do { + try record(moduleID: moduleID, startedAt: completion.startedAt, duration: completion.duration) + load() + } catch { + errorMessage = error.localizedDescription + } + } + } + + static func clockText(_ interval: TimeInterval) -> String { + let seconds = Int(interval.rounded(.up)) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + // MARK: Private + + private func startTicking() { + tickTask?.cancel() + tickTask = Task { [weak self] in + while let self, self.timer.isRunning, !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + self.tick() + } + } + } + + private func scheduleEndNotification() async { + guard let scheduler, let end = timer.phaseEnd else { return } + let isFocus = timer.phase == .focus + let notification = StudyNotification( + id: StudyNotification.studyTimerIdentifier, + title: isFocus ? "Fokuszeit vorbei" : "Pause vorbei", + body: isFocus ? "Zeit für eine Pause." : "Weiter geht's mit der nächsten Fokusphase.", + fireDate: end, + target: .today + ) + guard await scheduler.authorization() == .authorized else { return } + await scheduler.schedule([notification]) + } + + private func cancelEndNotification() { + scheduler?.cancel(identifiers: [StudyNotification.studyTimerIdentifier]) + } +} diff --git a/StudyAppTests/StudyTimerTests.swift b/StudyAppTests/StudyTimerTests.swift index 56c6811..fdf8eaf 100644 --- a/StudyAppTests/StudyTimerTests.swift +++ b/StudyAppTests/StudyTimerTests.swift @@ -143,3 +143,50 @@ struct StudyTimerNotificationTests { #expect(Array(scheduler.scheduled.keys) == [StudyNotification.studyTimerIdentifier]) } } + +@MainActor +struct StudyTimerViewModelTests { + private final class Clock { + var now = Date(timeIntervalSince1970: 1_760_000_000) + } + + let repository = InMemoryStudySessionRepository() + let settings = InMemoryStudyPreferences() + let scheduler = FakeNotificationScheduler() + private let clock = Clock() + + private func makeViewModel() -> StudyTimerViewModel { + StudyTimerViewModel(repository: repository, settings: settings, scheduler: scheduler, clock: { [clock] in clock.now }) + } + + @Test func recordsAFinishedFocusPhaseForTheChosenModule() async { + let viewModel = makeViewModel() + let module = UUID() + viewModel.moduleID = module + await viewModel.start() + #expect(viewModel.remainingText == "25:00") + + clock.now += 25 * 60 + viewModel.tick() + + #expect(repository.sessions.map(\.moduleID) == [module]) + #expect(viewModel.timer.phase == .shortBreak) + #expect(viewModel.statistics.minutesThisWeek == 25) + } + + @Test func pauseCancelsTheEndNotification() async { + let viewModel = makeViewModel() + await viewModel.start() + #expect(scheduler.scheduled[StudyNotification.studyTimerIdentifier] != nil) + + viewModel.pause() + #expect(scheduler.scheduled[StudyNotification.studyTimerIdentifier] == nil) + } + + @Test func settingsChangesArePersisted() { + let viewModel = makeViewModel() + viewModel.settings.focusMinutes = 50 + #expect(settings.studyTimerSettings.focusMinutes == 50) + #expect(viewModel.remainingText == "50:00") + } +} From 0f9e8b2c649e1a20245774803db40e139ae9e3df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:11:01 +0000 Subject: [PATCH 4/6] feat(learning): add the study timer panel StudyTimerPanel shows the phase, a progress ring with the remaining time, the module, start/pause, skip, reset and the phase lengths; StudyTimerToolbarButton shows the remaining time in the toolbar and preselects the open module. Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Presentation/StudyTimer/StudyTimerPanel.swift | 108 ++++++++++++++++++ .../StudyTimer/StudyTimerViewModel.swift | 7 ++ StudyAppTests/StudyTimerTests.swift | 7 ++ 3 files changed, 122 insertions(+) create mode 100644 Presentation/StudyTimer/StudyTimerPanel.swift diff --git a/Presentation/StudyTimer/StudyTimerPanel.swift b/Presentation/StudyTimer/StudyTimerPanel.swift new file mode 100644 index 0000000..2c955ad --- /dev/null +++ b/Presentation/StudyTimer/StudyTimerPanel.swift @@ -0,0 +1,108 @@ +import SwiftUI + +/// Controls of the study timer: phase, remaining time, module, start/pause, skip, reset and lengths. +struct StudyTimerPanel: View { + @Bindable var viewModel: StudyTimerViewModel + let modules: [StudyModule] + + private var tint: Color { viewModel.timer.phase.isBreak ? Theme.success : Theme.accent } + + var body: some View { + VStack(spacing: 16) { + Text(viewModel.timer.phase.title) + .font(Theme.Typography.overline) + .textCase(.uppercase) + .foregroundStyle(Theme.textSecondary) + + ZStack { + Circle().stroke(Theme.surfaceSecondary, lineWidth: 8) + Circle() + .trim(from: 0, to: viewModel.progress) + .stroke(tint, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text(viewModel.remainingText) + .font(.system(size: 38, weight: .semibold, design: .rounded).monospacedDigit()) + .foregroundStyle(Theme.textPrimary) + } + .frame(width: 160, height: 160) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(viewModel.timer.phase.title), noch \(viewModel.remainingText)") + + Picker("Modul", selection: $viewModel.moduleID) { + Text("Ohne Modul").tag(UUID?.none) + ForEach(modules) { module in + Text(module.name).tag(UUID?.some(module.id)) + } + } + + HStack { + Button(primaryTitle) { + if viewModel.timer.isRunning { + viewModel.pause() + } else { + Task { await viewModel.start() } + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + Button("Überspringen") { viewModel.skip() } + Button("Zurücksetzen") { viewModel.reset() } + .disabled(!viewModel.isActive && viewModel.timer.completedFocusPhases == 0) + } + + DisclosureGroup("Dauer") { + minutesStepper("Fokus", value: $viewModel.settings.focusMinutes) + minutesStepper("Kurze Pause", value: $viewModel.settings.shortBreakMinutes) + minutesStepper("Lange Pause", value: $viewModel.settings.longBreakMinutes) + } + .disabled(viewModel.isActive) + + let statistics = viewModel.statistics + Text("Heute \(statistics.lastSevenDays.last?.minutes ?? 0) Min. · Serie \(statistics.streakDays) \(statistics.streakDays == 1 ? "Tag" : "Tage")") + .font(Theme.Typography.small) + .foregroundStyle(Theme.textSecondary) + } + .padding(20) + .frame(width: 300) + .task { viewModel.load() } + } + + private var primaryTitle: String { + if viewModel.timer.isRunning { return "Pause" } + return viewModel.timer.isPaused ? "Weiter" : "Start" + } + + private func minutesStepper(_ title: String, value: Binding) -> some View { + Stepper(value: value, in: StudyTimerSettings.minuteRange) { + LabeledContent(title, value: "\(value.wrappedValue) Min.") + } + } +} + +/// Toolbar button with the remaining time while the timer runs; opens the timer panel. +struct StudyTimerToolbarButton: View { + let viewModel: StudyTimerViewModel + let modules: [StudyModule] + /// Module that is open, preselected when the timer is idle. + let currentModuleID: UUID? + @State private var isPresented = false + + var body: some View { + Button { + if !viewModel.isActive { viewModel.moduleID = currentModuleID } + isPresented.toggle() + } label: { + if viewModel.isActive { + Label(viewModel.remainingText, systemImage: viewModel.timer.isRunning ? "timer" : "pause.circle") + .labelStyle(.titleAndIcon) + .monospacedDigit() + } else { + Label("Lerntimer", systemImage: "timer") + } + } + .help("Lerntimer") + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + StudyTimerPanel(viewModel: viewModel, modules: modules) + } + } +} diff --git a/Presentation/StudyTimer/StudyTimerViewModel.swift b/Presentation/StudyTimer/StudyTimerViewModel.swift index 4a1c282..ba1590f 100644 --- a/Presentation/StudyTimer/StudyTimerViewModel.swift +++ b/Presentation/StudyTimer/StudyTimerViewModel.swift @@ -97,6 +97,13 @@ final class StudyTimerViewModel { } } + /// „45 Min.“ or „2 Std. 5 Min.“. + static func durationText(_ minutes: Int) -> String { + guard minutes >= 60 else { return "\(minutes) Min." } + let rest = minutes % 60 + return rest == 0 ? "\(minutes / 60) Std." : "\(minutes / 60) Std. \(rest) Min." + } + static func clockText(_ interval: TimeInterval) -> String { let seconds = Int(interval.rounded(.up)) return String(format: "%d:%02d", seconds / 60, seconds % 60) diff --git a/StudyAppTests/StudyTimerTests.swift b/StudyAppTests/StudyTimerTests.swift index fdf8eaf..b97e635 100644 --- a/StudyAppTests/StudyTimerTests.swift +++ b/StudyAppTests/StudyTimerTests.swift @@ -183,6 +183,13 @@ struct StudyTimerViewModelTests { #expect(scheduler.scheduled[StudyNotification.studyTimerIdentifier] == nil) } + @Test func formatsDurationsAndClock() { + #expect(StudyTimerViewModel.durationText(45) == "45 Min.") + #expect(StudyTimerViewModel.durationText(120) == "2 Std.") + #expect(StudyTimerViewModel.durationText(125) == "2 Std. 5 Min.") + #expect(StudyTimerViewModel.clockText(59.2) == "1:00") + } + @Test func settingsChangesArePersisted() { let viewModel = makeViewModel() viewModel.settings.focusMinutes = 50 From 28c371f8608801832e5c94426a61ce52966ac766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:11:02 +0000 Subject: [PATCH 5/6] feat(learning): add the statistics page and wire up the timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new destination „Statistik“ (sidebar, navigation menu, quick navigator) shows today, this week and the streak, a chart of the last seven days, minutes per module and the progress of the study plans. The timer sits in the main window's toolbar and reloads after a restore. Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 4 + App/StudyAppApp.swift | 5 + Presentation/Modules/SidebarView.swift | 2 + Presentation/Navigation/AppCommands.swift | 2 + Presentation/Navigation/AppDestination.swift | 5 + Presentation/Navigation/AppNavigation.swift | 4 + .../Navigation/QuickNavigatorItem.swift | 4 + Presentation/RootView.swift | 12 ++ Presentation/StudyTimer/StatisticsView.swift | 126 ++++++++++++++++++ StudyAppTests/AppNavigationTests.swift | 6 +- 10 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 Presentation/StudyTimer/StatisticsView.swift diff --git a/App/AppContainer.swift b/App/AppContainer.swift index a2d75fc..9a27fff 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -131,6 +131,10 @@ final class AppContainer { ) } + func makeStudyTimerViewModel() -> StudyTimerViewModel { + StudyTimerViewModel(repository: studySessionRepository, settings: preferences, scheduler: notificationScheduler) + } + func makeAssignmentsViewModel() -> AssignmentsViewModel { AssignmentsViewModel(repository: assignmentRepository) } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index d894132..e1783d1 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -16,6 +16,7 @@ struct StudyAppApp: App { @State private var backupViewModel: BackupViewModel @State private var notificationsViewModel: NotificationsViewModel @State private var examsViewModel: ExamsViewModel + @State private var studyTimerViewModel: StudyTimerViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard @@ -62,6 +63,7 @@ struct StudyAppApp: App { } responder.install() notificationResponder = responder + let studyTimer = container.makeStudyTimerViewModel() let backup = container.makeBackupViewModel() // After a restore every screen shows the restored data. backup.onRestored = { @@ -70,10 +72,12 @@ struct StudyAppApp: App { today.load() assignments.load() exams.load() + studyTimer.load() Task { await notifications.refresh() } } _backupViewModel = State(initialValue: backup) _examsViewModel = State(initialValue: exams) + _studyTimerViewModel = State(initialValue: studyTimer) } catch { fatalError("Datenbank konnte nicht geöffnet werden: \(error)") } @@ -90,6 +94,7 @@ struct StudyAppApp: App { studIPViewModel: studIPViewModel, backupViewModel: backupViewModel, examsViewModel: examsViewModel, + studyTimerViewModel: studyTimerViewModel, makeSlidesViewModel: { moduleID in container.makeSlidesViewModel(moduleID: moduleID) }, diff --git a/Presentation/Modules/SidebarView.swift b/Presentation/Modules/SidebarView.swift index 5f94acf..8849ba2 100644 --- a/Presentation/Modules/SidebarView.swift +++ b/Presentation/Modules/SidebarView.swift @@ -59,6 +59,8 @@ struct SidebarView: View { .tag(AppDestination.exams) Label("Noten & ECTS", systemImage: "graduationcap") .tag(AppDestination.grades) + Label("Statistik", systemImage: "chart.bar") + .tag(AppDestination.statistics) } Section { diff --git a/Presentation/Navigation/AppCommands.swift b/Presentation/Navigation/AppCommands.swift index b8d6571..481a16e 100644 --- a/Presentation/Navigation/AppCommands.swift +++ b/Presentation/Navigation/AppCommands.swift @@ -25,6 +25,8 @@ struct AppCommands: Commands { Button("Noten & ECTS") { navigation.showGrades() } + Button("Statistik") { navigation.showStatistics() } + Button("Neue Aufgabe …") { assignments.startNewAssignment(moduleID: navigation.selectedModuleID) } .keyboardShortcut("t", modifiers: [.command, .option]) diff --git a/Presentation/Navigation/AppDestination.swift b/Presentation/Navigation/AppDestination.swift index e083e0b..ba6a32b 100644 --- a/Presentation/Navigation/AppDestination.swift +++ b/Presentation/Navigation/AppDestination.swift @@ -9,6 +9,8 @@ enum AppDestination: Hashable { /// Upcoming exams of all modules. case exams case grades + /// Study time and study plan progress. + case statistics case module(UUID) /// Stable string for `UserDefaults`; modules are stored by their ID. @@ -19,6 +21,7 @@ enum AppDestination: Hashable { case .assignments: "assignments" case .exams: "exams" case .grades: "grades" + case .statistics: "statistics" case .module(let id): id.uuidString } } @@ -30,6 +33,7 @@ enum AppDestination: Hashable { case "assignments": self = .assignments case "exams": self = .exams case "grades": self = .grades + case "statistics": self = .statistics default: guard let id = UUID(uuidString: storageKey) else { return nil } self = .module(id) @@ -153,6 +157,7 @@ struct NavigationLocation: Hashable { static let grades = NavigationLocation(destination: .grades, section: nil) static let assignments = NavigationLocation(destination: .assignments, section: nil) static let exams = NavigationLocation(destination: .exams, section: nil) + static let statistics = NavigationLocation(destination: .statistics, section: nil) static func module(_ id: UUID, section: ModuleSection = .overview) -> NavigationLocation { NavigationLocation(destination: .module(id), section: section) diff --git a/Presentation/Navigation/AppNavigation.swift b/Presentation/Navigation/AppNavigation.swift index eaa4649..8a099e5 100644 --- a/Presentation/Navigation/AppNavigation.swift +++ b/Presentation/Navigation/AppNavigation.swift @@ -80,6 +80,10 @@ final class AppNavigation { navigate(to: .exams) } + func showStatistics() { + navigate(to: .statistics) + } + /// Öffnet ein Modul und gibt der Modulansicht einen Auftrag mit. func show(module id: UUID, section: ModuleSection, intent: ModuleIntent?) { pendingIntent = intent diff --git a/Presentation/Navigation/QuickNavigatorItem.swift b/Presentation/Navigation/QuickNavigatorItem.swift index 3d01a6b..d1ba972 100644 --- a/Presentation/Navigation/QuickNavigatorItem.swift +++ b/Presentation/Navigation/QuickNavigatorItem.swift @@ -45,6 +45,10 @@ enum QuickNavigatorIndex { id: "grades", title: "Noten & ECTS", subtitle: "Notenschnitt und Leistungspunkte", systemImage: "graduationcap", moduleColor: nil, location: .grades ), + QuickNavigatorItem( + id: "statistics", title: "Statistik", subtitle: "Lernzeit und Lernpläne", + systemImage: "chart.bar", moduleColor: nil, location: .statistics + ), ] for module in modules { for section in ModuleSection.allCases { diff --git a/Presentation/RootView.swift b/Presentation/RootView.swift index 68f5ea3..25f042c 100644 --- a/Presentation/RootView.swift +++ b/Presentation/RootView.swift @@ -9,6 +9,7 @@ struct RootView: View { @Bindable var studIPViewModel: StudIPViewModel @Bindable var backupViewModel: BackupViewModel @Bindable var examsViewModel: ExamsViewModel + let studyTimerViewModel: StudyTimerViewModel let makeSlidesViewModel: SlidesViewModelFactory let makeLearningViewModel: LearningViewModelFactory let makeCourseFilesViewModel: CourseFilesViewModelFactory @@ -34,6 +35,15 @@ struct RootView: View { detail } .toolbar { historyButtons } + .toolbar { + ToolbarItem(placement: .primaryAction) { + StudyTimerToolbarButton( + viewModel: studyTimerViewModel, + modules: viewModel.activeModules, + currentModuleID: navigation.selectedModuleID + ) + } + } .sheet(item: $assignmentsViewModel.editor) { draft in AssignmentEditorSheet( draft: draft, @@ -140,6 +150,8 @@ struct RootView: View { AssignmentsView(viewModel: assignmentsViewModel, module: nil, modules: viewModel.modules) case .exams: ExamSectionView(viewModel: examsViewModel, module: nil) + case .statistics: + StatisticsView(timer: studyTimerViewModel, modules: viewModel.modules, exams: examsViewModel.upcoming) case .grades: GradesView(viewModel: viewModel) { moduleID in navigation.show(module: moduleID, section: .overview) diff --git a/Presentation/StudyTimer/StatisticsView.swift b/Presentation/StudyTimer/StatisticsView.swift new file mode 100644 index 0000000..7ceb4e4 --- /dev/null +++ b/Presentation/StudyTimer/StatisticsView.swift @@ -0,0 +1,126 @@ +import Charts +import SwiftUI + +/// „Statistik“: study time of the last days and this week, streak, time per module and study plan progress. +struct StatisticsView: View { + let timer: StudyTimerViewModel + let modules: [StudyModule] + let exams: [ExamOverview] + + var body: some View { + let statistics = timer.statistics + ScrollView { + VStack(alignment: .leading, spacing: Theme.Spacing.section) { + PageHeader("Statistik", subtitle: "Lernzeit aus dem Lerntimer") + HStack(spacing: 14) { + MetricTile(title: "Heute", value: StudyTimerViewModel.durationText(statistics.lastSevenDays.last?.minutes ?? 0), systemImage: "sun.max") + MetricTile(title: "Diese Woche", value: StudyTimerViewModel.durationText(statistics.minutesThisWeek), systemImage: "calendar") + MetricTile( + title: "Serie", + value: statistics.streakDays == 1 ? "1 Tag" : "\(statistics.streakDays) Tage", + systemImage: "flame" + ) + } + weekChart(statistics.lastSevenDays) + if !statistics.modulesThisWeek.isEmpty { + modulesCard(statistics.modulesThisWeek) + } + if !exams.isEmpty { + studyPlansCard + } + } + .padding(Theme.Spacing.page) + .frame(maxWidth: 900, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.background) + .navigationTitle("Statistik") + .task { timer.load() } + } + + private func weekChart(_ days: [StudyStatistics.Day]) -> some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader("Letzte 7 Tage", systemImage: "chart.bar") + Chart(days, id: \.date) { day in + BarMark( + x: .value("Tag", day.date, unit: .day), + y: .value("Minuten", day.minutes) + ) + .foregroundStyle(Theme.accent) + .cornerRadius(4) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day)) { _ in + AxisValueLabel(format: .dateTime.weekday(.abbreviated)) + } + } + .chartYAxisLabel("Minuten") + .frame(height: 180) + } + .card() + } + + private func modulesCard(_ times: [StudyStatistics.ModuleTime]) -> some View { + let maximum = max(times.map(\.minutes).max() ?? 1, 1) + return VStack(alignment: .leading, spacing: 12) { + SectionHeader("Module diese Woche", systemImage: "books.vertical") + ForEach(times, id: \.moduleID) { time in + let module = modules.first { $0.id == time.moduleID } + HStack(spacing: 10) { + Circle() + .fill(module?.color.color ?? Theme.textTertiary) + .frame(width: 10, height: 10) + Text(module?.name ?? (time.moduleID == nil ? "Ohne Modul" : "Gelöschtes Modul")) + .lineLimit(1) + .frame(width: 200, alignment: .leading) + SlimProgressBar(progress: Double(time.minutes) / Double(maximum), tint: module?.color.color ?? Theme.accent) + Text(StudyTimerViewModel.durationText(time.minutes)) + .font(Theme.Typography.caption.monospacedDigit()) + .foregroundStyle(Theme.textSecondary) + .frame(width: 70, alignment: .trailing) + } + } + } + .card() + } + + private var studyPlansCard: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader("Lernpläne", systemImage: "flag.checkered") + ForEach(exams) { overview in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("\(overview.module.name) · \(overview.exam.title)") + .lineLimit(1) + Spacer() + Text("\(overview.plan.completed.count) von \(overview.plan.totalCount) · \(overview.countdown)") + .font(Theme.Typography.small) + .foregroundStyle(Theme.textSecondary) + } + SlimProgressBar(progress: overview.plan.progress, tint: overview.module.color.color) + } + } + } + .card() + } +} + +/// Small metric tile used on the statistics page. +private struct MetricTile: View { + let title: String + let value: String + let systemImage: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label(title, systemImage: systemImage) + .font(Theme.Typography.small) + .foregroundStyle(Theme.textSecondary) + Text(value) + .font(Theme.Typography.metric) + .foregroundStyle(Theme.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } +} diff --git a/StudyAppTests/AppNavigationTests.swift b/StudyAppTests/AppNavigationTests.swift index 99859e3..eae3932 100644 --- a/StudyAppTests/AppNavigationTests.swift +++ b/StudyAppTests/AppNavigationTests.swift @@ -154,7 +154,7 @@ struct AppNavigationTodayTests { } @Test func storageKeyRoundTripsEveryDestination() { - let destinations: [AppDestination] = [.today, .calendar, .assignments, .exams, .grades, .module(UUID())] + let destinations: [AppDestination] = [.today, .calendar, .assignments, .exams, .grades, .statistics, .module(UUID())] for destination in destinations { #expect(AppDestination(storageKey: destination.storageKey) == destination) } @@ -205,8 +205,8 @@ struct QuickNavigatorIndexTests { @Test func indexContainsTheOverviewsAndEverySectionOfEveryModule() { let items = QuickNavigatorIndex.items(modules: [analysis, algebra]) - #expect(items.count == 5 + 2 * ModuleSection.allCases.count) - #expect(items.map(\.location).prefix(5) == [.today, .calendar, .assignments, .exams, .grades]) + #expect(items.count == 6 + 2 * ModuleSection.allCases.count) + #expect(items.map(\.location).prefix(6) == [.today, .calendar, .assignments, .exams, .grades, .statistics]) #expect(Set(items.map(\.id)).count == items.count) } From e557c95d3386f8d3084c9702a12ebc15b4d701ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:11:02 +0000 Subject: [PATCH 6/6] docs(learning): describe the study timer and statistics Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/FEATURES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..b3283cb 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -47,6 +47,14 @@ Code: `Presentation/Notes/`, `Presentation/Learning/NotesOverviewView.swift`, `D Code: `Presentation/Learning/`, `Domain/UseCases/FlashcardUseCases.swift`, `Domain/UseCases/StudySheetUseCases.swift` +## Study timer and statistics + +- The timer in the toolbar runs Pomodoro phases: focus (25 min), short break (5 min) and a long break (15 min) after every fourth focus phase; all lengths are adjustable. It counts focus time for the open module (or another one, or none) and notifies when a phase ends if notifications are allowed. +- Completed focus phases are stored as study sessions (part of backups). +- „Statistik“ in the sidebar shows today, this week, the streak of study days, a chart of the last seven days, minutes per module this week and the progress of every study plan. + +Code: `Domain/UseCases/StudyTimer.swift`, `Domain/UseCases/StudyStatistics.swift`, `Presentation/StudyTimer/` + ## Assignments Assignments and submissions per module with due dates, shown on „Heute“, in the calendar, in the module and in „Aufgaben“ in the sidebar, which lists the assignments of all modules. The sidebar badge counts open assignments due within a week.