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: value labels 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 Backend seam, 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