Render on/off-task split in the reflection block

This commit is contained in:
2026-06-01 21:02:23 -04:00
parent 7eb2b5dc58
commit e5c32563d0
2 changed files with 93 additions and 9 deletions
+36 -9
View File
@@ -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.
+57
View File
@@ -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)
}
}