Files
routstrd/docs/plans/tui-history-charts.md
redshift 815f1139b7 fix(daemon): align windowed totals with History chart ranges
The windowed aggregates and the History timeline used different range
boundaries, so a tab's totals did not match the sum of its chart bars:

- today: totals covered the calendar day since local midnight, while the
  chart drew a rolling, hour-aligned last 24 hours.
- 7d/30d: totals were exact rolling (now - N*24h), while the chart snapped
  to whole local days, leaving up to 24h of drift at the left edge.

Extract the chart's bucket alignment into windowRangeStartUtc() and derive
windowAfter() from it, so both sides cover exactly the same range: 24 whole
local hours for today, 7/30 whole local days ending today. This also makes
the today window match its '24h' label in the TUI.

Tests: rename the calendar-day test, add a 24h boundary test (yesterday's
hours included, 25h ago excluded), and assert bars sum to window totals for
the today/7d/30d timelines.
2026-10-13 14:16:21 +08:00

3.2 KiB

TUI "History" tab with adaptive bar charts

Status: implemented. Branch feat/tui-history-charts (worktree .worktrees/tui-history-charts, based on main @ 5069b2d).

Goal

Replace the old Today tab (a totals box + a text "Recent Days" list + a horizontal hourly bar list) with a History tab that draws usage over time as bar charts, with the bucketing driven by the existing [W] time-window selector.

[W] window Bucket Bars
24h hour 24
7d day 7
30d day 30
All month one per calendar month since first usage

Y-axis is total spend; a token sparkline shares the x-axis (scaled independently to peak tokens, with both totals in the legend). This also makes the tab window-aware: the selector was previously hidden on it.

Backend — src/daemon/http/usage-summary.ts

  • New TimelineBucket (hour | day | month), TimelinePoint and Timeline types; WindowedStats gains a required timeline: Timeline.
  • buildTimeline(driver, baseFilter, window, now, tzOffsetMinutes):
    • today (24h) → aggregate({ groupBy: "hour" }) over the last 24 local-aligned hours. The SDK's hour key is HH with no date, so the window is aligned to whole hours (each hour-of-day appears at most once).
    • 7d / 30d → aggregate({ groupBy: "day" }) over the last N days.
    • all → aggregate({ groupBy: "day" }) with no after, folded into YYYY-MM months. The SDK has no month grouping, hence the fold.
    • Every series is zero-filled so the x-axis is evenly spaced.
  • buildTimeline is called once per summary build (after buildAggregates) and attached to both the all-time and windowed blocks. The zero-data short-circuit returns an empty timeline.

TUI

  • types.ts — TabId "today" → "history"; re-export the timeline types.
  • constants.ts — tab renamed to History; tabSupportsWindow now excludes only Overview and Recent.
  • render.ts — renderToday replaced by renderHistory + renderTimelineChart (vertical bars, y-axis gutter, x labels, token sparkline, legend). Long series are merged to fit narrow terminals.
  • data.ts — the old-daemon fallback windowFromAllTime supplies an empty timeline.

Tests

  • src/daemon/http/usage-summary.test.ts — hourly / 7-day / 30-day / month-folded timelines, including zero-fill and window totals.
  • src/tui/usage/render.test.ts — axis, labels, sparkline, legend, empty state, and full-width alignment of every rendered line.

Notes / limits

  • Daemons too old to return timeline still render: the History tab falls back to an empty chart rather than erroring.
  • The 24h window is hour-aligned rather than a rolling now - 24h, so each hour-of-day appears once and the HH keys stay unambiguous.
  • The windowed aggregates use the same bucket-aligned boundary as the chart (windowRangeStartUtc), so a tab's totals always equal the sum of its bars: 24 whole hours for 24h, whole local days for 7d/30d. Before this, the 24h totals were calendar-day (since local midnight) while the chart drew a rolling 24h, and 7d/30d totals were exact rolling windows while the chart snapped to whole days.