From 63d586c044df679701878e0f9ecccc34ccab79ca Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:09:15 +0200 Subject: [PATCH 1/7] refactor: send notifications through an injected seam WorkdayManager no longer reaches for NotificationManager.shared. It takes a WorkdayNotificationSending dependency through init, defaulting to the production adapter, the same way it receives its prompt presenter. The new-day notice is pinned through a recording test adapter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 8 +++ .../Core/Services/NotificationManager.swift | 9 ++- .../Core/Services/WorkdayManager.swift | 4 +- .../RecordingWorkdayNotifications.swift | 23 +++++++ .../WorkdayManagerNotificationTests.swift | 69 +++++++++++++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift create mode 100644 OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 6cfc625..545fd26 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 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 */; }; @@ -46,6 +47,7 @@ 7A4B038D2A8FCAF7D3990342 /* IdlePeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F818FB696E0038F88C3B05 /* IdlePeriod.swift */; }; 7D1AA8C747743D0DBCF47233 /* WorkdayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */; }; 7F193BB5F12158FB4A228045 /* BreakCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FBAF5148F3F5E30F02C437 /* BreakCalculatorTests.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 */; }; 993CFEE746E242DC4FE7A44E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09158E1920D76D6EF7B3585 /* AppDelegate.swift */; }; @@ -137,6 +139,7 @@ 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 = ""; }; 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 = ""; }; @@ -174,6 +177,7 @@ 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 = ""; }; 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 = ""; }; /* End PBXFileReference section */ @@ -288,6 +292,7 @@ A504CCBF9BE58EA9A65A32F7 /* InMemoryDailyLogStore.swift */, 4AA9BCF5F1EEE511993757D7 /* ManualClock.swift */, CB0E2224DEF41A69EC1E92AA /* PersistenceManagerTests.swift */, + F44D6ED6112B9E4E125F0378 /* RecordingWorkdayNotifications.swift */, 7C0158B151E8021863597395 /* TimeEntryTests.swift */, 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */, 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */, @@ -295,6 +300,7 @@ 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */, 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */, + 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */, 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */, C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */, 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */, @@ -512,6 +518,7 @@ CEF476E22F4EF95B83562444 /* InMemoryDailyLogStore.swift in Sources */, A8E0E039463DA1368A46C035 /* ManualClock.swift in Sources */, 3D60D0048DED858DE6BB52DA /* PersistenceManagerTests.swift in Sources */, + 82C002A2F7797D3FBD446009 /* RecordingWorkdayNotifications.swift in Sources */, 330D1906DCAC60438162015F /* TimeEntryTests.swift in Sources */, F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */, 6444D4148F53E5FA721F2814 /* WidgetSnapshotTests.swift in Sources */, @@ -519,6 +526,7 @@ 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */, BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */, BF6602F8D9B236C3229F2DF8 /* WorkdayManagerLifecycleAndMutationTests.swift in Sources */, + 5F0F01A2DD2D8315F7EC60D7 /* WorkdayManagerNotificationTests.swift in Sources */, 4352E3077EFA1D9BDA71E053 /* WorkdayManagerSettingsTests.swift in Sources */, ADE593C4C405609F5F1B3894 /* WorkdayManagerTests.swift in Sources */, 7D1AA8C747743D0DBCF47233 /* WorkdayTests.swift in Sources */, diff --git a/OpenWorktimeTracker/Core/Services/NotificationManager.swift b/OpenWorktimeTracker/Core/Services/NotificationManager.swift index 2735314..ae964cc 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(type: NotificationManager.ThresholdType) + func sendNewDayNotification() +} + +final class NotificationManager: WorkdayNotificationSending { static let shared = NotificationManager() private let center = UNUserNotificationCenter.current() diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index 8752230..456ec18 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -46,7 +46,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 @@ -59,6 +59,7 @@ final class WorkdayManager { store: DailyLogStore? = nil, idleDetector: IdleDetector? = nil, prompts: WorkdayPromptPresenting = IdlePromptWindowController.shared, + notifications: WorkdayNotificationSending = NotificationManager.shared, widgetStore: SharedDefaults = .shared ) { self.defaults = defaults @@ -66,6 +67,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) diff --git a/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift new file mode 100644 index 0000000..593f8ca --- /dev/null +++ b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift @@ -0,0 +1,23 @@ +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 { + + private(set) var thresholds: [NotificationManager.ThresholdType] = [] + private(set) var newDayCount = 0 + + /// The kinds of Notification Thresholds sent, in order. + var thresholdIdentifiers: [String] { thresholds.map(\.identifier) } + + func sendThresholdNotification(type: NotificationManager.ThresholdType) { + thresholds.append(type) + } + + func sendNewDayNotification() { + newDayCount += 1 + } +} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift new file mode 100644 index 0000000..33ecf5b --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift @@ -0,0 +1,69 @@ +import XCTest + +@testable import OpenWorktimeTracker + +/// The new-day notice, observed through WorkdayManager's notification seam. +final class WorkdayManagerNotificationTests: XCTestCase { + + private var clock: ManualClock! + private var store: InMemoryDailyLogStore! + private var notifications: RecordingWorkdayNotifications! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + clock = ManualClock(now: Calendar.current.date( + from: DateComponents(year: 2026, month: 9, day: 14, hour: 8))!) + store = InMemoryDailyLogStore() + notifications = RecordingWorkdayNotifications() + let suiteName = "notification-tests-\(UUID())" + let defaults = UserDefaults(suiteName: suiteName)! + self.defaults = defaults + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + } + + override func tearDown() { + clock = nil + store = nil + notifications = nil + defaults = nil + super.tearDown() + } + + private func makeManager() -> WorkdayManager { + WorkdayManager( + defaults: defaults, clock: clock, store: store, + idleDetector: IdleDetector(clock: clock, idleTime: { 0 }), + notifications: notifications) + } + + func testStartingANewWorkdaySendsOneNewDayNotice() { + defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) + + makeManager().startNewDay() + + XCTAssertEqual(notifications.newDayCount, 1) + XCTAssertTrue(notifications.thresholds.isEmpty) + } + + func testStartingANewWorkdayWithNotificationsDisabledSendsNoNotice() { + defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) + + makeManager().startNewDay() + + XCTAssertEqual(notifications.newDayCount, 0) + } + + func testResumingAnExistingWorkdayForTheSameDateSendsNoNotice() { + defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) + makeManager().startNewDay() + XCTAssertEqual(notifications.newDayCount, 1) + + let reloaded = makeManager() + reloaded.startNewDay() + reloaded.startNewDay() + + XCTAssertEqual(notifications.newDayCount, 1) + XCTAssertEqual(reloaded.state, .running) + } +} From 0ee66f84c7b7ea7bd6d5a8eec9789560b9394e13 Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:11:44 +0200 Subject: [PATCH 2/7] test: pin milestone Notification Threshold through tick() Describe the milestone through WorkdayManager's interface only: it notifies once when enabled, still prompts to end the day when notifications are disabled, is recorded in the Daily Log across a reload, yields to a pause or a pending Idle Period before the prompt appears, and waits for recent activity. The recording prompt adapter is shared so it can record the end-of-day prompt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 8 + .../RecordingWorkdayPrompts.swift | 23 ++ .../WorkdayManagerClockAndStoreTests.swift | 15 -- ...kdayManagerLifecycleAndMutationTests.swift | 15 -- ...dayManagerNotificationThresholdTests.swift | 197 ++++++++++++++++++ 5 files changed, 228 insertions(+), 30 deletions(-) create mode 100644 OpenWorktimeTrackerTests/RecordingWorkdayPrompts.swift create mode 100644 OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 545fd26..02eade2 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 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, ); }; }; @@ -51,6 +52,7 @@ 8FDF3B4B103BB741A36E13A7 /* IdleDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CB7F059336B48B3DEE9CC /* IdleDetector.swift */; }; 956F97E8ED6A08E13330D6A2 /* IdlePromptWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D365D24EC89B5F03E63743 /* IdlePromptWindowController.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 */; }; @@ -121,6 +123,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 = ""; }; @@ -174,6 +177,7 @@ 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 = ""; }; @@ -293,6 +297,7 @@ 4AA9BCF5F1EEE511993757D7 /* ManualClock.swift */, CB0E2224DEF41A69EC1E92AA /* PersistenceManagerTests.swift */, F44D6ED6112B9E4E125F0378 /* RecordingWorkdayNotifications.swift */, + DDC95091F252C01B1059F1EB /* RecordingWorkdayPrompts.swift */, 7C0158B151E8021863597395 /* TimeEntryTests.swift */, 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */, 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */, @@ -301,6 +306,7 @@ 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */, 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */, + 45F2ACA48F78D1F82D64377D /* WorkdayManagerNotificationThresholdTests.swift */, 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */, C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */, 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */, @@ -519,6 +525,7 @@ 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 */, @@ -527,6 +534,7 @@ BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */, BF6602F8D9B236C3229F2DF8 /* WorkdayManagerLifecycleAndMutationTests.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/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/WorkdayManagerClockAndStoreTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift index 92553ef..a6ad143 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift @@ -537,18 +537,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/WorkdayManagerNotificationThresholdTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift new file mode 100644 index 0000000..87b3a0e --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift @@ -0,0 +1,197 @@ +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. +/// +/// Auto Break is configured to zero so Net Work Time equals Gross Time and +/// the hours below read as the hours worked. +final class WorkdayManagerNotificationThresholdTests: XCTestCase { + + private var clock: ManualClock! + private var store: InMemoryDailyLogStore! + private var prompts: RecordingWorkdayPrompts! + private var notifications: RecordingWorkdayNotifications! + private var defaults: UserDefaults! + private var manager: WorkdayManager! + private var start: Date! + private 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-threshold-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() { + manager = nil + clock = nil + store = nil + prompts = nil + notifications = nil + defaults = nil + start = nil + super.tearDown() + } + + // MARK: - Helpers + + /// A fresh manager over the shared store, with fresh recorders. + private func makeManager() -> WorkdayManager { + prompts = RecordingWorkdayPrompts() + notifications = RecordingWorkdayNotifications() + let widgetSuiteName = "notification-threshold-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)) + } + + private func startWorkday() { + manager = makeManager() + manager.startNewDay() + } + + /// Moves the clock to `hours` after the Workday started and ticks once. + private 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. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1) + } + + private func count(_ identifier: String) -> Int { + notifications.thresholdIdentifiers.filter { $0 == identifier }.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 / 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]) + } +} From fce8211c7840e74839c8a73b61ae4d76c45fe80b Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:15:14 +0200 Subject: [PATCH 3/7] test: pin normal and critical Notification Thresholds through tick() Normal and critical each notify once, stay silent when notifications are disabled, and survive a reload from the same store. Pin the agreed edge cases where Net Work Time jumps past several Thresholds at once: critical is notified instead of normal, and critical follows the milestone one tick later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- ...dayManagerNotificationThresholdTests.swift | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift index 87b3a0e..996be5c 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift @@ -194,4 +194,73 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { 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.thresholdIdentifiers, ["normal"]) + + tick(atHours: 9) + tick(atHours: 9.5) + XCTAssertEqual(notifications.thresholdIdentifiers, ["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) + } + + func testJumpingStraightPastCriticalNotifiesCriticalFirst() { + // Intentional (confirmed 2026-09-14): when Net Work Time is already past + // critical, critical is notified instead of normal. + startWorkday() + + tick(atHours: 9) + + XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + } + + func testJumpingStraightPastTheMilestoneNotifiesCriticalOnTheNextTick() { + // Intentional (confirmed 2026-09-14): the milestone tick returns before + // critical is checked, so critical follows one tick later while the + // end-of-day prompt is open. + startWorkday() + + tick(atHours: 10) + XCTAssertEqual(notifications.thresholdIdentifiers, ["milestone"]) + + tick(atHours: 10 + 1 / 3600) + XCTAssertEqual(notifications.thresholdIdentifiers, ["milestone", "critical"]) + } + + func testNotifiedThresholdsSurviveAReloadFromTheSameStore() { + startWorkday() + tick(atHours: 8) + XCTAssertEqual(notifications.thresholdIdentifiers, ["normal"]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 8.5) + XCTAssertTrue(notifications.thresholds.isEmpty) + + tick(atHours: 9) + XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 9.5) + XCTAssertTrue(notifications.thresholds.isEmpty) + } } From 10ba348a4265fbffdf77f86de1a8c0d4fbe9204a Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:17:20 +0200 Subject: [PATCH 4/7] refactor: record notified Thresholds as a named type A Workday's notified Notification Thresholds are a Set instead of free strings that only matched the notification identifiers by coincidence; ThresholdType now derives its identifier from the same type. NotificationThreshold is string-backed, so Daily Logs encode the same strings as before, previous Daily Logs still count as already notified, and an unknown value is kept rather than making the file unreadable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- .../Core/Models/TimeEntry.swift | 18 +++++++- OpenWorktimeTracker/Core/Models/Workday.swift | 2 +- .../Core/Services/NotificationManager.swift | 10 +++-- .../Core/Services/WorkdayManager.swift | 12 +++--- .../DailyLogFormatTests.swift | 43 ++++++++++++++++++- .../PersistenceManagerTests.swift | 4 +- OpenWorktimeTrackerTests/TimeEntryTests.swift | 4 +- .../WorkdayManagerNotificationTests.swift | 36 ++++++++++++++++ 8 files changed, 111 insertions(+), 18 deletions(-) diff --git a/OpenWorktimeTracker/Core/Models/TimeEntry.swift b/OpenWorktimeTracker/Core/Models/TimeEntry.swift index 9fa3c13..98159af 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? @@ -28,7 +28,7 @@ struct TimeEntry: Codable, Identifiable { manualPauseSeconds: TimeInterval = 0, pauseStartedAt: Date? = nil, idleDecisions: [IdleDecision] = [], - notifiedThresholds: Set = [], + notifiedThresholds: Set = [], note: String = "", lastActivityTime: Date? = nil ) { @@ -92,6 +92,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 NotificationThreshold: RawRepresentable, Hashable, Codable { + let rawValue: String + + static let normal = NotificationThreshold(rawValue: "normal") + static let critical = NotificationThreshold(rawValue: "critical") + static let milestone = NotificationThreshold(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..2c6a3d1 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: NotificationThreshold) -> Workday { mutating { $0.notifiedThresholds.insert(threshold) } } diff --git a/OpenWorktimeTracker/Core/Services/NotificationManager.swift b/OpenWorktimeTracker/Core/Services/NotificationManager.swift index ae964cc..ec1f6d7 100644 --- a/OpenWorktimeTracker/Core/Services/NotificationManager.swift +++ b/OpenWorktimeTracker/Core/Services/NotificationManager.swift @@ -93,12 +93,14 @@ final class NotificationManager: WorkdayNotificationSending { case critical(hours: Double) case milestone(hours: Double) - var identifier: String { + var threshold: NotificationThreshold { switch self { - case .normal: return "normal" - case .critical: return "critical" - case .milestone: return "milestone" + case .normal: return .normal + case .critical: return .critical + case .milestone: return .milestone } } + + var identifier: String { threshold.rawValue } } } diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index 456ec18..eb8ccc9 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -491,8 +491,8 @@ extension WorkdayManager { // 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 = current.markingNotified(.milestone) currentWorkday = updated store.save(updated.payload) if thresholds.enabled { @@ -510,14 +510,14 @@ extension WorkdayManager { // Normal and critical notifications are only sent when enabled. guard thresholds.enabled else { return } - if hours >= thresholds.criticalHours && !notified.contains("critical") { + if hours >= thresholds.criticalHours && !notified.contains(.critical) { notifications.sendThresholdNotification(type: .critical(hours: hours)) - let updated = current.markingNotified("critical") + let updated = current.markingNotified(.critical) currentWorkday = updated store.save(updated.payload) - } else if hours >= thresholds.normalHours && !notified.contains("normal") { + } else if hours >= thresholds.normalHours && !notified.contains(.normal) { notifications.sendThresholdNotification(type: .normal(hours: hours)) - let updated = current.markingNotified("normal") + let updated = current.markingNotified(.normal) currentWorkday = updated store.save(updated.payload) } diff --git a/OpenWorktimeTrackerTests/DailyLogFormatTests.swift b/OpenWorktimeTrackerTests/DailyLogFormatTests.swift index b524121..711c18e 100644 --- a/OpenWorktimeTrackerTests/DailyLogFormatTests.swift +++ b/OpenWorktimeTrackerTests/DailyLogFormatTests.swift @@ -46,7 +46,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 +109,47 @@ final class DailyLogFormatTests: XCTestCase { ) } + private func decodeFixture(notifiedThresholds: String) throws -> TimeEntry { + let edited = fixture.replacingOccurrences( + of: #""notifiedThresholds": ["normal"]"#, + with: #""notifiedThresholds": \#(notifiedThresholds)"#) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(TimeEntry.self, from: Data(edited.utf8)) + } + + 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/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/WorkdayManagerNotificationTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift index 33ecf5b..eb5afe3 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift @@ -66,4 +66,40 @@ final class WorkdayManagerNotificationTests: XCTestCase { XCTAssertEqual(notifications.newDayCount, 1) XCTAssertEqual(reloaded.state, .running) } + + 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: clock.now))", + "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))) + let prompts = RecordingWorkdayPrompts() + let manager = WorkdayManager( + defaults: defaults, clock: clock, store: store, + idleDetector: IdleDetector(clock: clock, idleTime: { 0 }), + prompts: prompts, notifications: notifications) + manager.evaluateWorkday() + clock.now = clock.now.addingTimeInterval(12 * 3600) + + manager.tick() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1) + + XCTAssertEqual(manager.state, .running) + XCTAssertTrue(notifications.thresholds.isEmpty) + XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) + } } From 7625fd366f8e52579dad0089ff31276e8c0cc156 Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:27:24 +0200 Subject: [PATCH 5/7] fix: report only the highest crossed Notification Threshold When Net Work Time jumps past several Notification Thresholds at once, only the highest one is notified. The lower Thresholds crossed in the same jump are recorded as notified, so they no longer follow one tick later or after a reload. The milestone records them even when notifications are disabled. Crossing Thresholds one after another still notifies each once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- .../Core/Services/WorkdayManager.swift | 17 +++++- ...dayManagerNotificationThresholdTests.swift | 56 ++++++++++++++++--- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index eb8ccc9..f81ff61 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -489,10 +489,23 @@ 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: NotificationThreshold) -> 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) + let updated = markingCrossedBelow(current, .milestone) currentWorkday = updated store.save(updated.payload) if thresholds.enabled { @@ -512,7 +525,7 @@ extension WorkdayManager { if hours >= thresholds.criticalHours && !notified.contains(.critical) { notifications.sendThresholdNotification(type: .critical(hours: hours)) - let updated = current.markingNotified(.critical) + let updated = markingCrossedBelow(current, .critical) currentWorkday = updated store.save(updated.payload) } else if hours >= thresholds.normalHours && !notified.contains(.normal) { diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift index 996be5c..ca72aa3 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift @@ -222,27 +222,65 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { XCTAssertTrue(notifications.thresholds.isEmpty) } - func testJumpingStraightPastCriticalNotifiesCriticalFirst() { - // Intentional (confirmed 2026-09-14): when Net Work Time is already past - // critical, critical is notified instead of normal. + 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.thresholdIdentifiers, ["critical"]) + tick(atHours: 9 + 1.0 / 3600) + tick(atHours: 9.5) XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + + manager = makeManager() + manager.evaluateWorkday() + tick(atHours: 9.75) + XCTAssertTrue(notifications.thresholds.isEmpty) } - func testJumpingStraightPastTheMilestoneNotifiesCriticalOnTheNextTick() { - // Intentional (confirmed 2026-09-14): the milestone tick returns before - // critical is checked, so critical follows one tick later while the - // end-of-day prompt is open. + 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.thresholdIdentifiers, ["milestone"]) - tick(atHours: 10 + 1 / 3600) - XCTAssertEqual(notifications.thresholdIdentifiers, ["milestone", "critical"]) + tick(atHours: 10 + 1.0 / 3600) + tick(atHours: 11) + drainMainQueue() + XCTAssertEqual(notifications.thresholdIdentifiers, ["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) } func testNotifiedThresholdsSurviveAReloadFromTheSameStore() { From 905ae860b51231f66af10acf4657b7a8bce67f0b Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:27:38 +0200 Subject: [PATCH 6/7] refactor: address review of the Notification Threshold seam - Rename NotificationThreshold to NotifiedThreshold so it no longer reads as the configured NotificationThresholds. - The WorkdayNotificationSending seam speaks in NotifiedThreshold plus hours; NotificationManager.ThresholdType is gone, leaving one list of Threshold kinds. The recording adapter records the typed kind. - Notification tests share one fixture with recording prompts, an isolated widget store and main-queue draining, and release their manager in tearDown. The previous-version Daily Log test moves next to the other threshold tests. - The disabled-notifications test gains a positive control, and small wording and decoder duplication fixes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 4 + .../Core/Models/TimeEntry.swift | 12 +- OpenWorktimeTracker/Core/Models/Workday.swift | 2 +- .../Core/Services/NotificationManager.swift | 38 ++--- .../Core/Services/WorkdayManager.swift | 8 +- .../DailyLogFormatTests.swift | 13 +- .../RecordingWorkdayNotifications.swift | 10 +- .../WorkdayManagerNotificationTestCase.swift | 86 ++++++++++ .../WorkdayManagerNotificationTests.swift | 87 +--------- ...dayManagerNotificationThresholdTests.swift | 150 +++++++----------- 10 files changed, 188 insertions(+), 222 deletions(-) create mode 100644 OpenWorktimeTrackerTests/WorkdayManagerNotificationTestCase.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 02eade2..edc1fe0 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -76,6 +76,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 */ @@ -143,6 +144,7 @@ 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 = ""; }; @@ -305,6 +307,7 @@ 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */, 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */, + 85A2357BB2FA13AEF4F2D412 /* WorkdayManagerNotificationTestCase.swift */, 84E375BDF0FBA1A9FC5F9F28 /* WorkdayManagerNotificationTests.swift */, 45F2ACA48F78D1F82D64377D /* WorkdayManagerNotificationThresholdTests.swift */, 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */, @@ -533,6 +536,7 @@ 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 */, diff --git a/OpenWorktimeTracker/Core/Models/TimeEntry.swift b/OpenWorktimeTracker/Core/Models/TimeEntry.swift index 98159af..7627a98 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? @@ -28,7 +28,7 @@ struct TimeEntry: Codable, Identifiable { manualPauseSeconds: TimeInterval = 0, pauseStartedAt: Date? = nil, idleDecisions: [IdleDecision] = [], - notifiedThresholds: Set = [], + notifiedThresholds: Set = [], note: String = "", lastActivityTime: Date? = nil ) { @@ -98,12 +98,12 @@ struct TimeEntry: Codable, Identifiable { /// 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 NotificationThreshold: RawRepresentable, Hashable, Codable { +struct NotifiedThreshold: RawRepresentable, Hashable, Codable { let rawValue: String - static let normal = NotificationThreshold(rawValue: "normal") - static let critical = NotificationThreshold(rawValue: "critical") - static let milestone = NotificationThreshold(rawValue: "milestone") + static let normal = NotifiedThreshold(rawValue: "normal") + static let critical = NotifiedThreshold(rawValue: "critical") + static let milestone = NotifiedThreshold(rawValue: "milestone") } struct IdleDecision: Codable, Identifiable { diff --git a/OpenWorktimeTracker/Core/Models/Workday.swift b/OpenWorktimeTracker/Core/Models/Workday.swift index 2c6a3d1..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: NotificationThreshold) -> 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 ec1f6d7..9d82223 100644 --- a/OpenWorktimeTracker/Core/Services/NotificationManager.swift +++ b/OpenWorktimeTracker/Core/Services/NotificationManager.swift @@ -7,7 +7,7 @@ private let logger = Logger(subsystem: "com.openworktimetracker.app", category: /// Sends the system notifications a Workday raises: Notification Thresholds /// and the new-day notice. NotificationManager is the production adapter. protocol WorkdayNotificationSending { - func sendThresholdNotification(type: NotificationManager.ThresholdType) + func sendThresholdNotification(_ threshold: NotifiedThreshold, hours: Double) func sendNewDayNotification() } @@ -33,31 +33,33 @@ final class NotificationManager: WorkdayNotificationSending { // 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 ) @@ -87,20 +89,4 @@ final class NotificationManager: WorkdayNotificationSending { } } } - - enum ThresholdType { - case normal(hours: Double) - case critical(hours: Double) - case milestone(hours: Double) - - var threshold: NotificationThreshold { - switch self { - case .normal: return .normal - case .critical: return .critical - case .milestone: return .milestone - } - } - - var identifier: String { threshold.rawValue } - } } diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index f81ff61..add4f38 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -491,7 +491,7 @@ extension WorkdayManager { // 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: NotificationThreshold) -> Workday { + func markingCrossedBelow(_ workday: Workday, _ threshold: NotifiedThreshold) -> Workday { var marked = workday.markingNotified(threshold) if threshold == .milestone && hours >= thresholds.criticalHours { marked = marked.markingNotified(.critical) @@ -509,7 +509,7 @@ extension WorkdayManager { 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 @@ -524,12 +524,12 @@ extension WorkdayManager { guard thresholds.enabled else { return } if hours >= thresholds.criticalHours && !notified.contains(.critical) { - notifications.sendThresholdNotification(type: .critical(hours: hours)) + 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)) + 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 711c18e..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 { @@ -110,12 +114,9 @@ final class DailyLogFormatTests: XCTestCase { } private func decodeFixture(notifiedThresholds: String) throws -> TimeEntry { - let edited = fixture.replacingOccurrences( + try decode(fixture.replacingOccurrences( of: #""notifiedThresholds": ["normal"]"#, - with: #""notifiedThresholds": \#(notifiedThresholds)"#) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(TimeEntry.self, from: Data(edited.utf8)) + with: #""notifiedThresholds": \#(notifiedThresholds)"#)) } private func encodedNotifiedThresholds(of entry: TimeEntry) throws -> [String] { diff --git a/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift index 593f8ca..6463265 100644 --- a/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift +++ b/OpenWorktimeTrackerTests/RecordingWorkdayNotifications.swift @@ -7,14 +7,12 @@ import Foundation /// been delivered without touching UserNotifications. final class RecordingWorkdayNotifications: WorkdayNotificationSending { - private(set) var thresholds: [NotificationManager.ThresholdType] = [] + /// The Notification Thresholds sent, in order. + private(set) var thresholds: [NotifiedThreshold] = [] private(set) var newDayCount = 0 - /// The kinds of Notification Thresholds sent, in order. - var thresholdIdentifiers: [String] { thresholds.map(\.identifier) } - - func sendThresholdNotification(type: NotificationManager.ThresholdType) { - thresholds.append(type) + func sendThresholdNotification(_ threshold: NotifiedThreshold, hours: Double) { + thresholds.append(threshold) } func sendNewDayNotification() { 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 index eb5afe3..b83ce7f 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationTests.swift @@ -3,44 +3,10 @@ import XCTest @testable import OpenWorktimeTracker /// The new-day notice, observed through WorkdayManager's notification seam. -final class WorkdayManagerNotificationTests: XCTestCase { - - private var clock: ManualClock! - private var store: InMemoryDailyLogStore! - private var notifications: RecordingWorkdayNotifications! - private var defaults: UserDefaults! - - override func setUp() { - super.setUp() - clock = ManualClock(now: Calendar.current.date( - from: DateComponents(year: 2026, month: 9, day: 14, hour: 8))!) - store = InMemoryDailyLogStore() - notifications = RecordingWorkdayNotifications() - let suiteName = "notification-tests-\(UUID())" - let defaults = UserDefaults(suiteName: suiteName)! - self.defaults = defaults - addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } - } - - override func tearDown() { - clock = nil - store = nil - notifications = nil - defaults = nil - super.tearDown() - } - - private func makeManager() -> WorkdayManager { - WorkdayManager( - defaults: defaults, clock: clock, store: store, - idleDetector: IdleDetector(clock: clock, idleTime: { 0 }), - notifications: notifications) - } +final class WorkdayManagerNotificationTests: WorkdayManagerNotificationTestCase { func testStartingANewWorkdaySendsOneNewDayNotice() { - defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) - - makeManager().startNewDay() + startWorkday() XCTAssertEqual(notifications.newDayCount, 1) XCTAssertTrue(notifications.thresholds.isEmpty) @@ -49,57 +15,20 @@ final class WorkdayManagerNotificationTests: XCTestCase { func testStartingANewWorkdayWithNotificationsDisabledSendsNoNotice() { defaults.set(false, forKey: AppSettingsKey.notificationsEnabled) - makeManager().startNewDay() + startWorkday() XCTAssertEqual(notifications.newDayCount, 0) } func testResumingAnExistingWorkdayForTheSameDateSendsNoNotice() { - defaults.set(true, forKey: AppSettingsKey.notificationsEnabled) - makeManager().startNewDay() + startWorkday() XCTAssertEqual(notifications.newDayCount, 1) - let reloaded = makeManager() - reloaded.startNewDay() - reloaded.startNewDay() - - XCTAssertEqual(notifications.newDayCount, 1) - XCTAssertEqual(reloaded.state, .running) - } - - 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: clock.now))", - "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))) - let prompts = RecordingWorkdayPrompts() - let manager = WorkdayManager( - defaults: defaults, clock: clock, store: store, - idleDetector: IdleDetector(clock: clock, idleTime: { 0 }), - prompts: prompts, notifications: notifications) - manager.evaluateWorkday() - clock.now = clock.now.addingTimeInterval(12 * 3600) - - manager.tick() - let drained = expectation(description: "main queue drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1) + manager = makeManager() + manager.startNewDay() + manager.startNewDay() + XCTAssertEqual(notifications.newDayCount, 0) XCTAssertEqual(manager.state, .running) - XCTAssertTrue(notifications.thresholds.isEmpty) - XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) } } diff --git a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift index ca72aa3..468815b 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerNotificationThresholdTests.swift @@ -5,85 +5,10 @@ import XCTest /// 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. -/// -/// Auto Break is configured to zero so Net Work Time equals Gross Time and -/// the hours below read as the hours worked. -final class WorkdayManagerNotificationThresholdTests: XCTestCase { - - private var clock: ManualClock! - private var store: InMemoryDailyLogStore! - private var prompts: RecordingWorkdayPrompts! - private var notifications: RecordingWorkdayNotifications! - private var defaults: UserDefaults! - private var manager: WorkdayManager! - private var start: Date! - private 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-threshold-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() { - manager = nil - clock = nil - store = nil - prompts = nil - notifications = nil - defaults = nil - start = nil - super.tearDown() - } - - // MARK: - Helpers - - /// A fresh manager over the shared store, with fresh recorders. - private func makeManager() -> WorkdayManager { - prompts = RecordingWorkdayPrompts() - notifications = RecordingWorkdayNotifications() - let widgetSuiteName = "notification-threshold-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)) - } +final class WorkdayManagerNotificationThresholdTests: WorkdayManagerNotificationTestCase { - private func startWorkday() { - manager = makeManager() - manager.startNewDay() - } - - /// Moves the clock to `hours` after the Workday started and ticks once. - private 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. - private func drainMainQueue() { - let drained = expectation(description: "main queue drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1) - } - - private func count(_ identifier: String) -> Int { - notifications.thresholdIdentifiers.filter { $0 == identifier }.count + private func count(_ threshold: NotifiedThreshold) -> Int { + notifications.thresholds.filter { $0 == threshold }.count } // MARK: - Milestone @@ -96,7 +21,7 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { tick(atHours: 10) drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) XCTAssertEqual(prompts.maxHoursPrompts, [10]) } @@ -118,11 +43,11 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { tick(atHours: 10) drainMainQueue() - tick(atHours: 10 + 1 / 3600) + tick(atHours: 10 + 1.0 / 3600) tick(atHours: 11) drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) XCTAssertEqual(prompts.maxHoursPrompts.count, 1) } @@ -132,7 +57,7 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { tick(atHours: 9) tick(atHours: 10) drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) manager = makeManager() manager.evaluateWorkday() @@ -153,7 +78,7 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { manager.pause() drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) } @@ -171,7 +96,7 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { XCTAssertEqual(prompts.periods.count, 1) drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) } @@ -184,14 +109,14 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { tick(atHours: 10) tick(atHours: 10.5) drainMainQueue() - XCTAssertEqual(count("milestone"), 0) + XCTAssertEqual(count(.milestone), 0) XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) idleSeconds = 0 tick(atHours: 10.5) drainMainQueue() - XCTAssertEqual(count("milestone"), 1) + XCTAssertEqual(count(.milestone), 1) XCTAssertEqual(prompts.maxHoursPrompts, [10.5]) } @@ -204,11 +129,11 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { tick(atHours: 8) tick(atHours: 8.5) - XCTAssertEqual(notifications.thresholdIdentifiers, ["normal"]) + XCTAssertEqual(notifications.thresholds, [.normal]) tick(atHours: 9) tick(atHours: 9.5) - XCTAssertEqual(notifications.thresholdIdentifiers, ["normal", "critical"]) + XCTAssertEqual(notifications.thresholds, [.normal, .critical]) } func testNormalAndCriticalAreNotNotifiedWhenNotificationsAreDisabled() { @@ -218,8 +143,14 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { 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() { @@ -228,11 +159,11 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { startWorkday() tick(atHours: 9) - XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + XCTAssertEqual(notifications.thresholds, [.critical]) tick(atHours: 9 + 1.0 / 3600) tick(atHours: 9.5) - XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + XCTAssertEqual(notifications.thresholds, [.critical]) manager = makeManager() manager.evaluateWorkday() @@ -247,12 +178,12 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { startWorkday() tick(atHours: 10) - XCTAssertEqual(notifications.thresholdIdentifiers, ["milestone"]) + XCTAssertEqual(notifications.thresholds, [.milestone]) tick(atHours: 10 + 1.0 / 3600) tick(atHours: 11) drainMainQueue() - XCTAssertEqual(notifications.thresholdIdentifiers, ["milestone"]) + XCTAssertEqual(notifications.thresholds, [.milestone]) XCTAssertEqual(prompts.maxHoursPrompts, [10]) manager = makeManager() @@ -283,10 +214,12 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { XCTAssertTrue(prompts.maxHoursPrompts.isEmpty) } + // MARK: - Daily Log + func testNotifiedThresholdsSurviveAReloadFromTheSameStore() { startWorkday() tick(atHours: 8) - XCTAssertEqual(notifications.thresholdIdentifiers, ["normal"]) + XCTAssertEqual(notifications.thresholds, [.normal]) manager = makeManager() manager.evaluateWorkday() @@ -294,11 +227,40 @@ final class WorkdayManagerNotificationThresholdTests: XCTestCase { XCTAssertTrue(notifications.thresholds.isEmpty) tick(atHours: 9) - XCTAssertEqual(notifications.thresholdIdentifiers, ["critical"]) + 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) + } } From 5b4c701c5a94ec1da7c53f1630dbb4e146967d7b Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:28:32 +0200 Subject: [PATCH 7/7] chore: ignore local .scratch tickets Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/