# 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 ``, 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` — `/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 — café "x"`, URL: "https://example.com/a?b=1&c=2", Domain: "example.com", } got := r.EncodeLine() if !strings.Contains(got, `"title": "R&D — 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-.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 ` `; 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 `▪ `; 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 |