docs: add autotrack daemon + activity/calendar timeline spec and plan
03f5912c457d1e1c56ff430d4c672139427c4cfe
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 11:58
parent f1a8ae47
2 files changed
docs/superpowers/plans/2026-07-08-tt-autotrack-daemon-and-activity-timeline.md +2625 −0
@@ -0,0 +1,2625 @@
+# tt autotrack daemon + activity/calendar timeline — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Port the Python autotrack daemon into `tt` as `tt autotrack` (writer) and render its activity log plus filtered calendar events in the grid's ACTIVITY column (reader), so `ticktock-old` can be archived.
+
+**Architecture:** Two subsystems inside the one `tt` binary. Writer: `internal/autotrack` — pure segmentation state machine (`Classify`/`CtxKey`/`Step`), byte-compatible jsonl log writer, darwin-only OS sampling behind build tags, poll loop with signal flush, wired to a new `autotrack` cobra subcommand and a launchd agent. Reader: `internal/activity` — tolerant jsonl + `cal-events` JSON parsing with a config-driven calendar filter, painted into the grid via a new `paintActivity` that mirrors `paintLogged`.
+
+**Tech Stack:** Go 1.26 (stdlib only for new code — `encoding/json`, `os/exec`, `os/signal`, `net/url`), existing charm stack (bubbletea/lipgloss) for the grid, cobra for the CLI, `swiftc` for the two vendored native helpers (`winctx`, `cal-events`), launchd for the daemon.
+
+**Design spec (source of truth):** `docs/superpowers/specs/2026-07-08-tt-autotrack-daemon-and-activity-timeline-design.md`
+
+## Global Constraints
+
+Copied from the spec — every task's requirements implicitly include these:
+
+- Go 1.26, `module ticktock`, binary `tt`.
+- All OS integration is darwin-only behind `//go:build darwin` build tags, with a `!darwin` stub (`stub.go` returns empty context) so the package builds and tests run everywhere.
+- The pure segmentation and log/calendar parsing are portable and unit-tested on any platform.
+- Shared directories unchanged: config `~/.config/ticktock/config.json`, data `~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl` — the Go daemon writes the same stream the Python one did, and the reader sees one continuous history.
+- Activity record encoding: `{start, end, secs, app, title, url, domain, idle}`, ISO-8601 seconds precision — **byte-compatible with the Python output** (`json.dumps(..., ensure_ascii=False)`: `", "` / `": "` separators, that exact key order, no HTML escaping).
+- The daemon writes NOTHING to tock — only its own staging log (suggest-then-confirm). tock stays the source of truth.
+- The Python daemon in `ticktock-old` stays untouched as rollback: repoint the plist back to `autotrack.py` if the Go daemon misbehaves. Archive `ticktock-old` only after a verified normal day.
+- Native Swift helpers (`winctx`, `cal-events`) are built from vendored source via Makefile `swiftc` targets that **skip gracefully** (print a note, continue) when `swiftc` is unavailable — the daemon degrades to System Events titles and the reader shows no calendar lane.
+- Every OS call has a timeout and returns empty on failure — never panics the loop.
+- The reader skips malformed jsonl lines rather than failing the view; missing file / missing helper / denied access are never user-facing errors.
+- **VCS: this is a jj-colocated repo (`.jj/` present). Every commit step uses `jj commit -m "type: msg"` (which finalizes the current working copy and starts a new one). NEVER run `git add`, `git commit`, or any other mutating git command.**
+
+## File Structure
+
+```
+internal/config/config.go MODIFY + Tracking block (Task 1), + Calendars block (Task 5)
+internal/config/config_test.go MODIFY new tests; DeepEqual fix in Task 5
+internal/autotrack/segment.go CREATE Ctx, Segment, Domain, Away, Classify, Key, CtxKey (T2), Step (T3)
+internal/autotrack/segment_test.go CREATE table tests (T2, T3)
+internal/autotrack/log.go CREATE Record, EncodeLine, LogPath, WriteSegment (T4)
+internal/autotrack/log_test.go CREATE byte-parity + min-secs tests (T4)
+internal/autotrack/sample.go CREATE Sample, parseHIDIdle, parseLocked, parseWinctx, HelperPath (T10)
+internal/autotrack/sample_test.go CREATE parser + HelperPath tests (T10)
+internal/autotrack/darwin.go CREATE //go:build darwin — osascript/ioreg/winctx sampling (T10)
+internal/autotrack/stub.go CREATE //go:build !darwin — empty ReadSample (T10)
+internal/autotrack/run.go CREATE Run poll loop + signal flush (T11)
+internal/autotrack/run_test.go CREATE flush-on-signal test (T11)
+internal/activity/activity.go CREATE Segment, LoadDay (T6)
+internal/activity/activity_test.go CREATE tolerant-parse + round-trip tests (T6)
+internal/activity/calendar.go CREATE Event, CalendarFilter, parseEvents, LoadCalendar (T7)
+internal/activity/calendar_test.go CREATE parse + filter tests (T7)
+internal/tui/grid/paint.go MODIFY Cell.Act, ActKind, paintActivity (T8)
+internal/tui/grid/paint_test.go MODIFY paintActivity tests (T8)
+internal/tui/grid/model.go MODIFY ActivitySource, WithActivity, load/rebuild wiring (T9)
+internal/tui/grid/view.go MODIFY render activity lane, gCal accent, renderActCell (T9)
+internal/tui/grid/model_test.go MODIFY wiring tests (T9)
+cmd/ticktock/main.go MODIFY autotrack subcommand, timeline WithActivity (T12)
+cmd/ticktock/main_test.go CREATE subcommand registration test (T12)
+native/winctx.swift CREATE vendored from ticktock-old (T13)
+native/cal-events.swift CREATE vendored from ticktock-old (T13)
+Makefile MODIFY helpers target (T13)
+deploy/autotrack.plist.template CREATE launchd template (T14)
+deploy/install-autotrack.sh CREATE render + bootout/bootstrap (T14)
+```
+
+Porting references (read-only, live outside this repo):
+- `/Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py` — the daemon being ported.
+- `/Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift`, `/Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift` — helper sources to vendor.
+- `~/.local/share/ticktock/activity-2026-07-07.jsonl` — real log to match byte-for-byte. First line:
+ `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}`
+
+### Design decisions locked here (flagged deviations/interpretations)
+
+1. **Per-slot ACTIVITY precedence ranks.** The spec lists: calendar label at its start slot > active segment > idle > empty. It doesn't rank calendar *continuation* slots; we rank them just above empty so a meeting stays visible in otherwise-quiet slots but never hides captured activity: `CalLabel(4) > Active(3) > Idle(2) > CalCont(1) > Empty(0)`. Ties (two segments contesting a slot at equal rank) go to the later-painted segment, mirroring `paintLogged`'s "later-start wins".
+2. **Activity label fallback.** Spec says label is `<domain-or-title>`, but real logs have segments with empty title *and* domain (e.g. app "Trace"). Label falls back domain → title → app so those rows aren't blank.
+3. **Byte parity needs a hand-rolled encoder.** Go's `encoding/json` emits `{"start":"…"` (no spaces) and HTML-escapes `<>&`; Python's `json.dumps` emits `{"start": "…", ` and doesn't. `Record.EncodeLine` therefore formats the line manually with `json.Encoder.SetEscapeHTML(false)` for strings.
+4. **Config comparability.** Adding `Calendars` (`[]string` fields) makes `config.Config` non-comparable; the existing `c != Default()` test is updated to `reflect.DeepEqual` in Task 5.
+5. **launchd label.** The *live* agent is `com.humdrum.ticktock.autotrack` (checked `~/Library/LaunchAgents/`); the old repo template says `com.ticktock.autotrack`. Per spec ("label unchanged"), we use the live label `com.humdrum.ticktock.autotrack`.
+6. **Grid window is not expanded by activity.** `BuildGrid` keeps expanding only for tock entries; activity/calendar spans outside the window clamp to the first/last row via `Grid.RowOf` (the lane is display-only in this port).
+
+---
+
+## Task group A — writer, pure core (portable, no OS calls)
+
+### Task 1: `internal/config` — Tracking block
+
+**Files:**
+- Modify: `internal/config/config.go`
+- Test: `internal/config/config_test.go`
+
+**Interfaces:**
+- Consumes: existing `Config`, `Default()`, `LoadFrom(path string) Config`.
+- Produces: `type Tracking struct { PollSecs, IdleGraceSecs, MinSecs int }`; `Config.Tracking Tracking`; defaults `{5, 900, 15}`; JSON keys `tracking.poll_secs`, `tracking.idle_grace_secs`, `tracking.min_secs`. Later tasks call `cfg.Tracking` (run loop) — exact field names matter.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/config/config_test.go`:
+
+```go
+func TestTrackingDefaults(t *testing.T) {
+ c := Default()
+ if c.Tracking.PollSecs != 5 || c.Tracking.IdleGraceSecs != 900 || c.Tracking.MinSecs != 15 {
+ t.Fatalf("bad tracking defaults: %+v", c.Tracking)
+ }
+}
+
+func TestLoadFromMergesTracking(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(p, []byte(`{"tracking":{"poll_secs":10,"min_secs":30}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := LoadFrom(p)
+ if c.Tracking.PollSecs != 10 {
+ t.Errorf("poll_secs=%d, want 10", c.Tracking.PollSecs)
+ }
+ if c.Tracking.IdleGraceSecs != 900 { // absent key keeps default
+ t.Errorf("idle_grace_secs=%d, want 900", c.Tracking.IdleGraceSecs)
+ }
+ if c.Tracking.MinSecs != 30 {
+ t.Errorf("min_secs=%d, want 30", c.Tracking.MinSecs)
+ }
+}
+
+func TestLoadFromMalformedTrackingFallsBack(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(p, []byte(`{"tracking":"nope"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := LoadFrom(p)
+ if c.Tracking != Default().Tracking {
+ t.Errorf("malformed tracking should keep defaults, got %+v", c.Tracking)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/config -run 'TestTracking|TestLoadFromMergesTracking|TestLoadFromMalformedTracking' -v`
+Expected: FAIL (build error) — `c.Tracking undefined (type Config has no field or method Tracking)`
+
+- [ ] **Step 3: Implement**
+
+In `internal/config/config.go`, replace the `Config` struct and `Default` with:
+
+```go
+// Config controls grid presentation and daemon tuning. Zero value is not
+// valid; use Default.
+type Config struct {
+ SlotMinutes int
+ GridStart string
+ GridEnd string
+ Project string
+ Tracking Tracking
+}
+
+// Tracking tunes the autotrack daemon; read from config.json's `tracking`
+// object. Defaults 5, 900, 15 match the Python daemon's constants.
+type Tracking struct {
+ PollSecs int // fine poll → tighter boundaries, catch short switches
+ IdleGraceSecs int // HID gap < this stays active; >= → idle
+ MinSecs int // drop segments shorter than this
+}
+
+// Default returns the documented fallbacks used when config is absent.
+func Default() Config {
+ return Config{
+ SlotMinutes: 30, GridStart: "07:00", GridEnd: "21:00", Project: "",
+ Tracking: Tracking{PollSecs: 5, IdleGraceSecs: 900, MinSecs: 15},
+ }
+}
+```
+
+Replace the `raw` type with:
+
+```go
+// raw mirrors the on-disk JSON; pointers distinguish "absent" from "zero".
+type raw struct {
+ SlotMinutes *int `json:"slot_minutes"`
+ GridStart *string `json:"grid_start"`
+ GridEnd *string `json:"grid_end"`
+ Project *string `json:"project"`
+ Tracking *rawTracking `json:"tracking"`
+}
+
+type rawTracking struct {
+ PollSecs *int `json:"poll_secs"`
+ IdleGraceSecs *int `json:"idle_grace_secs"`
+ MinSecs *int `json:"min_secs"`
+}
+```
+
+In `LoadFrom`, the existing whole-file behavior already gives the right fallback for a malformed `tracking` value (unmarshal error ⇒ all defaults, matching the Python `load_tracking` which also falls back wholesale on any error). Only the merge is new — add, before the final `return c`:
+
+```go
+ if r.Tracking != nil {
+ if r.Tracking.PollSecs != nil {
+ c.Tracking.PollSecs = *r.Tracking.PollSecs
+ }
+ if r.Tracking.IdleGraceSecs != nil {
+ c.Tracking.IdleGraceSecs = *r.Tracking.IdleGraceSecs
+ }
+ if r.Tracking.MinSecs != nil {
+ c.Tracking.MinSecs = *r.Tracking.MinSecs
+ }
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/config -v`
+Expected: PASS (all tests including pre-existing `TestDefault`, `TestLoadFromMissingFileReturnsDefaults`, `TestLoadFromMergesAndClampsSlot`)
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(config): tracking block (poll/idle-grace/min secs) with python-parity defaults"
+```
+
+---
+
+### Task 2: `internal/autotrack/segment.go` — Ctx, Domain, Classify, CtxKey
+
+**Files:**
+- Create: `internal/autotrack/segment.go`
+- Test: `internal/autotrack/segment_test.go`
+
+**Interfaces:**
+- Consumes: nothing from this repo (stdlib only).
+- Produces (used by Tasks 3, 4, 10, 11, 12):
+ - `type Ctx struct { App, Title, URL, Domain string; Idle bool }` (json tags `app,title,url,domain,idle`)
+ - `type Segment struct { Ctx; Start, End time.Time }`
+ - `func Domain(rawURL string) string`
+ - `func Away(raw *Ctx) bool`
+ - `func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx`
+ - `type Key struct { Valid, Idle bool; App, Rest string }` (comparable; zero Key = no context)
+ - `func CtxKey(c *Ctx) Key`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/segment_test.go`:
+
+```go
+package autotrack
+
+import "testing"
+
+func TestDomain(t *testing.T) {
+ cases := []struct{ url, want string }{
+ {"https://www.github.com/foo", "github.com"},
+ {"https://mail.google.com/mail", "mail.google.com"},
+ {"HTTPS://WWW.Example.COM/x", "example.com"}, // lowercased, www stripped
+ {"", ""},
+ {"://not-a-url", ""},
+ }
+ for _, c := range cases {
+ if got := Domain(c.url); got != c.want {
+ t.Errorf("Domain(%q)=%q, want %q", c.url, got, c.want)
+ }
+ }
+}
+
+func TestClassify(t *testing.T) {
+ active := &Ctx{App: "Trace", Title: "doc"}
+ away := &Ctx{App: "loginwindow"}
+
+ if got := Classify(0, active, 900, true); got == nil || !got.Idle {
+ t.Errorf("locked ⇒ idle, got %+v", got)
+ }
+ if got := Classify(0, away, 900, false); got == nil || !got.Idle {
+ t.Errorf("away app ⇒ idle, got %+v", got)
+ }
+ if got := Classify(900, active, 900, false); got == nil || !got.Idle {
+ t.Errorf("idle >= grace ⇒ idle, got %+v", got)
+ }
+ if got := Classify(899, active, 900, false); got != active {
+ t.Errorf("present-but-passive under grace passes through, got %+v", got)
+ }
+ if got := Classify(0, nil, 900, false); got != nil {
+ t.Errorf("no foreground app, not locked ⇒ nil, got %+v", got)
+ }
+ if got := Classify(0, nil, 900, true); got == nil || !got.Idle {
+ t.Errorf("locked with nil ctx still idle, got %+v", got)
+ }
+}
+
+func TestCtxKey(t *testing.T) {
+ if CtxKey(nil) != (Key{}) {
+ t.Error("nil ctx should give zero key")
+ }
+ idle1, idle2 := CtxKey(&Ctx{Idle: true}), CtxKey(&Ctx{Idle: true, App: "x"})
+ if idle1 != idle2 {
+ t.Error("idle is its own key regardless of app")
+ }
+ browser := CtxKey(&Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"})
+ if browser != (Key{Valid: true, App: "Safari", Rest: "github.com"}) {
+ t.Errorf("browser key should use domain, got %+v", browser)
+ }
+ editor := CtxKey(&Ctx{App: "Trace", Title: "notes.md"})
+ if editor != (Key{Valid: true, App: "Trace", Rest: "notes.md"}) {
+ t.Errorf("non-browser key should use title, got %+v", editor)
+ }
+ if CtxKey(&Ctx{App: "Trace", Title: "a"}) == CtxKey(&Ctx{App: "Trace", Title: "b"}) {
+ t.Error("different titles should be different activities")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `no Go files in .../internal/autotrack` or `undefined: Domain`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/segment.go`:
+
+```go
+// Package autotrack ports the Python passive activity daemon: poll the
+// frontmost app / window title / browser tab / idle state, coalesce
+// consecutive same-context samples into segments, and append them to a daily
+// staging log (~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl). It writes
+// NOTHING to tock — suggest-then-confirm.
+package autotrack
+
+import (
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Ctx is one classified foreground context. JSON tags match the Python
+// record keys (used by --once output and the winctx helper).
+type Ctx struct {
+ App string `json:"app"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Domain string `json:"domain"`
+ Idle bool `json:"idle"`
+}
+
+// Segment is an open (End zero) or closed span of one activity.
+type Segment struct {
+ Ctx
+ Start, End time.Time
+}
+
+// awayApps: frontmost one of these ⇒ locked/screensaver ⇒ away.
+var awayApps = map[string]bool{"loginwindow": true, "ScreenSaverEngine": true}
+
+// Away reports whether the raw context is the lock/screensaver window.
+func Away(raw *Ctx) bool { return raw != nil && awayApps[raw.App] }
+
+// Domain extracts the URL's hostname, lowercased, with "www." stripped —
+// parity with Python's urlparse(url).hostname.replace("www.", "").
+func Domain(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return ""
+ }
+ return strings.ReplaceAll(strings.ToLower(u.Hostname()), "www.", "")
+}
+
+// Classify maps (idle seconds, foreground context) to the segment context to
+// record. Locked or an away-app ⇒ idle immediately; idle >= idleGrace ⇒ idle;
+// otherwise the active context passes through (present-but-passive, e.g.
+// watching an agent run or being on a call, counts as active). nil when there
+// is nothing to record.
+func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx {
+ if locked || Away(raw) {
+ return &Ctx{Idle: true}
+ }
+ if idle >= idleGrace {
+ return &Ctx{Idle: true}
+ }
+ return raw
+}
+
+// Key identifies "the same activity". Idle is its own key; active is
+// (app, domain-or-title). The zero Key means no context. Comparable, so the
+// segmentation loop can use != directly.
+type Key struct {
+ Valid bool
+ Idle bool
+ App string
+ Rest string // domain, or title for non-browsers
+}
+
+// CtxKey computes the identity key for a classified context.
+func CtxKey(c *Ctx) Key {
+ if c == nil {
+ return Key{}
+ }
+ if c.Idle {
+ return Key{Valid: true, Idle: true}
+ }
+ rest := c.Domain
+ if rest == "" {
+ rest = c.Title
+ }
+ return Key{Valid: true, App: c.App, Rest: rest}
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — `TestDomain`, `TestClassify`, `TestCtxKey`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): pure context types — Ctx, Domain, Classify, CtxKey"
+```
+
+---
+
+### Task 3: `internal/autotrack/segment.go` — Step state machine
+
+**Files:**
+- Modify: `internal/autotrack/segment.go` (append `Step`)
+- Test: `internal/autotrack/segment_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: `Ctx`, `Segment`, `Key`, `CtxKey` from Task 2.
+- Produces: `func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment)` — returns (new open segment, its key, emitted closed segment or nil). Used by Task 11's `Run`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/autotrack/segment_test.go` (add `"time"` to its imports):
+
+```go
+func tt3(h, m, s int) time.Time { return time.Date(2026, 7, 7, h, m, s, 0, time.Local) }
+
+func TestStepSameContextExtends(t *testing.T) {
+ ctx := &Ctx{App: "Trace", Title: "doc"}
+ cur := &Segment{Ctx: *ctx, Start: tt3(9, 0, 0)}
+ now := tt3(9, 0, 5)
+ got, key, emitted := Step(cur, CtxKey(ctx), now, 0, ctx, 900, false)
+ if emitted != nil {
+ t.Fatalf("same context should not emit, got %+v", emitted)
+ }
+ if got == nil || !got.End.Equal(now) || !got.Start.Equal(tt3(9, 0, 0)) {
+ t.Fatalf("open segment should extend to now, got %+v", got)
+ }
+ if key != CtxKey(ctx) {
+ t.Fatalf("key should be unchanged")
+ }
+}
+
+func TestStepContextChangeEmitsAndOpens(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ b := &Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"}
+ cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+ now := tt3(9, 5, 0)
+ got, key, emitted := Step(cur, CtxKey(a), now, 0, b, 900, false)
+ if emitted == nil || emitted.App != "Trace" || !emitted.End.Equal(now) {
+ t.Fatalf("context change should close old segment at now, got %+v", emitted)
+ }
+ if got == nil || got.App != "Safari" || !got.Start.Equal(now) {
+ t.Fatalf("new segment should open at now, got %+v", got)
+ }
+ if key != CtxKey(b) {
+ t.Fatalf("key should follow new ctx")
+ }
+}
+
+func TestStepSoftIdleCreditsGrace(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+ now := tt3(10, 0, 0)
+ // 1000s HID gap, 900s grace, soft ⇒ boundary = now - (1000-900) = now-100s
+ got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, false)
+ wantBoundary := now.Add(-100 * time.Second)
+ if emitted == nil || !emitted.End.Equal(wantBoundary) {
+ t.Fatalf("soft idle should close at now-(idle-grace), got %+v", emitted)
+ }
+ if got == nil || !got.Idle || !got.Start.Equal(wantBoundary) {
+ t.Fatalf("idle segment should open at boundary, got %+v", got)
+ }
+}
+
+func TestStepHardIdleSnapsToLastInputAndClamps(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ // segment opened 500s ago; idle says last input was 1000s ago ⇒ clamp to Start
+ now := tt3(10, 0, 0)
+ cur := &Segment{Ctx: *a, Start: now.Add(-500 * time.Second)}
+ got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true)
+ if emitted == nil || !emitted.End.Equal(cur.Start) {
+ t.Fatalf("hard-idle boundary must clamp to cur.Start, got %+v", emitted)
+ }
+ if got == nil || !got.Start.Equal(cur.Start) {
+ t.Fatalf("idle segment starts at clamped boundary, got %+v", got)
+ }
+}
+
+func TestStepHardIdleUnclamped(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ now := tt3(10, 0, 0)
+ cur := &Segment{Ctx: *a, Start: now.Add(-2000 * time.Second)}
+ // hard idle, 1000s since last input ⇒ boundary = now-1000s (inside segment)
+ _, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true)
+ if emitted == nil || !emitted.End.Equal(now.Add(-1000*time.Second)) {
+ t.Fatalf("hard idle should close at now-idle, got %+v", emitted)
+ }
+}
+
+func TestStepNilCtxClosesWithoutOpening(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)}
+ now := tt3(9, 5, 0)
+ got, key, emitted := Step(cur, CtxKey(a), now, 0, nil, 900, false)
+ if emitted == nil || !emitted.End.Equal(now) {
+ t.Fatalf("nil ctx should close open segment at now, got %+v", emitted)
+ }
+ if got != nil || key != (Key{}) {
+ t.Fatalf("nothing should be open after nil ctx, got %+v / %+v", got, key)
+ }
+}
+
+func TestStepFromNothingOpensWithoutEmitting(t *testing.T) {
+ a := &Ctx{App: "Trace", Title: "doc"}
+ now := tt3(9, 0, 0)
+ got, key, emitted := Step(nil, Key{}, now, 0, a, 900, false)
+ if emitted != nil {
+ t.Fatalf("nothing to close, got %+v", emitted)
+ }
+ if got == nil || got.App != "Trace" || !got.Start.Equal(now) || key != CtxKey(a) {
+ t.Fatalf("should open new segment at now, got %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: Step`
+
+- [ ] **Step 3: Implement**
+
+Append to `internal/autotrack/segment.go`:
+
+```go
+// Step is the pure segmentation state machine. Given the open segment
+// (cur/curKey) and a fresh sample (now, idle seconds, classified ctx), it
+// returns (cur, curKey, emitted) where emitted is a finished segment to
+// persist, or nil.
+//
+// A context change closes the open segment and opens a new one; the same
+// context just extends the open segment's end.
+//
+// Crossing into idle anchors the transition boundary:
+// - hard idle (screen locked / away app): now - idle — the whole gap is
+// away, so the active tail is trimmed to the last real input.
+// - soft idle (HID gap alone): now - (idle - grace) — the grace window is
+// credited to the active app, so present-but-passive work keeps its time.
+//
+// The boundary is clamped to [cur.Start, now] so a segment never ends before
+// it began nor in the future.
+func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment) {
+ key := CtxKey(ctx)
+ boundary := now
+ if ctx != nil && ctx.Idle {
+ offset := idle
+ if !hard {
+ offset = idle - grace
+ if offset < 0 {
+ offset = 0
+ }
+ }
+ boundary = now.Add(-time.Duration(offset) * time.Second)
+ }
+ var emitted *Segment
+ if key != curKey {
+ if cur != nil {
+ if boundary.After(now) {
+ boundary = now
+ }
+ if boundary.Before(cur.Start) {
+ boundary = cur.Start
+ }
+ closed := *cur
+ closed.End = boundary
+ emitted = &closed
+ }
+ if ctx != nil {
+ cur = &Segment{Ctx: *ctx, Start: boundary}
+ } else {
+ cur = nil
+ }
+ curKey = key
+ } else if cur != nil {
+ next := *cur
+ next.End = now
+ cur = &next
+ }
+ return cur, curKey, emitted
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — all `TestStep*` plus Task 2 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): Step segmentation state machine with soft/hard idle anchoring"
+```
+
+---
+
+### Task 4: `internal/autotrack/log.go` — Record encoding + WriteSegment
+
+**Files:**
+- Create: `internal/autotrack/log.go`
+- Test: `internal/autotrack/log_test.go`
+
+**Interfaces:**
+- Consumes: `Segment` from Task 2.
+- Produces (used by Tasks 6, 11):
+ - `type Record struct { Start, End time.Time; Secs int; App, Title, URL, Domain string; Idle bool }`
+ - `func (r Record) EncodeLine() string` — the exact Python byte shape.
+ - `func LogPath(dir string, when time.Time) string` — `<dir>/activity-YYYY-MM-DD.jsonl`.
+ - `func WriteSegment(dir string, seg *Segment, minSecs int) error` — drops sub-minSecs, appends one line, creates dir.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/log_test.go`:
+
+```go
+package autotrack
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestEncodeLineByteParityWithPython pins the exact byte shape of a real line
+// from ~/.local/share/ticktock/activity-2026-07-07.jsonl written by the
+// Python daemon (json.dumps ", "/": " separators, key order, no escaping).
+func TestEncodeLineByteParityWithPython(t *testing.T) {
+ r := Record{
+ Start: time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local),
+ End: time.Date(2026, 7, 7, 9, 0, 9, 0, time.Local),
+ Secs: 49, App: "Trace",
+ }
+ want := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}`
+ if got := r.EncodeLine(); got != want {
+ t.Errorf("byte mismatch:\n got %s\nwant %s", got, want)
+ }
+}
+
+func TestEncodeLineNoHTMLEscaping(t *testing.T) {
+ r := Record{
+ Start: time.Date(2026, 7, 7, 10, 0, 0, 0, time.Local),
+ End: time.Date(2026, 7, 7, 10, 1, 0, 0, time.Local),
+ Secs: 60, App: "Safari", Title: `R&D <notes> — café "x"`,
+ URL: "https://example.com/a?b=1&c=2", Domain: "example.com",
+ }
+ got := r.EncodeLine()
+ if !strings.Contains(got, `"title": "R&D <notes> — café \"x\""`) {
+ t.Errorf("ensure_ascii=False parity broken: %s", got)
+ }
+ if !strings.Contains(got, `"url": "https://example.com/a?b=1&c=2"`) {
+ t.Errorf("url should not be HTML-escaped: %s", got)
+ }
+}
+
+func TestWriteSegmentDropsShortAndAppends(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "data") // must be created by WriteSegment
+ start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+
+ short := &Segment{Ctx: Ctx{App: "Blip"}, Start: start, End: start.Add(10 * time.Second)}
+ if err := WriteSegment(dir, short, 15); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(LogPath(dir, start)); !os.IsNotExist(err) {
+ t.Fatal("sub-minSecs segment must be dropped (no file written)")
+ }
+
+ long := &Segment{Ctx: Ctx{App: "Trace", Title: "doc"}, Start: start, End: start.Add(50 * time.Second)}
+ if err := WriteSegment(dir, long, 15); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteSegment(dir, long, 15); err != nil { // append, not truncate
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(LogPath(dir, start))
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("want 2 appended lines, got %d: %q", len(lines), string(b))
+ }
+ want := `{"start": "2026-07-07T09:00:00", "end": "2026-07-07T09:00:50", "secs": 50, "app": "Trace", "title": "doc", "url": "", "domain": "", "idle": false}`
+ if lines[0] != want {
+ t.Errorf("line mismatch:\n got %s\nwant %s", lines[0], want)
+ }
+ if err := WriteSegment(dir, nil, 15); err != nil {
+ t.Errorf("nil segment must be a no-op, got %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: Record`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/log.go`:
+
+```go
+package autotrack
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// stampLayout matches Python isoformat(timespec="seconds"): local time, no
+// offset, e.g. 2026-07-07T08:59:19.
+const stampLayout = "2006-01-02T15:04:05"
+
+// Record is one on-disk activity line: {start, end, secs, app, title, url,
+// domain, idle} — byte-compatible with the Python daemon's
+// json.dumps(..., ensure_ascii=False) output.
+type Record struct {
+ Start, End time.Time
+ Secs int
+ App, Title, URL, Domain string
+ Idle bool
+}
+
+// jsonStr encodes s as a JSON string without HTML escaping (parity with
+// Python, which writes <, >, & and non-ASCII raw).
+func jsonStr(s string) string {
+ var b bytes.Buffer
+ enc := json.NewEncoder(&b)
+ enc.SetEscapeHTML(false)
+ _ = enc.Encode(s) // encoding a plain string cannot fail
+ return string(bytes.TrimRight(b.Bytes(), "\n"))
+}
+
+// EncodeLine renders the record exactly as the Python daemon did, including
+// json.dumps' ", " / ": " separators and key order.
+func (r Record) EncodeLine() string {
+ return fmt.Sprintf(
+ `{"start": %q, "end": %q, "secs": %d, "app": %s, "title": %s, "url": %s, "domain": %s, "idle": %t}`,
+ r.Start.Format(stampLayout), r.End.Format(stampLayout), r.Secs,
+ jsonStr(r.App), jsonStr(r.Title), jsonStr(r.URL), jsonStr(r.Domain), r.Idle)
+}
+
+// LogPath is the daily staging log for the given moment under dir.
+func LogPath(dir string, when time.Time) string {
+ return filepath.Join(dir, "activity-"+when.Format("2006-01-02")+".jsonl")
+}
+
+// WriteSegment appends seg to its start-day's log under dir, creating dir if
+// needed. Segments shorter than minSecs are dropped as noise. nil is a no-op.
+func WriteSegment(dir string, seg *Segment, minSecs int) error {
+ if seg == nil {
+ return nil
+ }
+ secs := seg.End.Sub(seg.Start).Seconds()
+ if secs < float64(minSecs) {
+ return nil
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ rec := Record{
+ Start: seg.Start, End: seg.End, Secs: int(secs),
+ App: seg.App, Title: seg.Title, URL: seg.URL, Domain: seg.Domain, Idle: seg.Idle,
+ }
+ f, err := os.OpenFile(LogPath(dir, seg.Start), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.WriteString(rec.EncodeLine() + "\n")
+ return err
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — all autotrack tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): byte-compatible Record encoding + WriteSegment daily log appender"
+```
+
+---
+
+## Task group B — reader, pure core (portable)
+
+### Task 5: `internal/config` — Calendars block
+
+**Files:**
+- Modify: `internal/config/config.go`
+- Test: `internal/config/config_test.go` (new tests + `reflect.DeepEqual` fix)
+
+**Interfaces:**
+- Consumes: `Config`, `raw` from Task 1.
+- Produces: `type Calendars struct { Show, Hide []string }`; `Config.Calendars Calendars`; JSON `calendars.show` / `calendars.hide`. Task 12 builds `activity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide}` from this.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/config/config_test.go`:
+
+```go
+func TestLoadFromMergesCalendars(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(p, []byte(`{"calendars":{"show":["Work","Focus"],"hide":["Holidays"]}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := LoadFrom(p)
+ if !reflect.DeepEqual(c.Calendars.Show, []string{"Work", "Focus"}) {
+ t.Errorf("show=%v, want [Work Focus]", c.Calendars.Show)
+ }
+ if !reflect.DeepEqual(c.Calendars.Hide, []string{"Holidays"}) {
+ t.Errorf("hide=%v, want [Holidays]", c.Calendars.Hide)
+ }
+}
+
+func TestCalendarsDefaultEmpty(t *testing.T) {
+ c := Default()
+ if len(c.Calendars.Show) != 0 || len(c.Calendars.Hide) != 0 {
+ t.Errorf("default calendars should be empty (all pass), got %+v", c.Calendars)
+ }
+}
+```
+
+Also update the pre-existing equality test — `Config` becomes non-comparable once it holds slices. In `TestLoadFromMissingFileReturnsDefaults`, replace:
+
+```go
+ if c != Default() {
+```
+
+with:
+
+```go
+ if !reflect.DeepEqual(c, Default()) {
+```
+
+and add `"reflect"` to the test file's imports.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/config -v`
+Expected: FAIL (build error) — `c.Calendars undefined (type Config has no field or method Calendars)`
+
+- [ ] **Step 3: Implement**
+
+In `internal/config/config.go`, add the `Calendars` field to `Config` (after `Tracking Tracking`):
+
+```go
+ Calendars Calendars
+```
+
+Add the type below `Tracking`:
+
+```go
+// Calendars selects which calendar names appear in the grid's ACTIVITY lane.
+// Non-empty Show is an allowlist (case-insensitive exact match on calendar
+// name); Hide is a blocklist applied after Show. Both empty ⇒ all pass.
+type Calendars struct {
+ Show []string
+ Hide []string
+}
+```
+
+Add to `raw`:
+
+```go
+ Calendars *rawCalendars `json:"calendars"`
+```
+
+and the mirror type:
+
+```go
+type rawCalendars struct {
+ Show []string `json:"show"`
+ Hide []string `json:"hide"`
+}
+```
+
+In `LoadFrom`, after the Tracking merge block, add:
+
+```go
+ if r.Calendars != nil {
+ c.Calendars.Show = r.Calendars.Show
+ c.Calendars.Hide = r.Calendars.Hide
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/config -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(config): calendars show/hide block for timeline calendar filtering"
+```
+
+---
+
+### Task 6: `internal/activity` — Segment + LoadDay
+
+**Files:**
+- Create: `internal/activity/activity.go`
+- Test: `internal/activity/activity_test.go`
+
+**Interfaces:**
+- Consumes: `autotrack.WriteSegment`, `autotrack.Segment`, `autotrack.Ctx` (round-trip test only).
+- Produces (used by Tasks 8, 9, 12):
+ - `type Segment struct { Start, End time.Time; App, Title, URL, Domain string; Idle bool }`
+ - `func LoadDay(dataDir, date string) ([]Segment, error)` — tolerant parse; missing file or empty dataDir ⇒ `nil, nil`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activity/activity_test.go`:
+
+```go
+package activity
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "ticktock/internal/autotrack"
+)
+
+func TestLoadDayParsesAndSkipsMalformed(t *testing.T) {
+ dir := t.TempDir()
+ lines := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}
+this line is not json
+{"start": "not-a-time", "end": "2026-07-07T09:10:00", "secs": 1, "app": "Bad", "title": "", "url": "", "domain": "", "idle": false}
+{"start": "2026-07-07T09:30:00", "end": "2026-07-07T10:00:00", "secs": 1800, "app": "", "title": "", "url": "", "domain": "", "idle": true}
+`
+ if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(lines), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ segs, err := LoadDay(dir, "2026-07-07")
+ if err != nil {
+ t.Fatalf("tolerant parse must not error: %v", err)
+ }
+ if len(segs) != 2 {
+ t.Fatalf("want 2 valid segments (malformed skipped), got %d", len(segs))
+ }
+ want := time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local)
+ if segs[0].App != "Trace" || !segs[0].Start.Equal(want) {
+ t.Errorf("seg0 = %+v", segs[0])
+ }
+ if !segs[1].Idle {
+ t.Errorf("seg1 should be idle: %+v", segs[1])
+ }
+}
+
+func TestLoadDayMissingFileIsEmptyNoError(t *testing.T) {
+ segs, err := LoadDay(t.TempDir(), "2026-01-01")
+ if err != nil || len(segs) != 0 {
+ t.Fatalf("missing file ⇒ empty, nil; got %v, %v", segs, err)
+ }
+ segs, err = LoadDay("", "2026-01-01")
+ if err != nil || len(segs) != 0 {
+ t.Fatalf("empty dataDir ⇒ empty, nil; got %v, %v", segs, err)
+ }
+}
+
+// Round-trip: what the writer persists, the reader loads back — the two
+// halves of the port agree on the byte shape.
+func TestRoundTripWithWriter(t *testing.T) {
+ dir := t.TempDir()
+ start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+ seg := &autotrack.Segment{
+ Ctx: autotrack.Ctx{App: "Safari", Title: "GitHub", URL: "https://github.com/x", Domain: "github.com"},
+ Start: start, End: start.Add(120 * time.Second),
+ }
+ if err := autotrack.WriteSegment(dir, seg, 15); err != nil {
+ t.Fatal(err)
+ }
+ segs, err := LoadDay(dir, "2026-07-07")
+ if err != nil || len(segs) != 1 {
+ t.Fatalf("round trip failed: %v, %v", segs, err)
+ }
+ got := segs[0]
+ if got.App != "Safari" || got.Domain != "github.com" || got.URL != "https://github.com/x" ||
+ !got.Start.Equal(start) || !got.End.Equal(start.Add(120*time.Second)) || got.Idle {
+ t.Errorf("round-trip mismatch: %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/activity -v`
+Expected: FAIL (build error) — `no Go files in .../internal/activity` or `undefined: LoadDay`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/activity/activity.go`:
+
+```go
+// Package activity reads the autotrack staging log and calendar events for
+// display in the grid's ACTIVITY column. All parsing is tolerant: a bad line,
+// missing file, or missing helper never fails the view.
+package activity
+
+import (
+ "bufio"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// Segment is one recorded activity span from the daily jsonl log.
+type Segment struct {
+ Start, End time.Time
+ App, Title, URL, Domain string
+ Idle bool
+}
+
+// rawSegment mirrors one jsonl line; timestamps stay strings for tolerant
+// parsing (secs is derivable and unused by the reader).
+type rawSegment struct {
+ Start string `json:"start"`
+ End string `json:"end"`
+ App string `json:"app"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Domain string `json:"domain"`
+ Idle bool `json:"idle"`
+}
+
+// stampLayout matches the daemon's isoformat(timespec="seconds") local stamps.
+const stampLayout = "2006-01-02T15:04:05"
+
+// LoadDay reads activity-<date>.jsonl under dataDir. Malformed lines are
+// skipped — one bad row never fails the view. A missing file (or empty
+// dataDir) yields an empty slice with no error.
+func LoadDay(dataDir, date string) ([]Segment, error) {
+ if dataDir == "" {
+ return nil, nil
+ }
+ f, err := os.Open(filepath.Join(dataDir, "activity-"+date+".jsonl"))
+ if err != nil {
+ return nil, nil
+ }
+ defer f.Close()
+ var segs []Segment
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // long window titles/URLs
+ for sc.Scan() {
+ var r rawSegment
+ if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
+ continue
+ }
+ start, err := time.ParseInLocation(stampLayout, r.Start, time.Local)
+ if err != nil {
+ continue
+ }
+ end, err := time.ParseInLocation(stampLayout, r.End, time.Local)
+ if err != nil {
+ continue
+ }
+ segs = append(segs, Segment{
+ Start: start, End: end,
+ App: r.App, Title: r.Title, URL: r.URL, Domain: r.Domain, Idle: r.Idle,
+ })
+ }
+ return segs, nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/activity -v`
+Expected: PASS — `TestLoadDayParsesAndSkipsMalformed`, `TestLoadDayMissingFileIsEmptyNoError`, `TestRoundTripWithWriter`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(activity): tolerant LoadDay jsonl reader with writer round-trip parity"
+```
+
+---
+
+### Task 7: `internal/activity` — Event, CalendarFilter, LoadCalendar
+
+**Files:**
+- Create: `internal/activity/calendar.go`
+- Test: `internal/activity/calendar_test.go`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces (used by Tasks 8, 9, 12):
+ - `type Event struct { Start, End time.Time; Title, Calendar string }`
+ - `type CalendarFilter struct { Show, Hide []string }` with `func (f CalendarFilter) Allow(name string) bool`
+ - `func LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error)` — runs `<bin> <date>`; missing/denied/bad output ⇒ `nil, nil`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activity/calendar_test.go`:
+
+```go
+package activity
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// calSample matches the cal-events helper's output shape (ISO-8601 with
+// offset, one object per non-all-day event, tagged with its calendar name).
+const calSample = `[
+ {"title": "Standup", "start": "2026-07-07T09:00:00-05:00", "end": "2026-07-07T09:15:00-05:00", "calendar": "Archer"},
+ {"title": "Dentist", "start": "2026-07-07T13:00:00-05:00", "end": "2026-07-07T14:00:00-05:00", "calendar": "Personal"},
+ {"title": "Focus block", "start": "2026-07-07T15:00:00-05:00", "end": "2026-07-07T17:00:00-05:00", "calendar": "Focus"}
+]`
+
+func TestParseEventsSample(t *testing.T) {
+ events := parseEvents([]byte(calSample), CalendarFilter{})
+ if len(events) != 3 {
+ t.Fatalf("want 3 events, got %d", len(events))
+ }
+ if events[0].Title != "Standup" || events[0].Calendar != "Archer" {
+ t.Errorf("event0 = %+v", events[0])
+ }
+ wantStart, _ := time.Parse(time.RFC3339, "2026-07-07T09:00:00-05:00")
+ if !events[0].Start.Equal(wantStart) {
+ t.Errorf("start = %v, want %v", events[0].Start, wantStart)
+ }
+ if parseEvents([]byte("not json"), CalendarFilter{}) != nil {
+ t.Error("bad output ⇒ empty slice")
+ }
+}
+
+func TestCalendarFilterAllow(t *testing.T) {
+ cases := []struct {
+ name string
+ filter CalendarFilter
+ cal string
+ want bool
+ }{
+ {"empty filter passes all", CalendarFilter{}, "Personal", true},
+ {"show allowlist keeps listed", CalendarFilter{Show: []string{"Archer"}}, "Archer", true},
+ {"show allowlist drops unlisted", CalendarFilter{Show: []string{"Archer"}}, "Personal", false},
+ {"show is case-insensitive", CalendarFilter{Show: []string{"archer"}}, "Archer", true},
+ {"hide drops listed", CalendarFilter{Hide: []string{"Holidays"}}, "Holidays", false},
+ {"hide is case-insensitive", CalendarFilter{Hide: []string{"holidays"}}, "Holidays", false},
+ {"hide applies after show", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Focus", false},
+ {"show+hide keeps the rest", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Archer", true},
+ }
+ for _, c := range cases {
+ if got := c.filter.Allow(c.cal); got != c.want {
+ t.Errorf("%s: Allow(%q)=%v, want %v", c.name, c.cal, got, c.want)
+ }
+ }
+}
+
+func TestParseEventsAppliesFilter(t *testing.T) {
+ events := parseEvents([]byte(calSample), CalendarFilter{Show: []string{"Archer", "Focus"}})
+ if len(events) != 2 || events[0].Calendar != "Archer" || events[1].Calendar != "Focus" {
+ t.Fatalf("filtered = %+v", events)
+ }
+}
+
+func TestLoadCalendarDegradesGracefully(t *testing.T) {
+ if ev, err := LoadCalendar("", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+ t.Errorf("empty bin ⇒ nil, nil; got %v, %v", ev, err)
+ }
+ if ev, err := LoadCalendar("/nonexistent/cal-events", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+ t.Errorf("missing helper ⇒ nil, nil; got %v, %v", ev, err)
+ }
+}
+
+func TestLoadCalendarRunsHelper(t *testing.T) {
+ // Stand in for cal-events with a shell script that echoes the sample.
+ bin := filepath.Join(t.TempDir(), "cal-events")
+ script := "#!/bin/sh\ncat <<'EOF'\n" + calSample + "\nEOF\n"
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ events, err := LoadCalendar(bin, "2026-07-07", CalendarFilter{Hide: []string{"Personal"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("want 2 events after hide, got %+v", events)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/activity -v`
+Expected: FAIL (build error) — `undefined: parseEvents`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/activity/calendar.go`:
+
+```go
+package activity
+
+import (
+ "context"
+ "encoding/json"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// Event is one calendar event from the cal-events helper.
+type Event struct {
+ Start, End time.Time
+ Title, Calendar string
+}
+
+// CalendarFilter selects calendars by name, case-insensitively. Non-empty
+// Show is an allowlist; Hide is a blocklist applied after Show. Zero value
+// passes everything.
+type CalendarFilter struct {
+ Show []string
+ Hide []string
+}
+
+// Allow reports whether events from the named calendar should appear.
+func (f CalendarFilter) Allow(name string) bool {
+ n := strings.ToLower(name)
+ if len(f.Show) > 0 {
+ ok := false
+ for _, s := range f.Show {
+ if strings.ToLower(s) == n {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return false
+ }
+ }
+ for _, h := range f.Hide {
+ if strings.ToLower(h) == n {
+ return false
+ }
+ }
+ return true
+}
+
+// rawEvent mirrors one cal-events JSON object.
+type rawEvent struct {
+ Title string `json:"title"`
+ Start string `json:"start"`
+ End string `json:"end"`
+ Calendar string `json:"calendar"`
+}
+
+// parseEvents decodes the cal-events JSON array, keeping events that pass
+// filter. Any decode error yields an empty slice (tolerant).
+func parseEvents(b []byte, filter CalendarFilter) []Event {
+ var raws []rawEvent
+ if err := json.Unmarshal(b, &raws); err != nil {
+ return nil
+ }
+ var events []Event
+ for _, r := range raws {
+ start, err := time.Parse(time.RFC3339, r.Start)
+ if err != nil {
+ continue
+ }
+ end, err := time.Parse(time.RFC3339, r.End)
+ if err != nil {
+ continue
+ }
+ if !filter.Allow(r.Calendar) {
+ continue
+ }
+ events = append(events, Event{Start: start, End: end, Title: r.Title, Calendar: r.Calendar})
+ }
+ return events
+}
+
+// LoadCalendar runs the cal-events helper for date (YYYY-MM-DD) and filters
+// the result. Missing helper, denied access, or bad output ⇒ empty slice, no
+// error — the timeline still renders without calendar data.
+func LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error) {
+ if bin == "" {
+ return nil, nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, bin, date).Output()
+ if err != nil {
+ return nil, nil
+ }
+ return parseEvents(out, filter), nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/activity -v`
+Expected: PASS — all calendar + activity tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(activity): cal-events loader with show/hide CalendarFilter"
+```
+
+---
+
+### Task 8: `internal/tui/grid` — Cell.Act + paintActivity
+
+**Files:**
+- Modify: `internal/tui/grid/paint.go`
+- Test: `internal/tui/grid/paint_test.go`
+
+**Interfaces:**
+- Consumes: `Grid` (`RowOf`, `Rows`), `minuteOfDay`, `Cell`, `CellKind` (existing); `activity.Segment`, `activity.Event` (Tasks 6-7).
+- Produces (used by Task 9):
+ - `type ActKind int` with `ActNone, ActCalendar, ActActive, ActIdle`
+ - `Cell` gains field `Act ActKind` (zero `ActNone` for LOGGED cells — no behavior change there)
+ - `func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell`
+- Per-slot precedence (design decision 1): CalLabel(4) > Active(3) > Idle(2) > CalCont(1) > Empty(0); equal rank ⇒ later-painted wins.
+- Label text: calendar `▪ <title>`; activity `<domain-or-title>` (fallback app); idle `(idle)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/tui/grid/paint_test.go` (add `"ticktock/internal/activity"` to its imports):
+
+```go
+func TestPaintActivityLabelsAndContinuation(t *testing.T) {
+ g := BuildGrid(30, 7*60, 10*60, nil) // rows 07:00..10:00 = 6
+ segs := []activity.Segment{
+ {Start: at(7, 0), End: at(8, 0), App: "Safari", Title: "GitHub", Domain: "github.com"},
+ {Start: at(8, 0), End: at(8, 30), App: "Trace"}, // empty title+domain → app fallback
+ {Start: at(8, 30), End: at(9, 30), Idle: true},
+ }
+ cells := paintActivity(g, segs, nil)
+ if cells[0].Kind != CellLabel || cells[0].Text != "github.com" || cells[0].Act != ActActive {
+ t.Errorf("row0 = %+v, want github.com active label", cells[0])
+ }
+ if cells[1].Kind != CellCont || cells[1].Act != ActActive {
+ t.Errorf("row1 = %+v, want active continuation", cells[1])
+ }
+ if cells[2].Text != "Trace" {
+ t.Errorf("row2 = %+v, want app fallback label", cells[2])
+ }
+ if cells[3].Kind != CellLabel || cells[3].Text != "(idle)" || cells[3].Act != ActIdle {
+ t.Errorf("row3 = %+v, want (idle) label", cells[3])
+ }
+ if cells[4].Kind != CellCont || cells[4].Act != ActIdle {
+ t.Errorf("row4 = %+v, want idle continuation", cells[4])
+ }
+ if cells[5].Kind != CellEmpty {
+ t.Errorf("row5 = %+v, want empty gap", cells[5])
+ }
+}
+
+func TestPaintActivityTitleWhenNoDomain(t *testing.T) {
+ g := BuildGrid(30, 7*60, 8*60, nil)
+ segs := []activity.Segment{{Start: at(7, 0), End: at(7, 30), App: "Trace", Title: "notes.md"}}
+ cells := paintActivity(g, segs, nil)
+ if cells[0].Text != "notes.md" {
+ t.Errorf("row0 = %+v, want title label", cells[0])
+ }
+}
+
+func TestPaintActivityOverlapPrecedence(t *testing.T) {
+ g := BuildGrid(30, 9*60, 11*60, nil) // rows 09:00,09:30,10:00,10:30
+ segs := []activity.Segment{
+ {Start: at(9, 30), End: at(10, 0), App: "Safari", Domain: "github.com"},
+ {Start: at(10, 0), End: at(10, 30), Idle: true},
+ }
+ events := []activity.Event{
+ {Start: at(9, 0), End: at(11, 0), Title: "Sprint review", Calendar: "Archer"},
+ }
+ cells := paintActivity(g, segs, events)
+ // row0: calendar label — the human-meaningful anchor always wins
+ if cells[0].Kind != CellLabel || cells[0].Text != "▪ Sprint review" || cells[0].Act != ActCalendar {
+ t.Errorf("row0 = %+v, want calendar label", cells[0])
+ }
+ // row1: active segment beats the calendar continuation
+ if cells[1].Act != ActActive || cells[1].Text != "github.com" {
+ t.Errorf("row1 = %+v, want active over cal-cont", cells[1])
+ }
+ // row2: idle beats the calendar continuation
+ if cells[2].Act != ActIdle {
+ t.Errorf("row2 = %+v, want idle over cal-cont", cells[2])
+ }
+ // row3: nothing else there — calendar continuation fills the slot
+ if cells[3].Kind != CellCont || cells[3].Act != ActCalendar {
+ t.Errorf("row3 = %+v, want calendar continuation", cells[3])
+ }
+}
+
+func TestPaintActivityCalendarLabelBeatsActive(t *testing.T) {
+ g := BuildGrid(30, 9*60, 10*60, nil)
+ segs := []activity.Segment{{Start: at(9, 0), End: at(10, 0), App: "Safari", Domain: "github.com"}}
+ events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+ cells := paintActivity(g, segs, events)
+ if cells[0].Text != "▪ Standup" || cells[0].Act != ActCalendar {
+ t.Errorf("row0 = %+v, calendar label must beat active", cells[0])
+ }
+ if cells[1].Act != ActActive {
+ t.Errorf("row1 = %+v, active continues past the event", cells[1])
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/tui/grid -run TestPaintActivity -v`
+Expected: FAIL (build error) — `undefined: paintActivity` (and `ActActive` etc.)
+
+- [ ] **Step 3: Implement**
+
+In `internal/tui/grid/paint.go`, add `"ticktock/internal/activity"` to the imports, extend `Cell`, and append the painter. Replace the `Cell` declaration with:
+
+```go
+// Cell is one grid-column slot (LOGGED or ACTIVITY).
+type Cell struct {
+ Kind CellKind
+ Key string
+ Project string
+ Text string
+ Act ActKind // ACTIVITY-lane flavor; ActNone for LOGGED cells
+}
+
+// ActKind flavors an ACTIVITY-column cell for styling.
+type ActKind int
+
+const (
+ ActNone ActKind = iota
+ ActCalendar
+ ActActive
+ ActIdle
+)
+```
+
+Append to `internal/tui/grid/paint.go`:
+
+```go
+// Per-slot precedence ranks for the ACTIVITY lane. A calendar event's label
+// slot is the human-meaningful anchor and always shows; captured activity
+// beats idle; a calendar continuation fills otherwise-empty slots without
+// hiding activity. Equal rank ⇒ later-painted wins (mirrors paintLogged).
+const (
+ rankCalCont = 1
+ rankIdle = 2
+ rankActive = 3
+ rankCalLabel = 4
+)
+
+// activityLabel picks the display text for an active segment:
+// domain, else title, else app (real logs have segments with both empty).
+func activityLabel(s activity.Segment) string {
+ if s.Domain != "" {
+ return s.Domain
+ }
+ if s.Title != "" {
+ return s.Title
+ }
+ return s.App
+}
+
+// paintActivity builds the ACTIVITY column: one label on a segment/event's
+// first covered slot, continuation glyphs on later covered slots, exactly as
+// paintLogged does for tock entries, with rank-based overlap precedence.
+func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell {
+ cells := make([]Cell, g.Rows)
+ ranks := make([]int, g.Rows)
+ put := func(row, rank int, c Cell) {
+ if row < 0 || row >= g.Rows || rank < ranks[row] {
+ return
+ }
+ cells[row], ranks[row] = c, rank
+ }
+ span := func(start, end time.Time) (int, int) {
+ s := g.RowOf(minuteOfDay(start))
+ e := g.RowOf(minuteOfDay(end) - 1) // end exclusive, same as paintLogged
+ if e < s {
+ e = s
+ }
+ return s, e
+ }
+ for _, sg := range segs {
+ s, e := span(sg.Start, sg.End)
+ rank, act, label := rankActive, ActActive, activityLabel(sg)
+ if sg.Idle {
+ rank, act, label = rankIdle, ActIdle, "(idle)"
+ }
+ put(s, rank, Cell{Kind: CellLabel, Text: label, Act: act})
+ for r := s + 1; r <= e; r++ {
+ put(r, rank, Cell{Kind: CellCont, Act: act})
+ }
+ }
+ for _, ev := range events {
+ s, e := span(ev.Start, ev.End)
+ put(s, rankCalLabel, Cell{Kind: CellLabel, Text: "▪ " + ev.Title, Act: ActCalendar})
+ for r := s + 1; r <= e; r++ {
+ put(r, rankCalCont, Cell{Kind: CellCont, Act: ActCalendar})
+ }
+ }
+ return cells
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/tui/grid -v`
+Expected: PASS — all grid tests including the pre-existing paintLogged/view suites (Cell gained a zero-valued field only)
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(grid): paintActivity with calendar/active/idle overlap precedence"
+```
+
+---
+
+### Task 9: `internal/tui/grid` — Model wiring + View rendering
+
+**Files:**
+- Modify: `internal/tui/grid/model.go`
+- Modify: `internal/tui/grid/view.go`
+- Test: `internal/tui/grid/model_test.go`
+
+**Interfaces:**
+- Consumes: `paintActivity`, `ActKind` (Task 8); `activity.LoadDay`, `activity.LoadCalendar`, `activity.CalendarFilter` (Tasks 6-7).
+- Produces (used by Task 12):
+ - `type ActivitySource struct { DataDir, CalBin string; Filter activity.CalendarFilter }` — zero value disables the lane.
+ - `func (m Model) WithActivity(src ActivitySource) Model`
+ - `grid.New` signature unchanged; existing call sites keep compiling.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/tui/grid/model_test.go` (add `"os"`, `"path/filepath"`, and `"ticktock/internal/activity"` to its imports; `"strings"` is already imported):
+
+```go
+func TestEntriesMsgPaintsActivityLane(t *testing.T) {
+ m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+ segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+ events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+ nm, _ := m.Update(entriesMsg{segs: segs, events: events})
+ got := nm.(Model)
+ if len(got.activity) != got.grid.Rows {
+ t.Fatalf("activity lane not painted: %d cells for %d rows", len(got.activity), got.grid.Rows)
+ }
+ if got.activity[0].Kind != CellLabel || got.activity[0].Act != ActActive {
+ t.Errorf("row0 = %+v, want active label", got.activity[0])
+ }
+ view := got.View()
+ if !strings.Contains(view, "notes") {
+ t.Errorf("view should render the activity label, got:\n%s", view)
+ }
+ if !strings.Contains(view, "▪ Standup") {
+ t.Errorf("view should render the calendar label, got:\n%s", view)
+ }
+}
+
+func TestSelectionBandStillOverridesActivity(t *testing.T) {
+ m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+ segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+ nm, _ := m.Update(entriesMsg{segs: segs})
+ got := nm.(Model)
+ a := 0
+ got.anchor = &a
+ got.cursor = 1
+ view := got.View()
+ if !strings.Contains(view, "▓") {
+ t.Errorf("selection band must still render over the activity lane:\n%s", view)
+ }
+ if strings.Contains(view, "notes") {
+ t.Errorf("banded rows should not leak the activity label:\n%s", view)
+ }
+}
+
+func TestLoadFetchesActivityFromSource(t *testing.T) {
+ dir := t.TempDir()
+ line := `{"start": "2026-07-07T07:10:00", "end": "2026-07-07T07:40:00", "secs": 1800, "app": "Trace", "title": "notes", "url": "", "domain": "", "idle": false}` + "\n"
+ if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(line), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}).
+ WithActivity(ActivitySource{DataDir: dir})
+ msg := m.load()()
+ em, ok := msg.(entriesMsg)
+ if !ok {
+ t.Fatalf("load returned %T", msg)
+ }
+ if len(em.segs) != 1 || em.segs[0].App != "Trace" {
+ t.Fatalf("load should fetch the day's segments, got %+v", em.segs)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/tui/grid -v`
+Expected: FAIL (build error) — `unknown field segs in struct literal of type entriesMsg` / `undefined: ActivitySource`
+
+- [ ] **Step 3: Implement the Model wiring**
+
+In `internal/tui/grid/model.go`:
+
+Add `"ticktock/internal/activity"` to the imports.
+
+Add fields to `Model` (after `logged []Cell`):
+
+```go
+ src ActivitySource
+ segs []activity.Segment
+ events []activity.Event
+ activity []Cell
+```
+
+Add after the `Model` struct:
+
+```go
+// ActivitySource tells the grid where to read the ACTIVITY lane's data.
+// The zero value disables the lane (static gap lane, as before).
+type ActivitySource struct {
+ DataDir string // ~/.local/share/ticktock; "" disables activity
+ CalBin string // path to the cal-events helper; "" disables calendar
+ Filter activity.CalendarFilter
+}
+
+// WithActivity returns a copy of the model wired to read activity + calendar
+// data on every load.
+func (m Model) WithActivity(src ActivitySource) Model {
+ m.src = src
+ return m
+}
+```
+
+Replace `entriesMsg`:
+
+```go
+type entriesMsg struct {
+ entries []store.Entry
+ segs []activity.Segment
+ events []activity.Event
+ err error
+}
+```
+
+Replace `load`:
+
+```go
+func (m Model) load() tea.Cmd {
+ date, s, src := m.date, m.store, m.src
+ return func() tea.Msg {
+ es, err := s.Day(date)
+ segs, _ := activity.LoadDay(src.DataDir, date) // tolerant: never fails the view
+ events, _ := activity.LoadCalendar(src.CalBin, date, src.Filter)
+ return entriesMsg{entries: es, segs: segs, events: events, err: err}
+ }
+}
+```
+
+(The `n`/`p`/`t`/`r` day-nav paths all funnel through `m.load()`, so they pick this up with no further change.)
+
+In `rebuild`, after `m.logged = paintLogged(m.grid, m.entries)`, add:
+
+```go
+ m.activity = paintActivity(m.grid, m.segs, m.events)
+```
+
+In `Update`, replace the `entriesMsg` case body:
+
+```go
+ case entriesMsg:
+ m.entries, m.segs, m.events, m.err = msg.entries, msg.segs, msg.events, msg.err
+ m.rebuild()
+ return m, nil
+```
+
+- [ ] **Step 4: Implement the View rendering**
+
+In `internal/tui/grid/view.go`:
+
+Add the calendar accent style to the `var (...)` block (after `gDelHi`):
+
+```go
+ gCal = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) // calendar accent
+```
+
+Replace the ACTIVITY block inside `View`'s row loop:
+
+```go
+ // ACTIVITY column: gap lane, band if selected
+ act := strings.Repeat("░", actWidth)
+ if selecting && row >= lo && row <= hi {
+ act = gBand.Render(strings.Repeat("▓", actWidth))
+ }
+```
+
+with:
+
+```go
+ // ACTIVITY column: painted lane (calendar/active/idle) over the gap
+ // lane; the selection band still overrides while selecting.
+ act := strings.Repeat("░", actWidth)
+ if row < len(m.activity) && m.activity[row].Kind != CellEmpty {
+ act = renderActCell(m.activity[row])
+ }
+ if selecting && row >= lo && row <= hi {
+ act = gBand.Render(strings.Repeat("▓", actWidth))
+ }
+```
+
+Append to `internal/tui/grid/view.go`:
+
+```go
+// padCell pads s with spaces to exactly w runes. The activity cell must be
+// padded BEFORE styling: gridRow's %-*s pads by byte length, which ANSI
+// escapes would defeat, misaligning the column divider.
+func padCell(s string, w int) string {
+ if n := len([]rune(s)); n < w {
+ return s + strings.Repeat(" ", w-n)
+ }
+ return s
+}
+
+// renderActCell paints one ACTIVITY-lane cell: label text or continuation
+// glyph, colored by flavor — calendar accent, idle dimmed, active plain;
+// continuation glyphs render faint.
+func renderActCell(c Cell) string {
+ text := trunc(c.Text, actWidth)
+ if c.Kind == CellCont {
+ text = "│"
+ }
+ text = padCell(text, actWidth)
+ style := lipgloss.NewStyle()
+ switch c.Act {
+ case ActCalendar:
+ style = gCal
+ case ActIdle:
+ style = gDim
+ }
+ if c.Kind == CellCont {
+ style = style.Faint(true)
+ }
+ return style.Render(text)
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `go test ./internal/tui/grid -v && go build ./...`
+Expected: PASS — new tests plus every pre-existing grid test (New's signature is unchanged; `cmd/ticktock` still compiles)
+
+- [ ] **Step 6: Commit**
+
+```bash
+jj commit -m "feat(grid): render activity + calendar lane in the ACTIVITY column"
+```
+
+---
+
+## Task group A — writer, OS integration (darwin)
+
+### Task 10: `internal/autotrack` — OS sampling (parsers, darwin.go, stub.go, HelperPath)
+
+**Files:**
+- Create: `internal/autotrack/sample.go` (portable)
+- Create: `internal/autotrack/darwin.go` (`//go:build darwin`)
+- Create: `internal/autotrack/stub.go` (`//go:build !darwin`)
+- Test: `internal/autotrack/sample_test.go`
+
+**Interfaces:**
+- Consumes: `Ctx`, `Domain` (Task 2).
+- Produces (used by Tasks 11, 12):
+ - `type Sample struct { Idle int; Raw *Ctx; Locked bool }`
+ - `func ReadSample() Sample` (darwin: real sampling; !darwin: empty)
+ - `func HelperPath(name string) string` — bundled helper next to the resolved `tt` binary, else `$PATH`, else `""`.
+ - portable parsers `parseHIDIdle(string) int`, `parseLocked(string) bool`, `parseWinctx([]byte) (app, title string)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/autotrack/sample_test.go`:
+
+```go
+package autotrack
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParseHIDIdle(t *testing.T) {
+ out := `+-o IOHIDSystem <class IOHIDSystem, id 0x100000456>
+ {
+ "HIDIdleTime" = 2216064029
+ }`
+ if got := parseHIDIdle(out); got != 2 { // 2216064029 ns → 2 s
+ t.Errorf("parseHIDIdle=%d, want 2", got)
+ }
+ if got := parseHIDIdle("no idle info here"); got != 0 {
+ t.Errorf("absent key should give 0, got %d", got)
+ }
+ if got := parseHIDIdle(`"HIDIdleTime" = garbage`); got != 0 {
+ t.Errorf("malformed value should give 0, got %d", got)
+ }
+}
+
+func TestParseLocked(t *testing.T) {
+ if !parseLocked(` "CGSSessionScreenIsLocked" = Yes`) {
+ t.Error("locked output should parse true")
+ }
+ if parseLocked("") || parseLocked(`"SomethingElse"=Yes`) {
+ t.Error("unlocked output should parse false")
+ }
+}
+
+func TestParseWinctx(t *testing.T) {
+ app, title := parseWinctx([]byte(`{"app":"Trace","title":"notes — repo"}`))
+ if app != "Trace" || title != "notes — repo" {
+ t.Errorf("got %q/%q", app, title)
+ }
+ if app, title := parseWinctx([]byte("garbage")); app != "" || title != "" {
+ t.Errorf("bad json should give empties, got %q/%q", app, title)
+ }
+}
+
+func TestHelperPathFallsBackToPATH(t *testing.T) {
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "fakehelper")
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir)
+ if got := HelperPath("fakehelper"); got != bin {
+ t.Errorf("HelperPath=%q, want %q", got, bin)
+ }
+ if got := HelperPath("definitely-not-a-helper"); got != "" {
+ t.Errorf("missing helper should give empty, got %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/autotrack -v`
+Expected: FAIL (build error) — `undefined: parseHIDIdle`
+
+- [ ] **Step 3: Implement the portable half**
+
+Create `internal/autotrack/sample.go`:
+
+```go
+package autotrack
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Sample is one OS observation handed to the segmentation loop.
+type Sample struct {
+ Idle int // HID idle seconds
+ Raw *Ctx // foreground context; nil when no foreground app
+ Locked bool // screen locked (hard, immediate away signal)
+}
+
+// parseHIDIdle extracts HIDIdleTime (nanoseconds) from `ioreg -c IOHIDSystem`
+// output and returns whole seconds. 0 when absent or malformed.
+func parseHIDIdle(out string) int {
+ for _, line := range strings.Split(out, "\n") {
+ if !strings.Contains(line, "HIDIdleTime") {
+ continue
+ }
+ i := strings.LastIndex(line, "=")
+ if i < 0 {
+ continue
+ }
+ ns, err := strconv.ParseInt(strings.TrimSpace(line[i+1:]), 10, 64)
+ if err != nil {
+ continue
+ }
+ return int(ns / 1_000_000_000)
+ }
+ return 0
+}
+
+// parseLocked reports whether ioreg output shows the lock flag. The
+// CGSSessionScreenIsLocked key is present only while the screen is locked.
+func parseLocked(out string) bool {
+ return strings.Contains(strings.ReplaceAll(out, " ", ""), `"CGSSessionScreenIsLocked"=Yes`)
+}
+
+// parseWinctx decodes the winctx helper's {"app","title"} JSON. Empty on error.
+func parseWinctx(b []byte) (app, title string) {
+ var v struct {
+ App string `json:"app"`
+ Title string `json:"title"`
+ }
+ if err := json.Unmarshal(b, &v); err != nil {
+ return "", ""
+ }
+ return v.App, v.Title
+}
+
+// HelperPath finds a bundled native helper (winctx, cal-events): first next
+// to the symlink-resolved tt binary (make install symlinks ~/.local/bin/tt →
+// <repo>/bin/tt, and the helpers build into <repo>/bin), then on $PATH.
+// "" when not found — callers degrade gracefully.
+func HelperPath(name string) string {
+ if exe, err := os.Executable(); err == nil {
+ if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil {
+ p := filepath.Join(filepath.Dir(resolved), name)
+ if st, serr := os.Stat(p); serr == nil && !st.IsDir() {
+ return p
+ }
+ }
+ }
+ if p, err := exec.LookPath(name); err == nil {
+ return p
+ }
+ return ""
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS
+
+- [ ] **Step 5: Implement the darwin half and the stub**
+
+Create `internal/autotrack/darwin.go`:
+
+```go
+//go:build darwin
+
+package autotrack
+
+import (
+ "context"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// osTimeout bounds every OS shell-out so a wedged osascript can never stall
+// the poll loop (parity with the Python daemon's timeout=4).
+const osTimeout = 4 * time.Second
+
+// browsers maps app names to the osascript that reads their active-tab URL.
+var browsers = map[string]string{
+ "Safari": `tell application "Safari" to get URL of front document`,
+ "Google Chrome": `tell application "Google Chrome" to get URL of active tab of front window`,
+ "Brave Browser": `tell application "Brave Browser" to get URL of active tab of front window`,
+ "Microsoft Edge": `tell application "Microsoft Edge" to get URL of active tab of front window`,
+ "Arc": `tell application "Arc" to get URL of active tab of front window`,
+ "Dia": `tell application "Dia" to get URL of active tab of front window`,
+}
+
+// runCmd executes a command with the standard timeout, returning stdout
+// ("" on any failure — an OS hiccup must never panic the loop).
+func runCmd(name string, args ...string) string {
+ ctx, cancel := context.WithTimeout(context.Background(), osTimeout)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, name, args...).Output()
+ if err != nil {
+ return ""
+ }
+ return string(out)
+}
+
+func osa(script string) string { return strings.TrimSpace(runCmd("osascript", "-e", script)) }
+
+func frontApp() string {
+ return osa(`tell application "System Events" to get name of first application process whose frontmost is true`)
+}
+
+func frontTitle() string {
+ return osa(`tell application "System Events" to tell (first application process whose frontmost is true) to get name of front window`)
+}
+
+func browserURL(app string) string {
+ script, ok := browsers[app]
+ if !ok {
+ return ""
+ }
+ return osa(script)
+}
+
+func idleSecs() int { return parseHIDIdle(runCmd("ioreg", "-c", "IOHIDSystem")) }
+
+func screenLocked() bool {
+ return parseLocked(runCmd("ioreg", "-n", "Root", "-d1", "-r", "-k", "CGSSessionScreenIsLocked"))
+}
+
+// winctxApp reads the frontmost {app, title} via the bundled AX helper
+// (richer titles than System Events). Empties when the helper is missing,
+// denied, or errors — the caller falls back to osascript.
+func winctxApp() (string, string) {
+ bin := HelperPath("winctx")
+ if bin == "" {
+ return "", ""
+ }
+ out := strings.TrimSpace(runCmd(bin))
+ if out == "" {
+ return "", ""
+ }
+ return parseWinctx([]byte(out))
+}
+
+// readContext samples the current foreground context; nil when no app.
+// Prefers the AX helper's app+title, falling back to System Events per field.
+func readContext() *Ctx {
+ app, title := winctxApp()
+ if app == "" {
+ app = frontApp()
+ }
+ if app == "" {
+ return nil
+ }
+ if title == "" {
+ title = frontTitle()
+ }
+ u := browserURL(app)
+ return &Ctx{App: app, Title: title, URL: u, Domain: Domain(u)}
+}
+
+// ReadSample gathers one full OS observation for the poll loop.
+func ReadSample() Sample {
+ return Sample{Idle: idleSecs(), Raw: readContext(), Locked: screenLocked()}
+}
+```
+
+Create `internal/autotrack/stub.go`:
+
+```go
+//go:build !darwin
+
+package autotrack
+
+// ReadSample on non-darwin platforms returns an empty observation so the
+// package builds and its pure tests run everywhere; the daemon is
+// darwin-only.
+func ReadSample() Sample { return Sample{} }
+```
+
+- [ ] **Step 6: Verify both build-tag halves compile and tests still pass**
+
+Run: `go build ./... && GOOS=linux go build ./internal/autotrack && go test ./internal/autotrack -v`
+Expected: both builds succeed; tests PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+jj commit -m "feat(autotrack): darwin OS sampling with !darwin stub and portable parsers"
+```
+
+---
+
+### Task 11: `internal/autotrack/run.go` — poll loop with signal flush
+
+**Files:**
+- Create: `internal/autotrack/run.go`
+- Test: `internal/autotrack/run_test.go`
+
+**Interfaces:**
+- Consumes: `Sample`, `Classify`, `Away`, `Step`, `Key`, `WriteSegment` (Tasks 2-4, 10); `config.Tracking` (Task 1).
+- Produces (used by Task 12): `func Run(dataDir string, tr config.Tracking, sample func() Sample, sigc <-chan os.Signal)` — loops until a signal arrives, then flushes the open segment and returns.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/autotrack/run_test.go`:
+
+```go
+package autotrack
+
+import (
+ "os"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "ticktock/internal/config"
+)
+
+func TestRunFlushesOpenSegmentOnSignal(t *testing.T) {
+ dir := t.TempDir()
+ sigc := make(chan os.Signal, 1)
+ sample := func() Sample {
+ return Sample{Idle: 0, Raw: &Ctx{App: "TestApp", Title: "doc"}}
+ }
+ done := make(chan struct{})
+ go func() {
+ Run(dir, config.Tracking{PollSecs: 1, IdleGraceSecs: 900, MinSecs: 0}, sample, sigc)
+ close(done)
+ }()
+ time.Sleep(50 * time.Millisecond) // let the first poll open a segment
+ sigc <- syscall.SIGTERM
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return after signal")
+ }
+ b, err := os.ReadFile(LogPath(dir, time.Now()))
+ if err != nil {
+ t.Fatalf("flush should have written today's log: %v", err)
+ }
+ line := strings.TrimSpace(string(b))
+ if !strings.Contains(line, `"app": "TestApp"`) || !strings.Contains(line, `"idle": false`) {
+ t.Errorf("flushed segment malformed: %s", line)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/autotrack -run TestRunFlushes -v`
+Expected: FAIL (build error) — `undefined: Run`
+
+- [ ] **Step 3: Implement**
+
+Create `internal/autotrack/run.go`:
+
+```go
+package autotrack
+
+import (
+ "os"
+ "time"
+
+ "ticktock/internal/config"
+)
+
+// Run executes the poll loop: sample → Classify → Step → WriteSegment →
+// sleep, until a signal arrives on sigc; then it flushes the open segment and
+// returns (parity with the Python daemon's SIGTERM/SIGINT flush). sample is
+// injected (ReadSample in production) so the loop is testable.
+func Run(dataDir string, tr config.Tracking, sample func() Sample, sigc <-chan os.Signal) {
+ var cur *Segment
+ var curKey Key
+ poll := time.Duration(tr.PollSecs) * time.Second
+ for {
+ now := time.Now()
+ s := sample()
+ hard := s.Locked || Away(s.Raw)
+ ctx := Classify(s.Idle, s.Raw, tr.IdleGraceSecs, s.Locked)
+ var emitted *Segment
+ cur, curKey, emitted = Step(cur, curKey, now, s.Idle, ctx, tr.IdleGraceSecs, hard)
+ if emitted != nil {
+ _ = WriteSegment(dataDir, emitted, tr.MinSecs) // an IO hiccup must not kill the loop
+ }
+ select {
+ case <-sigc:
+ if cur != nil {
+ closed := *cur
+ closed.End = time.Now()
+ _ = WriteSegment(dataDir, &closed, tr.MinSecs)
+ }
+ return
+ case <-time.After(poll):
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/autotrack -v`
+Expected: PASS — including `TestRunFlushesOpenSegmentOnSignal`
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "feat(autotrack): Run poll loop with SIGTERM/SIGINT segment flush"
+```
+
+---
+
+### Task 12: `cmd/ticktock` — autotrack subcommand + timeline activity wiring
+
+**Files:**
+- Modify: `cmd/ticktock/main.go`
+- Create: `cmd/ticktock/main_test.go`
+
+**Interfaces:**
+- Consumes: `autotrack.Run`, `autotrack.ReadSample`, `autotrack.Classify`, `autotrack.HelperPath`, `activity.LoadCalendar`, `activity.CalendarFilter`, `config.Load` (`.Tracking`, `.Calendars`), `grid.ActivitySource`, `Model.WithActivity`.
+- Produces: `tt autotrack` (poll loop, what launchd invokes), `tt autotrack --once`, `tt autotrack --list-calendars`; `tt timeline` now shows the activity lane.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `cmd/ticktock/main_test.go`:
+
+```go
+package main
+
+import "testing"
+
+func TestRootHasAutotrackCommand(t *testing.T) {
+ root := newRootCmd()
+ cmd, _, err := root.Find([]string{"autotrack"})
+ if err != nil || cmd == nil || cmd.Name() != "autotrack" {
+ t.Fatalf("autotrack subcommand missing: %v", err)
+ }
+ if cmd.Flags().Lookup("once") == nil {
+ t.Error("autotrack should have --once")
+ }
+ if cmd.Flags().Lookup("list-calendars") == nil {
+ t.Error("autotrack should have --list-calendars")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./cmd/ticktock -v`
+Expected: FAIL — `autotrack subcommand missing: ...`
+
+- [ ] **Step 3: Implement**
+
+In `cmd/ticktock/main.go`:
+
+Extend the imports:
+
+```go
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/spf13/cobra"
+
+ "ticktock/internal/activity"
+ "ticktock/internal/autotrack"
+ "ticktock/internal/config"
+ "ticktock/internal/history"
+ "ticktock/internal/store"
+ "ticktock/internal/tui/day"
+ "ticktock/internal/tui/form"
+ "ticktock/internal/tui/grid"
+ "ticktock/internal/tui/menu"
+)
+```
+
+Register the subcommand in `newRootCmd` (after `root.AddCommand(newMenuCmd())`):
+
+```go
+ root.AddCommand(newAutotrackCmd())
+```
+
+Replace `newTimelineCmd`'s `RunE` body's model construction line:
+
+```go
+ m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
+```
+
+with:
+
+```go
+ cfg := config.Load()
+ m := grid.New(store.New(store.ExecRunner{}), cfg, resolveDate(arg), loadSuggest()).
+ WithActivity(activitySource(cfg))
+```
+
+Append the new functions:
+
+```go
+// dataDir is the shared activity-log directory (unchanged from the Python
+// daemon): ~/.local/share/ticktock.
+func dataDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".local", "share", "ticktock"), nil
+}
+
+// activitySource wires the grid's ACTIVITY lane: the shared data dir, the
+// cal-events helper, and the calendars.show/hide filter from config.
+func activitySource(cfg config.Config) grid.ActivitySource {
+ dir, err := dataDir()
+ if err != nil {
+ dir = "" // lane degrades to the static gap lane
+ }
+ return grid.ActivitySource{
+ DataDir: dir,
+ CalBin: autotrack.HelperPath("cal-events"),
+ Filter: activity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide},
+ }
+}
+
+func newAutotrackCmd() *cobra.Command {
+ var once, listCals bool
+ cmd := &cobra.Command{
+ Use: "autotrack",
+ Short: "Passive activity daemon (runs under launchd; writes only its staging log)",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if listCals {
+ return runListCalendars()
+ }
+ if once {
+ return runOnce()
+ }
+ dir, err := dataDir()
+ if err != nil {
+ return err
+ }
+ sigc := make(chan os.Signal, 1)
+ signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
+ autotrack.Run(dir, config.Load().Tracking, autotrack.ReadSample, sigc)
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&once, "once", false, "sample once and print the classified context")
+ cmd.Flags().BoolVar(&listCals, "list-calendars", false, "print distinct calendar names cal-events reports for today")
+ return cmd
+}
+
+// runOnce samples and prints the classified context — parity with the Python
+// daemon's --once, for quick permission/sanity checks.
+func runOnce() error {
+ s := autotrack.ReadSample()
+ ctx := autotrack.Classify(s.Idle, s.Raw, config.Load().Tracking.IdleGraceSecs, s.Locked)
+ switch {
+ case ctx == nil:
+ fmt.Println("no foreground app")
+ case ctx.Idle:
+ fmt.Println("idle")
+ default:
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+ return enc.Encode(ctx)
+ }
+ return nil
+}
+
+// runListCalendars prints the distinct calendar names cal-events reports for
+// today, unfiltered, so the user can copy exact names into calendars.show /
+// calendars.hide in config.json.
+func runListCalendars() error {
+ bin := autotrack.HelperPath("cal-events")
+ if bin == "" {
+ return fmt.Errorf("cal-events helper not found — build it with `make helpers`")
+ }
+ events, err := activity.LoadCalendar(bin, time.Now().Format("2006-01-02"), activity.CalendarFilter{})
+ if err != nil {
+ return err
+ }
+ seen := map[string]bool{}
+ for _, e := range events {
+ if !seen[e.Calendar] {
+ seen[e.Calendar] = true
+ fmt.Println(e.Calendar)
+ }
+ }
+ if len(seen) == 0 {
+ fmt.Println("(no events today — try a busier day, or check Calendar permission for cal-events)")
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./... && go build ./...`
+Expected: PASS across all packages; clean build
+
+- [ ] **Step 5: Manual smoke check (darwin)**
+
+Run: `go build -o bin/tt ./cmd/ticktock && ./bin/tt autotrack --once`
+Expected: a JSON object like `{"app": "...", "title": "...", "url": "", "domain": "", "idle": false}` (or `idle` / `no foreground app`). First run may trigger a macOS Automation permission prompt for System Events — approve it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+jj commit -m "feat(cmd): tt autotrack subcommand (--once, --list-calendars) + timeline activity wiring"
+```
+
+---
+
+## Infrastructure & cutover
+
+### Task 13: Vendored Swift helpers + Makefile targets
+
+**Files:**
+- Create: `native/winctx.swift` (vendored verbatim from `/Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift`)
+- Create: `native/cal-events.swift` (vendored verbatim from `/Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift`)
+- Modify: `Makefile`
+
+**Interfaces:**
+- Consumes: nothing from Go code.
+- Produces: `bin/winctx`, `bin/cal-events` (found by `autotrack.HelperPath` because they sit next to `bin/tt`); `make helpers` target that skips gracefully without `swiftc`.
+
+- [ ] **Step 1: Vendor the sources**
+
+```bash
+mkdir -p native
+cp /Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift native/winctx.swift
+cp /Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift native/cal-events.swift
+```
+
+Then verify the copies are byte-identical:
+
+Run: `diff native/winctx.swift /Users/kortum/Developer/Home/ticktock-old/bin/winctx.swift && diff native/cal-events.swift /Users/kortum/Developer/Home/ticktock-old/bin/cal-events.swift && echo identical`
+Expected: `identical`
+
+(For reference, `winctx.swift` prints `{"app":"<name>","title":"<focused window title>"}` via the AX API; `cal-events.swift` takes `<from YYYY-MM-DD> [to]` and prints a JSON array of `{title, start, end, calendar}` for non-all-day events via EventKit. Do not modify them — they are the working binaries' exact sources.)
+
+- [ ] **Step 2: Add the Makefile targets**
+
+Replace the entire `Makefile` with:
+
+```make
+.PHONY: build test install vet helpers
+build:
+ go build -o bin/tt ./cmd/ticktock
+test:
+ go test ./...
+vet:
+ go vet ./...
+# Native Swift helpers: winctx (AX window titles) and cal-events (EventKit).
+# Skips gracefully without swiftc — the daemon falls back to System Events
+# titles and the timeline simply shows no calendar lane.
+helpers:
+ @if command -v swiftc >/dev/null 2>&1; then \
+ mkdir -p bin; \
+ swiftc -O native/winctx.swift -o bin/winctx && echo "built bin/winctx"; \
+ swiftc -O native/cal-events.swift -o bin/cal-events && echo "built bin/cal-events"; \
+ else \
+ echo "swiftc not found — skipping native helpers (titles fall back to System Events; no calendar lane)"; \
+ fi
+install: build helpers
+ ln -sfn "$(PWD)/bin/tt" "$(HOME)/.local/bin/tt"
+```
+
+- [ ] **Step 3: Verify the build**
+
+Run: `make helpers`
+Expected: `built bin/winctx` and `built bin/cal-events` (this machine has swiftc; on a machine without it, the skip note prints and make exits 0)
+
+Run: `./bin/winctx`
+Expected: one JSON line, e.g. `{"app":"Terminal","title":"..."}` (title may be empty until Accessibility is granted to `bin/winctx` — System Settings → Privacy & Security → Accessibility)
+
+Run: `./bin/cal-events $(date +%F)`
+Expected: a JSON array (possibly `[]`); first run may prompt for Calendar access — grant it
+
+Run: `./bin/tt autotrack --list-calendars` (after `make build`)
+Expected: distinct calendar names, one per line (e.g. `Archer`, `Personal`, …)
+
+- [ ] **Step 4: Commit**
+
+```bash
+jj commit -m "build: vendor winctx/cal-events swift helpers with graceful-skip make target"
+```
+
+---
+
+### Task 14: launchd cutover — plist template, install script, integration verification
+
+**Files:**
+- Create: `deploy/autotrack.plist.template`
+- Create: `deploy/install-autotrack.sh`
+
+**Interfaces:**
+- Consumes: `bin/tt` (Task 12), the live launchd agent `com.humdrum.ticktock.autotrack` currently pointing at `/Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py`.
+- Produces: the agent repointed at `tt autotrack`; same label, same `StandardOut/ErrPath` under `~/.local/share/ticktock/`. Rollback = re-run `ticktock-old/daemon/install-daemon.sh`.
+
+- [ ] **Step 1: Create the plist template**
+
+Create `deploy/autotrack.plist.template`:
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.humdrum.ticktock.autotrack</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>@TT@</string>
+ <string>autotrack</string>
+ </array>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>KeepAlive</key>
+ <true/>
+ <key>ProcessType</key>
+ <string>Background</string>
+ <key>StandardOutPath</key>
+ <string>@HOME@/.local/share/ticktock/autotrack.log</string>
+ <key>StandardErrorPath</key>
+ <string>@HOME@/.local/share/ticktock/autotrack.err</string>
+</dict>
+</plist>
+```
+
+- [ ] **Step 2: Create the install script**
+
+Create `deploy/install-autotrack.sh` and `chmod +x` it:
+
+```bash
+#!/usr/bin/env bash
+# Repoint the com.humdrum.ticktock.autotrack launchd agent at `tt autotrack`.
+#
+# Rollback: the Python daemon in ticktock-old is untouched — re-run
+# ~/Developer/Home/ticktock-old/daemon/install-daemon.sh
+# (note: it installs under the old com.ticktock.autotrack label; bootout the
+# com.humdrum one first) or hand-edit the plist back to autotrack.py.
+set -euo pipefail
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TT="$REPO/bin/tt"
+LABEL="com.humdrum.ticktock.autotrack"
+PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
+
+[ -x "$TT" ] || { echo "missing $TT — run: make build helpers" >&2; exit 1; }
+mkdir -p "${HOME}/.local/share/ticktock" "${HOME}/Library/LaunchAgents"
+
+sed -e "s#@TT@#${TT}#g" -e "s#@HOME@#${HOME}#g" \
+ "$REPO/deploy/autotrack.plist.template" > "$PLIST"
+
+launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+launchctl bootstrap "gui/$(id -u)" "$PLIST"
+echo "✓ ${LABEL} → ${TT} autotrack (${PLIST})"
+echo " verify: launchctl print gui/$(id -u)/${LABEL} | grep -E 'state|program'"
+echo " logs: tail -f ~/.local/share/ticktock/autotrack.err"
+```
+
+- [ ] **Step 3: Full pre-cutover verification**
+
+Run each and confirm:
+
+1. `make build helpers && make test` — Expected: helpers built, `ok` for every package.
+2. `./bin/tt autotrack --once` — Expected: JSON context (or `idle`).
+3. Short live run: `./bin/tt autotrack & sleep 45; kill -TERM %1; wait` — Expected: exits 0; then
+ `tail -2 ~/.local/share/ticktock/activity-$(date +%F).jsonl` shows a fresh line in the exact Python shape (`{"start": "...", "end": "...", "secs": NN, "app": "...", ...}`) whose `end` is within the last minute. (The Python daemon is still running under launchd at this point — interleaved lines from both writers are fine, the reader tolerates them.)
+4. `./bin/tt timeline` — Expected: the ACTIVITY column shows today's captured segments (labels + `│` continuations, `(idle)` dimmed) and calendar events (`▪ Title` in the blue accent); space-selection still draws the `▓` band over the lane; `n`/`p`/`t`/`r` reload the lane for other days.
+5. Optional: add `"calendars": {"show": ["Archer"]}` to `~/.config/ticktock/config.json` and re-open `tt timeline` — Expected: only Archer events in the lane. (Use `./bin/tt autotrack --list-calendars` for exact names.)
+
+- [ ] **Step 4: Cut launchd over**
+
+```bash
+./deploy/install-autotrack.sh
+launchctl print "gui/$(id -u)/com.humdrum.ticktock.autotrack" | grep -E "state|program"
+```
+
+Expected: `state = running` and `program` pointing at `.../ticktock-go/bin/tt`. Then confirm the Go daemon is writing:
+
+```bash
+sleep 30 && tail -1 ~/.local/share/ticktock/activity-$(date +%F).jsonl
+```
+
+Expected: a fresh segment line (end timestamp within the last minute).
+
+Note: the daemon now runs `bin/winctx` from launchd; if window titles come back empty, grant Accessibility to `bin/winctx` (System Settings → Privacy & Security → Accessibility). The daemon still runs without it (System Events fallback).
+
+**Rollback (any time):** repoint the plist back at the Python daemon —
+`launchctl bootout gui/$(id -u)/com.humdrum.ticktock.autotrack` then edit `~/Library/LaunchAgents/com.humdrum.ticktock.autotrack.plist`'s `ProgramArguments` back to `[/usr/bin/python3, /Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py]` and `launchctl bootstrap gui/$(id -u) <plist>`. The Python daemon and its data format are untouched.
+
+- [ ] **Step 5: Commit**
+
+```bash
+jj commit -m "deploy: launchd plist template + install script for tt autotrack cutover"
+```
+
+- [ ] **Step 6: Post-cutover (next day, manual)**
+
+After a normal day on the Go daemon: skim `tt timeline` for yesterday+today, spot-check `activity-*.jsonl` for sane segments (no zero-length spam, idle blocks where expected). Once satisfied, `ticktock-old` can be archived. Not a code step — record the outcome in the project doc.
+
+---
+
+## Verification summary
+
+| Spec requirement | Covered by |
+|---|---|
+| segment.go pure port (Ctx, Classify, CtxKey, Step) + table tests | Tasks 2, 3 |
+| Record byte-compatible `{start,end,secs,app,title,url,domain,idle}` | Task 4 (pinned against a real Python-written line) |
+| log.go WriteSegment (min-secs drop, daily file, mkdir) | Task 4 |
+| darwin.go OS IO + !darwin stub, timeouts, browser map, winctx fallback | Task 10 |
+| run.go poll loop + SIGTERM/SIGINT flush | Task 11 |
+| config Tracking block (defaults 5/900/15, tolerant) | Task 1 |
+| config calendars show/hide | Task 5 |
+| `tt autotrack`, `--once`, `--list-calendars` | Task 12 |
+| Makefile swiftc targets, graceful skip, vendored sources | Task 13 |
+| launchd template + cutover + rollback | Task 14 |
+| activity.Segment/Event, LoadDay tolerant parse | Task 6 |
+| LoadCalendar + CalendarFilter (show/hide, case-insensitive) | Task 7 |
+| paintActivity (labels, continuation, overlap precedence) | Task 8 |
+| grid Model activity field, rebuild/load/day-nav wiring, View render (accent, dimmed idle, ▓ band wins) | Task 9 |
+| integration/cutover, archive note | Task 14 |
docs/superpowers/specs/2026-07-08-tt-autotrack-daemon-and-activity-timeline-design.md +214 −0
@@ -0,0 +1,214 @@
+# 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-<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:
+
+```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-<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 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 → `▪ <title>`; 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`.