Skip to content

feat(overview): one range selector across every panel - #90

Closed
Fl0p wants to merge 5 commits into
mainfrom
flo-588-overview-range
Closed

feat(overview): one range selector across every panel#90
Fl0p wants to merge 5 commits into
mainfrom
flo-588-overview-range

Conversation

@Fl0p

@Fl0p Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the Overview page around a single time-range selector, and extends the range API contract so every panel can honour it. Implements ADR-0014, building on ADR-0011 and ADR-0012.

API

range is now accepted on four endpoints, with defaults chosen so no existing caller's meaning changes:

Endpoint Default Notes
/api/v1/overview month replaces the hardcoded 30-day window; every figure is range-scoped and the resolved range is echoed
/api/v1/sessions all adds covered_since (RFC3339 or null)
/api/v1/costs month explicit from/to win and range is ignored
/api/v1/models all echoes the resolved range

users_count on /overview previously ignored both the range and the user_id filter (SELECT COUNT(DISTINCT user_id) FROM spans, no clause) — it now honours both.

Long ranges (year, all) resolve against the spansdaily_usage union at the ADR-0011 raw-floor split, reusing the rangeUsageCTE pattern from internal/storage/users.go. Answering year from spans alone after retention has purged them is precisely the defect ADR-0011 exists to prevent. The sessions list is the one metric that genuinely cannot come from the union — daily_usage cannot reconstruct a session's start time, model or status — so it clamps to the raw floor and reports the real window via covered_since rather than silently absorbing the gap. Unrecognised range values fall back to the endpoint default and never 400.

Frontend

  • One SegmentedControl in the header, persisted through useRangeCookie('cotel_overview_range') (its own key), beside the RefreshIndicator — matching the Users page toolbar. Every section obeys it, including Tools which previously hardcoded 'all'.
  • New Users section at the top of the stack, top 5 by cost, served by the existing useUsersPage — no new endpoint.
  • Section order is now Users → History → Costs → Tools → Models → Sessions.
  • The Costs section drops its inner by_model table; the Models section below is the same data at full width.
  • KPI labels use RANGE_SUFFIX instead of a hardcoded (30d); All renders no suffix.
  • UserSearch is deleted (nothing else imported it), but ?user_id= keeps working: the page shows a clearable indicator of who it is scoped to and keeps passing user_id to every section.
  • Loading, empty and error states are handled on every section, including the new Users one.

Tests

internal/api/overview_range_test.go seeds spans and daily_usage rows straddling the raw floor and asserts: day/week/month are answered from raw spans; year/all include the aggregate rows; the floor day is not double counted from both sides; range and user_id compose; and /sessions and /models with no range return exactly what they return today.

Verification

  • CGO_ENABLED=1 go test ./... in golang:1.23-bookworm — green, all 9 packages. go vet ./... clean. (No native Go toolchain on the build box; CI is authoritative.)
  • npm ci && npm run build (tsc && vite build) clean in frontend/.
  • Browser-verified, not assumed: rebuilt the Go binary after npm run build so the go:embeded assets were current, then drove / with playwright-core against /usr/bin/chromium, on a 113 MB copy of production first run through a real retention roll-up (138,420 of 167,119 raw spans purged, so everything before 2026-07-21 survives only as aggregates). The numbers move and the long ranges hold: All/Year 1246 sessions / $1502.90, Month 127 / $6.16, Week and Day 0. Answered from spans alone, year would have collapsed onto the month figure — the ADR-0011 defect. /sessions clamps to 252 rows and reports its covered_since.

Risk

Medium — four public endpoints gain a parameter and three gain response fields, and this is the dashboard's front door. No schema, migration, auth, billing or CI changes.

Implemented by Wayland; reviewed and approved by Daedalus at 13e949a.

Summary by CodeRabbit

  • New Features

    • Added shared date-range selection across Overview dashboard sections and related API endpoints.
    • Added URL-based user filtering with a clearable header chip.
    • Added top-five user spending, expanded dashboard sections, and coverage indicators for limited historical data.
    • Added API metadata describing selected ranges and session coverage.
  • Bug Fixes

    • Corrected user counts, cost and token totals, and queries involving missing session IDs.
    • Restored reliable span filtering and duration reporting.
  • Documentation

    • Updated API, dashboard, README, and architecture documentation for the new range and filtering behavior.

Daedalus and others added 4 commits August 20, 2026 22:27
…beys

The Overview stacks five panels on three different time windows: KPIs and
Costs on a hardcoded 30 days, History on 30 days computed in the page,
Sessions and Models on all time with no filter available, Tools on the
range key it already accepts. Only the KPI labels name their window, and
they do it as a literal "(30d)" in the string.

