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, binarytt. - All OS integration is darwin-only behind
//go:build darwinbuild tags, with a!darwinstub (stub.goreturns 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-oldstays untouched as rollback: repoint the plist back toautotrack.pyif the Go daemon misbehaves. Archiveticktock-oldonly after a verified normal day. - Native Swift helpers (
winctx,cal-events) are built from vendored source via Makefileswiftctargets that skip gracefully (print a note, continue) whenswiftcis 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 usesjj commit -m "type: msg"(which finalizes the current working copy and starts a new one). NEVER rungit 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)
- 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, mirroringpaintLogged's "later-start wins". - 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. - Byte parity needs a hand-rolled encoder. Go's
encoding/jsonemits{"start":"…"(no spaces) and HTML-escapes<>&; Python'sjson.dumpsemits{"start": "…",and doesn't.Record.EncodeLinetherefore formats the line manually withjson.Encoder.SetEscapeHTML(false)for strings. - Config comparability. Adding
Calendars([]stringfields) makesconfig.Confignon-comparable; the existingc != Default()test is updated toreflect.DeepEqualin Task 5. - launchd label. The live agent is
com.humdrum.ticktock.autotrack(checked~/Library/LaunchAgents/); the old repo template sayscom.ticktock.autotrack. Per spec ("label unchanged"), we use the live labelcom.humdrum.ticktock.autotrack. - Grid window is not expanded by activity.
BuildGridkeeps expanding only for tock entries; activity/calendar spans outside the window clamp to the first/last row viaGrid.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 keystracking.poll_secs,tracking.idle_grace_secs,tracking.min_secs. Later tasks callcfg.Tracking(run loop) — exact field names matter. -
Step 1: Write the failing tests
Append to internal/config/config_test.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:
// 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:
// 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:
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
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 tagsapp,title,url,domain,idle)type Segment struct { Ctx; Start, End time.Time }func Domain(rawURL string) stringfunc Away(raw *Ctx) boolfunc Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctxtype 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:
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:
// 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
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(appendStep) - Test:
internal/autotrack/segment_test.go(append tests)
Interfaces:
-
Consumes:
Ctx,Segment,Key,CtxKeyfrom 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'sRun. -
Step 1: Write the failing tests
Append to internal/autotrack/segment_test.go (add "time" to its imports):
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:
// 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
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:
Segmentfrom 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:
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:
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
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.DeepEqualfix)
Interfaces:
-
Consumes:
Config,rawfrom Task 1. -
Produces:
type Calendars struct { Show, Hide []string };Config.Calendars Calendars; JSONcalendars.show/calendars.hide. Task 12 buildsactivity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide}from this. -
Step 1: Write the failing tests
Append to internal/config/config_test.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:
if c != Default() {
with:
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):
Calendars Calendars
Add the type below Tracking:
// 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:
Calendars *rawCalendars `json:"calendars"`
and the mirror type:
type rawCalendars struct {
Show []string `json:"show"`
Hide []string `json:"hide"`
}
In LoadFrom, after the Tracking merge block, add:
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
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:
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:
// 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
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 }withfunc (f CalendarFilter) Allow(name string) boolfunc 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:
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:
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
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 intwithActNone, ActCalendar, ActActive, ActIdleCellgains fieldAct ActKind(zeroActNonefor 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):
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:
// 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:
// 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
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) Modelgrid.Newsignature 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):
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):
src ActivitySource
segs []activity.Segment
events []activity.Event
activity []Cell
Add after the Model struct:
// 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:
type entriesMsg struct {
entries []store.Entry
segs []activity.Segment
events []activity.Event
err error
}
Replace load:
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:
m.activity = paintActivity(m.grid, m.segs, m.events)
In Update, replace the entriesMsg case body:
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):
gCal = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) // calendar accent
Replace the ACTIVITY block inside View's row loop:
// ACTIVITY column: gap lane, band if selected
act := strings.Repeat("░", actWidth)
if selecting && row >= lo && row <= hi {
act = gBand.Render(strings.Repeat("▓", actWidth))
}
with:
// 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:
// 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
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 resolvedttbinary, 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:
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:
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: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: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
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:
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:
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
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 timelinenow shows the activity lane. -
Step 1: Write the failing test
Create cmd/ticktock/main_test.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:
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())):
root.AddCommand(newAutotrackCmd())
Replace newTimelineCmd's RunE body's model construction line:
m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
with:
cfg := config.Load()
m := grid.New(store.New(store.ExecRunner{}), cfg, resolveDate(arg), loadSuggest()).
WithActivity(activitySource(cfg))
Append the new functions:
// 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
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 byautotrack.HelperPathbecause they sit next tobin/tt);make helperstarget that skips gracefully withoutswiftc. -
Step 1: Vendor the sources
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:
.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
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 agentcom.humdrum.ticktock.autotrackcurrently pointing at/Users/kortum/Developer/Home/ticktock-old/daemon/autotrack.py. -
Produces: the agent repointed at
tt autotrack; same label, sameStandardOut/ErrPathunder~/.local/share/ticktock/. Rollback = re-runticktock-old/daemon/install-daemon.sh. -
Step 1: Create the plist template
Create deploy/autotrack.plist.template:
<?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:
#!/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:
make build helpers && make test— Expected: helpers built,okfor every package../bin/tt autotrack --once— Expected: JSON context (oridle).- Short live run:
./bin/tt autotrack & sleep 45; kill -TERM %1; wait— Expected: exits 0; thentail -2 ~/.local/share/ticktock/activity-$(date +%F).jsonlshows a fresh line in the exact Python shape ({"start": "...", "end": "...", "secs": NN, "app": "...", ...}) whoseendis 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.) ./bin/tt timeline— Expected: the ACTIVITY column shows today's captured segments (labels +│continuations,(idle)dimmed) and calendar events (▪ Titlein the blue accent); space-selection still draws the▓band over the lane;n/p/t/rreload the lane for other days.- Optional: add
"calendars": {"show": ["Archer"]}to~/.config/ticktock/config.jsonand re-opentt timeline— Expected: only Archer events in the lane. (Use./bin/tt autotrack --list-calendarsfor exact names.)
- Step 4: Cut launchd over
./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:
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
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 |