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