tt autotrack daemon + activity/calendar timeline — design
- Date: 2026-07-08
- Status: Draft (awaiting user review)
- Repo:
~/Developer/Home/ticktock-go(module ticktock, binarytt) - Supersedes: the Python daemon in
ticktock-old/daemon/autotrack.pyand thetock-daycalendar merge. This is the parity port that letsticktock-oldbe 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 atnow-idle; soft idle atnow-(idle-grace)(grace credited to the active app). Boundary clamped to[cur.Start, now]— never negative, never future.Recordencoding:{start, end, secs, app, title, url, domain, idle}, ISO-8601 seconds precision — byte-compatible with the Python output.
darwin.go(build tagdarwin). OS sampling, same shell-outs the Python used:- front app/title via
osascriptSystem Events; - active browser URL via a per-browser
osascriptmap (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
winctxSwift 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.
- front app/title via
log.go(portable).WriteSegment(dir, seg, minSecs)— drops sub-minSecssegments, else appends one JSON line toactivity-<seg.Start date>.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:
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 namescal-eventsreports for today, so the user can copy exact names intocalendars.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)— readsactivity-<date>.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 thecal-eventshelper fordate, parses its JSON array, and keeps only events whosecalendarpassesfilter. 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:
"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 aspaintLoggeddoes for tock entries. Per-slot precedence when things overlap:- a calendar event's label at its start slot (the human-meaningful anchor),
- else an active auto-tracked segment (
domainortitle), - else idle (
(idle), dimmed), - else the empty gap lane.
Label text: calendar →
▪ <title>; activity →<domain-or-title>; idle →(idle).
grid.Modelgains anactivity []Cellfield alongsidelogged.rebuild()paints it; the day loader (load, and then/p/t/rday-nav + reload paths) fetches the day's segments and calendar events alongside tock entries.Viewrenders 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.templatewithProgramArguments = [<tt path>, autotrack], label unchanged (com.humdrum.ticktock.autotrack), sameStandardOut/ErrPathunder~/.local/share/ticktock/.- An install step renders the template with the installed
ttpath and repoints the running agent (launchctl bootout+bootstrap) from the Python script tott autotrack. - Rollback: the Python daemon in
ticktock-oldis untouched; if the Go daemon misbehaves, repoint the plist back toautotrack.pyand restart. Once the Go daemon is verified over a normal day,ticktock-oldcan 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/deniedcal-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_SECSdrop, context-change emit, boundary clamping. - Log I/O: round-trip a
Recordand re-read viaLoadDay; assert byte-shape parity with a sample line from a realticktock-oldactivity file. - Calendar parse + filter: parse a captured
cal-eventsJSON sample into[]Event; assertshowallowlist andhideblocklist keep/drop the right calendars (emptyshow⇒ all pass). - paintActivity: table tests for label placement, continuation, and overlap precedence.
- Manual/integration:
tt autotrack --onceprints 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)
- Build
tt+bin/{winctx,cal-events}. - Verify tests +
tt autotrack --once+ grid rendering on today's real data. - Repoint launchd to
tt autotrack; confirm it writes today's log. - Run a normal day; confirm parity.
- Archive
ticktock-old.
Rollback at any point: repoint the plist back to autotrack.py.