Extend the range contract from ADR-0011/0012 to /overview, /sessions,
/costs and /models rather than translating in the frontend (which cannot
scope three of them at all, and would answer year/all from spans alone
after retention has deleted them) or adding a second aggregated endpoint
that would drift from the pages each section links to.

Defaults preserve current behaviour instead of converging: month for
/overview and /costs, all for /sessions and /models. Explicit from/to
beats range on /costs. Long ranges resolve against the spans ∪
daily_usage union. The sessions list cannot reconstruct rows for
rolled-up days, so it clamps to raw coverage and reports covered_since
rather than absorbing the shortfall.

Co-Authored-By: Daedalus <daedalus@agents.flopbut.local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… models

The dashboard's read endpoints disagreed on what "now" means: /overview was
hardcoded to 30 days, /sessions and /models had no time filter at all, and
/costs defaulted to 30 days via from/to. A single Overview range selector is
unsatisfiable while those four windows are independent.

All four now accept `range` with the same five keys, the same fallback-don't-400
rule, and the same spans-union-daily_usage resolution as /users and /tools.
Defaults preserve today's behaviour rather than converging: /overview and /costs
default to month, /sessions and /models to all, so no existing caller's meaning
changes. Explicit from/to on /costs still beat the range key.

Long ranges resolve over the shared usageCTE, the ADR-0011 raw-floor split, so
year and all keep answering after retention has deleted the raw spans instead of
silently repeating the month figure. The sessions list is the one panel that
cannot come from the roll-up - daily_usage keeps no start time, model or status -
so it stays raw-only, clamps, and reports covered_since rather than absorbing the
shortfall.

Also fixes users_count, which ignored both the range and the user_id filter, and
counted the anonymous bucket as zero principals rather than one.

Co-Authored-By: Wayland <wayland@agents.flopbut.local>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Overview was five windows stacked vertically: the KPIs showed 30 days, the
Sessions and Models blocks showed all time, History and Costs 30 days each by
their own arithmetic, and only the KPI labels said which — as a literal (30d)
baked into the string. A reader comparing the Sessions KPI against the Models
table below it was comparing 30 days against all time.

The header now carries one SegmentedControl, persisted under its own
cotel_overview_range cookie so it cannot move the Users or Tools page, and every
section takes the selection: Users, History (hour granularity on Day, day
otherwise), Costs, Tools, Models, Sessions. KPI labels take their suffix from
RANGE_SUFFIX; All renders none.

A new Users block leads the stack with the top 5 principals by spend, answered
by the existing /users list rather than a new endpoint. Sessions moves to the
bottom, as the one block that cannot honour a long range, and states the window
it actually covers when the server clamps it. The Costs block drops its inner
by-model table — the Models block below it is the same data at full width.

The user-search typeahead is gone and UserSearch with it; nothing else imported
it. ?user_id= still scopes the page, and now says so: a chip in the header names
the user and clears the scope on click, instead of a silently filtered page.

Co-Authored-By: Wayland <wayland@agents.flopbut.local>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sessions

Two disagreements survived the range rework, both found by driving the page
against a copy of production after a real retention roll-up.

/history reads raw spans only and has no range parameter, so on a long range it
charted a visibly shorter window than the Costs panel beside it with nothing
saying so — the exact defect the single selector exists to remove. Its coverage
starts at the same raw floor the sessions list already reports, so the page now
states it in one line, the same way the Sessions block does. The two share one
request: same SWR key, not a second fetch.

/sessions also counted a span with an empty session_id as a session of its own.
That produced a list row whose link 404s, and put the Sessions page one ahead of
the Overview's session count for the same window. An empty session_id is not a
session — it is what the roll-up records as the unknown sentinel — so both the
count and the list now exclude it.

Co-Authored-By: Wayland <wayland@agents.flopbut.local>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b0543b9-9b06-409c-b984-9ea3b80083fa

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20dbd074-8d26-4dcc-8f48-139bd4b96182

📥 Commits

Reviewing files that changed from the base of the PR and between 9c7c38d and 13e949a.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • README.md
  • docs/decisions/0014-overview-single-range-selector.md
  • docs/decisions/index.md
  • docs/design/pages.md
  • docs/operations/api-reference.md
  • frontend/src/api/index.ts
  • frontend/src/components/UserSearch.module.css
  • frontend/src/components/UserSearch.tsx
  • frontend/src/components/index.tsx
  • frontend/src/pages/Overview.module.css
  • frontend/src/pages/Overview.tsx
  • internal/api/handler.go
  • internal/api/overview_range_test.go
  • internal/dashboard/static/index.html

📝 Walkthrough

Walkthrough

The change adds shared range support to summary APIs, combines raw and daily usage data, updates the Overview dashboard to use one range selector, replaces user search with URL scoping, and adds API tests and documentation.

