PrimeTime
PrimeTime is a macOS menu-bar time tracker. Its instructive decision is in the
data model: instead of filing time into one folder hierarchy, every tracked
span carries key: value labels — repo: sfi-website, feat: kb-graph,
type: review — inspired by Prometheus metrics. That one choice is what makes
time queryable rather than merely recorded, and it is what lets the tracker
answer the questions the agent era actually asks: where did the day go, across
which projects, and joined to what shipped.
SFI project: shipping as of v0.1.0 — a signed, notarized macOS build, available from primetime.tools; used daily inside SFI. Swift snippets are lifted from
Sources/PrimeTime/at current HEAD; the server is a GraphQL backend derived from traggo.
Labels, not folders
The domain model is deliberately tiny. A timespan is an interval with a note and a flat list of key/value labels:
/// A key/value pair attached to a timespan.
struct SpanLabel: Hashable {
let key: String
let value: String
}
/// A tracked interval of time, possibly still running, with its labels.
struct TimeSpan: Identifiable, Equatable {
let id: Int
let start: Date
let end: Date? // nil == currently running
let note: String
let labels: [SpanLabel]
var isRunning: Bool { end == nil }
}
A folder hierarchy forces one decision up front — is this span filed under
client then project, or project then client? — and every later question
that cuts the other way requires re-filing. Labels defer that decision forever.
The same span tagged repo: sfi-website, type: review, team: platform
answers "time by repo," "time by type," and "time by team" without being moved,
because it was never nested in the first place. The History view leans directly
on this: it charts time grouped by any axis, with a second grouping to
compare against, so "time by type" and "time by repo" sit side by side — a view
that only exists because the axes are independent.
The cost of the model is vocabulary discipline, which is a real cost and gets its own section below.
What dimensions unlock
Two workflows drove the design, and both are downstream of "time is queryable data."
Time joined to what shipped. A span labeled repo: sfi-website,
feat: kb-knowledge-graph, type: review isn't a timesheet line — it's a
coordinate that joins to real commits and PRs. A span starts at noon; the
deliverables merge at 12:45 and are live at 12:52. Invoicing and internal
records derive from labeled spans joined to shipped artifacts, not from
after-the-fact reconstruction of where the hours went. The label is the
foreign key.
Context-switching across parallel agent loops. Driving several agent loops
at once, the bottleneck stops being how many you can run and becomes how fast
you can re-orient when you come back to one. PrimeTime splits that into two
affordances: labels answer where was I (the macro breadcrumb —
repo: sfi-website, feat: kb-graph), and the per-span note answers what was I
doing (the micro detail — "graph query returns dupes, check the join in
loader.ts"). Multiple concurrent timers make the parallelism first-class rather
than something you fake by stopping and starting. A glance at a label plus a
one-line note re-seats the context in seconds.
Both workflows are why concurrent timers, after-the-fact editing, and notes are core rather than nice-to-have: real agent-era work overlaps, gets interrupted, and needs correcting after the fact.
The storage seam
PrimeTime is local-first — fully functional offline, local SQLite is the source
of truth — with optional sync to a server the user hosts. The seam that keeps
both honest is a single Backend protocol that the state layer talks to,
implemented by a local GRDB store and a GraphQL client alike:
/// The storage seam between the state layer and wherever timespans live.
/// Deliberately data-only: session lifecycle (login, tokens) is a per-backend
/// concern — a local store has no notion of logging in.
protocol Backend {
func currentUser() async throws -> User?
func timers() async throws -> [TimeSpan]
func startTimeSpan(start: Date, labels: [SpanLabel], note: String) async throws -> TimeSpan
func updateTimeSpan(id: Int, start: Date, end: Date?, labels: [SpanLabel], note: String) async throws -> TimeSpan
func timeSpans(from: Date, to: Date, page: PageToken?) async throws -> TimeSpanPage
// ...
}
Keeping the protocol data-only — pushing session, tokens, and login out to
whoever constructs the backend — is what lets LocalBackend conform without
pretending to have a login it doesn't need. The free product is the local app,
complete; sync is the thing a hosted server adds, the same paywall-as-storage-
boundary shape that dial-in uses. The general local-first tradeoffs —
last-write-wins over CRDTs, tombstones, multi-device testing from the first
sync feature — are documented in local-first-sync.
The sync server itself is a case study in reuse. Rather than build a GraphQL backend from scratch, PrimeTime's server is derived from traggo/server: its UI stripped, module renamed, and the PrimeTime v1 API grown on top. That derivation is two documented techniques — git-subtree-vendoring to bring the GPL tree in as first-class files with provenance pinned, and deriving-from-gpl-code to structure the licensing (GPL core untouched, AGPL on our additions, SPDX headers as the boundary) so a hosted-service model survives copyleft.
Vocabulary drift is inevitable
The price of labels-not-folders is that vocabulary drifts: one week says
project, a stray day says proj; a value lands under the wrong key. The
Label Review tab is the cleanup surface — every key and value with usage
counts, and drag-or-rename to fix drift across thousands of spans.
The engineering underneath is worth its own two notes. traggo's API offers only
per-item mutation and no transactions, so a rename of 400 spans is 400
un-rollbackable round trips. The answer is staged-batch-rewrites: describe
each edit as intent (a from/to predicate, not a captured edit list) so it is
idempotent and its work list can be recomputed at apply time, and design the
batch to converge on retry rather than pretend at transactionality. The
drag-to-move gesture that stages those edits is swiftui-drag-drop-transferable
— small Codable payloads, and the discipline that a drop stages an
operation for review rather than committing it, because a gesture can't express
the part of the intent (the target value's new spelling) that usually changes
in the same motion.
Getting it onto other Macs
A tracker people run every day has to survive Gatekeeper and update cleanly.
v0.1.0 ships as a signed, notarized bundle — Developer ID signature, hardened
runtime, a stapled notarization ticket — so it launches cleanly on a Mac it has
never met. The full path from a bare SwiftPM executable to that bundle — the
trust chain, .app packaging, an icon pipeline with no third-party rasterizer,
and the sign → notarize → staple sequence — is macos-app-distribution. The
marketing site and (eventually) the sync server ride the shared
ci-build-deploy-pipeline: the same three-job Gitea Actions contract
(check → build-and-push → deploy-prod) every SFI website repo uses, where build
is unconditional but deploy is gated on the checks.
Honest debt
v0.1.0 is a first public release, and the register states what's still ahead plainly:
- Export is still ahead: JSON is the designed first target (spans with
key: valuelabels serialize naturally), but the export surface hasn't shipped in the app yet. - The sync server is proven as a personal host — the app syncs against
PrimeTime Server through the
Backendseam, and a one-shot importer copies a Traggo server's full history across — but it hasn't yet been run at multi-user team scale. - It is a
0.release. The label model and the views built on it are the stable core; the surface around them — export formats, team sync — is still widening.
None of it is hidden. The lessons — dimensional labeling, the storage seam, convergent batch rewrites, deriving from copyleft — are what the release is built on.
References
- Prometheus data model — the label/dimension model that inspired
key: valuespans - traggo/server — the GPL GraphQL backend the sync server derives from
- Related: local-first-sync, deriving-from-gpl-code, git-subtree-vendoring, staged-batch-rewrites, swiftui-drag-drop-transferable, macos-app-distribution, ci-build-deploy-pipeline