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

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