Split session time into on/off/unclassified buckets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:58:16 -04:00
parent 6b99804132
commit 7eb2b5dc58
3 changed files with 72 additions and 10 deletions
+37 -9
View File
@@ -11,14 +11,17 @@ type bucketKey struct{ Class, Title string }
// EvidenceStats is the in-memory accounting for the current session only.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
OnTask map[bucketKey]time.Duration // on-task portion per bucket
OffTask map[bucketKey]time.Duration // off-task portion per bucket
unclassified time.Duration // idle/pending time; aggregate only
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
// applyEvent advances stats by one observation: it credits the prior segment to
@@ -26,7 +29,7 @@ type EvidenceStats struct {
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
}
newKey := keyFor(snap)
if c.stats.hasLast && newKey != c.stats.lastKey {
@@ -38,6 +41,24 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
c.stats.Current = snap
}
// creditLocked credits duration d to bucket k: always to the running total, and
// to the on/off/unclassified split per the live drift status — which, at every
// credit site, is the classification of the segment being credited (applyEvent
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
// before the state transition). idle/pending route to unclassified: honest, never
// falsely on-task. Caller holds mu.
func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
c.stats.Buckets[k] += d
switch c.driftStatus {
case driftOnTask:
c.stats.OnTask[k] += d
case driftDrifting:
c.stats.OffTask[k] += d
default: // driftIdle, driftPending
c.stats.unclassified += d
}
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
@@ -46,6 +67,8 @@ func (c *Controller) replayStats(sessionID string) {
SessionID: sessionID,
StartedUnix: c.clock().Unix(),
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
return
}
@@ -53,7 +76,12 @@ func (c *Controller) replayStats(sessionID string) {
SessionID: sessionID,
StartedUnix: events[0].AtUnixMillis / 1000,
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
// Drift status is not persisted, so replayed pre-crash segments credit to
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
// only the on/off split degrades — honest, never falsely on-task.
for _, e := range events {
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
}