Changes

Overview range unification

Layer / File(s) Summary
Range contract and documentation
CHANGELOG.md, README.md, docs/decisions/..., docs/operations/api-reference.md
The API and dashboard documentation define shared ranges, endpoint defaults, coverage behavior, cost-bound precedence, and scoped user counts.
Range-aware API aggregation
internal/api/handler.go, internal/api/overview_range_test.go
Handlers combine raw spans with daily_usage, apply range and user filters, return coverage metadata, and validate overview, sessions, costs, and models behavior.
Dashboard range and user scope
frontend/src/api/index.ts, frontend/src/pages/Overview.tsx, frontend/src/pages/Overview.module.css, frontend/src/components/index.tsx, docs/design/pages.md, internal/dashboard/static/index.html
Frontend hooks and response types support ranges and retained loading data. Overview renders the selected range, URL user scope, coverage notes, and the new section order. The user-search export is removed and built asset references are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant APIClient
  participant Overview
  participant internal_api_handler
  participant spans
  participant daily_usage
  APIClient->>Overview: select range
  Overview->>internal_api_handler: request overview data with range and user_id
  internal_api_handler->>spans: query raw usage
  internal_api_handler->>daily_usage: query rolled-up usage
  spans-->>internal_api_handler: raw usage rows
  daily_usage-->>internal_api_handler: daily usage rows
  internal_api_handler-->>Overview: range-scoped metrics and coverage
  Overview-->>APIClient: render dashboard sections and labels
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch flo-588-overview-range

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Squash-merged to main locally as 2ff50d4 (author and committer = Prospero, per the repo identity rule — server-side squash would land as Flop/GitHub). CI was green on this PR head before the merge: Build & Test, Smoke test, and docs all passed. Closing and deleting the branch.

@Fl0p Fl0p closed this Aug 20, 2026
@Fl0p
Fl0p deleted the flo-588-overview-range branch August 20, 2026 21:42
@Fl0p
Fl0p restored the flo-588-overview-range branch August 20, 2026 21:48
@Fl0p Fl0p reopened this Aug 20, 2026
@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Reopened. Context for anyone reading this thread cold:

Why it got closed: this repo merges default-branch PRs by squashing locally (identity rule — a server-side gh pr merge --squash lands as author Flop / committer GitHub). GitHub cannot detect a local squash, so the PR has to be closed by hand afterwards. That is why it reads "Closed" rather than "Merged" even though the code shipped as 2ff50d4.

What that cost us: CodeRabbit started reviewing at 21:39 and the PR was closed at 21:42 — three minutes later. Its review aborted with "Review failed: The pull request is closed." All that survived was the walkthrough. There were no findings, because no review ever ran.

State now: branch restored at 13e949a, PR reopened. The squash on main is a fresh commit off 9c7c38d, so the merge base is unchanged and this PR still shows the true 15-file diff — CodeRabbit can review it properly. git diff 13e949a 2ff50d4 is empty, so what is under review here is byte-for-byte what is serving production.

Requesting the full review now. Anything it finds gets fixed as new commits on this branch, and those commits are the delta that lands on main.

@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 54 minutes.

@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

CodeRabbit is rate-limited@coderabbitai full review came back with "Review rate limited … your next included review will be available in 54 minutes." So the automated review still has not run on this change. I will re-trigger it once the window opens; a push to this branch also re-triggers it automatically, so any fix commit will pull a fresh review in with it.

In the meantime, my own pass over the diff (15 files, +1242/-397).

Ticket points — all five present in the code:

Asked for In the diff
Drop the user cloud + user search UserSearch.tsx and .module.css deleted, export removed from components/index.tsx
Range selector in the header, all data follows it SegmentedControl in the header; every section takes range
Users block first, top 5 + view all UsersSection is the first StatSection, limit: 5 sorted by cost desc, viewAllHref="/users"
Sessions to the bottom Order is Users → History → Costs → Tools → Models → Sessions
Drop the models block inside Costs CostsSection is chart-only now; the standalone Models section below is untouched

Every section handles loading, error and empty separately — no blank panels.

The one thing that looked like a bug, and why it is not. usageCTE maps the roll-up sentinel back to NULL for session_id, model and tool_name, but not for user_id. That asymmetry is what an unknown-principal leak would look like, so I chased it: the roll-up writes COALESCE(NULLIF(…), sentinel) for exactly those three PK columns and plain MAX(user_id) for the user, and every write path into both tables (InsertSpans, ImportSpans, ImportDailyUsage) funnels the user through nullableStr, so `` never reaches either table. The anonymous bucket is NULL-only on both sides of the UNION, which is what COUNT(DISTINCT user_id) + CASE WHEN … IS NULL assumes. Correct as written — validated at the boundary, not re-checked per query.

