diff --git a/App/AppContainer.swift b/App/AppContainer.swift index c41ebc9..0f91409 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -25,6 +25,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 @@ -44,6 +45,7 @@ final class AppContainer { notificationScheduler = UserNotificationScheduler() examRepository = SwiftDataExamRepository(context: context) announcementRepository = SwiftDataAnnouncementRepository(context: context) + studySessionRepository = SwiftDataStudySessionRepository(context: context) calendarFeedLoader = URLSessionCalendarFeedLoader() calendarEventParser = ICalendarFeedParser() pdfInspector = PDFKitInspector() @@ -100,6 +102,7 @@ final class AppContainer { calendar: studyCalendarRepository, courseFiles: courseFileRepository, exams: examRepository, + studySessions: studySessionRepository, files: fileStorage ) } @@ -138,6 +141,10 @@ final class AppContainer { AnnouncementsViewModel(repository: announcementRepository, modules: moduleRepository) } + 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 64c29eb..e09dd88 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -17,6 +17,7 @@ struct StudyAppApp: App { @State private var notificationsViewModel: NotificationsViewModel @State private var examsViewModel: ExamsViewModel @State private var announcementsViewModel: AnnouncementsViewModel + @State private var studyTimerViewModel: StudyTimerViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard @@ -65,6 +66,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 = { @@ -74,11 +76,13 @@ struct StudyAppApp: App { assignments.load() exams.load() announcements.load() + studyTimer.load() Task { await notifications.refresh() } } _backupViewModel = State(initialValue: backup) _examsViewModel = State(initialValue: exams) _announcementsViewModel = State(initialValue: announcements) + _studyTimerViewModel = State(initialValue: studyTimer) } catch { fatalError("Datenbank konnte nicht geöffnet werden: \(error)") } @@ -96,6 +100,7 @@ struct StudyAppApp: App { backupViewModel: backupViewModel, examsViewModel: examsViewModel, announcementsViewModel: announcementsViewModel, + studyTimerViewModel: studyTimerViewModel, makeSlidesViewModel: { moduleID in container.makeSlidesViewModel(moduleID: moduleID) }, 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 10321fb..7df991f 100644 --- a/Data/SwiftData/Persistence.swift +++ b/Data/SwiftData/Persistence.swift @@ -16,6 +16,7 @@ enum Persistence { CourseFileRecord.self, ExamRecord.self, AnnouncementRecord.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/Entities/StudyNotification.swift b/Domain/Entities/StudyNotification.swift index 6ddd582..1e06c03 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/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 new file mode 100644 index 0000000..88a8191 --- /dev/null +++ b/Domain/Repositories/StudySessionRepository.swift @@ -0,0 +1,10 @@ +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 + 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/Domain/UseCases/NotificationUseCases.swift b/Domain/UseCases/NotificationUseCases.swift index 801bc11..9ae0a6c 100644 --- a/Domain/UseCases/NotificationUseCases.swift +++ b/Domain/UseCases/NotificationUseCases.swift @@ -157,6 +157,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/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 30ab418..8ffd50d 100644 --- a/Presentation/RootView.swift +++ b/Presentation/RootView.swift @@ -10,6 +10,7 @@ struct RootView: View { @Bindable var backupViewModel: BackupViewModel @Bindable var examsViewModel: ExamsViewModel @Bindable var announcementsViewModel: AnnouncementsViewModel + let studyTimerViewModel: StudyTimerViewModel let makeSlidesViewModel: SlidesViewModelFactory let makeLearningViewModel: LearningViewModelFactory let makeCourseFilesViewModel: CourseFilesViewModelFactory @@ -42,6 +43,15 @@ struct RootView: View { } } .toolbar { historyButtons } + .toolbar { + ToolbarItem(placement: .primaryAction) { + StudyTimerToolbarButton( + viewModel: studyTimerViewModel, + modules: viewModel.activeModules, + currentModuleID: navigation.selectedModuleID + ) + } + } .sheet(item: $assignmentsViewModel.editor) { draft in AssignmentEditorSheet( draft: draft, @@ -153,6 +163,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/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 new file mode 100644 index 0000000..ba1590f --- /dev/null +++ b/Presentation/StudyTimer/StudyTimerViewModel.swift @@ -0,0 +1,141 @@ +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 + } + } + } + + /// „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) + } + + // 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/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) } 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 new file mode 100644 index 0000000..b97e635 --- /dev/null +++ b/StudyAppTests/StudyTimerTests.swift @@ -0,0 +1,199 @@ +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 } } + func deleteAll() throws { sessions.removeAll() } +} + +@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]) + } +} + +@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 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 + #expect(settings.studyTimerSettings.focusMinutes == 50) + #expect(viewModel.remainingText == "50:00") + } +} 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 ``` 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.