feat(autotrack): Step segmentation state machine with soft/hard idle anchoring
3c66bf64baecdb351cf109fed56c05df51ae026d
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:08
parent 02291b31
2 files changed
internal/autotrack/segment.go +56 −0
@@ -85,3 +85,59 @@ rest = c.Title
}
return Key{Valid: true, App: c.App, Rest: rest}
}
+
+// 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
+}
internal/autotrack/segment_test.go +104 −1
@@ -1,6 +1,9 @@
package autotrack
-import "testing"
+import (
+ "testing"
+ "time"
+)
func TestDomain(t *testing.T) {
cases := []struct{ url, want string }{
@@ -61,3 +64,103 @@ if CtxKey(&Ctx{App: "Trace", Title: "a"}) == CtxKey(&Ctx{App: "Trace", Title: "b"}) {
t.Error("different titles should be different activities")
}
}
+
+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)
+ }
+}