# tt autotrack daemon + activity/calendar timeline — design - **Date:** 2026-07-08 - **Status:** Draft (awaiting user review) - **Repo:** `~/Developer/Home/ticktock-go` (`module ticktock`, binary `tt`) - **Supersedes:** the Python daemon in `ticktock-old/daemon/autotrack.py` and the `tock-day` calendar merge. This is the parity port that lets `ticktock-old` be archived. ## Goal (plain language) A background daemon quietly records what the user is doing — frontmost app, window title, active browser tab, and idle/away state — and appends it to a daily log. The `tt` timeline (grid) view then shows that captured activity in its ACTIVITY column, side by side with the day's calendar events, in a clean readable form. The user reads the timeline to see where their time actually went and to log it into tock. The user does not interact with the daemon directly; it runs under launchd. tock stays the source of truth — the daemon writes only to its own staging log, never to tock (suggest-then-confirm). ## Background `ticktock-old` (Python) already does this: `daemon/autotrack.py` polls context and writes `~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl`; `tock-day` merged that log with work-calendar events from a compiled Swift `cal-events` (EventKit) helper. tt has replaced the day/grid views in Go but has neither the daemon nor the activity/calendar reader. This design ports both, natively, into the single `tt` binary. The daemon and reader share the existing directories unchanged: - Config: `~/.config/ticktock/config.json` - Data: `~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl` Because the paths are unchanged, cutover is seamless: the Go daemon writes the same stream the Python one did, and the reader sees one continuous history. ## Non-goals (this port) - Writing suggestions back into tock automatically. The ACTIVITY column is display-only in this port; turning a segment into a tock entry stays a later step. - Rule-teaching / activity-inspector / suggestion-mapping (the rest of M2c). - Non-macOS platforms. All OS integration is darwin-only behind build tags; the pure segmentation and log/calendar parsing are portable and unit-tested on any platform. ## Architecture Two subsystems, both inside the `tt` binary. ``` ┌──────────────────────────────┐ launchd ── tt autotrack │ internal/autotrack │ │ darwin IO ─▶ Classify ─▶ Step │──▶ activity-YYYY-MM-DD.jsonl └──────────────────────────────┘ │ ▼ cal-events (Swift/EventKit) ──▶ calendar JSON ────┐ ┌──────────────────┐ ├──────▶ │ internal/activity │ activity-YYYY-MM-DD.jsonl ─┘ │ LoadDay + merge │ └──────────────────┘ │ ▼ internal/tui/grid ACTIVITY column ``` ### A. Writer — `tt autotrack` New package `internal/autotrack`: - **`segment.go` (portable, pure).** Direct port of the Python core, clock injected so it is deterministic and table-testable: - `Ctx` — `{App, Title, URL, Domain string; Idle bool}`. - `Segment` — `{Ctx; Start, End time.Time}`. - `Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx` — locked or an away-app (`loginwindow`, `ScreenSaverEngine`) ⇒ idle; `idle >= idleGrace` ⇒ idle; otherwise the active context passes through (present-but-passive counts as active). - `CtxKey(*Ctx)` — identity of "same activity": idle is its own key; active is `(app, domain-or-title)`. - `Step(cur *Segment, curKey key, now time.Time, idle int, ctx *Ctx, grace int, hard bool)` `→ (cur, curKey, emitted *Segment)` — the segmentation state machine. Context change closes the open segment and opens a new one; same context extends it. Idle transition anchors the boundary: hard idle at `now-idle`; soft idle at `now-(idle-grace)` (grace credited to the active app). Boundary clamped to `[cur.Start, now]` — never negative, never future. - `Record` encoding: `{start, end, secs, app, title, url, domain, idle}`, ISO-8601 seconds precision — byte-compatible with the Python output. - **`darwin.go` (build tag `darwin`).** OS sampling, same shell-outs the Python used: - front app/title via `osascript` System Events; - active browser URL via a per-browser `osascript` map (Safari/Chrome/Brave/Edge/Arc/Dia); - HID idle seconds via `ioreg -c IOHIDSystem` (`HIDIdleTime`); - screen-locked via `ioreg … -k CGSSessionScreenIsLocked`; - richer window title via the bundled `winctx` Swift helper (falls back to System Events when the helper is missing/denied). - A `stub.go` (`!darwin`) returns empty context so the package builds and tests run everywhere. - **`log.go` (portable).** `WriteSegment(dir, seg, minSecs)` — drops sub-`minSecs` segments, else appends one JSON line to `activity-.jsonl`, creating the dir. - **`run.go`.** Poll loop: sample → `Classify` → `Step` → `WriteSegment(emitted)` → sleep. SIGTERM/SIGINT flush the open segment before exit (parity with the Python daemon). `internal/config` gains a `Tracking` block read from the same file: ```go type Tracking struct { PollSecs, IdleGraceSecs, MinSecs int } // defaults 5, 900, 15 ``` read from `config.json`'s existing `tracking` object; missing/malformed keys fall back to defaults, matching the Python `load_tracking`. `cmd/ticktock` gains an `autotrack` subcommand: - `tt autotrack` — run the poll loop (what launchd invokes). - `tt autotrack --once` — sample once and print the classified context (parity with the Python `--once`), for quick permission/sanity checks. - `tt autotrack --list-calendars` — print the distinct calendar names `cal-events` reports for today, so the user can copy exact names into `calendars.show` / `calendars.hide`. ### B. Reader — ACTIVITY column (activity + calendar) New package `internal/activity`: - **`Segment`** — `{Start, End time.Time; App, Title, URL, Domain string; Idle bool}`. - **`Event`** — `{Start, End time.Time; Title, Calendar string}` (a calendar event). - **`LoadDay(dataDir, date string) ([]Segment, error)`** — reads `activity-.jsonl`, tolerant parse (skip malformed lines, never fail the view on one bad row). Missing file ⇒ empty slice, no error. - **`LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error)`** — runs the `cal-events` helper for `date`, parses its JSON array, and keeps only events whose `calendar` passes `filter`. Helper missing / access denied / bad output ⇒ empty slice, no error (the timeline still renders without calendar data). **Calendar selection.** The user keeps several calendars for different purposes and must be able to silence the noisy ones. `cal-events` already returns every event tagged with its `calendar` name, so filtering is config-driven in the reader — no change to the helper. `internal/config` gains a `calendars` block: ```jsonc "calendars": { "show": ["Work", "Focus"] // allowlist: only these calendars appear in the timeline } ``` Semantics: if `show` is non-empty, only listed calendars pass (case-insensitive exact match on the calendar name); if `show` is absent or empty, all calendars pass (current behaviour). An optional `"hide": [...]` blocklist is applied after `show` for the "everything except a couple" case. To discover the exact names, `tt autotrack --list-calendars` prints the distinct calendar names `cal-events` reports for today, so the user can copy them into config. `CalendarFilter` is built from this config and passed into `LoadCalendar`. Grid integration (mirrors the existing LOGGED painting): - **`paintActivity(g Grid, segs []Segment, events []Event) []Cell`** — one label on a segment/event's first covered slot, continuation glyph on later covered slots, exactly as `paintLogged` does for tock entries. Per-slot precedence when things overlap: 1. a calendar event's label at its start slot (the human-meaningful anchor), 2. else an active auto-tracked segment (`domain` or `title`), 3. else idle (`(idle)`, dimmed), 4. else the empty gap lane. Label text: calendar → `▪ `; activity → `<domain-or-title>`; idle → `(idle)`. - **`grid.Model`** gains an `activity []Cell` field alongside `logged`. `rebuild()` paints it; the day loader (`load`, and the `n`/`p`/`t`/`r` day-nav + reload paths) fetches the day's segments and calendar events alongside tock entries. - **`View`** renders the painted activity cell in the ACTIVITY column in place of today's static `░` gap lane. The `▓` selection band still overrides while a range is being selected; idle and continuation glyphs render dimmed; calendar events use a distinct accent color so they read apart from auto-tracked activity. ### Native helpers (Swift) build `winctx` (AX window context) and `cal-events` (EventKit) are compiled Swift binaries. Vendor the two `.swift` sources into the tt repo (e.g. `native/winctx.swift`, `native/cal-events.swift`) and add Makefile targets that build them to `bin/winctx` and `bin/cal-events` via `swiftc`. The build **skips gracefully** when `swiftc` is unavailable (prints a note, continues) — the daemon degrades to System Events titles and the reader simply shows no calendar events, so a machine without Xcode tools still builds and runs `tt`. ### launchd cutover - `deploy/autotrack.plist.template` with `ProgramArguments = [<tt path>, autotrack]`, label unchanged (`com.humdrum.ticktock.autotrack`), same `StandardOut/ErrPath` under `~/.local/share/ticktock/`. - An install step renders the template with the installed `tt` path and repoints the running agent (`launchctl bootout` + `bootstrap`) from the Python script to `tt autotrack`. - **Rollback:** the Python daemon in `ticktock-old` is untouched; if the Go daemon misbehaves, repoint the plist back to `autotrack.py` and restart. Once the Go daemon is verified over a normal day, `ticktock-old` can be archived. ## Error handling / degradation - Every OS call has a timeout and returns empty on failure (never panics the loop) — matches the Python behaviour. - Missing `winctx` → System Events titles. Missing/denied `cal-events` → no calendar lane. Missing activity log → empty ACTIVITY column. None of these are errors to the user. - The reader skips malformed jsonl lines rather than failing the whole view. ## Testing - **Segmentation:** Go table tests port the Python cases — idle-grace pass-through, soft vs hard idle boundary anchoring, sub-`MIN_SECS` drop, context-change emit, boundary clamping. - **Log I/O:** round-trip a `Record` and re-read via `LoadDay`; assert byte-shape parity with a sample line from a real `ticktock-old` activity file. - **Calendar parse + filter:** parse a captured `cal-events` JSON sample into `[]Event`; assert `show` allowlist and `hide` blocklist keep/drop the right calendars (empty `show` ⇒ all pass). - **paintActivity:** table tests for label placement, continuation, and overlap precedence. - **Manual/integration:** `tt autotrack --once` prints current context; a short live run appends valid jsonl; the grid renders today's activity + calendar; then cut launchd over and confirm the Go daemon writes the same stream. ## Cutover & rollback (summary) 1. Build `tt` + `bin/{winctx,cal-events}`. 2. Verify tests + `tt autotrack --once` + grid rendering on today's real data. 3. Repoint launchd to `tt autotrack`; confirm it writes today's log. 4. Run a normal day; confirm parity. 5. Archive `ticktock-old`. Rollback at any point: repoint the plist back to `autotrack.py`.