diff --git a/.gitignore b/.gitignore index eef7983..8701eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ Local.xcconfig *.provisionprofile sparkle_private_key +# Local agent tickets and scratch work +.scratch/ + # Docs docs/node_modules/ docs/dist/ diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index d0188a3..203a00c 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -35,9 +35,11 @@ 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */; }; 4848E362C452EB5A63DB318E /* ThresholdLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */; }; 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */; }; + 515DC1B69C2BDB10210F82F9 /* WorkdayManagerNotificationThresholdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F2ACA48F78D1F82D64377D /* WorkdayManagerNotificationThresholdTests.swift */; }; 526B978F0FFA28453AD9DAAE /* IdleThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1B7072C1ED5F1279DE5285 /* IdleThreshold.swift */; }; 534225F52B177F08D7B9A813 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 03948982F36D08786005F7B3 /* Localizable.strings */; }; 55E3067ACA31DF95588F7244 /* WorktimeWidgetExtension.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = 92ECFCF3E46C5163E13E2DBE /* WorktimeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5F0F01A2DD2D8315F7EC60D7 /* WorkdayManagerNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */; }; 62CA3C821F6C7FBC5AE5D390 /* AutoBreakRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE27F160308976C1903EB127 /* AutoBreakRules.swift */; }; 6444D4148F53E5FA721F2814 /* WidgetSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */; }; 68ACEAD4CBCEF8E71A25AD6D /* WorktimeWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CEB27ED8F85FFCEC6C42FF7 /* WorktimeWidget.swift */; }; @@ -50,10 +52,12 @@ 7D1AA8C747743D0DBCF47233 /* WorkdayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */; }; 7F193BB5F12158FB4A228045 /* BreakCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FBAF5148F3F5E30F02C437 /* BreakCalculatorTests.swift */; }; 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */; }; + 82C002A2F7797D3FBD446009 /* RecordingWorkdayNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44D6ED6112B9E4E125F0378 /* RecordingWorkdayNotifications.swift */; }; 8FDF3B4B103BB741A36E13A7 /* IdleDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CB7F059336B48B3DEE9CC /* IdleDetector.swift */; }; 956F97E8ED6A08E13330D6A2 /* IdlePromptWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D365D24EC89B5F03E63743 /* IdlePromptWindowController.swift */; }; 9884BB01691FD68AE126CC2E /* WorkdayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */; }; 993CFEE746E242DC4FE7A44E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09158E1920D76D6EF7B3585 /* AppDelegate.swift */; }; + 9BB7CEC3EFC672E770DC6641 /* RecordingWorkdayPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC95091F252C01B1059F1EB /* RecordingWorkdayPrompts.swift */; }; 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F597F7658C3A59EB200E8F1 /* WorkdayManager.swift */; }; A37E2A4BE6EA5DF1F0FBA60B /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1763B590C81EB59DF0B300 /* ProgressBarView.swift */; }; A46982C34CFF559FED894D6F /* MaxHoursPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7255E8D3611E793FAB71F0AC /* MaxHoursPromptView.swift */; }; @@ -79,6 +83,7 @@ F9CEAFEDD47DBEF79E904301 /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F26F3D087AB990B78FB7094 /* PersistenceManager.swift */; }; FAA1B14DC80293F0B721C180 /* DailyLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C8B5661F461CF5F7351190 /* DailyLogStore.swift */; }; FD9C3BA959F3FA91E9956165 /* MetricCardsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95771E351BA49672B02C68D /* MetricCardsView.swift */; }; + FE94C604420335FED4ACB672 /* WorkdayManagerNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A2357BB2FA13AEF4F2D412 /* WorkdayManagerNotificationTestCase.swift */; }; FEBEC5FF73CDE0307ABF6FB6 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F66D750410EAFCCE00D0FB84 /* Date+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -127,6 +132,7 @@ 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewLayoutTests.swift; sourceTree = ""; }; 2F26F3D087AB990B78FB7094 /* PersistenceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceManager.swift; sourceTree = ""; }; 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotTests.swift; sourceTree = ""; }; + 45F2ACA48F78D1F82D64377D /* WorkdayManagerNotificationThresholdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerNotificationThresholdTests.swift; sourceTree = ""; }; 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerClockAndStoreTests.swift; sourceTree = ""; }; 4AA9BCF5F1EEE511993757D7 /* ManualClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClock.swift; sourceTree = ""; }; 5202C6AD0AF52C2D73DC6E17 /* SummaryStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryStatsView.swift; sourceTree = ""; }; @@ -145,6 +151,8 @@ 78CD4A8501FEDAAE565AEAC8 /* LogEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEditorView.swift; sourceTree = ""; }; 7C0158B151E8021863597395 /* TimeEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeEntryTests.swift; sourceTree = ""; }; 8316E37B0FE467EDC5CE6E6D /* GlobalShortcutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalShortcutManager.swift; sourceTree = ""; }; + 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerNotificationTests.swift; sourceTree = ""; }; + 85A2357BB2FA13AEF4F2D412 /* WorkdayManagerNotificationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerNotificationTestCase.swift; sourceTree = ""; }; 86E9F0E6F30AAE618BE4982D /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; }; 889ACF5AC45EDAC1DD0F8BF8 /* Clock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clock.swift; sourceTree = ""; }; 8BB9FC7AA9A4FDD8B540808B /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; @@ -181,9 +189,11 @@ CB71FDA60A573DB4F2C4E3D6 /* WorkdayDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayDetector.swift; sourceTree = ""; }; D3FBAF5148F3F5E30F02C437 /* BreakCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreakCalculatorTests.swift; sourceTree = ""; }; D7D3BBAB16A1244C64A343A0 /* WorktimeWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorktimeWidgetBundle.swift; sourceTree = ""; }; + DDC95091F252C01B1059F1EB /* RecordingWorkdayPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWorkdayPrompts.swift; sourceTree = ""; }; E32C98F3FE145BECC4EC7022 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; EEB4DD34A3D82EECDD4CBFB1 /* OpenWorktimeTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenWorktimeTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F4023084E6F188BEE2079400 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + F44D6ED6112B9E4E125F0378 /* RecordingWorkdayNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWorkdayNotifications.swift; sourceTree = ""; }; F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Duration.swift"; sourceTree = ""; }; F66D750410EAFCCE00D0FB84 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDefaults.swift; sourceTree = ""; }; @@ -302,6 +312,8 @@ A504CCBF9BE58EA9A65A32F7 /* InMemoryDailyLogStore.swift */, 4AA9BCF5F1EEE511993757D7 /* ManualClock.swift */, CB0E2224DEF41A69EC1E92AA /* PersistenceManagerTests.swift */, + F44D6ED6112B9E4E125F0378 /* RecordingWorkdayNotifications.swift */, + DDC95091F252C01B1059F1EB /* RecordingWorkdayPrompts.swift */, 7C0158B151E8021863597395 /* TimeEntryTests.swift */, 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */, 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */, @@ -310,6 +322,9 @@ 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */, 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */, + 85A2357BB2FA13AEF4F2D412 /* WorkdayManagerNotificationTestCase.swift */, + 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */, + 45F2ACA48F78D1F82D64377D /* WorkdayManagerNotificationThresholdTests.swift */, 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */, C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */, 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */, @@ -527,6 +542,8 @@ CEF476E22F4EF95B83562444 /* InMemoryDailyLogStore.swift in Sources */, A8E0E039463DA1368A46C035 /* ManualClock.swift in Sources */, 3D60D0048DED858DE6BB52DA /* PersistenceManagerTests.swift in Sources */, + 82C002A2F7797D3FBD446009 /* RecordingWorkdayNotifications.swift in Sources */, + 9BB7CEC3EFC672E770DC6641 /* RecordingWorkdayPrompts.swift in Sources */, 330D1906DCAC60438162015F /* TimeEntryTests.swift in Sources */, F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */, 6444D4148F53E5FA721F2814 /* WidgetSnapshotTests.swift in Sources */, @@ -535,6 +552,9 @@ 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */, BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */, BF6602F8D9B236C3229F2DF8 /* WorkdayManagerLifecycleAndMutationTests.swift in Sources */, + FE94C604420335FED4ACB672 /* WorkdayManagerNotificationTestCase.swift in Sources */, + 5F0F01A2DD2D8315F7EC60D7 /* WorkdayManagerNotificationTests.swift in Sources */, + 515DC1B69C2BDB10210F82F9 /* WorkdayManagerNotificationThresholdTests.swift in Sources */, 4352E3077EFA1D9BDA71E053 /* WorkdayManagerSettingsTests.swift in Sources */, ADE593C4C405609F5F1B3894 /* WorkdayManagerTests.swift in Sources */, 7D1AA8C747743D0DBCF47233 /* WorkdayTests.swift in Sources */, diff --git a/OpenWorktimeTracker/Core/Models/TimeEntry.swift b/OpenWorktimeTracker/Core/Models/TimeEntry.swift index 617622f..5f98f80 100644 --- a/OpenWorktimeTracker/Core/Models/TimeEntry.swift +++ b/OpenWorktimeTracker/Core/Models/TimeEntry.swift @@ -9,7 +9,7 @@ struct TimeEntry: Codable, Identifiable { var manualPauseSeconds: TimeInterval var pauseStartedAt: Date? var idleDecisions: [IdleDecision] - var notifiedThresholds: Set + var notifiedThresholds: Set var note: String var lastActivityTime: Date? @@ -29,7 +29,7 @@ struct TimeEntry: Codable, Identifiable { manualPauseSeconds: TimeInterval = 0, pauseStartedAt: Date? = nil, idleDecisions: [IdleDecision] = [], - notifiedThresholds: Set = [], + notifiedThresholds: Set = [], note: String = "", lastActivityTime: Date? = nil ) { @@ -93,6 +93,20 @@ struct TimeEntry: Codable, Identifiable { } } +/// A Notification Threshold a Workday has already notified, as recorded in its +/// Daily Log. +/// +/// String-backed rather than an enum: it encodes as exactly the shipped +/// strings, and a value this version doesn't know (a hand edit, a newer +/// version) is kept instead of making the whole Daily Log unreadable. +struct NotifiedThreshold: RawRepresentable, Hashable, Codable { + let rawValue: String + + static let normal = NotifiedThreshold(rawValue: "normal") + static let critical = NotifiedThreshold(rawValue: "critical") + static let milestone = NotifiedThreshold(rawValue: "milestone") +} + struct IdleDecision: Codable, Identifiable { let id: UUID let idleStart: Date diff --git a/OpenWorktimeTracker/Core/Models/Workday.swift b/OpenWorktimeTracker/Core/Models/Workday.swift index 7228122..7ae4119 100644 --- a/OpenWorktimeTracker/Core/Models/Workday.swift +++ b/OpenWorktimeTracker/Core/Models/Workday.swift @@ -159,7 +159,7 @@ struct Workday { mutating { $0.idleDecisions.append(decision) } } - func markingNotified(_ threshold: String) -> Workday { + func markingNotified(_ threshold: NotifiedThreshold) -> Workday { mutating { $0.notifiedThresholds.insert(threshold) } } diff --git a/OpenWorktimeTracker/Core/Services/NotificationManager.swift b/OpenWorktimeTracker/Core/Services/NotificationManager.swift index 2735314..9d82223 100644 --- a/OpenWorktimeTracker/Core/Services/NotificationManager.swift +++ b/OpenWorktimeTracker/Core/Services/NotificationManager.swift @@ -4,7 +4,14 @@ import os.log private let logger = Logger(subsystem: "com.openworktimetracker.app", category: "Notifications") -final class NotificationManager { +/// Sends the system notifications a Workday raises: Notification Thresholds +/// and the new-day notice. NotificationManager is the production adapter. +protocol WorkdayNotificationSending { + func sendThresholdNotification(_ threshold: NotifiedThreshold, hours: Double) + func sendNewDayNotification() +} + +final class NotificationManager: WorkdayNotificationSending { static let shared = NotificationManager() private let center = UNUserNotificationCenter.current() @@ -26,31 +33,33 @@ final class NotificationManager { // MARK: - Threshold Notifications - func sendThresholdNotification(type: ThresholdType) { + func sendThresholdNotification(_ threshold: NotifiedThreshold, hours: Double) { let content = UNMutableNotificationContent() - switch type { - case .normal(let hours): + switch threshold { + case .normal: content.title = String(localized: "notification.normal.title") content.body = String(format: String(localized: "notification.normal.body"), hours) - content.sound = .default - case .critical(let hours): + case .critical: content.title = String(localized: "notification.critical.title") content.body = String( format: String(localized: "notification.critical.body"), hours) - content.sound = .default - case .milestone(let hours): + case .milestone: content.title = String(localized: "notification.milestone.title") content.body = String(format: String(localized: "notification.milestone.body"), hours) - content.sound = .default + + default: + logger.error("No notification for unknown Threshold: \(threshold.rawValue)") + return } + content.sound = .default content.interruptionLevel = .active let request = UNNotificationRequest( - identifier: "threshold-\(type.identifier)", + identifier: "threshold-\(threshold.rawValue)", content: content, trigger: nil // Deliver immediately ) @@ -80,18 +89,4 @@ final class NotificationManager { } } } - - enum ThresholdType { - case normal(hours: Double) - case critical(hours: Double) - case milestone(hours: Double) - - var identifier: String { - switch self { - case .normal: return "normal" - case .critical: return "critical" - case .milestone: return "milestone" - } - } - } } diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index e3d4e1c..4f36a01 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -39,7 +39,7 @@ final class WorkdayManager { let persistence = PersistenceManager() let idleDetector: IdleDetector - private let notifications = NotificationManager.shared + private let notifications: WorkdayNotificationSending private let defaults: UserDefaults private let clock: Clock private let store: DailyLogStore @@ -52,6 +52,7 @@ final class WorkdayManager { store: DailyLogStore? = nil, idleDetector: IdleDetector? = nil, prompts: WorkdayPromptPresenting = IdlePromptWindowController.shared, + notifications: WorkdayNotificationSending = NotificationManager.shared, widgetStore: SharedDefaults = .shared ) { self.defaults = defaults @@ -59,6 +60,7 @@ final class WorkdayManager { self.store = store ?? persistence self.idleDetector = idleDetector ?? IdleDetector(clock: clock) self.prompts = prompts + self.notifications = notifications self.widgetStore = widgetStore self.notificationThresholds = .resolved(from: defaults) self.newDayStartHour = Self.resolvedNewDayStartHour(from: defaults) @@ -475,14 +477,27 @@ extension WorkdayManager { let notified = current.payload.notifiedThresholds let thresholds = notificationThresholds + // Only the highest crossed Threshold is reported. Lower ones crossed in + // the same jump are recorded as notified so they never follow later. + func markingCrossedBelow(_ workday: Workday, _ threshold: NotifiedThreshold) -> Workday { + var marked = workday.markingNotified(threshold) + if threshold == .milestone && hours >= thresholds.criticalHours { + marked = marked.markingNotified(.critical) + } + if hours >= thresholds.normalHours { + marked = marked.markingNotified(.normal) + } + return marked + } + // The 10h milestone popup is a legal safeguard (ArbZG) and must appear // regardless of whether notifications are enabled. - if hours >= thresholds.milestoneHours && !notified.contains("milestone") { - let updated = current.markingNotified("milestone") + if hours >= thresholds.milestoneHours && !notified.contains(.milestone) { + let updated = markingCrossedBelow(current, .milestone) currentWorkday = updated store.save(updated.payload) if thresholds.enabled { - notifications.sendThresholdNotification(type: .milestone(hours: hours)) + notifications.sendThresholdNotification(.milestone, hours: hours) } // Show popup asking to end the day DispatchQueue.main.async { [weak self] in @@ -496,14 +511,14 @@ extension WorkdayManager { // Normal and critical notifications are only sent when enabled. guard thresholds.enabled else { return } - if hours >= thresholds.criticalHours && !notified.contains("critical") { - notifications.sendThresholdNotification(type: .critical(hours: hours)) - let updated = current.markingNotified("critical") + if hours >= thresholds.criticalHours && !notified.contains(.critical) { + notifications.sendThresholdNotification(.critical, hours: hours) + let updated = markingCrossedBelow(current, .critical) currentWorkday = updated store.save(updated.payload) - } else if hours >= thresholds.normalHours && !notified.contains("normal") { - notifications.sendThresholdNotification(type: .normal(hours: hours)) - let updated = current.markingNotified("normal") + } else if hours >= thresholds.normalHours && !notified.contains(.normal) { + notifications.sendThresholdNotification(.normal, hours: hours) + let updated = current.markingNotified(.normal) currentWorkday = updated store.save(updated.payload) } diff --git a/OpenWorktimeTrackerTests/DailyLogFormatTests.swift b/OpenWorktimeTrackerTests/DailyLogFormatTests.swift index b524121..2325ca2 100644 --- a/OpenWorktimeTrackerTests/DailyLogFormatTests.swift +++ b/OpenWorktimeTrackerTests/DailyLogFormatTests.swift @@ -32,9 +32,13 @@ final class DailyLogFormatTests: XCTestCase { """ private func decodeFixture() throws -> TimeEntry { + try decode(fixture) + } + + private func decode(_ json: String) throws -> TimeEntry { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(TimeEntry.self, from: Data(fixture.utf8)) + return try decoder.decode(TimeEntry.self, from: Data(json.utf8)) } func testAShippedDailyLogStillDecodes() throws { @@ -46,7 +50,7 @@ final class DailyLogFormatTests: XCTestCase { XCTAssertEqual(entry.note, "Sprint review") XCTAssertEqual(entry.idleDecisions.count, 1) XCTAssertEqual(entry.idleDecisions.first?.decision, .pause) - XCTAssertTrue(entry.notifiedThresholds.contains("normal")) + XCTAssertTrue(entry.notifiedThresholds.contains(.normal)) XCTAssertNil(entry.pauseStartedAt) XCTAssertNil(entry.lastActivityTime) } @@ -109,6 +113,44 @@ final class DailyLogFormatTests: XCTestCase { ) } + private func decodeFixture(notifiedThresholds: String) throws -> TimeEntry { + try decode(fixture.replacingOccurrences( + of: #""notifiedThresholds": ["normal"]"#, + with: #""notifiedThresholds": \#(notifiedThresholds)"#)) + } + + private func encodedNotifiedThresholds(of entry: TimeEntry) throws -> [String] { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: encoder.encode(entry)) as? [String: Any] + ) + return try XCTUnwrap(object["notifiedThresholds"] as? [String]) + } + + func testNotifiedThresholdsWrittenByThePreviousVersionDecode() throws { + let entry = try decodeFixture(notifiedThresholds: #"["normal", "critical", "milestone"]"#) + + XCTAssertEqual(entry.notifiedThresholds, [.normal, .critical, .milestone]) + } + + func testNotifiedThresholdsEncodeAsTheShippedStrings() throws { + var entry = try decodeFixture() + entry.notifiedThresholds = [.normal, .critical, .milestone] + + XCTAssertEqual( + Set(try encodedNotifiedThresholds(of: entry)), + ["normal", "critical", "milestone"] + ) + } + + func testAnUnknownNotifiedThresholdDoesNotMakeTheDailyLogUnreadable() throws { + let entry = try decodeFixture(notifiedThresholds: #"["normal", "weekly"]"#) + + XCTAssertTrue(entry.notifiedThresholds.contains(.normal)) + XCTAssertEqual(Set(try encodedNotifiedThresholds(of: entry)), ["normal", "weekly"]) + } + func testOptionalLastActivityRoundTripsWithoutChangingOtherFields() throws { var entry = try decodeFixture() entry.lastActivityTime = entry.startTime.addingTimeInterval(3600) diff --git a/OpenWorktimeTrackerTests/PersistenceManagerTests.swift b/OpenWorktimeTrackerTests/PersistenceManagerTests.swift index a3d7320..001d754 100644 --- a/OpenWorktimeTrackerTests/PersistenceManagerTests.swift +++ b/OpenWorktimeTrackerTests/PersistenceManagerTests.swift @@ -117,13 +117,13 @@ final class PersistenceManagerTests: XCTestCase { func testNotifiedThresholdsRoundTrip() { var entry = TimeEntry(date: "2099-01-01", startTime: Date()) - entry.notifiedThresholds = ["normal", "critical"] + entry.notifiedThresholds = [.normal, .critical] manager.save(entry) manager.flush() let loaded = manager.load(for: "2099-01-01") - XCTAssertEqual(loaded?.notifiedThresholds, ["normal", "critical"]) + XCTAssertEqual(loaded?.notifiedThresholds, [.normal, .critical]) } // MARK: - Overwrite diff --git a/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift new file mode 100644 index 0000000..6463265 --- /dev/null +++ b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift @@ -0,0 +1,21 @@ +import Foundation + +@testable import OpenWorktimeTracker + +/// Test double for `WorkdayNotificationSending`. Records every system +/// notification WorkdayManager asks for, so tests assert on what would have +/// been delivered without touching UserNotifications. +final class RecordingWorkdayNotifications: WorkdayNotificationSending { + + /// The Notification Thresholds sent, in order. + private(set) var thresholds: [NotifiedThreshold] = [] + private(set) var newDayCount = 0 + + func sendThresholdNotification(_ threshold: NotifiedThreshold, hours: Double) { + thresholds.append(threshold) + } + + func sendNewDayNotification() { + newDayCount += 1 + } +} diff --git a/OpenWorktimeTrackerTests/RecordingWorkdayPrompts.swift b/OpenWorktimeTrackerTests/RecordingWorkdayPrompts.swift new file mode 100644 index 0000000..e99ca49 --- /dev/null +++ b/OpenWorktimeTrackerTests/RecordingWorkdayPrompts.swift @@ -0,0 +1,23 @@ +import Foundation + +@testable import OpenWorktimeTracker + +/// Test double for `WorkdayPromptPresenting`. Records every prompt +/// WorkdayManager asks for instead of opening a panel. +final class RecordingWorkdayPrompts: WorkdayPromptPresenting { + private(set) var periods: [IdlePeriod] = [] + private(set) var maxHoursPrompts: [Double] = [] + private(set) var dismissCount = 0 + + func show(idlePeriod: IdlePeriod, manager: WorkdayManager) { + periods.append(idlePeriod) + } + + func showMaxHoursPrompt(hours: Double, manager: WorkdayManager) { + maxHoursPrompts.append(hours) + } + + func dismiss() { + dismissCount += 1 + } +} diff --git a/OpenWorktimeTrackerTests/TimeEntryTests.swift b/OpenWorktimeTrackerTests/TimeEntryTests.swift index 5d36162..0ddc5ca 100644 --- a/OpenWorktimeTrackerTests/TimeEntryTests.swift +++ b/OpenWorktimeTrackerTests/TimeEntryTests.swift @@ -134,7 +134,7 @@ final class TimeEntryTests: XCTestCase { manualPauseSeconds: 1800, note: "Full day" ) - original.notifiedThresholds = ["normal", "critical"] + original.notifiedThresholds = [.normal, .critical] original.idleDecisions = [ IdleDecision( idleStart: Date().addingTimeInterval(3600), @@ -156,7 +156,7 @@ final class TimeEntryTests: XCTestCase { XCTAssertEqual(decoded.status, .ended) XCTAssertEqual(decoded.manualPauseSeconds, 1800) XCTAssertEqual(decoded.note, "Full day") - XCTAssertEqual(decoded.notifiedThresholds, ["normal", "critical"]) + XCTAssertEqual(decoded.notifiedThresholds, [.normal, .critical]) XCTAssertEqual(decoded.idleDecisions.count, 1) XCTAssertEqual(decoded.idleDecisions[0].decision, .pause) } diff --git a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift index ab59cf9..4dd6879 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift @@ -536,18 +536,3 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { } } - -private final class RecordingWorkdayPrompts: WorkdayPromptPresenting { - private(set) var periods: [IdlePeriod] = [] - private(set) var dismissCount = 0 - - func show(idlePeriod: IdlePeriod, manager: WorkdayManager) { - periods.append(idlePeriod) - } - - func showMaxHoursPrompt(hours: Double, manager: WorkdayManager) {} - - func dismiss() { - dismissCount += 1 - } -} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerLifecycleAndMutationTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerLifecycleAndMutationTests.swift index 34f4951..e6ce569 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerLifecycleAndMutationTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerLifecycleAndMutationTests.swift @@ -319,18 +319,3 @@ final class WorkdayManagerLifecycleAndMutationTests: XCTestCase { XCTAssertTrue(prompts.periods.isEmpty) } } - -private final class RecordingWorkdayPrompts: WorkdayPromptPresenting { - private(set) var periods: [IdlePeriod] = [] - private(set) var dismissCount = 0 - - func show(idlePeriod: IdlePeriod, manager: WorkdayManager) { - periods.append(idlePeriod) - } - - func showMaxHoursPrompt(hours: Double, manager: WorkdayManager) {} - - func dismiss() { - dismissCount += 1 - } -} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationTestCase.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTestCase.swift new file mode 100644 index 0000000..96d4bed --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTestCase.swift @@ -0,0 +1,86 @@ +import XCTest + +@testable import OpenWorktimeTracker + +/// Shared fixture for WorkdayManager's notification tests: a ManualClock, an +/// in-memory Daily Log store, recording prompt and notification adapters, an +/// isolated widget store, and fixed Notification Thresholds (8h normal, 9h +/// critical, 10h milestone). +/// +/// Auto Break is configured to zero so Net Work Time equals Gross Time and the +/// hours in tests read directly as Net Work Time. +class WorkdayManagerNotificationTestCase: XCTestCase { + + var clock: ManualClock! + var store: InMemoryDailyLogStore! + var prompts: RecordingWorkdayPrompts! + var notifications: RecordingWorkdayNotifications! + var defaults: UserDefaults! + var manager: WorkdayManager! + var start: Date! + /// Seconds since the last keyboard or mouse input, as the IdleDetector sees it. + var idleSeconds: TimeInterval = 0 + + override func setUp() { + super.setUp() + start = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 14, hour: 8))! + clock = ManualClock(now: start) + store = InMemoryDailyLogStore() + idleSeconds = 0 + let suiteName = "notification-tests-\(UUID())" + let defaults = UserDefaults(suiteName: suiteName)! + self.defaults = defaults + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(0, forKey: AppSettingsKey.breakAfter6hMinutes) + defaults.set(0, forKey: AppSettingsKey.breakAfter9hMinutes) + defaults.set(8.0, forKey: AppSettingsKey.normalNotificationHours) + defaults.set(9.0, forKey: AppSettingsKey.criticalNotificationHours) + defaults.set(10.0, forKey: AppSettingsKey.milestoneNotificationHours) + defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) + } + + override func tearDown() { + // Releasing the manager invalidates its Timer, and an end-of-day prompt + // still queued finds no manager and does nothing. + manager = nil + clock = nil + store = nil + prompts = nil + notifications = nil + defaults = nil + start = nil + super.tearDown() + } + + /// A fresh manager over the shared store, with fresh recorders. + func makeManager() -> WorkdayManager { + prompts = RecordingWorkdayPrompts() + notifications = RecordingWorkdayNotifications() + let widgetSuiteName = "notification-widget-tests-\(UUID())" + let widgetDefaults = UserDefaults(suiteName: widgetSuiteName)! + addTeardownBlock { widgetDefaults.removePersistentDomain(forName: widgetSuiteName) } + return WorkdayManager( + defaults: defaults, clock: clock, store: store, + idleDetector: IdleDetector(clock: clock, idleTime: { [weak self] in self?.idleSeconds ?? 0 }), + prompts: prompts, notifications: notifications, + widgetStore: SharedDefaults(defaults: widgetDefaults)) + } + + func startWorkday() { + manager = makeManager() + manager.startNewDay() + } + + /// Moves the clock to `hours` after the Workday started and ticks once. + func tick(atHours hours: Double) { + clock.now = start.addingTimeInterval(hours * 3600) + manager.tick() + } + + /// The end-of-day prompt is presented asynchronously on the main queue. + func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1) + } +} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift new file mode 100644 index 0000000..b83ce7f --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift @@ -0,0 +1,34 @@ +import XCTest + +@testable import OpenWorktimeTracker + +/// The new-day notice, observed through WorkdayManager's notification seam. +final class WorkdayManagerNotificationTests: WorkdayManagerNotificationTestCase { + + func testStartingANewWorkdaySendsOneNewDayNotice() { + startWorkday() + + XCTAssertEqual(notifications.newDayCount, 1) + XCTAssertTrue(notifications.thresholds.isEmpty) + } + + func testStartingANewWorkdayWithNotificationsDisabledSendsNoNotice() { + defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) + + startWorkday() + + XCTAssertEqual(notifications.newDayCount, 0) + } + + func testResumingAnExistingWorkdayForTheSameDateSendsNoNotice() { + startWorkday() + XCTAssertEqual(notifications.newDayCount, 1) + + manager = makeManager() + manager.startNewDay() + manager.startNewDay() + + XCTAssertEqual(notifications.newDayCount, 0) + XCTAssertEqual(manager.state, .running) + } +} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift new file mode 100644 index 0000000..468815b --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift @@ -0,0 +1,266 @@ +import XCTest + +@testable import OpenWorktimeTracker + +/// Notification Thresholds, driven only through `tick()`: the ManualClock +/// moves Net Work Time, the injected idle time controls recent activity, and +/// outcomes are read from the recording adapters and the Daily Log store. +final class WorkdayManagerNotificationThresholdTests: WorkdayManagerNotificationTestCase { + + private func count(_ threshold: NotifiedThreshold) -> Int { + notifications.thresholds.filter { $0 == threshold }.count + } + + // MARK: - Milestone + + func testCrossingTheMilestoneNotifiesOnceAndPromptsToEndTheDay() { + startWorkday() + tick(atHours: 8) + tick(atHours: 9) + + tick(atHours: 10) + drainMainQueue() + + XCTAssertEqual(count(.milestone), 1) + XCTAssertEqual(prompts.maxHoursPrompts, [10]) + } + + func testCrossingTheMilestoneWithNotificationsDisabledStillPromptsToEndTheDay() { + defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) + startWorkday() + + tick(atHours: 10) + drainMainQueue() + + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertEqual(prompts.maxHoursPrompts, [10]) + } + + func testFurtherTicksPastTheMilestoneNeitherNotifyNorPromptAgain() { + startWorkday() + tick(atHours: 8) + tick(atHours: 9) + tick(atHours: 10) + drainMainQueue() + + tick(atHours: 10 + 1.0 / 3600) + tick(atHours: 11) + drainMainQueue() + + XCTAssertEqual(count(.milestone), 1) + XCTAssertEqual(prompts.maxHoursPrompts.count, 1) + } + + func testTheMilestoneIsRecordedSoAReloadedManagerDoesNotNotifyOrPromptAgain() { + startWorkday() + tick(atHours: 8) + tick(atHours: 9) + tick(atHours: 10) + drainMainQueue() + XCTAssertEqual(count(.milestone), 1) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 11) + drainMainQueue() + + XCTAssertEqual(manager.state, .running) + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } + + func testNoPromptWhenTheWorkdayIsPausedBeforeThePromptIsPresented() { + startWorkday() + tick(atHours: 8) + tick(atHours: 9) + tick(atHours: 10) + + manager.pause() + drainMainQueue() + + XCTAssertEqual(count(.milestone), 1) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } + + func testNoPromptWhenAnIdlePeriodIsPendingBeforeThePromptIsPresented() { + manager = makeManager() + manager.bootstrap() + tick(atHours: 8) + tick(atHours: 9) + tick(atHours: 10) + + // A locked screen long enough to count as an Idle Period. + manager.handleActivityEvent(.lock) + clock.now = clock.now.addingTimeInterval(10 * 60) + manager.handleActivityEvent(.unlock) + XCTAssertEqual(prompts.periods.count, 1) + drainMainQueue() + + XCTAssertEqual(count(.milestone), 1) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } + + func testTheMilestoneWaitsForRecentActivityAndFiresOnTheFirstTickItReturns() { + startWorkday() + tick(atHours: 8) + tick(atHours: 9) + + idleSeconds = 3600 + tick(atHours: 10) + tick(atHours: 10.5) + drainMainQueue() + XCTAssertEqual(count(.milestone), 0) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + + idleSeconds = 0 + tick(atHours: 10.5) + drainMainQueue() + + XCTAssertEqual(count(.milestone), 1) + XCTAssertEqual(prompts.maxHoursPrompts, [10.5]) + } + + // MARK: - Normal and critical + + func testCrossingNormalThenCriticalNotifiesEachOnce() { + startWorkday() + tick(atHours: 7.9) + XCTAssertTrue(notifications.thresholds.isEmpty) + + tick(atHours: 8) + tick(atHours: 8.5) + XCTAssertEqual(notifications.thresholds, [.normal]) + + tick(atHours: 9) + tick(atHours: 9.5) + XCTAssertEqual(notifications.thresholds, [.normal, .critical]) + } + + func testNormalAndCriticalAreNotNotifiedWhenNotificationsAreDisabled() { + defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) + startWorkday() + + tick(atHours: 8) + tick(atHours: 9) + tick(atHours: 9.5) + XCTAssertTrue(notifications.thresholds.isEmpty) + + // Positive control: the check does run on these ticks, because the + // milestone still prompts. + tick(atHours: 10) + drainMainQueue() + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertEqual(prompts.maxHoursPrompts, [10]) + } + + func testJumpingStraightPastCriticalNotifiesOnlyCritical() { + // Decided 2026-09-14: only the highest crossed Threshold is reported; + // normal is recorded as notified and never follows, even after a reload. + startWorkday() + + tick(atHours: 9) + XCTAssertEqual(notifications.thresholds, [.critical]) + + tick(atHours: 9 + 1.0 / 3600) + tick(atHours: 9.5) + XCTAssertEqual(notifications.thresholds, [.critical]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 9.75) + XCTAssertTrue(notifications.thresholds.isEmpty) + } + + func testJumpingStraightPastTheMilestoneNotifiesOnlyTheMilestone() { + // Decided 2026-09-14: only the highest crossed Threshold is reported; + // critical and normal are recorded as notified and never follow, even + // after a reload. + startWorkday() + + tick(atHours: 10) + XCTAssertEqual(notifications.thresholds, [.milestone]) + + tick(atHours: 10 + 1.0 / 3600) + tick(atHours: 11) + drainMainQueue() + XCTAssertEqual(notifications.thresholds, [.milestone]) + XCTAssertEqual(prompts.maxHoursPrompts, [10]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 11.5) + drainMainQueue() + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } + + func testJumpingPastTheMilestoneWithNotificationsDisabledStillRecordsTheLowerThresholds() { + // Decided 2026-09-14: the milestone marks critical and normal as notified + // even when nothing is sent, so enabling notifications later sends neither. + defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) + startWorkday() + tick(atHours: 10) + drainMainQueue() + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertEqual(prompts.maxHoursPrompts, [10]) + + defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 11) + drainMainQueue() + + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } + + // MARK: - Daily Log + + func testNotifiedThresholdsSurviveAReloadFromTheSameStore() { + startWorkday() + tick(atHours: 8) + XCTAssertEqual(notifications.thresholds, [.normal]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 8.5) + XCTAssertTrue(notifications.thresholds.isEmpty) + + tick(atHours: 9) + XCTAssertEqual(notifications.thresholds, [.critical]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 9.5) + XCTAssertTrue(notifications.thresholds.isEmpty) + } + + func testThresholdsNotifiedByThePreviousVersionAreNotNotifiedAgain() throws { + // A running Daily Log as the previous version wrote it, with every + // Notification Threshold already notified. + let dailyLog = """ + { + "id": "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "date": "2026-09-14", + "startTime": "\(ISO8601DateFormatter().string(from: start))", + "status": "running", + "manualPauseSeconds": 0, + "idleDecisions": [], + "notifiedThresholds": ["normal", "critical", "milestone"], + "note": "" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + store.save(try decoder.decode(TimeEntry.self, from: Data(dailyLog.utf8))) + manager = makeManager() + manager.evaluateWorkday() + + tick(atHours: 12) + drainMainQueue() + + XCTAssertEqual(manager.state, .running) + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } +}