TruePace
Native macOS and iOS focus timer with adaptive work modes, intelligent check-ins, and flow state tracking.
Problem
Fixed-interval Pomodoro timers (25 minutes on, 5 off) apply one rhythm to every kind of work. A 10-minute bug fix, a 90-minute deep-flow coding block, and a 3-hour architecture-planning session all get forced through the same clock. In practice this produces one of two outcomes: the timer interrupts a session that was still productive, or it gets switched off entirely once the mismatch becomes annoying enough — at which point the app stops being used within the first week.
Approach
TruePace treats session length and check-in cadence as properties of the task rather than a single constant, and ships seven work modes (Micro, Pomodoro, Extended, Custom, Flow, Adaptive, Pace) instead of layering settings on top of one fixed timer. Flow and Adaptive replace the hard stop with graduated, self-reported check-ins — the user flags what they’re noticing (re-reading without progress, decisions slowing down, body tense, thirsty) rather than the app inferring it from sensors — so an in-progress session isn’t cut off at an arbitrary boundary. Deliberately not built: a server-side account system, any sync path outside the user’s own iCloud account, and a machine-learned model anywhere in the estimation or check-in loop — the estimation coach is a stateless median/mean-ratio statistic over past sessions, and mode recommendations read that history directly, traded for auditability over the predictive accuracy a learned model might add. Where an LLM is used at all (a one-line coaching tip, a weekly review, parsing a typed task list), it defaults to Apple’s on-device model and only reaches a cloud model behind an explicit, per-feature opt-in.
Architecture
TruePace is a single SwiftUI codebase (Shared/) with thin per-platform shells for macOS, iOS/iPadOS, and watchOS, plus a small standalone Cloudflare Worker. TimerEngine (~3,265 lines, flagged in-repo for a future split but not yet split — the team judged the risk of splitting a hot, freshly-shipped file higher than the file-size debt) is the state machine at the center of everything: it owns the active session’s phase, drives all seven work modes, and is the one place [Session] completion writes fan out from. Storage spans three local stores: two SwiftData containers — one CloudKit-synced, one device-local-only — plus a separate raw-SQLite database (DatabaseManager, via CSQLite, no SwiftData) that holds the session journal AdaptiveFocusPolicy and the Journal views read from. Anything HealthKit-derived or per-device (a running task batch) is kept out of the CloudKit-synced container. AI is two-tier and opt-in at the cloud tier: Apple’s on-device Foundation Models model is the default and only path for most builds; a Groq cloud fallback exists behind a self-hosted Cloudflare Worker proxy, off by default, reached only when on-device inference is unavailable and the user has explicitly allowed cloud AI. No account system exists anywhere — sync is the user’s own iCloud, full stop.
flowchart TD
subgraph Surfaces["Menu Bar Extra (macOS) / App (iOS, iPadOS) / Complication (watchOS)"]
UI[Start / pause / mode pick]
end
UI --> TE[TimerEngine<br/>phase machine, 7 work modes]
TE -->|graduated check-in| CI[Self-report check-in<br/>SignalCheckView: cognitive + body signals]
CI --> TE
HK[(HealthKit<br/>HR, HRV, steps, sleep)] -->|read, opt-in| FI[Flow indicator<br/>deterministic weighted formula]
FI --> TE
TE -->|session end| CLOUD[(SwiftData: CloudKit container<br/>FocusSession, FocusTask, TaskSessionLink)]
TE -->|session end, device-scoped| LOCAL[(SwiftData: local-only container<br/>TaskBatch, IntentionProfile, WeeklyRollup)]
CLOUD -->|private DB, ~15s| OtherDevices[iOS / iPadOS / watchOS]
TE -.same-WiFi, less than 1s.-> BONJOUR[Network framework / Bonjour<br/>Mac <-> iPhone live timer state]
TE -.WatchConnectivity, less than 1s.-> WATCH[iPhone <-> Watch live timer state]
EST[EstimationFeedbackEngine<br/>median/mean ratio, no ML] --> COACH{AI Coach<br/>tip / plan critique / task parse}
CLOUD -.TaskSessionLink history.-> EST
COACH -->|default| FM[Apple Foundation Models<br/>on-device, offline]
COACH -.opt-in only.-> PROXY[Cloudflare Worker<br/>truepace-ai-proxy]
PROXY -->|App Attest + rate limit| GROQ[Groq API<br/>gpt-oss-120b / gpt-oss-20b / llama-3.3-70b]
FM --> GUARD[CoachOutputGuard<br/>reject numbers not in source data]
GROQ --> GUARD
GUARD --> TE
TimerEngine— owns the active session’s mode, elapsed time, and phase transitions for all seven modes: Micro (12/3 min), Pomodoro (25/5), Extended (50/12), Custom (user-set), Flow (adaptive, 90 min hard cap), Adaptive (signal-based, 90 min hard cap), and Pace (runs a task list back-to-back with no inserted breaks, per-step target pulled from the task’s estimate). Crash/force-kill recovery reads persistedUserDefaultsstate and offers to resume a session up to 90 minutes stale.- Check-ins are self-reported, not sensed.
SignalCheckViewpresents a short multi-select list — cognitive signals (re-reading without progress, decisions getting slower, small errors, task drift) and body signals (tense/hunched, thirsty/hungry, eyes strained) — the user taps what they’re noticing. There is no mouse-idle monitor and no keystroke timing anywhere in the codebase. - App activity is tracked on macOS, on by default.
AppActivityTrackerpollsNSWorkspace.shared.frontmostApplicationevery 2 seconds while a session is running, accumulates per-app time, and writes it to a local SQLite database (activity.db) — visible to the user in an in-app Activity view. Tracking is enabled by default (toggleable off in Settings) and the data never leaves the device. - HealthKit (read: heart rate, HRV, steps, sleep, exercise, mindfulness minutes; narrow write: mindful minutes only) feeds a deterministic flow indicator — a five-component weighted average (HR stability 0.25, HR-vs-personal-baseline 0.20, movement stillness 0.25, HRV-vs-baseline 0.20, session-duration decay 0.10), not a trained model. All HealthKit-derived fields are stored device-local only and are stripped before any network call, including the Groq cloud path.
- Two SwiftData containers, plus a third raw-SQLite store. The CloudKit-synced container holds
FocusSession,AppUsageRecord,UserPreferences,FocusTask,TaskChecklistItem,TaskSessionLink,TaskGroup, andTaskTypeEstimationStat— CloudKit’s private-database.automaticsync, ~15s latency, no custom sync code. A second, deliberately non-synced SwiftData container holdsTaskBatch(a running batch shouldn’t resume on a different device mid-flight),IntentionProfile,HourProfile,WeeklyRollup,WeeklyReviewCache, and four planner models (DailyPlan,DailyPlanBlock,PlannerProject,PlannerMilestone) — local because they’re either derived from HealthKit data (Apple’s 5.1.3(ii) guideline requires that never sync to a cloud backend) or, for the planner models, a deliberate not-yet-synced slice of the scheduler. Outside SwiftData entirely, a separate raw-SQLite database (DatabaseManager, viaCSQLite) holds the session journal thatAdaptiveFocusPolicyand the Journal views read from — local-only, no CloudKit path. - Cross-device realtime paths, independent of CloudKit’s ~15s lag. Network Framework over Bonjour broadcasts live timer state Mac↔iPhone on the same Wi-Fi (<1s); WatchConnectivity does the same iPhone↔Watch, plus
transferUserInfo()for completed session records. EstimationFeedbackEngine— a pure, stateless statistic (no SwiftData model, recomputed on demand fromTaskSessionLinkrows): median and mean actual/estimated-time ratio per task intention, with a suggested-minutes default gated tomedium/highconfidence (5+ / 15+ samples). This — not a model — is what the estimation coach and Pace mode’s per-step targets read.- AI Coach (Estimation Coach, Plan Coach, Task Decomposer, dictation repair) — all four features share the same two-tier inference shape. Default tier: Apple’s on-device Foundation Models (
LanguageModelSession, iOS/macOS 26+), gated by a 3-state availability router (available/warmingUp/unavailableCloudEligible) that never silently falls back to cloud during a transient “model still downloading” state. Optional tier: Groq, reached only when the on-device model is permanently ineligible on that device and the user has explicitly opted into cloud AI in Settings. truepace-ai-proxy— a small Cloudflare Worker that is the entire backend TruePace has. It holds the Groq API key as an encrypted Worker secret (the app ships no key), remaps whatever model the client asked for through a server-side allowlist, strips every other client field before forwarding, and is gated by an App Attest device-integrity check (state held in a Durable Object with SQLite, for a strongly-consistent anti-replay counter) plus Cloudflare’s native rate-limiting binding (20 req/60s per IP). App Attest enforcement currently ships in accept-but-log mode (REQUIRE_APP_ATTEST=off) — logged for coverage, not yet rejecting requests.CoachOutputGuard/PlanCoachSafety/EstimationCoachSafety— every LLM-generated coach message (weekly review, daily insight, plan critique, estimation tip) is scanned post-generation against a “numbers actually present in the source data” allow-list (±2 integer tolerance, ±0.05 fractional); any number outside that set drops the whole response to a deterministic template fallback instead of shipping a fabricated statistic.- Surfaces —
MenuBarExtraon macOS (no Dock icon in menu-bar-only mode), anActivityKitLive Activity for the iOS lock screen, andWidgetKitwidgets/complications on iOS home screen, iOS lock screen, and watchOS.
How it works
- The user picks a mode — Micro, Pomodoro, Extended, Custom, Flow, Adaptive, or Pace — and
TimerEngineloads that mode’s duration and check-in cadence, writing the session’s phase state. - Whichever surface is active (menu bar popover, iOS/iPadOS app, watchOS complication) renders
TimerEngine’s live countdown; on macOS the session keeps ticking whether the popover is open or not. If a second Apple device is on the same Wi-Fi/paired, Network Framework (Mac↔iPhone) or WatchConnectivity (iPhone↔Watch) mirrors the live state there in under a second. - In Flow or Adaptive mode, at each graduated interval
SignalCheckViewprompts the user to self-report what they’re noticing — cognitive signals (re-reading, slowing decisions, errors, drift) and, separately, body signals (tense, thirsty, eyes strained) — rather than inferring anything from device sensors. - If HealthKit access is granted, the app reads heart rate, HRV, and step data across the session and computes the deterministic flow indicator (weighted HR-stability / HR-baseline / stillness / HRV / duration formula) — this never leaves the device.
- On iOS,
FocusInterruptionTrackerlistens forUIApplicationbackground/foreground notifications during a session and logs each departure/return as aSwitchEvent, rolling up into a 0–1focusScore. On macOS,AppActivityTrackerruns a different, continuous mechanism instead: it polls the frontmost app every 2 seconds for the session’s duration and accumulates per-app time — on by default, local-only, visible in the Activity view. - On session end,
TimerEngine’s completion path stamps the session’s task/batch linkage, updatesEstimationFeedbackEngine’s per-intention median/mean ratio from the just-completedTaskSessionLink, and writes: CloudKit-synced fields (FocusSession,FocusTask,TaskSessionLink) to the synced SwiftData container, and anything HealthKit-derived or per-device (TaskBatch,WeeklyRollup) to the local-only container — the split enforced at the container level, not by a runtime check. - The CloudKit-synced container propagates to the user’s other Apple devices via CloudKit’s private database, typically within ~15 seconds — no TruePace server is in this path.
- When the user requests a coaching tip, plan critique, or task parse, the app first runs the deterministic engine for that feature (
EstimationFeedbackEnginestats,PlanCoachEngine’s schedule projection, or raw typed text) and only then asks an LLM to phrase a summary — the LLM is never the source of the numbers, only their narration. AppleOnDeviceAvailabilityRouterchecks Foundation Models availability; if.available, aLanguageModelSessionruns on-device with the deterministic data as its prompt and returns in-process, no network call.- If on-device is
.unavailableCloudEligibleand the user has opted into cloud AI, the same request instead POSTs totruepace-ai-proxy, which validates it, remaps the model through its allowlist, strips everything else, and forwards to Groq with the server-held API key — the app itself never holds a Groq credential. - Either path’s raw text passes through
CoachOutputGuard: every number in the response is checked against the set of numbers the source payload actually contained; a mismatch drops the response and the UI shows the deterministic template instead of a possibly-fabricated figure. - Weekly reviews follow the same shape but cache their result in
WeeklyReviewCache(device-local, never CloudKit-synced) keyed by week-start, so reopening the same week doesn’t re-bill Groq.
Tech stack
Swift and SwiftUI across macOS, iOS, iPadOS, and watchOS from one shared codebase. SwiftData in two containers — a CloudKit-synced one (.automatic, private database) for cross-device session/task data, and a device-local-only one for anything HealthKit-derived or per-device. HealthKit for read-only biometric signal (plus a narrow mindful-minutes write) feeding a deterministic, non-ML flow-indicator formula. Apple Foundation Models (LanguageModelSession, on-device, iOS/macOS 26+) as the default and usually-only AI tier; Groq (gpt-oss-120b default, gpt-oss-20b for weekly review, llama-3.3-70b-versatile for task parsing) as an opt-in cloud fallback reached through a self-hosted Cloudflare Worker (truepace-ai-proxy) that holds the Groq key, enforces a model allowlist, and gates requests with Apple App Attest (state in a Durable Object) plus Cloudflare’s native rate limiter. Network Framework/Bonjour and WatchConnectivity for sub-second cross-device timer state outside CloudKit’s ~15s sync path. WidgetKit and ActivityKit for iOS/watchOS widgets, complications, and the Live Activity. macOS-specific: MenuBarExtra for menu-bar-only chrome.
Results
Worked example from AdaptiveFocusPolicy: once a user has logged at least 3 Adaptive-mode sessions, the engine ranks them by a computed quality score (session length relative to the group’s median, adjusted for how the session ended), keeps the stronger half, and takes a weighted median of their durations as the next session’s soft target — with check-in points placed around that target and a recommended break length pulled from the same history’s highest-quality breaks. Sessions that ended via hard-cap, were skipped, or ran under 5 minutes are excluded as evidence. Sessions that ended on a decay-signals self-report (the user stopped after flagging a signal) are still counted as evidence, but they additionally cap the target: the median length of those decay-signal sessions becomes an upper bound the learned target can’t exceed, so a run of early self-reported stops pulls the target down rather than being discarded outright.
⚠️ no benchmark yet — memory footprint, battery impact, on-device vs. Groq coaching-tip quality, and App Attest rejection rate under REQUIRE_APP_ATTEST=on have not been measured against a baseline.
Lessons
- Native Swift/SwiftUI was chosen over Electron because a menu bar app runs all day; the typical Electron memory footprint was judged not worth trading for a faster cross-platform build cycle on a utility this size.
- Two SwiftData containers (CloudKit-synced vs. device-local) were chosen over one, specifically so
WeeklyRollup,WeeklyReviewCache, and any HealthKit-derived field structurally cannot reach CloudKit — enforced by which container a model is registered in, not by a runtime check that a future change could bypass. - Every AI-generated coaching surface — weekly review, daily insight, plan critique, estimation tip — runs through a shared post-generation numeric guard (
CoachOutputGuard) that rejects any number the model states but the source data didn’t contain, trading a template fallback on rejection for the guarantee that a shipped number is never fabricated. - The on-device-first AI router treats “model still warming up” and “model permanently unavailable” as different states on purpose: only the latter is cloud-eligible. Collapsing them would silently route a transient first-run download delay to Groq, defeating the point of defaulting to on-device.
Open challenges
Actively in progress, stated plainly rather than hidden:
- Physiological signals. The check-in loop is self-reported today — the user flags what they notice (re-reading without progress, decisions slowing, body tense). Active work is folding passive bio-signals (heart rate, HRV via HealthKit) in as corroborating inputs, so Adaptive and Flow modes can weigh a physiological read against the self-report instead of relying on it alone — while keeping that data in the device-local SwiftData container that never syncs to CloudKit (the same 5.1.3 constraint that already scopes HealthKit-derived rollups).
- Coaching reliability. The AI coaching surfaces (weekly review, daily insight, estimation tip) are numerically guarded by
CoachOutputGuard, but the on-device Foundation Models tier’s coaching quality is still inconsistent run to run. Improving reliability — prompt tightening, smarter on-device-vs-Groq routing for quality-sensitive surfaces, and measuring tip quality against a baseline (currently ⚠️ unmeasured) — is open work, not a solved problem.