StateMachine owns the timer lifecycle:
working -> warning -> breakDue -> breaking -> breakCompleted -> working
breakDue/breaking/breakCompleted -> postponed -> breakDue
working/warning/postponed -> suspended -> previous state or a fresh cycle (sleep/inactivity, or user pause until 9 AM)
The UI calls explicit methods such as takeBreakNow, cancelManualBreak, extendFocus(by:), postpone, useEmergencyOverride(), startBreak, and completeBreak(). markBreakTaken is still part of the machine and still tested, but nothing calls it right now: its "Just Took a Break" menu entry is commented out in MenuBarController while the feature is parked. Every completed break credits the actual focused minutes of its cycle to the daily focus totals. Streaks, daily focus totals, and violation counters are updated only by the state machine.
takeBreakNow distinguishes manual breaks from scheduled ones by capturing a ManualBreakOrigin snapshot (interrupted phase, remaining time to the deadline, capture timestamp) in the runtime state; scheduled breaks reached through tick never set it. While the origin exists, the overlay offers only cancelManualBreak, which rebuilds the interrupted state with the remaining time re-anchored to the current moment (landing directly in warning when the remaining time is inside the warning lead), shifts the cycle start forward so overlay time is not credited as focus, and records nothing.
extendFocus(by:) shifts the current work (or postponed) deadline forward before the break is due. It is planned-ahead honesty rather than a postponement: no violation is recorded and no statistics change, while the extended time later counts toward focus minutes. It sets runtime.focusExtended, which keeps the menu bar in its caution color until a new cycle clears the flag. With the harderToSkipBreaks setting on, the state machine allows one extension per cycle (canExtendFocus) and, once the cycle's first skip action — an extension or a postponement — is spent, reports postponePenalized, which doubles the hold the overlay's postpone buttons require.
useEmergencyOverride() is the escape hatch from that pressure: on a scheduled break it swaps the break for a .postponed window of EmergencyOverride.focusGrant, ignoring both gates above. It is not free — it records a violated cycle and resets the clean streak like a postponement, and it spends the cycle's extension and skip allowance so the grant cannot be stacked with a further extension. canUseEmergencyOverride requires a scheduled break (a manual one already has the free cancelManualBreak exit) and an elapsed cooldown; emergencyOverrideAvailableAt is runtime.emergencyOverrideUsedAt plus EmergencyOverride.cooldown, a rolling seven days rather than a calendar week so it cannot be spent twice across a weekend. The timestamp lives in RuntimeState precisely because "Restore Defaults" and "Reset Statistics" replace AppSettings and Statistics wholesale, either of which would refill the quota; startWorkCycle() therefore carries it explicitly through its rebuild of the runtime.
The Tapering focus pace shortens each window as the day accumulates. It measures focus actually put in rather than cycles completed: a cycle counter rewards anyone who takes several short manual breaks in a row, since each one closes a cycle. runtime.taperedFocusSeconds accumulates the focus of every cycle as it closes, and one accumulated focus minute costs 1.1 seconds off the next window — linear, with a non-configurable FocusPace.taperingMinimumInterval as a safety stop, since the rule has no asymptote of its own. A gap of settings.taperingResetGap without focus means the workday ended and the accumulator returns to zero. The gap is measured against runtime.lastFocusAt — the last monitored countdown second, stamped minute-coarse by tick() — not against the closed cycle's end: administrative restarts overnight (wake recovery, an expired timed pause) move the cycle's end forward without any focus happening, and each one would otherwise re-arm the gap and carry tapering into the next morning.
Both the accumulator and the statistics credit derive from a single private closedCycleFocus(), which reports where the closing cycle's focus ended and how long it ran. Sharing one definition means the minutes charged to tapering and the minutes credited to statistics cannot drift apart.
cycleFocusDuration and breakStartedAt are captured by startBreak() and describe the break in progress, so every exit from a break clears them: completeBreak through startWorkCycle, and cancelManualBreak and postpone directly. A capture left behind understates the closing cycle's focus — it reports the work done before the break and silently drops anything done after it, which is what happens when a postponed break falls due and the machine sleeps before the overlay opens. closedCycleFocus() still switches on the state rather than reading the fields unconditionally, so the guarantee stays structural instead of depending on every future exit path to remember.
The suspended state is entered two ways: automatically by sleep/inactivity preservation (suspend(until: nil)), and by the user-facing Pause Until 9 AM menu action, which calls suspend(until:) with the next 9:00 AM. A timed pause outlives sleep and relaunch: restoreAfterSleep() leaves it untouched while its end date is in the future and starts a fresh cycle once that date has passed. While a pause of either kind is active, the menu offers Resume Now.
A new cycle's warning deadline goes through AppSettings.effectiveWarningLeadTime(for:), which caps the configured lead at half the interval the cycle actually runs. clamp() can only bound the setting against the raw work interval, but the real interval may be shorter — scaled by the focus pace, or trimmed by tapering — and a lead reaching the whole window would open every cycle already in warning.
BreakGuard stores absolute deadlines for work, warning, break, postpone, and suspension timing. UI timers only refresh display text and call tick; they do not decrement authoritative counters. Tests use a fake clock to validate transitions without real waiting.
PersistenceStore writes one JSON file atomically under:
~/Library/Application Support/BreakGuard/state.json
The persisted data includes a schema version, settings, statistics, current state, cycle violation status, current-cycle postponement count, the focus-extended flag, cycle start date, the captured focus duration of the current cycle, preserved sleep/suspension timing, the break start timestamp (drives the completion-screen rest count-up), the manual-break origin snapshot, the tapering focus accumulator, and the last use of the emergency override. Statistics credit focus time in minutes of actual focused work per cycle (postponed time counts, paused/sleep time does not) into daily totals keyed by local-calendar day. Files with a non-current schema version are discarded and replaced with defaults.
Later additions stay on schema 3 by being decode-lenient: AppSettings and RuntimeState use a lenient init(from:) so fields absent from older files (the working-hours settings, focusExtended) fall back to their defaults instead of failing the load, and JSON keys from removed features (the former focus-tag catalog and per-tag counters) are silently ignored. TimerState cases deliberately keep their persisted payload shape; per-break metadata belongs in RuntimeState, not in new associated values.
OverlayScreenManager creates one borderless NSPanel per NSScreen, keeps the windows above normal application windows, joins Spaces, participates as a full-screen auxiliary window where macOS permits, and updates when displays change. Duplicate overlay windows are avoided by tracking screen identifiers.
The overlay view has two modes. During the break it shows the countdown plus the postpone buttons for scheduled breaks, or a single Cancel Break button for manually started ones (postponing a break the user just chose makes no sense). After the countdown reaches zero it switches to the completion screen, where a green clock counts total rest time upward (now minus break start, refreshed by the same one-second publish cycle as the countdown) and a single Continue Working button completes the break.
The overlay is a best-effort blocking interface. macOS still allows Force Quit, process termination, and system-level navigation.
SleepWakeManager listens for workspace sleep/wake and session active/inactive notifications. Before sleep or inactivity, BreakGuard preserves remaining time and cancels warnings. After wake or reactivation, it resumes from the preserved duration so sleep time is not counted as work or break time.
Restoration applies a long-pause rule: if the preserved timestamp is at least one break duration in the past, the user certainly rested, so a fresh full work cycle starts instead (nothing is recorded, on the honor-system reading that time away from the screen was rest). This covers sleep, clean quit/relaunch (stop() preserves before saving, and the persistence-restoring initializer calls the same restoration path), and pending-break states. As a crash-recovery fallback, a restored working/warning/postponed state without a preserved timestamp starts a fresh cycle when the persisted heartbeat (lastTickAt, stamped minute-coarse on every tick) is at least one break duration stale — or, for pre-heartbeat files, when the absolute deadline is. Shorter pauses resume exactly where they left off. resume() itself applies the long-pause rule and resolves an elapsed timed pause as verified rest, so ending any pause — the tick reaching a timed pause's end date, or the user pressing Resume Now — behaves identically on the tick path and the wake path.
Notifications alone cannot see two kinds of absence, so two backstops in AppState.tick() bracket downtime after the fact, both reusing preserveForSleep(at:)/restoreAfterSleep() with a back-dated timestamp. Input idle: once CGEventSource reports no user input for IdleAway.threshold (10 minutes), the countdown suspends with the bracket back-dated to the last input, so an awake-but-unattended machine (lid-closed insomnia, an unrequested wake after a timed pause expires) cannot fabricate focus; input returning restores through the normal long-pause rule. The known cost is that fully passive viewing with zero input suspends the countdown. Tick gap: if consecutive timer fires are more than 90 seconds apart, the process lost time without any sleep signal (dark wake, missed willSleep), and the gap is bracketed from the last observed tick. A camera in use suppresses idle bracketing — a call is presence without input. As defense in depth, no single cycle can credit more than StatisticsIntegrity.maxCreditablePerCycle (4 hours) to statistics.
CameraUsageMonitor watches CoreMediaIO's DeviceIsRunningSomewhere property across all video devices (event-driven listener blocks, no polling; reading device state needs no camera permission). While any camera is in use and holdBreaksWhileOnCamera is on, StateMachine.tick() pins every countdown deadline at least cameraHoldRunway (the warning lead, floored at 2 minutes) ahead of now, so the countdown freezes at the runway and a break can never land mid-call; when the lead equals the runway the pinned state is a .warning whose deadline advances each tick. cycleStartDate is untouched — held time still counts as focus, including toward tapering. Because the pinned deadline moves every second, warning notifications are handled on the engagement edges in AppState: engaging cancels the pending warning (rescheduling against a moving deadline would fire one every second into the call), releasing re-arms it — a pinned .working re-arms through the normal reconcile path, a pinned .warning is re-armed explicitly so the user still gets the cue as the post-call runway starts. The hold never touches states at or past breakDue: turning a camera on does not dismiss an imposed break. The flag is transient on StateMachine (set by AppState before each tick, never persisted) and the menu bar marks the engaged hold with a caution pill — deliberately never the red one, which would cry wolf for the length of a call.
AppKit owns the menu bar item and its standard NSMenu, windows, activation policy, screen behavior, and lifecycle. SwiftUI renders the break overlay and the tabbed settings window. Menu titles, available actions, and the menu bar emphasis (none/caution/urgent) are derived from a testable presentation model shared with the AppKit controller. caution (a muted amber pill) marks borrowed time — a postponed break, an extended focus window, or time outside the configured working hours; urgent (the red pill) marks the warning lead window and always wins over caution. Both pills are pre-rendered non-template bitmaps because the menu bar's template/vibrancy pipeline flattens explicit text colors.
NotificationManager owns both scheduled warning notifications and the Settings preview. A small notification-center client adapter makes system settings and scheduling behavior testable. The manager checks authorization, alert style, sound, and time-sensitive capability separately. The preview uses the same title, body, and sound preference as the real warning, but always uses regular active delivery; it reports queued, foreground-delivered, or unobserved delivery states through the notification-center delegate and a delivered-notification check.
Warning content is marked .timeSensitive only when UNNotificationSettings.timeSensitiveSetting is enabled; otherwise it is explicitly .active. Authorization requests include alert and sound only because the former time-sensitive authorization option is deprecated in favor of the entitlement. The matching entitlement is restricted — launchd refuses to spawn an ad-hoc signed bundle that carries it — so build.sh deliberately signs without it. Re-signing with an eligible provisioning profile enables true time-sensitive delivery without code changes, subject to the user's notification settings.