From e5c32563d0f86e698d502ed3e952f7683a125138 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 21:02:23 -0400 Subject: [PATCH] Render on/off-task split in the reflection block --- internal/session/roles.go | 45 ++++++++++++++++++++----- internal/session/session_test.go | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/internal/session/roles.go b/internal/session/roles.go index f9127d7..52f448d 100644 --- a/internal/session/roles.go +++ b/internal/session/roles.go @@ -263,9 +263,10 @@ func (c *Controller) startReflectionFetchLocked() { } // buildReflectionFinishedLocked renders the just-finished session as a compact -// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set -// (End clears them, but enterReview runs before End). Reuses bucketViews for the -// per-window totals, already sorted desc by seconds. +// block for the reviewer: the commitment, the outcome, on/off/unclassified time +// totals, and a top-N on-task list and top-N off-task list. The split fields are +// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still +// set (End clears them, but enterReview runs before End). func (c *Controller) buildReflectionFinishedLocked() string { var na, sc string if c.commitment != nil { @@ -280,17 +281,43 @@ func (c *Controller) buildReflectionFinishedLocked() string { fmt.Fprintf(&b, "Success condition: %s\n", sc) fmt.Fprintf(&b, "Outcome: %s\n", outcome) if c.stats != nil { + onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60 + offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60 + unclMin := int64(c.stats.unclassified.Seconds()) / 60 + fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin) fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount) - for i, bv := range bucketViews(c.stats.Buckets) { - if i >= reflectionTopBuckets { - break - } - fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60) - } + writeBucketList(&b, "On-task", c.stats.OnTask) + writeBucketList(&b, "Off-task", c.stats.OffTask) } return strings.TrimRight(b.String(), "\n") } +// writeBucketList renders a labeled, time-descending list of buckets capped at +// reflectionTopBuckets. It writes nothing — not even the label — when the map is +// empty, so a single-sided session shows only the list that has time. +func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) { + views := bucketViews(m) + if len(views) == 0 { + return + } + fmt.Fprintf(b, "%s:\n", label) + for i, bv := range views { + if i >= reflectionTopBuckets { + break + } + fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60) + } +} + +// sumDurations totals the durations in a bucket map. +func sumDurations(m map[bucketKey]time.Duration) time.Duration { + var total time.Duration + for _, d := range m { + total += d + } + return total +} + // buildReflectionHistory renders the last few prior sessions as compact lines. // The just-finished session is not yet in the chain (End appends it), so it is // not double-counted. Returns "" when there is no usable history. diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 3232e3a..cb67479 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1408,3 +1408,60 @@ func TestCreditLockedSplitsByDriftStatus(t *testing.T) { t.Errorf("Buckets total = %v, want 20s", got) } } + +func TestReflectionBlockShowsOnOffSplit(t *testing.T) { + c, _ := newTestController(t) + startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats + + c.stats.SwitchCount = 2 + c.stats.OnTask = map[bucketKey]time.Duration{ + {Class: "code", Title: "main.go"}: 20 * time.Minute, + } + c.stats.OffTask = map[bucketKey]time.Duration{ + {Class: "firefox", Title: "YouTube"}: 8 * time.Minute, + {Class: "firefox", Title: "Reddit"}: 4 * time.Minute, + } + c.stats.unclassified = 5 * time.Minute + c.outcomePending = "completed" + + c.mu.Lock() + got := c.buildReflectionFinishedLocked() + c.mu.Unlock() + + want := "Next action: write report\n" + + "Success condition: report drafted\n" + + "Outcome: completed\n" + + "On-task 20m / Off-task 12m / Unclassified 5m\n" + + "Context switches: 2\n" + + "On-task:\n" + + "- code · main.go: 20m\n" + + "Off-task:\n" + + "- firefox · YouTube: 8m\n" + + "- firefox · Reddit: 4m" + if got != want { + t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) { + c, _ := newTestController(t) + startActive(t, c, nil) + + c.stats.OnTask = map[bucketKey]time.Duration{ + {Class: "code", Title: "main.go"}: 20 * time.Minute, + } + c.stats.OffTask = map[bucketKey]time.Duration{} // none + c.stats.unclassified = 0 + c.outcomePending = "completed" + + c.mu.Lock() + got := c.buildReflectionFinishedLocked() + c.mu.Unlock() + + if strings.Contains(got, "Off-task:") { + t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got) + } + if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") { + t.Errorf("totals line missing or wrong, got:\n%s", got) + } +}