From e041bdb8bbd4cd1e24088f89da245115e7875f90 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Sat, 30 May 2026 01:20:49 +0300 Subject: [PATCH] feat: add menu bar countdown timer with configurable display Show remaining time next to the menu bar icon. Two modes: - Full timer: always visible, toggled via Settings > General - Countdown: appears only in the last N minutes before a break Timer updates instantly on preference changes and break scheduling instead of waiting for the 10-second polling loop. Sub-second floating-point remainders are floored before ceil-rounding to prevent 60.01s from displaying as "2m". --- StandLock/AppState/AppCoordinator.swift | 30 ++++++- StandLock/StandLockApp.swift | 4 + .../Views/Settings/GeneralSettingsView.swift | 42 +++++++++ .../Sources/StandLockCore/BreakProgress.swift | 26 ++++++ .../StandLockCore/Models/AppPreferences.swift | 12 ++- .../BreakProgressTests.swift | 86 +++++++++++++++++++ 6 files changed, 197 insertions(+), 3 deletions(-) diff --git a/StandLock/AppState/AppCoordinator.swift b/StandLock/AppState/AppCoordinator.swift index ef0d466..0707929 100644 --- a/StandLock/AppState/AppCoordinator.swift +++ b/StandLock/AppState/AppCoordinator.swift @@ -41,6 +41,7 @@ final class AppCoordinator: ObservableObject { @Published var preferences: AppPreferences = AppPreferences() @Published var hasCompletedOnboarding: Bool = false @Published private(set) var breakProgress: Double = 0 + @Published private(set) var menuBarTimerText: String? let permissionChecker = PermissionChecker() @@ -121,6 +122,7 @@ final class AppCoordinator: ObservableObject { guard let data = try? JSONEncoder().encode(preferences) else { return } UserDefaults.standard.set(data, forKey: "preferences") coordinator?.updatePreferences(preferences) + updateMenuBarTimer() } private func syncPreferencesWithPermissions() { @@ -220,6 +222,7 @@ final class AppCoordinator: ObservableObject { nextBreakTime = date breakScheduledAt = Date() recalculateProgress() + updateMenuBarTimer() case .breakStarted(let e): deferralReason = nil @@ -232,6 +235,7 @@ final class AppCoordinator: ObservableObject { isBreakActive = false currentBreakRemaining = 0 breakProgress = 0 + menuBarTimerText = nil case .breakDeferred(let reason, _): deferralReason = reason @@ -239,10 +243,12 @@ final class AppCoordinator: ObservableObject { case .schedulePaused(let until): isPaused = true pausedUntil = until + menuBarTimerText = nil case .scheduleResumed: isPaused = false pausedUntil = nil + updateMenuBarTimer() case .statisticsUpdated(let stats): todayStats = stats @@ -304,13 +310,33 @@ final class AppCoordinator: ObservableObject { private func startProgressTimer() { progressTimer = Task { while !Task.isCancelled { - try? await Task.sleep(for: .seconds(10)) - guard !Task.isCancelled else { break } recalculateProgress() + updateMenuBarTimer() + + let interval: Duration = if let remaining = nextBreakTime?.timeIntervalSinceNow, + menuBarTimerText != nil, remaining < 60 { + .seconds(1) + } else { + .seconds(10) + } + try? await Task.sleep(for: interval, tolerance: interval == .seconds(10) ? .seconds(2) : .milliseconds(100)) + guard !Task.isCancelled else { break } } } } + private func updateMenuBarTimer() { + let remaining = nextBreakTime?.timeIntervalSinceNow ?? 0 + menuBarTimerText = formatMenuBarTimer( + secondsRemaining: remaining, + showFullTimer: preferences.showFullWorkTimer, + countdownMinutes: preferences.menuBarCountdownMinutes, + isBreakActive: isBreakActive, + isPaused: isPaused, + hasScheduledBreak: nextBreakTime != nil + ) + } + private func recalculateProgress() { if isPaused { return } breakProgress = calculateBreakProgress( diff --git a/StandLock/StandLockApp.swift b/StandLock/StandLockApp.swift index a9393bd..53853ec 100644 --- a/StandLock/StandLockApp.swift +++ b/StandLock/StandLockApp.swift @@ -14,6 +14,10 @@ struct StandLockApp: App { .environmentObject(appDelegate.updateObserver) } label: { Image(nsImage: MenuBarIcon.make(progress: appCoordinator.breakProgress)) + if let timerText = appCoordinator.menuBarTimerText { + Text(timerText) + .monospacedDigit() + } } .menuBarExtraStyle(.window) diff --git a/StandLock/Views/Settings/GeneralSettingsView.swift b/StandLock/Views/Settings/GeneralSettingsView.swift index cc6cae3..e32639c 100644 --- a/StandLock/Views/Settings/GeneralSettingsView.swift +++ b/StandLock/Views/Settings/GeneralSettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI import ServiceManagement struct GeneralSettingsView: View { + @EnvironmentObject private var coordinator: AppCoordinator @State private var launchAtStartup = SMAppService.mainApp.status == .enabled @State private var errorMessage: String? @State private var isUpdating = false @@ -32,6 +33,47 @@ struct GeneralSettingsView: View { .foregroundStyle(.red) } } + + Section("Menu Bar Display") { + Toggle(isOn: $coordinator.preferences.showFullWorkTimer) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Show Full Work Timer") + Text("Always display remaining time next to the icon") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "timer") + } + } + .onChange(of: coordinator.preferences.showFullWorkTimer) { _ in + coordinator.savePreferences() + } + + if !coordinator.preferences.showFullWorkTimer { + Picker(selection: $coordinator.preferences.menuBarCountdownMinutes) { + Text("1 min").tag(1) + Text("2 min").tag(2) + Text("3 min").tag(3) + Text("5 min").tag(5) + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Countdown Start") + Text("Show timer in the last minutes before break") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "clock.badge.checkmark") + } + } + .onChange(of: coordinator.preferences.menuBarCountdownMinutes) { _ in + coordinator.savePreferences() + } + } + } } .formStyle(.grouped) } diff --git a/StandLockKit/Sources/StandLockCore/BreakProgress.swift b/StandLockKit/Sources/StandLockCore/BreakProgress.swift index 478d01e..c49a479 100644 --- a/StandLockKit/Sources/StandLockCore/BreakProgress.swift +++ b/StandLockKit/Sources/StandLockCore/BreakProgress.swift @@ -14,6 +14,32 @@ public func calculateBreakProgress( return min(1.0, max(0.0, elapsed / total)) } +public func formatMenuBarTimer( + secondsRemaining: TimeInterval, + showFullTimer: Bool, + countdownMinutes: Int, + isBreakActive: Bool, + isPaused: Bool, + hasScheduledBreak: Bool +) -> String? { + if isBreakActive || isPaused || !hasScheduledBreak { return nil } + let remaining = max(0, secondsRemaining) + + if !showFullTimer { + let threshold = TimeInterval(countdownMinutes * 60) + if remaining > threshold { return nil } + } + + let wholeSeconds = remaining.rounded(.down) + if wholeSeconds < 60 { + let seconds = Int(wholeSeconds) % 60 + return String(format: "0:%02d", seconds) + } else { + let minutes = Int(ceil(wholeSeconds / 60)) + return "\(minutes)m" + } +} + public enum ProgressDisplayBranch: Sendable, Equatable { case empty case partial diff --git a/StandLockKit/Sources/StandLockCore/Models/AppPreferences.swift b/StandLockKit/Sources/StandLockCore/Models/AppPreferences.swift index 30d2be3..ab0a942 100644 --- a/StandLockKit/Sources/StandLockCore/Models/AppPreferences.swift +++ b/StandLockKit/Sources/StandLockCore/Models/AppPreferences.swift @@ -21,6 +21,9 @@ public struct AppPreferences: Codable, Sendable, Equatable { public var resetIntervalOnSkip: Bool + public var showFullWorkTimer: Bool + public var menuBarCountdownMinutes: Int + public init( gentleDailySkipLimit: Int = 5, firmSkipDelay: TimeInterval = 10, @@ -36,7 +39,9 @@ public struct AppPreferences: Codable, Sendable, Equatable { focusModeDetection: DetectionBehavior = .deferBreak, idleDetectionEnabled: Bool = true, pauseMediaDuringBreak: Bool = true, - resetIntervalOnSkip: Bool = true + resetIntervalOnSkip: Bool = true, + showFullWorkTimer: Bool = false, + menuBarCountdownMinutes: Int = 1 ) { self.gentleDailySkipLimit = gentleDailySkipLimit self.firmSkipDelay = firmSkipDelay @@ -53,6 +58,8 @@ public struct AppPreferences: Codable, Sendable, Equatable { self.idleDetectionEnabled = idleDetectionEnabled self.pauseMediaDuringBreak = pauseMediaDuringBreak self.resetIntervalOnSkip = resetIntervalOnSkip + self.showFullWorkTimer = showFullWorkTimer + self.menuBarCountdownMinutes = menuBarCountdownMinutes } private enum CodingKeys: String, CodingKey { @@ -65,6 +72,7 @@ public struct AppPreferences: Codable, Sendable, Equatable { case focusModeDetection, idleDetectionEnabled case pauseMediaDuringBreak case resetIntervalOnSkip + case showFullWorkTimer, menuBarCountdownMinutes } public init(from decoder: Decoder) throws { @@ -84,6 +92,8 @@ public struct AppPreferences: Codable, Sendable, Equatable { idleDetectionEnabled = try c.decodeIfPresent(Bool.self, forKey: .idleDetectionEnabled) ?? true pauseMediaDuringBreak = try c.decodeIfPresent(Bool.self, forKey: .pauseMediaDuringBreak) ?? true resetIntervalOnSkip = try c.decodeIfPresent(Bool.self, forKey: .resetIntervalOnSkip) ?? true + showFullWorkTimer = try c.decodeIfPresent(Bool.self, forKey: .showFullWorkTimer) ?? false + menuBarCountdownMinutes = try c.decodeIfPresent(Int.self, forKey: .menuBarCountdownMinutes) ?? 1 } } diff --git a/StandLockKit/Tests/StandLockCoreTests/BreakProgressTests.swift b/StandLockKit/Tests/StandLockCoreTests/BreakProgressTests.swift index 8f498b5..7300747 100644 --- a/StandLockKit/Tests/StandLockCoreTests/BreakProgressTests.swift +++ b/StandLockKit/Tests/StandLockCoreTests/BreakProgressTests.swift @@ -99,4 +99,90 @@ struct BreakProgressTests { @Test func overOneClampsToFull() { #expect(ProgressDisplayBranch(progress: 2.0) == .full) } + + // MARK: - formatMenuBarTimer + + @Test func timerHiddenWhenBreakActive() { + #expect(formatMenuBarTimer( + secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1, + isBreakActive: true, isPaused: false, hasScheduledBreak: true + ) == nil) + } + + @Test func timerHiddenWhenPaused() { + #expect(formatMenuBarTimer( + secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: true, hasScheduledBreak: true + ) == nil) + } + + @Test func timerHiddenWhenNoSchedule() { + #expect(formatMenuBarTimer( + secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: false + ) == nil) + } + + @Test func fullTimerShowsMinutes() { + #expect(formatMenuBarTimer( + secondsRemaining: 1920, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "32m") + } + + @Test func fullTimerShowsSecondsUnder60() { + #expect(formatMenuBarTimer( + secondsRemaining: 45, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "0:45") + } + + @Test func countdownHiddenAboveThreshold() { + #expect(formatMenuBarTimer( + secondsRemaining: 120, showFullTimer: false, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == nil) + } + + @Test func countdownVisibleWithinThreshold() { + #expect(formatMenuBarTimer( + secondsRemaining: 45, showFullTimer: false, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "0:45") + } + + @Test func countdownThreeMinutesShowsMinutes() { + #expect(formatMenuBarTimer( + secondsRemaining: 150, showFullTimer: false, countdownMinutes: 3, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "3m") + } + + @Test func minuteRoundsUp() { + #expect(formatMenuBarTimer( + secondsRemaining: 90, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "2m") + } + + @Test func exactMinuteBoundary() { + #expect(formatMenuBarTimer( + secondsRemaining: 60, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "1m") + } + + @Test func fractionalSecondsAboveMinuteNotRoundedUp() { + #expect(formatMenuBarTimer( + secondsRemaining: 60.01, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "1m") + } + + @Test func zeroSecondsFormatsCorrectly() { + #expect(formatMenuBarTimer( + secondsRemaining: 0, showFullTimer: true, countdownMinutes: 1, + isBreakActive: false, isPaused: false, hasScheduledBreak: true + ) == "0:00") + } }