Also spot-checked and clean: the CTE argument order matches placeholder order (raw filter, then aggregate), and the aggregate side is bounded du.day < raw_floor strictly, so the boundary day is not counted on both sides.

Two notes, neither blocking:

  1. HistorySection derives its own window client-side from RANGE_DAYS while every other section passes range to the server and lets rangeSince compute it. Two sources of truth for one selector — the History chart can disagree with the panels beside it at a boundary. This is already the subject of a separate in-flight ticket, so I am leaving it there rather than widening this PR.
  2. users_count on the overview counts principals active in the selected range, while the Users page lists registered users plus Anonymous. Both defensible, but they will show different numbers for the same moment. If that stat is meant to read as "active users", the label should say so.

Nothing here blocks. Holding the PR open for the CodeRabbit pass.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@Fl0p, thanks for the detailed verification.

Your checks support the usageCTE handling. nullableStr makes the NULL-only anonymous-principal invariant valid across raw spans and daily aggregates. The strict du.day < raw_floor boundary also prevents duplicate coverage on the floor day.

The two noted semantic differences are clear:

  • HistorySection has a separate in-flight range-boundary follow-up.
  • users_count measures active principals in the selected range. The Users page measures registered users plus Anonymous.

The full automated review remains pending until the rate-limit window allows a new request.
🐇


✏️ Learnings added
Learnt from: Fl0p
URL: https://github.com/Flopsstuff/cotel/pull/90

Timestamp: 2026-08-20T21:52:16.772Z
Learning: In this Go application, all user identifiers written through `InsertSpans`, `ImportSpans`, and `ImportDailyUsage` pass through `nullableStr`, so empty strings do not reach `spans` or `daily_usage`. `user_id` uses NULL as its only anonymous-principal representation across the raw-plus-rollup `usageCTE`; unlike `session_id`, `model`, and `tool_name`, it does not use the roll-up sentinel.

Learnt from: Fl0p
URL: https://github.com/Flopsstuff/cotel/pull/90

Timestamp: 2026-08-20T21:52:16.772Z
Learning: On the Overview page, `users_count` represents distinct principals active in the selected range, including a NULL `user_id` as the Anonymous principal. The Users page instead lists registered users plus Anonymous, so its total can differ from the Overview KPI at the same time.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

The KPI reads COUNT(DISTINCT user_id) over the selected range, so it counts
principals with usage in that window. The Users page lists registered users
whether or not they were active, so the two totals legitimately differ and
the bare "Users" label invited reading them as the same number.

Co-Authored-By: Daedalus <daedalus@agents.flopbut.local>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 0640eac — the first of the two things my own pass flagged, fixed rather than left as a note.

The users KPI now reads "Active Users". It is COUNT(DISTINCT user_id) over the selected range, so it counts principals with usage in that window. The Users page it sits above lists registered users whether or not they were active — all_users is built from the users table LEFT JOINed to usage, so a user who did nothing in the last 30 days is a row there and is not in this count. Two different populations under one word: "Users (30d)" next to a Users page showing a larger total reads like a bug in one of them. Renaming the KPI is the honest fix; the number was already right.

Also added the KPI to the field table in docs/design/pages.md, which never listed it, with the distinction spelled out.

Verified: npm run build (tsc && vite build) clean; regenerated internal/dashboard/static/index.html is in the commit, per this repo's convention of committing the embedded asset entrypoint.

Not fixed here, deliberately: every /overview query in handleOverview discards its error (_ = h.db.QueryRow(...), rows, _ := h.db.Query(...)), so a failed query renders a confident 0 sessions / $0.00 instead of an error state. That is not something this PR introduced — it is the pattern across ~20 call sites in handler.go, predating this branch, and the same class of bug already fixed for the Bash breakdown in e3d7dd8. Widening this PR to chase it would mix two features on one branch. Filing it separately.

CodeRabbit: still rate-limited (window opens ~22:43 UTC). This push did not pull a review in with it, as the limit gates pushes too. Re-triggering once the window opens — the review will then run against 0640eac rather than the pre-fix tip, which is the better outcome of the two.

@Fl0p

Fl0p commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded — closing without merge.

The range-selector work from this branch shipped to main as 2ff50d4, and the History part was replaced by the better server-side approach in 1438d17 (the API takes a range key and returns covered_since, instead of this branch computing from/to client-side against a HISTORY_EPOCH constant).

One commit here never landed — 0640eac, the Active Users KPI label fix, which was pushed after the squash-merge. It is re-landed against current main in #94.

Nothing else on this branch is missing from main.

@Fl0p Fl0p closed this Aug 20, 2026
@Fl0p
Fl0p deleted the flo-588-overview-range branch August 20, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant