▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
87.1 KB raw

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:

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):

Design decisions locked here (flagged deviations/interpretations)

  1. Per-slot ACTIVITY precedence ranks. The spec lists: calendar label at its start slot > active segment > idle > empty. It doesn't rank calendar continuation slots; we rank them just above empty so a meeting stays visible in otherwise-quiet slots but never hides captured activity: CalLabel(4) > Active(3) > Idle(2) > CalCont(1) > Empty(0). Ties (two segments contesting a slot at equal rank) go to the later-painted segment, mirroring paintLogged's "later-start wins".
  2. Activity label fallback. Spec says label is <domain-or-title>, but real logs have segments with empty title and domain (e.g. app "Trace"). Label falls back domain → title → app so those rows aren't blank.
  3. Byte parity needs a hand-rolled encoder. Go's encoding/json emits {"start":"…" (no spaces) and HTML-escapes <>&; Python's json.dumps emits {"start": "…", and doesn't. Record.EncodeLine therefore formats the line manually with json.Encoder.SetEscapeHTML(false) for strings.
  4. Config comparability. Adding Calendars ([]string fields) makes config.Config non-comparable; the existing c != Default() test is updated to reflect.DeepEqual in Task 5.
  5. launchd label. The live agent is com.humdrum.ticktock.autotrack (checked ~/Library/LaunchAgents/); the old repo template says com.ticktock.autotrack. Per spec ("label unchanged"), we use the live label com.humdrum.ticktock.autotrack.
  6. Grid window is not expanded by activity. BuildGrid keeps expanding only for tock entries; activity/calendar spans outside the window clamp to the first/last row via Grid.RowOf (the lane is display-only in this port).

Task group A — writer, pure core (portable, no OS calls)

Task 1: internal/config — Tracking block

Files:

Interfaces:

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)
	}
}

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)

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
		}
	}

Run: go test ./internal/config -v Expected: PASS (all tests including pre-existing TestDefault, TestLoadFromMissingFileReturnsDefaults, TestLoadFromMergesAndClampsSlot)

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:

Interfaces:

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")
	}
}

Run: go test ./internal/autotrack -v Expected: FAIL (build error) — no Go files in .../internal/autotrack or undefined: Domain

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}
}

Run: go test ./internal/autotrack -v Expected: PASS — TestDomain, TestClassify, TestCtxKey

jj commit -m "feat(autotrack): pure context types — Ctx, Domain, Classify, CtxKey"

Task 3: internal/autotrack/segment.go — Step state machine

Files:

Interfaces:

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)
	}
}

Run: go test ./internal/autotrack -v Expected: FAIL (build error) — undefined: Step

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
}

Run: go test ./internal/autotrack -v Expected: PASS — all TestStep* plus Task 2 tests

jj commit -m "feat(autotrack): Step segmentation state machine with soft/hard idle anchoring"

Task 4: internal/autotrack/log.go — Record encoding + WriteSegment

Files:

Interfaces:

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)
	}
}

Run: go test ./internal/autotrack -v Expected: FAIL (build error) — undefined: Record

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
}

Run: go test ./internal/autotrack -v Expected: PASS — all autotrack tests

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:

Interfaces:

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.

Run: go test ./internal/config -v Expected: FAIL (build error) — c.Calendars undefined (type Config has no field or method Calendars)

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
	}

Run: go test ./internal/config -v Expected: PASS

jj commit -m "feat(config): calendars show/hide block for timeline calendar filtering"

Task 6: internal/activity — Segment + LoadDay

Files:

Interfaces:

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)
	}
}

Run: go test ./internal/activity -v Expected: FAIL (build error) — no Go files in .../internal/activity or undefined: LoadDay

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
}

Run: go test ./internal/activity -v Expected: PASS — TestLoadDayParsesAndSkipsMalformed, TestLoadDayMissingFileIsEmptyNoError, TestRoundTripWithWriter

jj commit -m "feat(activity): tolerant LoadDay jsonl reader with writer round-trip parity"

Task 7: internal/activity — Event, CalendarFilter, LoadCalendar

Files:

Interfaces:

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)
	}
}

Run: go test ./internal/activity -v Expected: FAIL (build error) — undefined: parseEvents

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
}

Run: go test ./internal/activity -v Expected: PASS — all calendar + activity tests

jj commit -m "feat(activity): cal-events loader with show/hide CalendarFilter"

Task 8: internal/tui/grid — Cell.Act + paintActivity

Files:

Interfaces:

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])
	}
}

Run: go test ./internal/tui/grid -run TestPaintActivity -v Expected: FAIL (build error) — undefined: paintActivity (and ActActive etc.)

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
}

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)

jj commit -m "feat(grid): paintActivity with calendar/active/idle overlap precedence"

Task 9: internal/tui/grid — Model wiring + View rendering

Files:

Interfaces:

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)
	}
}

Run: go test ./internal/tui/grid -v Expected: FAIL (build error) — unknown field segs in struct literal of type entriesMsg / undefined: ActivitySource

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

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)
}

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)

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:

Interfaces:

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)
	}
}

Run: go test ./internal/autotrack -v Expected: FAIL (build error) — undefined: parseHIDIdle

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 ""
}

Run: go test ./internal/autotrack -v Expected: PASS

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{} }

Run: go build ./... && GOOS=linux go build ./internal/autotrack && go test ./internal/autotrack -v Expected: both builds succeed; tests PASS

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:

Interfaces:

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)
	}
}

Run: go test ./internal/autotrack -run TestRunFlushes -v Expected: FAIL (build error) — undefined: Run

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):
		}
	}
}

Run: go test ./internal/autotrack -v Expected: PASS — including TestRunFlushesOpenSegmentOnSignal

jj commit -m "feat(autotrack): Run poll loop with SIGTERM/SIGINT segment flush"

Task 12: cmd/ticktock — autotrack subcommand + timeline activity wiring

Files:

Interfaces:

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")
	}
}

Run: go test ./cmd/ticktock -v Expected: FAIL — autotrack subcommand missing: ...

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
}

Run: go test ./... && go build ./... Expected: PASS across all packages; clean build

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.

jj commit -m "feat(cmd): tt autotrack subcommand (--once, --list-calendars) + timeline activity wiring"

Infrastructure & cutover

Task 13: Vendored Swift helpers + Makefile targets

Files:

Interfaces:

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.)

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"

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, …)

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:

Interfaces:

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>

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"

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.)
./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.

jj commit -m "deploy: launchd plist template + install script for tt autotrack cutover"

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