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

feat(autotrack): pure context types — Ctx, Domain, Classify, CtxKey

02291b31c0402d88177f741c8b4b11eb11c174aa
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:04

parent 39ee59ad

2 files changed

internal/autotrack/segment.go +87 −0
@@ -0,0 +1,87 @@
+// 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}
+}
internal/autotrack/segment_test.go +63 −0
@@ -0,0 +1,63 @@
+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")
+	}
+}