From 25cfe0c334a845de2885d4a8d34b7e4ac14c02ae Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:10:04 +0200 Subject: [PATCH 1/4] refactor: type the widget snapshot's state and Threshold Ladder Move the tracking state out of WorkdayManager into Shared as WorkdayState so the widget compares a real type instead of strings, and publish the Threshold Ladder as one value instead of colour-named threshold fields. A snapshot stored by the previous version fails to decode and reads as unavailable until the app publishes again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 6 ++ .../Core/Services/WorkdayManager.swift | 16 ++--- .../WidgetSnapshotTests.swift | 69 ++++++++++++++++--- .../WorkdayManagerClockAndStoreTests.swift | 15 ++-- Shared/SharedDefaults.swift | 20 +++--- Shared/ThresholdLevel.swift | 2 +- Shared/WorkdayState.swift | 11 +++ WorktimeWidget/WorktimeWidget.swift | 37 +++++----- 8 files changed, 115 insertions(+), 61 deletions(-) create mode 100644 Shared/WorkdayState.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 6cfc625..96fdf8a 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 7F193BB5F12158FB4A228045 /* BreakCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FBAF5148F3F5E30F02C437 /* BreakCalculatorTests.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 */; }; 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F597F7658C3A59EB200E8F1 /* WorkdayManager.swift */; }; A37E2A4BE6EA5DF1F0FBA60B /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1763B590C81EB59DF0B300 /* ProgressBarView.swift */; }; @@ -68,6 +69,7 @@ E2776F062926941396F6798E /* DailyLogFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0534E968BFC7EE74714A15B /* DailyLogFormatTests.swift */; }; E7DB5A1B699217F6B7749C2E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7D8E211502FB3F2B407C29 /* SettingsView.swift */; }; EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */; }; + F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */; }; F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */; }; F9CEAFEDD47DBEF79E904301 /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F26F3D087AB990B78FB7094 /* PersistenceManager.swift */; }; FAA1B14DC80293F0B721C180 /* DailyLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C8B5661F461CF5F7351190 /* DailyLogStore.swift */; }; @@ -163,6 +165,7 @@ B96BF05DC18B804752127B67 /* IdlePromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdlePromptView.swift; sourceTree = ""; }; BC1763B590C81EB59DF0B300 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; BE27F160308976C1903EB127 /* AutoBreakRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBreakRules.swift; sourceTree = ""; }; + BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayState.swift; sourceTree = ""; }; C14FD2343077B9FC533EAC07 /* BreakCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreakCalculator.swift; sourceTree = ""; }; C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerTests.swift; sourceTree = ""; }; C8CBF6F9629F77799CD434AF /* GlassContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassContainer.swift; sourceTree = ""; }; @@ -206,6 +209,7 @@ 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */, FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */, 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */, + BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */, ); path = Shared; sourceTree = ""; @@ -568,6 +572,7 @@ CB63BC5DE6E64B89F587BF8D /* Workday.swift in Sources */, D0BA8C03FE5E382A0104E788 /* WorkdayDetector.swift in Sources */, 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */, + 9884BB01691FD68AE126CC2E /* WorkdayState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -578,6 +583,7 @@ 6B04E7D7384A3DC2FD19B4BF /* Color+Extensions.swift in Sources */, 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */, EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */, + F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */, 68ACEAD4CBCEF8E71A25AD6D /* WorktimeWidget.swift in Sources */, 2C62CB1A6DA628C4FB944F2C /* WorktimeWidgetBundle.swift in Sources */, ); diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index 8752230..585a749 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -11,14 +11,7 @@ final class WorkdayManager { // MARK: - State - enum State: String { - case notStarted - case running - case paused - case ended - } - - private(set) var state: State = .notStarted + private(set) var state: WorkdayState = .notStarted private(set) var currentWorkday: Workday? private(set) var displayTime: TimeInterval = 0 private(set) var grossTime: TimeInterval = 0 @@ -461,14 +454,13 @@ extension WorkdayManager { let thresholds = currentWorkday?.thresholds ?? .resolved(from: defaults) let snapshot = WidgetSnapshot( measuredAt: instant, - state: state.rawValue, + state: state, netTime: netTime, grossTime: grossTime, startTime: currentWorkday?.startTime, workDate: currentWorkday?.date ?? "", targetHours: notificationThresholds.normalHours, - orangeThreshold: thresholds.elevatedHours, - redThreshold: thresholds.criticalHours + thresholds: thresholds ) do { try widgetStore.publish(snapshot) @@ -752,7 +744,7 @@ extension WorkdayManager { // MARK: - State Localization -extension WorkdayManager.State { +extension WorkdayState { var localizedLabel: String { switch self { case .notStarted: return String(localized: "state.notStarted") diff --git a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift index 4bec10a..1929284 100644 --- a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift +++ b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift @@ -23,11 +23,14 @@ final class WidgetSnapshotTests: XCTestCase { } } - private func snapshot(state: String = "running", netTime: TimeInterval = 3600) -> WidgetSnapshot { + private func snapshot( + state: WorkdayState = .running, netTime: TimeInterval = 3600, + thresholds: ThresholdLadder = ThresholdLadder(elevatedHours: 7.5, criticalHours: 9) + ) -> WidgetSnapshot { WidgetSnapshot( measuredAt: measuredAt, state: state, netTime: netTime, grossTime: 4200, startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", - targetHours: 7, orangeThreshold: 7.5, redThreshold: 9) + targetHours: 7, thresholds: thresholds) } func testDefaultsRoundTripReplacesOneCompleteValue() throws { @@ -38,9 +41,9 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertEqual(try reader.readSnapshot(), first) let second = WidgetSnapshot( - measuredAt: measuredAt.addingTimeInterval(900), state: "ended", netTime: 4500, + measuredAt: measuredAt.addingTimeInterval(900), state: .ended, netTime: 4500, grossTime: 5100, startTime: first.startTime, workDate: first.workDate, - targetHours: 8, orangeThreshold: 8, redThreshold: 9.5) + targetHours: 8, thresholds: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) try writer.publish(second) XCTAssertEqual(try reader.readSnapshot(), second) @@ -58,8 +61,8 @@ final class WidgetSnapshotTests: XCTestCase { try writer.publish(snapshot()) XCTAssertEqual(try reader.readSnapshot(), snapshot()) - try writer.publish(snapshot(state: "paused")) - XCTAssertEqual(try reader.readSnapshot(), snapshot(state: "paused")) + try writer.publish(snapshot(state: .paused)) + XCTAssertEqual(try reader.readSnapshot(), snapshot(state: .paused)) } func testLegacyKeysAreNotCombinedIntoAnUnmeasuredSnapshot() throws { @@ -68,8 +71,8 @@ final class WidgetSnapshotTests: XCTestCase { let store = SharedDefaults(defaults: defaults, fallbackURL: fileURL) XCTAssertNil(try store.readSnapshot()) - try store.publish(snapshot(state: "ended")) - XCTAssertEqual(try store.readSnapshot(), snapshot(state: "ended")) + try store.publish(snapshot(state: .ended)) + XCTAssertEqual(try store.readSnapshot(), snapshot(state: .ended)) } func testCorruptDefaultsReportsFailureRatherThanReadingLegacyFields() { @@ -104,7 +107,13 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertThrowsError(try store.publish(snapshot(netTime: value))) XCTAssertEqual(try store.readSnapshot(), snapshot()) } - XCTAssertThrowsError(try store.publish(snapshot(state: "unknown"))) + for ladder in [ + ThresholdLadder(elevatedHours: .nan, criticalHours: 9), + ThresholdLadder(elevatedHours: 8, criticalHours: -1) + ] { + XCTAssertThrowsError(try store.publish(snapshot(thresholds: ladder))) + XCTAssertEqual(try store.readSnapshot(), snapshot()) + } } func testInvalidDecodedSnapshotIsRejected() throws { @@ -114,6 +123,44 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertThrowsError(try SharedDefaults(defaults: defaults).readSnapshot()) } + func testUnknownDecodedStateIsRejected() throws { + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot())) as? [String: Any]) + object["state"] = "unknown" + defaults.set(try JSONSerialization.data(withJSONObject: object), forKey: SharedDefaults.snapshotKey) + + XCTAssertThrowsError(try SharedDefaults(defaults: defaults).readSnapshot()) + } + + /// The layout published by v0.7.0 and earlier, with colour-named thresholds. + private struct PreviousVersionSnapshot: Encodable { + let measuredAt: Date + let state: String + let netTime: TimeInterval + let grossTime: TimeInterval + let startTime: Date? + let workDate: String + let targetHours: Double + let orangeThreshold: Double + let redThreshold: Double + } + + /// Chosen behaviour: a snapshot from the previous version is reported + /// unavailable rather than migrated. The widget shows its "no current data" + /// state until the updated app publishes, which it does on launch and every tick. + func testPreviousVersionSnapshotIsUnavailableUntilNextPublish() throws { + let previous = PreviousVersionSnapshot( + measuredAt: measuredAt, state: "running", netTime: 3600, grossTime: 4200, + startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", + targetHours: 7, orangeThreshold: 7.5, redThreshold: 9) + defaults.set(try JSONEncoder().encode(previous), forKey: SharedDefaults.snapshotKey) + let store = SharedDefaults(defaults: defaults, fallbackURL: fileURL) + + XCTAssertThrowsError(try store.readSnapshot()) + try store.publish(snapshot()) + XCTAssertEqual(try store.readSnapshot(), snapshot()) + } + func testDelayedReadKeepsRunningTimerAnchoredToMeasurement() { let value = snapshot() let readAt = measuredAt.addingTimeInterval(900) @@ -125,7 +172,7 @@ final class WidgetSnapshotTests: XCTestCase { } func testPausedEndedAndNotStartedSnapshotsDoNotAccumulateTime() { - for state in ["paused", "ended", "notStarted"] { + for state in [WorkdayState.paused, .ended, .notStarted] { let value = snapshot(state: state) XCTAssertFalse(value.isRunning) XCTAssertEqual(value.netTime(at: measuredAt.addingTimeInterval(86_400)), value.netTime) @@ -143,7 +190,7 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertEqual(value.thresholdLevel(at: measuredAt.addingTimeInterval(1800)), .elevated) XCTAssertEqual(value.thresholdLevel(at: measuredAt.addingTimeInterval(7200)), .critical) XCTAssertEqual( - snapshot(state: "paused", netTime: 7 * 3600) + snapshot(state: .paused, netTime: 7 * 3600) .thresholdLevel(at: measuredAt.addingTimeInterval(7200)), .normal) } } diff --git a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift index 92553ef..8fcfefa 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift @@ -50,7 +50,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { manager.startNewDay() let start = clock.now var snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "running") + XCTAssertEqual(snapshot.state, .running) XCTAssertEqual(snapshot.measuredAt, start) XCTAssertEqual(snapshot.startTime, start) XCTAssertEqual(snapshot.workDate, manager.currentEntry?.date) @@ -59,7 +59,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = start.addingTimeInterval(3600) manager.pause() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "paused") + XCTAssertEqual(snapshot.state, .paused) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.netTime(at: clock.now.addingTimeInterval(600)), 3600) @@ -67,7 +67,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = clock.now.addingTimeInterval(600) manager.resume() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "running") + XCTAssertEqual(snapshot.state, .running) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.grossTime, 4200) @@ -76,7 +76,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = clock.now.addingTimeInterval(300) manager.endDay() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "ended") + XCTAssertEqual(snapshot.state, .ended) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3900) clock.now = clock.now.addingTimeInterval(900) @@ -93,8 +93,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) XCTAssertEqual(snapshot.targetHours, 7) - XCTAssertEqual(snapshot.orangeThreshold, 7.5) - XCTAssertEqual(snapshot.redThreshold, 8.5) + XCTAssertEqual(snapshot.thresholds, ThresholdLadder(elevatedHours: 7.5, criticalHours: 8.5)) } func testDeletingCurrentLogPublishesEmptySnapshotWithoutStaleStartTime() throws { @@ -102,7 +101,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { XCTAssertTrue(manager.deleteLog(manager.currentEntry!)) let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "notStarted") + XCTAssertEqual(snapshot.state, .notStarted) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertNil(snapshot.startTime) XCTAssertEqual(snapshot.workDate, "") @@ -119,7 +118,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { manager.handleIdleDecisionAndEndDay() let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "ended") + XCTAssertEqual(snapshot.state, .ended) XCTAssertEqual(snapshot.measuredAt, end) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.netTime(at: clock.now), 3600) diff --git a/Shared/SharedDefaults.swift b/Shared/SharedDefaults.swift index 76aeefe..c3585ce 100644 --- a/Shared/SharedDefaults.swift +++ b/Shared/SharedDefaults.swift @@ -2,16 +2,15 @@ import Foundation struct WidgetSnapshot: Codable, Equatable { let measuredAt: Date - let state: String + let state: WorkdayState let netTime: TimeInterval let grossTime: TimeInterval let startTime: Date? let workDate: String let targetHours: Double - let orangeThreshold: Double - let redThreshold: Double + let thresholds: ThresholdLadder - var isRunning: Bool { state == "running" } + var isRunning: Bool { state == .running } /// Anchored to the measurement, never to the widget's later read time. var liveNetStart: Date { measuredAt.addingTimeInterval(-netTime) } @@ -21,18 +20,19 @@ struct WidgetSnapshot: Codable, Equatable { } func thresholdLevel(at date: Date) -> ThresholdLevel { - ThresholdLadder(elevatedHours: orangeThreshold, criticalHours: redThreshold) - .level(for: netTime(at: date)) + thresholds.level(for: netTime(at: date)) } + /// The state is checked by decoding; a snapshot published by an earlier + /// version, with colour-named threshold fields, fails to decode and reads as + /// unavailable until the app publishes again. fileprivate func validate() throws { - guard ["notStarted", "running", "paused", "ended"].contains(state), - measuredAt.timeIntervalSince1970.isFinite, + guard measuredAt.timeIntervalSince1970.isFinite, startTime?.timeIntervalSince1970.isFinite ?? true, netTime.isFinite, netTime >= 0, grossTime.isFinite, grossTime >= 0, targetHours.isFinite, targetHours > 0, - orangeThreshold.isFinite, orangeThreshold >= 0, - redThreshold.isFinite, redThreshold >= 0 else { + thresholds.elevatedHours.isFinite, thresholds.elevatedHours >= 0, + thresholds.criticalHours.isFinite, thresholds.criticalHours >= 0 else { throw CocoaError(.coderInvalidValue) } } diff --git a/Shared/ThresholdLevel.swift b/Shared/ThresholdLevel.swift index ca5a04c..daf3090 100644 --- a/Shared/ThresholdLevel.swift +++ b/Shared/ThresholdLevel.swift @@ -14,7 +14,7 @@ enum ThresholdLevel: Equatable { /// /// Distinct from the notification Thresholds, which fire at their own points and /// are deliberately allowed to differ. -struct ThresholdLadder { +struct ThresholdLadder: Codable, Equatable { let elevatedHours: Double let criticalHours: Double diff --git a/Shared/WorkdayState.swift b/Shared/WorkdayState.swift new file mode 100644 index 0000000..f5e5f37 --- /dev/null +++ b/Shared/WorkdayState.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Where the tracked Workday stands, shared with the widget through its snapshot. +/// +/// Raw values are part of the published snapshot and must not change. +enum WorkdayState: String, Codable, CaseIterable { + case notStarted + case running + case paused + case ended +} diff --git a/WorktimeWidget/WorktimeWidget.swift b/WorktimeWidget/WorktimeWidget.swift index d2a23fc..fc4d223 100644 --- a/WorktimeWidget/WorktimeWidget.swift +++ b/WorktimeWidget/WorktimeWidget.swift @@ -18,14 +18,13 @@ struct WorktimeProvider: TimelineProvider { let now = Date() return WorktimeEntry(date: now, snapshot: WidgetSnapshot( measuredAt: now, - state: "running", + state: .running, netTime: 5 * 3600 + 23 * 60, grossTime: 6 * 3600, startTime: Calendar.current.date(bySettingHour: 8, minute: 30, second: 0, of: now), workDate: "2024-01-15", targetHours: 8.0, - orangeThreshold: 8.0, - redThreshold: 9.5 + thresholds: ThresholdLadder(elevatedHours: 8.0, criticalHours: 9.5) )) } @@ -110,20 +109,20 @@ struct WorktimeWidgetSmallView: View { case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) case .normal: switch snapshot.state { - case "running": return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case "paused": return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case "ended": return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - default: return .secondary + case .running: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) + case .paused: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) + case .ended: return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) + case .notStarted: return .secondary } } } private var stateLabel: String { switch snapshot.state { - case "running": return String(localized: "widget.state.running") - case "paused": return String(localized: "widget.state.paused") - case "ended": return String(localized: "widget.state.ended") - default: return String(localized: "widget.state.idle") + case .running: return String(localized: "widget.state.running") + case .paused: return String(localized: "widget.state.paused") + case .ended: return String(localized: "widget.state.ended") + case .notStarted: return String(localized: "widget.state.idle") } } } @@ -216,20 +215,20 @@ struct WorktimeWidgetMediumView: View { case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) case .normal: switch snapshot.state { - case "running": return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case "paused": return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case "ended": return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - default: return .secondary + case .running: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) + case .paused: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) + case .ended: return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) + case .notStarted: return .secondary } } } private var stateLabel: String { switch snapshot.state { - case "running": return String(localized: "widget.state.running") - case "paused": return String(localized: "widget.state.paused") - case "ended": return String(localized: "widget.state.ended") - default: return String(localized: "widget.state.idle") + case .running: return String(localized: "widget.state.running") + case .paused: return String(localized: "widget.state.paused") + case .ended: return String(localized: "widget.state.ended") + case .notStarted: return String(localized: "widget.state.idle") } } From 94be7fd558d93283ff9d7ac7e56f446e589c666c Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:13:46 +0200 Subject: [PATCH 2/4] refactor: share how a Workday looks between app and widget Add a shared appearance module holding the palette, the colour and label of each tracking state, the indicator and progress colours per Threshold Level, and the two-digit-hour duration format. Both widget sizes take colour, label and duration text from it, so the widget adopts the app's palette and shows durations as 08:05. DesignTokens reads its accents from the shared palette so the hex values live in one place. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 10 ++ OpenWorktimeTracker/Design/DesignTokens.swift | 12 +-- .../Utilities/Date+Extensions.swift | 7 -- .../WorkdayAppearanceTests.swift | 72 ++++++++++++++ Shared/SharedDefaults.swift | 4 + Shared/WorkdayAppearance.swift | 97 +++++++++++++++++++ WorktimeWidget/WorktimeWidget.swift | 76 ++------------- 7 files changed, 199 insertions(+), 79 deletions(-) create mode 100644 OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift create mode 100644 Shared/WorkdayAppearance.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 96fdf8a..1b52646 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -26,7 +26,9 @@ 3700A1C2970A97914BE1CAF3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8D50E67E11943961E87A65F6 /* Assets.xcassets */; }; 3753A83912DE9247D4CA46CC /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */; }; 3C023C1963125F860E660730 /* BreakCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14FD2343077B9FC533EAC07 /* BreakCalculator.swift */; }; + 3CA50EE21B7AC6011D3894E4 /* WorkdayAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */; }; 3D60D0048DED858DE6BB52DA /* PersistenceManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0E2224DEF41A69EC1E92AA /* PersistenceManagerTests.swift */; }; + 3F944373B6FF10C633D2D18C /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */; }; 4352E3077EFA1D9BDA71E053 /* WorkdayManagerSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */; }; 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1089C3A7EF27586EB233E4B /* WeekHistoryView.swift */; }; 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */; }; @@ -46,6 +48,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 */; }; + 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.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 */; }; @@ -111,6 +114,7 @@ /* Begin PBXFileReference section */ 032AEE83355F68A71F71365C /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 03F65852D76A2EC82ACFC07D /* OpenWorktimeTracker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenWorktimeTracker.entitlements; sourceTree = ""; }; + 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayAppearance.swift; sourceTree = ""; }; 0CB576F2CF5F2605464A6FEC /* TimerDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerDisplayView.swift; sourceTree = ""; }; 0E368E8701FFBF179A415BBF /* IdleDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleDetectorTests.swift; sourceTree = ""; }; 0E7D73A4BB2ACE5955D1E84A /* OpenWorktimeTrackerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenWorktimeTrackerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -152,6 +156,7 @@ 92ECFCF3E46C5163E13E2DBE /* WorktimeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = WorktimeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96852475F3CF42C080C27424 /* TimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeEntry.swift; sourceTree = ""; }; 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerSettingsTests.swift; sourceTree = ""; }; + 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayAppearanceTests.swift; sourceTree = ""; }; A3636564F038D5714D3F2E9D /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; A504CCBF9BE58EA9A65A32F7 /* InMemoryDailyLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryDailyLogStore.swift; sourceTree = ""; }; A95771E351BA49672B02C68D /* MetricCardsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricCardsView.swift; sourceTree = ""; }; @@ -209,6 +214,7 @@ 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */, FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */, 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */, + 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */, BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */, ); path = Shared; @@ -295,6 +301,7 @@ 7C0158B151E8021863597395 /* TimeEntryTests.swift */, 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */, 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */, + 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */, 92CC8582D1A82A4ED0B8BE3D /* WorkdayDetectorTests.swift */, 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */, 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, @@ -519,6 +526,7 @@ 330D1906DCAC60438162015F /* TimeEntryTests.swift in Sources */, F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */, 6444D4148F53E5FA721F2814 /* WidgetSnapshotTests.swift in Sources */, + 3CA50EE21B7AC6011D3894E4 /* WorkdayAppearanceTests.swift in Sources */, 2E50C9C91938F36E479FAB46 /* WorkdayDetectorTests.swift in Sources */, 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */, BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */, @@ -570,6 +578,7 @@ 01C8C4A1D1D00C2934B8A4A9 /* TimerDisplayView.swift in Sources */, 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */, CB63BC5DE6E64B89F587BF8D /* Workday.swift in Sources */, + 3F944373B6FF10C633D2D18C /* WorkdayAppearance.swift in Sources */, D0BA8C03FE5E382A0104E788 /* WorkdayDetector.swift in Sources */, 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */, 9884BB01691FD68AE126CC2E /* WorkdayState.swift in Sources */, @@ -583,6 +592,7 @@ 6B04E7D7384A3DC2FD19B4BF /* Color+Extensions.swift in Sources */, 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */, EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */, + 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */, F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */, 68ACEAD4CBCEF8E71A25AD6D /* WorktimeWidget.swift in Sources */, 2C62CB1A6DA628C4FB944F2C /* WorktimeWidgetBundle.swift in Sources */, diff --git a/OpenWorktimeTracker/Design/DesignTokens.swift b/OpenWorktimeTracker/Design/DesignTokens.swift index c27f280..346f037 100644 --- a/OpenWorktimeTracker/Design/DesignTokens.swift +++ b/OpenWorktimeTracker/Design/DesignTokens.swift @@ -12,11 +12,11 @@ enum DesignTokens { static let tertiary = Color("tertiary", bundle: nil) static let error = Color("error", bundle: nil) - // Adaptive fallbacks using system colors - static let accentBlue = Color(light: .init(hex: 0x0058BC), dark: .init(hex: 0x007AFF)) - static let accentGreen = Color(light: .init(hex: 0x006B27), dark: .init(hex: 0x34C759)) - static let accentOrange = Color(light: .init(hex: 0x995000), dark: .init(hex: 0xFF9500)) - static let accentRed = Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) + // Accents come from the palette shared with the widget + static let accentBlue = PaletteColor.blue.color + static let accentGreen = PaletteColor.green.color + static let accentOrange = PaletteColor.orange.color + static let accentRed = PaletteColor.red.color static let surface = Color(light: .init(hex: 0xF9F9FE), dark: .init(hex: 0x1A1C1F)) static let surfaceContainerLow = Color( @@ -29,7 +29,7 @@ enum DesignTokens { static let onSurface = Color(light: .init(hex: 0x1A1C1F), dark: .init(hex: 0xE2E2E7)) static let onAccent = Color.white - static let onSurfaceVariant = Color(light: .init(hex: 0x414755), dark: .init(hex: 0xC1C6D7)) + static let onSurfaceVariant = PaletteColor.secondary.color static let outlineVariant = Color(light: .init(hex: 0xC1C6D7), dark: .init(hex: 0x44474E)) // Glass effect diff --git a/OpenWorktimeTracker/Utilities/Date+Extensions.swift b/OpenWorktimeTracker/Utilities/Date+Extensions.swift index c45e553..93bb6e1 100644 --- a/OpenWorktimeTracker/Utilities/Date+Extensions.swift +++ b/OpenWorktimeTracker/Utilities/Date+Extensions.swift @@ -25,13 +25,6 @@ extension Date { } extension TimeInterval { - var hoursMinutesFormatted: String { - let totalMinutes = Int(self) / 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - return String(format: "%02d:%02d", hours, minutes) - } - var hoursComponent: Int { Int(self) / 3600 } diff --git a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift new file mode 100644 index 0000000..8b3dd56 --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift @@ -0,0 +1,72 @@ +import XCTest + +@testable import OpenWorktimeTracker + +final class WorkdayAppearanceTests: XCTestCase { + + func testIndicatorFollowsStateAtNormalLevelAndThresholdLevelAbove() { + let expected: [WorkdayState: PaletteColor] = [ + .notStarted: .secondary, .running: .green, .paused: .orange, .ended: .blue + ] + for state in WorkdayState.allCases { + XCTAssertEqual( + WorkdayAppearance(state: state, level: .normal).indicator, expected[state], "\(state)") + XCTAssertEqual(WorkdayAppearance(state: state, level: .elevated).indicator, .orange, "\(state)") + XCTAssertEqual(WorkdayAppearance(state: state, level: .critical).indicator, .red, "\(state)") + } + } + + func testProgressIsGreenUntilTheThresholdLadderIsReached() { + let expected: [ThresholdLevel: PaletteColor] = [.normal: .green, .elevated: .orange, .critical: .red] + for state in WorkdayState.allCases { + for (level, color) in expected { + XCTAssertEqual(WorkdayAppearance(state: state, level: level).progress, color, "\(state) \(level)") + } + } + } + + func testLabelsFollowTheStateInEachTarget() { + let expected: [WorkdayState: (app: String, widget: String)] = [ + .notStarted: ("state.notStarted", "widget.state.idle"), + .running: ("state.running", "widget.state.running"), + .paused: ("state.paused", "widget.state.paused"), + .ended: ("state.ended", "widget.state.ended") + ] + for state in WorkdayState.allCases { + XCTAssertEqual(state.labelKey, expected[state]?.app) + XCTAssertEqual(state.widgetLabelKey, expected[state]?.widget) + } + } + + func testWidgetLabelsExistInEveryWidgetLocalization() throws { + let widgetDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("WorktimeWidget", isDirectory: true) + for language in ["de", "en"] { + let url = widgetDirectory.appendingPathComponent("\(language).lproj/Localizable.strings") + let table = try XCTUnwrap(NSDictionary(contentsOf: url) as? [String: String], language) + for state in WorkdayState.allCases { + XCTAssertNotNil(table[state.widgetLabelKey], "\(language) \(state)") + } + } + } + + func testDurationUsesTwoDigitHours() { + XCTAssertEqual(TimeInterval(8 * 3600 + 5 * 60).hoursMinutesFormatted, "08:05") + XCTAssertEqual(TimeInterval(10 * 3600 + 30 * 60 + 59).hoursMinutesFormatted, "10:30") + XCTAssertEqual(TimeInterval(0).hoursMinutesFormatted, "00:00") + } + + func testSnapshotAppearanceUsesThresholdLevelAtReadTime() { + let measuredAt = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = WidgetSnapshot( + measuredAt: measuredAt, state: .running, netTime: 7.5 * 3600, grossTime: 8 * 3600, + startTime: nil, workDate: "2027-01-15", targetHours: 8, + thresholds: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) + + XCTAssertEqual(snapshot.appearance(at: measuredAt), WorkdayAppearance(state: .running, level: .normal)) + XCTAssertEqual( + snapshot.appearance(at: measuredAt.addingTimeInterval(1800)), + WorkdayAppearance(state: .running, level: .elevated)) + } +} diff --git a/Shared/SharedDefaults.swift b/Shared/SharedDefaults.swift index c3585ce..a815ddd 100644 --- a/Shared/SharedDefaults.swift +++ b/Shared/SharedDefaults.swift @@ -23,6 +23,10 @@ struct WidgetSnapshot: Codable, Equatable { thresholds.level(for: netTime(at: date)) } + func appearance(at date: Date) -> WorkdayAppearance { + WorkdayAppearance(state: state, level: thresholdLevel(at: date)) + } + /// The state is checked by decoding; a snapshot published by an earlier /// version, with colour-named threshold fields, fails to decode and reads as /// unavailable until the app publishes again. diff --git a/Shared/WorkdayAppearance.swift b/Shared/WorkdayAppearance.swift new file mode 100644 index 0000000..a1474fc --- /dev/null +++ b/Shared/WorkdayAppearance.swift @@ -0,0 +1,97 @@ +import SwiftUI + +// MARK: - Palette + +/// A colour of the palette the app and the widget paint a Workday with, named by +/// role. The one place its light and dark values live. +enum PaletteColor: CaseIterable { + case blue + case green + case orange + case red + case secondary + + var color: Color { + switch self { + case .blue: return Values.blue + case .green: return Values.green + case .orange: return Values.orange + case .red: return Values.red + case .secondary: return Values.secondary + } + } + + private enum Values { + static let blue = Color(light: .init(hex: 0x0058BC), dark: .init(hex: 0x007AFF)) + static let green = Color(light: .init(hex: 0x006B27), dark: .init(hex: 0x34C759)) + static let orange = Color(light: .init(hex: 0x995000), dark: .init(hex: 0xFF9500)) + static let red = Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) + static let secondary = Color(light: .init(hex: 0x414755), dark: .init(hex: 0xC1C6D7)) + } +} + +// MARK: - Tracking State + +extension WorkdayState { + /// The colour of the tracking state on its own, before any Threshold Level. + var accent: PaletteColor { + switch self { + case .notStarted: return .secondary + case .running: return .green + case .paused: return .orange + case .ended: return .blue + } + } + + /// The app's label key. + var labelKey: String { + "state.\(rawValue)" + } + + /// The widget keeps its own, shorter wording under its own keys. + var widgetLabelKey: String { + switch self { + case .notStarted: return "widget.state.idle" + case .running, .paused, .ended: return "widget.state.\(rawValue)" + } + } + + /// Resolves in the widget extension's bundle. + var widgetLabel: String { + String(localized: String.LocalizationValue(widgetLabelKey)) + } +} + +// MARK: - Workday Appearance + +/// How a Workday looks for a tracking state at a Threshold Level. +struct WorkdayAppearance: Equatable { + let state: WorkdayState + let level: ThresholdLevel + + /// Elevated and critical Threshold Levels take over from the tracking state. + var indicator: PaletteColor { + switch level { + case .normal: return state.accent + case .elevated: return .orange + case .critical: return .red + } + } + + /// Progress toward the daily goal stays green until the Threshold Ladder is reached. + var progress: PaletteColor { + level == .normal ? .green : indicator + } +} + +// MARK: - Duration + +extension TimeInterval { + /// Hours always take two digits: `08:05`. + var hoursMinutesFormatted: String { + let totalMinutes = Int(self) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + return String(format: "%02d:%02d", hours, minutes) + } +} diff --git a/WorktimeWidget/WorktimeWidget.swift b/WorktimeWidget/WorktimeWidget.swift index fc4d223..57d3870 100644 --- a/WorktimeWidget/WorktimeWidget.swift +++ b/WorktimeWidget/WorktimeWidget.swift @@ -68,9 +68,9 @@ struct WorktimeWidgetSmallView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Circle() - .fill(stateColor) + .fill(appearance.indicator.color) .frame(width: 6, height: 6) - Text(stateLabel) + Text(appearance.state.widgetLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -82,7 +82,7 @@ struct WorktimeWidgetSmallView: View { if snapshot.isRunning { Text(timerInterval: snapshot.liveNetStart...Date.distantFuture, countsDown: false) } else { - Text(formatTime(snapshot.netTime)) + Text(snapshot.netTime.hoursMinutesFormatted) } } .font(.system(size: 28, weight: .medium, design: .rounded)) @@ -103,28 +103,7 @@ struct WorktimeWidgetSmallView: View { .containerBackground(.fill.tertiary, for: .widget) } - private var stateColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: - switch snapshot.state { - case .running: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case .paused: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .ended: return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - case .notStarted: return .secondary - } - } - } - - private var stateLabel: String { - switch snapshot.state { - case .running: return String(localized: "widget.state.running") - case .paused: return String(localized: "widget.state.paused") - case .ended: return String(localized: "widget.state.ended") - case .notStarted: return String(localized: "widget.state.idle") - } - } + private var appearance: WorkdayAppearance { snapshot.appearance(at: date) } } // MARK: - Medium Widget View @@ -140,9 +119,9 @@ struct WorktimeWidgetMediumView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Circle() - .fill(stateColor) + .fill(appearance.indicator.color) .frame(width: 6, height: 6) - Text(stateLabel) + Text(appearance.state.widgetLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -156,7 +135,7 @@ struct WorktimeWidgetMediumView: View { timerInterval: snapshot.liveNetStart...Date.distantFuture, countsDown: false) } else { - Text(formatTime(snapshot.netTime)) + Text(snapshot.netTime.hoursMinutesFormatted) } } .font(.system(size: 32, weight: .medium, design: .rounded)) @@ -184,7 +163,7 @@ struct WorktimeWidgetMediumView: View { .trim( from: 0, to: min(1.0, netTimeSeconds / (snapshot.targetHours * 3600)) ) - .stroke(progressColor, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .stroke(appearance.progress.color, style: StrokeStyle(lineWidth: 6, lineCap: .round)) .rotationEffect(.degrees(-90)) Text( String( @@ -199,7 +178,7 @@ struct WorktimeWidgetMediumView: View { Text( String( format: String(localized: "widget.target"), - formatTime(snapshot.targetHours * 3600)) + (snapshot.targetHours * 3600).hoursMinutesFormatted) ) .font(.system(size: 9)) .foregroundStyle(.secondary) @@ -209,46 +188,11 @@ struct WorktimeWidgetMediumView: View { .containerBackground(.fill.tertiary, for: .widget) } - private var stateColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: - switch snapshot.state { - case .running: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case .paused: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .ended: return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - case .notStarted: return .secondary - } - } - } - - private var stateLabel: String { - switch snapshot.state { - case .running: return String(localized: "widget.state.running") - case .paused: return String(localized: "widget.state.paused") - case .ended: return String(localized: "widget.state.ended") - case .notStarted: return String(localized: "widget.state.idle") - } - } - - private var progressColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - } - } + private var appearance: WorkdayAppearance { snapshot.appearance(at: date) } } // MARK: - Helpers -private func formatTime(_ seconds: TimeInterval) -> String { - let h = Int(seconds) / 3600 - let m = (Int(seconds) % 3600) / 60 - return String(format: "%d:%02d", h, m) -} - private let hourMinuteFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" From d063cdc52b866c9d667fd0f9fbaf436f0df4c614 Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:17:21 +0200 Subject: [PATCH 3/4] refactor: colour and label the app's Workday through the shared appearance The menu bar, popover badge, log editor chip and every Threshold Level accent now ask the shared appearance module. DesignTokens keeps the palette but no longer maps states or Threshold Levels to colours. The menu bar still leaves the normal level to native rendering, and the app's visible colours are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker/App/AppDelegate.swift | 10 ++++--- .../Core/Models/TimeEntry.swift | 11 +++++++ .../Core/Services/WorkdayManager.swift | 19 +----------- OpenWorktimeTracker/Design/DesignTokens.swift | 11 ------- OpenWorktimeTracker/Views/LogEditorView.swift | 2 +- .../Views/LogEntryEditView.swift | 12 ++------ OpenWorktimeTracker/Views/MenuBarView.swift | 7 +---- .../Views/TimerDisplayView.swift | 2 +- .../Views/WeekHistoryView.swift | 2 +- .../WorkdayAppearanceTests.swift | 24 +++++++++++++++ Shared/WorkdayAppearance.swift | 29 +++++++++++++++---- 11 files changed, 72 insertions(+), 57 deletions(-) diff --git a/OpenWorktimeTracker/App/AppDelegate.swift b/OpenWorktimeTracker/App/AppDelegate.swift index 48fc43e..1ac39cf 100644 --- a/OpenWorktimeTracker/App/AppDelegate.swift +++ b/OpenWorktimeTracker/App/AppDelegate.swift @@ -189,10 +189,12 @@ final class MenuBarController: NSObject { var environment = EnvironmentValues() environment.colorScheme = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .dark : .light - let resolved = manager.thresholdLevel.accent.resolve(in: environment) - let color: NSColor? = manager.thresholdLevel == .normal ? nil : NSColor( - srgbRed: CGFloat(resolved.red), green: CGFloat(resolved.green), - blue: CGFloat(resolved.blue), alpha: CGFloat(resolved.opacity)) + let color = manager.thresholdLevel.menuBarAccent.map { accent in + let resolved = accent.color.resolve(in: environment) + return NSColor( + srgbRed: CGFloat(resolved.red), green: CGFloat(resolved.green), + blue: CGFloat(resolved.blue), alpha: CGFloat(resolved.opacity)) + } button?.contentTintColor = nil if let color { // A non-template image preserves threshold colors through menu-bar compositing. diff --git a/OpenWorktimeTracker/Core/Models/TimeEntry.swift b/OpenWorktimeTracker/Core/Models/TimeEntry.swift index 9fa3c13..617622f 100644 --- a/OpenWorktimeTracker/Core/Models/TimeEntry.swift +++ b/OpenWorktimeTracker/Core/Models/TimeEntry.swift @@ -13,6 +13,7 @@ struct TimeEntry: Codable, Identifiable { var note: String var lastActivityTime: Date? + /// Maps onto `WorkdayState`, which adds `notStarted` for when no Daily Log exists. enum Status: String, Codable { case running case paused @@ -114,3 +115,13 @@ struct IdleDecision: Codable, Identifiable { self.decision = decision } } + +extension WorkdayState { + init(_ status: TimeEntry.Status) { + switch status { + case .running: self = .running + case .paused: self = .paused + case .ended: self = .ended + } + } +} diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index 585a749..c27d529 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -245,11 +245,7 @@ final class WorkdayManager { private func activate(_ workday: Workday) { currentWorkday = workday - switch workday.status { - case .running: state = .running - case .paused: state = .paused - case .ended: state = .ended - } + state = WorkdayState(workday.status) startTimer() if state == .running { idleDetector.startMonitoring() @@ -741,16 +737,3 @@ extension WorkdayManager { startDay(at: max(detector.startOfEffectiveDay(for: clock.now), period.idleEnd)) } } - -// MARK: - State Localization - -extension WorkdayState { - var localizedLabel: String { - switch self { - case .notStarted: return String(localized: "state.notStarted") - case .running: return String(localized: "state.running") - case .paused: return String(localized: "state.paused") - case .ended: return String(localized: "state.ended") - } - } -} diff --git a/OpenWorktimeTracker/Design/DesignTokens.swift b/OpenWorktimeTracker/Design/DesignTokens.swift index 346f037..8edfd07 100644 --- a/OpenWorktimeTracker/Design/DesignTokens.swift +++ b/OpenWorktimeTracker/Design/DesignTokens.swift @@ -82,14 +82,3 @@ enum DesignTokens { static let popoverMinHeight: CGFloat = 640 static let promptWidth: CGFloat = 380 } - -extension ThresholdLevel { - /// The accent a Workday is painted with at this level. - var accent: Color { - switch self { - case .normal: return DesignTokens.Colors.accentBlue - case .elevated: return DesignTokens.Colors.accentOrange - case .critical: return DesignTokens.Colors.accentRed - } - } -} diff --git a/OpenWorktimeTracker/Views/LogEditorView.swift b/OpenWorktimeTracker/Views/LogEditorView.swift index 919bc79..2decf7e 100644 --- a/OpenWorktimeTracker/Views/LogEditorView.swift +++ b/OpenWorktimeTracker/Views/LogEditorView.swift @@ -109,6 +109,6 @@ private struct LogEntryRow: View { } private var timeColor: Color { - workday.thresholdLevel.accent + workday.thresholdLevel.accent.color } } diff --git a/OpenWorktimeTracker/Views/LogEntryEditView.swift b/OpenWorktimeTracker/Views/LogEntryEditView.swift index c865489..be2f4f5 100644 --- a/OpenWorktimeTracker/Views/LogEntryEditView.swift +++ b/OpenWorktimeTracker/Views/LogEntryEditView.swift @@ -102,19 +102,11 @@ struct LogEntryEditView: View { } private var statusLabel: String { - switch entry.status { - case .running: return String(localized: "state.running") - case .paused: return String(localized: "state.paused") - case .ended: return String(localized: "state.ended") - } + WorkdayState(entry.status).localizedLabel } private var statusColor: Color { - switch entry.status { - case .running: return DesignTokens.Colors.accentGreen - case .paused: return DesignTokens.Colors.accentOrange - case .ended: return DesignTokens.Colors.accentBlue - } + WorkdayState(entry.status).accent.color } // MARK: - Time diff --git a/OpenWorktimeTracker/Views/MenuBarView.swift b/OpenWorktimeTracker/Views/MenuBarView.swift index db86511..6aa831c 100644 --- a/OpenWorktimeTracker/Views/MenuBarView.swift +++ b/OpenWorktimeTracker/Views/MenuBarView.swift @@ -207,11 +207,6 @@ struct MenuBarView: View { // MARK: - Helpers private var stateColor: Color { - switch manager.state { - case .notStarted: return DesignTokens.Colors.onSurfaceVariant - case .running: return DesignTokens.Colors.accentGreen - case .paused: return DesignTokens.Colors.accentOrange - case .ended: return DesignTokens.Colors.accentBlue - } + manager.state.accent.color } } diff --git a/OpenWorktimeTracker/Views/TimerDisplayView.swift b/OpenWorktimeTracker/Views/TimerDisplayView.swift index ebd4886..76313b5 100644 --- a/OpenWorktimeTracker/Views/TimerDisplayView.swift +++ b/OpenWorktimeTracker/Views/TimerDisplayView.swift @@ -76,6 +76,6 @@ struct TimerDisplayView: View { } private var thresholdColor: Color { - manager.thresholdLevel.accent + manager.thresholdLevel.accent.color } } diff --git a/OpenWorktimeTracker/Views/WeekHistoryView.swift b/OpenWorktimeTracker/Views/WeekHistoryView.swift index 5ab4896..cbaaa4b 100644 --- a/OpenWorktimeTracker/Views/WeekHistoryView.swift +++ b/OpenWorktimeTracker/Views/WeekHistoryView.swift @@ -107,7 +107,7 @@ private struct DayRow: View { } private var barColor: Color { - workday.thresholdLevel.accent + workday.thresholdLevel.accent.color } private var weekdayAbbr: String { diff --git a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift index 8b3dd56..8b546af 100644 --- a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift @@ -38,6 +38,30 @@ final class WorkdayAppearanceTests: XCTestCase { } } + func testThresholdLevelAccentIsBlueUntilTheThresholdLadderIsReached() { + XCTAssertEqual(ThresholdLevel.normal.accent, .blue) + XCTAssertEqual(ThresholdLevel.elevated.accent, .orange) + XCTAssertEqual(ThresholdLevel.critical.accent, .red) + } + + func testMenuBarIsOnlyPaintedAboveTheNormalLevel() { + XCTAssertNil(ThresholdLevel.normal.menuBarAccent) + XCTAssertEqual(ThresholdLevel.elevated.menuBarAccent, .orange) + XCTAssertEqual(ThresholdLevel.critical.menuBarAccent, .red) + } + + func testDailyLogStatusMapsToTheSameTrackingState() { + XCTAssertEqual(WorkdayState(TimeEntry.Status.running), .running) + XCTAssertEqual(WorkdayState(TimeEntry.Status.paused), .paused) + XCTAssertEqual(WorkdayState(TimeEntry.Status.ended), .ended) + } + + func testAppLabelsResolveInTheAppBundle() { + for state in WorkdayState.allCases { + XCTAssertNotEqual(state.localizedLabel, state.labelKey, "\(state)") + } + } + func testWidgetLabelsExistInEveryWidgetLocalization() throws { let widgetDirectory = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent() diff --git a/Shared/WorkdayAppearance.swift b/Shared/WorkdayAppearance.swift index a1474fc..b7ba3a6 100644 --- a/Shared/WorkdayAppearance.swift +++ b/Shared/WorkdayAppearance.swift @@ -48,6 +48,11 @@ extension WorkdayState { "state.\(rawValue)" } + /// Resolves in the app's bundle. + var localizedLabel: String { + String(localized: String.LocalizationValue(labelKey)) + } + /// The widget keeps its own, shorter wording under its own keys. var widgetLabelKey: String { switch self { @@ -62,6 +67,24 @@ extension WorkdayState { } } +// MARK: - Threshold Level + +extension ThresholdLevel { + /// The accent a Workday's Net Work Time is painted with at this level. + var accent: PaletteColor { + switch self { + case .normal: return .blue + case .elevated: return .orange + case .critical: return .red + } + } + + /// `nil` leaves the menu bar to its native, wallpaper-aware rendering. + var menuBarAccent: PaletteColor? { + self == .normal ? nil : accent + } +} + // MARK: - Workday Appearance /// How a Workday looks for a tracking state at a Threshold Level. @@ -71,11 +94,7 @@ struct WorkdayAppearance: Equatable { /// Elevated and critical Threshold Levels take over from the tracking state. var indicator: PaletteColor { - switch level { - case .normal: return state.accent - case .elevated: return .orange - case .critical: return .red - } + level == .normal ? state.accent : level.accent } /// Progress toward the daily goal stays green until the Threshold Ladder is reached. From ece532e5ddba6a0e3f0652e92985a21aeac40d8e Mon Sep 17 00:00:00 2001 From: Lunicorn Date: Mon, 14 Sep 2026 21:26:24 +0200 Subject: [PATCH 4/4] refactor: version the widget snapshot key and pin the shared palette Rename the snapshot's Threshold Ladder field, store snapshots under a versioned key so an older format reads as unavailable, move duration formatting into its own shared file and pin the palette's values. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFc8P7zoT4V7GQaqwegkcm --- OpenWorktimeTracker.xcodeproj/project.pbxproj | 6 ++++ .../Core/Services/WorkdayManager.swift | 2 +- .../WidgetSnapshotTests.swift | 20 +++++++------ .../WorkdayAppearanceTests.swift | 29 ++++++++++++++++++- .../WorkdayManagerClockAndStoreTests.swift | 2 +- Shared/SharedDefaults.swift | 17 ++++++----- Shared/TimeInterval+Duration.swift | 11 +++++++ Shared/WorkdayAppearance.swift | 17 ++--------- WorktimeWidget/WorktimeWidget.swift | 2 +- 9 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 Shared/TimeInterval+Duration.swift diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 1b52646..d0188a3 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 3F944373B6FF10C633D2D18C /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */; }; 4352E3077EFA1D9BDA71E053 /* WorkdayManagerSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */; }; 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1089C3A7EF27586EB233E4B /* WeekHistoryView.swift */; }; + 45624526A8C5B075E6BD6398 /* TimeInterval+Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */; }; 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 */; }; @@ -60,6 +61,7 @@ ADE593C4C405609F5F1B3894 /* WorkdayManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */; }; AEE1E6715AC00AE314B9D854 /* OpenWorktimeTrackerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A98A9F938927C616301EA5 /* OpenWorktimeTrackerApp.swift */; }; B2942C9DBBAD0D5F95307152 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B2CA010B0A3CBEDC5FA6F9 /* MenuBarView.swift */; }; + B9D145B81FC0A69E5F5951C2 /* TimeInterval+Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */; }; BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */; }; BF24A802B731C7DB18835234 /* LogEditorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEEFF607EEBE5390F88EB0B /* LogEditorWindowController.swift */; }; BF6602F8D9B236C3229F2DF8 /* WorkdayManagerLifecycleAndMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */; }; @@ -182,6 +184,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 = ""; }; + 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 = ""; }; /* End PBXFileReference section */ @@ -214,6 +217,7 @@ 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */, FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */, 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */, + F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */, 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */, BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */, ); @@ -575,6 +579,7 @@ 15061C70D45A5BEF76336071 /* SummaryStatsView.swift in Sources */, 4848E362C452EB5A63DB318E /* ThresholdLevel.swift in Sources */, 253CF5BCF181B9CD0DF0734F /* TimeEntry.swift in Sources */, + 45624526A8C5B075E6BD6398 /* TimeInterval+Duration.swift in Sources */, 01C8C4A1D1D00C2934B8A4A9 /* TimerDisplayView.swift in Sources */, 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */, CB63BC5DE6E64B89F587BF8D /* Workday.swift in Sources */, @@ -592,6 +597,7 @@ 6B04E7D7384A3DC2FD19B4BF /* Color+Extensions.swift in Sources */, 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */, EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */, + B9D145B81FC0A69E5F5951C2 /* TimeInterval+Duration.swift in Sources */, 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */, F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */, 68ACEAD4CBCEF8E71A25AD6D /* WorktimeWidget.swift in Sources */, diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index c27d529..e3d4e1c 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -456,7 +456,7 @@ extension WorkdayManager { startTime: currentWorkday?.startTime, workDate: currentWorkday?.date ?? "", targetHours: notificationThresholds.normalHours, - thresholds: thresholds + thresholdLadder: thresholds ) do { try widgetStore.publish(snapshot) diff --git a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift index 1929284..3daa7dd 100644 --- a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift +++ b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift @@ -25,12 +25,12 @@ final class WidgetSnapshotTests: XCTestCase { private func snapshot( state: WorkdayState = .running, netTime: TimeInterval = 3600, - thresholds: ThresholdLadder = ThresholdLadder(elevatedHours: 7.5, criticalHours: 9) + thresholdLadder: ThresholdLadder = ThresholdLadder(elevatedHours: 7.5, criticalHours: 9) ) -> WidgetSnapshot { WidgetSnapshot( measuredAt: measuredAt, state: state, netTime: netTime, grossTime: 4200, startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", - targetHours: 7, thresholds: thresholds) + targetHours: 7, thresholdLadder: thresholdLadder) } func testDefaultsRoundTripReplacesOneCompleteValue() throws { @@ -43,7 +43,7 @@ final class WidgetSnapshotTests: XCTestCase { let second = WidgetSnapshot( measuredAt: measuredAt.addingTimeInterval(900), state: .ended, netTime: 4500, grossTime: 5100, startTime: first.startTime, workDate: first.workDate, - targetHours: 8, thresholds: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) + targetHours: 8, thresholdLadder: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) try writer.publish(second) XCTAssertEqual(try reader.readSnapshot(), second) @@ -111,7 +111,7 @@ final class WidgetSnapshotTests: XCTestCase { ThresholdLadder(elevatedHours: .nan, criticalHours: 9), ThresholdLadder(elevatedHours: 8, criticalHours: -1) ] { - XCTAssertThrowsError(try store.publish(snapshot(thresholds: ladder))) + XCTAssertThrowsError(try store.publish(snapshot(thresholdLadder: ladder))) XCTAssertEqual(try store.readSnapshot(), snapshot()) } } @@ -145,20 +145,22 @@ final class WidgetSnapshotTests: XCTestCase { let redThreshold: Double } - /// Chosen behaviour: a snapshot from the previous version is reported - /// unavailable rather than migrated. The widget shows its "no current data" - /// state until the updated app publishes, which it does on launch and every tick. + /// Chosen behaviour: a snapshot left under the previous version's key is + /// ignored rather than migrated, without reporting a read failure. The widget + /// shows its "no current data" state until the updated app publishes, which + /// it does on launch and every tick, and publishing removes the stale value. func testPreviousVersionSnapshotIsUnavailableUntilNextPublish() throws { let previous = PreviousVersionSnapshot( measuredAt: measuredAt, state: "running", netTime: 3600, grossTime: 4200, startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", targetHours: 7, orangeThreshold: 7.5, redThreshold: 9) - defaults.set(try JSONEncoder().encode(previous), forKey: SharedDefaults.snapshotKey) + defaults.set(try JSONEncoder().encode(previous), forKey: "widget_snapshot_v1") let store = SharedDefaults(defaults: defaults, fallbackURL: fileURL) - XCTAssertThrowsError(try store.readSnapshot()) + XCTAssertNil(try store.readSnapshot()) try store.publish(snapshot()) XCTAssertEqual(try store.readSnapshot(), snapshot()) + XCTAssertNil(defaults.object(forKey: "widget_snapshot_v1")) } func testDelayedReadKeepsRunningTimerAnchoredToMeasurement() { diff --git a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift index 8b546af..402a9bd 100644 --- a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift @@ -1,9 +1,36 @@ +import AppKit import XCTest @testable import OpenWorktimeTracker final class WorkdayAppearanceTests: XCTestCase { + /// Pins the app's look: these values are what both targets paint with. + func testPaletteKeepsTheAppsLightAndDarkValues() throws { + let expected: [PaletteColor: (light: UInt, dark: UInt)] = [ + .blue: (0x0058BC, 0x007AFF), + .green: (0x006B27, 0x34C759), + .orange: (0x995000, 0xFF9500), + .red: (0xBA1A1A, 0xFF453A), + .secondary: (0x414755, 0xC1C6D7) + ] + XCTAssertEqual(Set(expected.keys), Set(PaletteColor.allCases)) + for (paletteColor, values) in expected { + for (name, hex) in [(NSAppearance.Name.aqua, values.light), (.darkAqua, values.dark)] { + var resolved: NSColor? + try XCTUnwrap(NSAppearance(named: name)).performAsCurrentDrawingAppearance { + resolved = NSColor(paletteColor.color).usingColorSpace(.sRGB) + } + let color = try XCTUnwrap(resolved) + let actual = UInt((color.redComponent * 255).rounded()) << 16 + | UInt((color.greenComponent * 255).rounded()) << 8 + | UInt((color.blueComponent * 255).rounded()) + XCTAssertEqual( + String(actual, radix: 16), String(hex, radix: 16), "\(paletteColor) \(name.rawValue)") + } + } + } + func testIndicatorFollowsStateAtNormalLevelAndThresholdLevelAbove() { let expected: [WorkdayState: PaletteColor] = [ .notStarted: .secondary, .running: .green, .paused: .orange, .ended: .blue @@ -86,7 +113,7 @@ final class WorkdayAppearanceTests: XCTestCase { let snapshot = WidgetSnapshot( measuredAt: measuredAt, state: .running, netTime: 7.5 * 3600, grossTime: 8 * 3600, startTime: nil, workDate: "2027-01-15", targetHours: 8, - thresholds: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) + thresholdLadder: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) XCTAssertEqual(snapshot.appearance(at: measuredAt), WorkdayAppearance(state: .running, level: .normal)) XCTAssertEqual( diff --git a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift index 8fcfefa..ab59cf9 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift @@ -93,7 +93,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) XCTAssertEqual(snapshot.targetHours, 7) - XCTAssertEqual(snapshot.thresholds, ThresholdLadder(elevatedHours: 7.5, criticalHours: 8.5)) + XCTAssertEqual(snapshot.thresholdLadder, ThresholdLadder(elevatedHours: 7.5, criticalHours: 8.5)) } func testDeletingCurrentLogPublishesEmptySnapshotWithoutStaleStartTime() throws { diff --git a/Shared/SharedDefaults.swift b/Shared/SharedDefaults.swift index a815ddd..8831820 100644 --- a/Shared/SharedDefaults.swift +++ b/Shared/SharedDefaults.swift @@ -8,7 +8,7 @@ struct WidgetSnapshot: Codable, Equatable { let startTime: Date? let workDate: String let targetHours: Double - let thresholds: ThresholdLadder + let thresholdLadder: ThresholdLadder var isRunning: Bool { state == .running } @@ -20,23 +20,21 @@ struct WidgetSnapshot: Codable, Equatable { } func thresholdLevel(at date: Date) -> ThresholdLevel { - thresholds.level(for: netTime(at: date)) + thresholdLadder.level(for: netTime(at: date)) } func appearance(at date: Date) -> WorkdayAppearance { WorkdayAppearance(state: state, level: thresholdLevel(at: date)) } - /// The state is checked by decoding; a snapshot published by an earlier - /// version, with colour-named threshold fields, fails to decode and reads as - /// unavailable until the app publishes again. + /// The state is checked by decoding. fileprivate func validate() throws { guard measuredAt.timeIntervalSince1970.isFinite, startTime?.timeIntervalSince1970.isFinite ?? true, netTime.isFinite, netTime >= 0, grossTime.isFinite, grossTime >= 0, targetHours.isFinite, targetHours > 0, - thresholds.elevatedHours.isFinite, thresholds.elevatedHours >= 0, - thresholds.criticalHours.isFinite, thresholds.criticalHours >= 0 else { + thresholdLadder.elevatedHours.isFinite, thresholdLadder.elevatedHours >= 0, + thresholdLadder.criticalHours.isFinite, thresholdLadder.criticalHours >= 0 else { throw CocoaError(.coderInvalidValue) } } @@ -45,7 +43,9 @@ struct WidgetSnapshot: Codable, Equatable { struct SharedDefaults { static let appGroupIdentifier = "group.com.openworktimetracker" static let shared = SharedDefaults() - static let snapshotKey = "widget_snapshot_v1" + /// Versioned with the stored format; a value under an earlier key is ignored. + static let snapshotKey = "widget_snapshot_v2" + private static let previousSnapshotKey = "widget_snapshot_v1" // AppSettingsKey/AppDefaults reference these shared setting definitions. static let normalHoursSettingKey = "normalNotificationHours" @@ -80,6 +80,7 @@ struct SharedDefaults { if let defaults { // One value replaces the entire measurement, including its settings. defaults.set(data, forKey: Self.snapshotKey) + defaults.removeObject(forKey: Self.previousSnapshotKey) } else { try FileManager.default.createDirectory( at: fallbackURL.deletingLastPathComponent(), withIntermediateDirectories: true) diff --git a/Shared/TimeInterval+Duration.swift b/Shared/TimeInterval+Duration.swift new file mode 100644 index 0000000..a7f1d81 --- /dev/null +++ b/Shared/TimeInterval+Duration.swift @@ -0,0 +1,11 @@ +import Foundation + +extension TimeInterval { + /// Hours always take two digits: `08:05`. + var hoursMinutesFormatted: String { + let totalMinutes = Int(self) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + return String(format: "%02d:%02d", hours, minutes) + } +} diff --git a/Shared/WorkdayAppearance.swift b/Shared/WorkdayAppearance.swift index b7ba3a6..1dc91a1 100644 --- a/Shared/WorkdayAppearance.swift +++ b/Shared/WorkdayAppearance.swift @@ -2,8 +2,9 @@ import SwiftUI // MARK: - Palette -/// A colour of the palette the app and the widget paint a Workday with, named by -/// role. The one place its light and dark values live. +/// The accent colours the app and the widget paint a Workday with, plus the +/// muted `secondary` for text and a Workday not yet started. The one place their +/// light and dark values live. enum PaletteColor: CaseIterable { case blue case green @@ -102,15 +103,3 @@ struct WorkdayAppearance: Equatable { level == .normal ? .green : indicator } } - -// MARK: - Duration - -extension TimeInterval { - /// Hours always take two digits: `08:05`. - var hoursMinutesFormatted: String { - let totalMinutes = Int(self) / 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - return String(format: "%02d:%02d", hours, minutes) - } -} diff --git a/WorktimeWidget/WorktimeWidget.swift b/WorktimeWidget/WorktimeWidget.swift index 57d3870..4e80ddb 100644 --- a/WorktimeWidget/WorktimeWidget.swift +++ b/WorktimeWidget/WorktimeWidget.swift @@ -24,7 +24,7 @@ struct WorktimeProvider: TimelineProvider { startTime: Calendar.current.date(bySettingHour: 8, minute: 30, second: 0, of: now), workDate: "2024-01-15", targetHours: 8.0, - thresholds: ThresholdLadder(elevatedHours: 8.0, criticalHours: 9.5) + thresholdLadder: ThresholdLadder(elevatedHours: 8.0, criticalHours: 9.5) )) }