Skip to content

feat(analytics): sticky range + sortable per-model table columns - #490

Open
jasnoorgill wants to merge 2 commits into
tashfeenahmed:mainfrom
jasnoorgill:feat/analytics-table-sort-rebased
Open

feat(analytics): sticky range + sortable per-model table columns#490
jasnoorgill wants to merge 2 commits into
tashfeenahmed:mainfrom
jasnoorgill:feat/analytics-table-sort-rebased

Conversation

@jasnoorgill

Copy link
Copy Markdown
Contributor

Summary

Two small but related client-side improvements to the analytics page,
shipped together because they share infrastructure and would otherwise
conflict on the same lines of AnalyticsPage.tsx:

  1. Sticky time range — the selected 24h/7d/30d/90d range now persists
    in localStorage so reloads and new sessions land on the user's
    last choice.
  2. Sortable per-model table — every column in the "per-model
    breakdown" table (except pinned, which renders ) is now
    clickable to sort. 3-state cycle: unsorted (API order) → asc → desc → unsorted. Last selection persists in localStorage too.

Both use the same persistence shape as the existing theme toggle
and freellmapi.locale preference. Same useState + useEffect
pattern, same try/catch on every storage access.

Why combined

Originally shipped as PR #462 (sticky range only). Adding the sort
work on top of #462's branch required a rebase that would have
re-written #462's commits — at that point the cleanest move was a
single PR covering both. The two features are thematically adjacent
(analytics-page UX) and small individually, so combining avoids
two PRs reviewing the same file.

Rebase onto current origin/main (9858eee)

This is a re-submission of #463 (which was based on a stale origin/main
snapshot @ 9048dd6). The analytics page changed significantly upstream
since the original PR — main now uses a <SegmentedControl> shared
component for the range selector, TimeRange widens to include 90d,
stats load via skeletons, and several i18n keys changed. The
localStorage pattern itself is unchanged.

Two conflicts during cherry-pick, both resolved in favor of keeping
both changes:

  1. useState<TimeRange>('7d') line — kept the PR's loadStoredRange
    init and added upstream's const [now] capture alongside it.
  2. byModel.map((m, i) => ( vs PR's sortedByModel.map(...)
    took the PR's sort-applied version.

Also: added '90d' to the TIME_RANGES allowlist (the PR pre-dated
that range option; without it, a user who selected 90d would have
their sticky selection silently dropped to '7d' on the next reload).

Changes

File Change
client/src/pages/AnalyticsPage.tsx +189 / -11. Added useMemo to imports, STORAGE_KEY/loadStoredRange/sort types, SortableHeader helper component, sort state + persistence effect, onHeaderClick reducer, sortedByModel memoized sort, replaced 8 of 9 column headers with <SortableHeader> (pinned stays static), added lucide-react ArrowUp/ArrowDown/ChevronsUpDown imports.

1 file, +189 / -11 across 2 commits.

Sort behavior

State Indicator Order
Unsorted ChevronsUpDown (faded) API-returned (requests DESC)
Asc ArrowUp A→Z / 0→9
Desc ArrowDown Z→A / 9→0
  • Click another column → starts at asc on the new column.
  • Click the same column three times → back to API order, storage
    key removed.
  • Null values sort last in both directions (Excel/Sheets convention).
  • Right-aligned columns flip the indicator with flex-row-reverse so
    the label stays closest to the data.
  • aria-sort attribute is set on each header button for screen readers.

pinned is intentionally not sortable: it renders for zero-pinned
rows, and the 0/>0 distinction is meaningless to sort on.

localStorage keys

Key Value When written
freellmapi.analytics.range '24h' | '7d' | '30d' | '90d' On range change
freellmapi.analytics.byModelSort {"column": "...", "direction": "asc"|"desc"} or absent On sort change (removed on unsort)

Both keys are validated against an allowlist on read so a corrupted
entry falls back to the default rather than crashing the page. Both
writes are wrapped in try/catch for SSR / private-mode safety.

Test plan

  • cd client && npx tsc -b — clean, 0 errors.
  • cd client && npm run build✓ built in 8.72s.
  • Bundle contains both storage keys + the icon imports:
    grep -oE 'freellmapi\.analytics\.[a-zA-Z]+' client/dist/assets/index-*.js
    returns both freellmapi.analytics.range and
    freellmapi.analytics.byModelSort.

Manual smoke (assumes the user runs locally):

  1. Open /analytics, click 30d. Hard-reload — opens on 30d.
  2. Click any column header (e.g. "Latency") — sorts asc, indicator
    shows ↑, header text darkens.
  3. Click again — sorts desc, indicator shows ↓.
  4. Click a third time — back to API order, indicator faded.
  5. Click a different column — starts at asc on the new column.
  6. Reload — your last sort is restored.
  7. Hard-reload after clicking through to unsort — the key is
    removed from localStorage, the page loads in API order.

Compatibility

No breaking changes. Defaults are unchanged (7d range, API-natural
row order). The persistence is additive. New localStorage keys
are namespaced under freellmapi.* per the existing convention.

Supersedes

Closes #462 (the sticky-range half of this work is included here).
This PR supersedes #463, which was the original submission based on
a stale main snapshot and is now CLOSED.

References operator sessions 2026-07-04 / 2026-07-07. No upstream
issue number yet — happy to file one if requested.

Remembers the last-selected 24h/7d/30d range across reloads and new
sessions so the user lands back on the window they were inspecting.
Same localStorage shape as the theme toggle (key: 'theme') and the
locale picker (key: 'freellmapi.locale').

Storage key: 'freellmapi.analytics.range'. Invalid or missing values
fall back to the 7d default; storage errors (quota, private mode) are
swallowed so the page keeps working.
3-state cycle per column: unsorted (API order) → asc → desc → unsorted.
Clicking a different column resets to asc on the new column. Last
selection persists in localStorage so reloads land on the user's
preferred sort (key: 'freellmapi.analytics.byModelSort').

Sortable columns: model, provider, requests, success%, latency,
inTokens, outTokens, saved ($). The 'pinned' column stays static
because it renders '—' for zero-pinned rows and the unsortable
0/>0 distinction would confuse the indicator. Null values sort last
in both directions (Excel/Sheets convention).

Same persistence pattern as the range selector (tashfeenahmed#462) and the
existing 'theme' / 'freellmapi.locale' keys. Right-aligned columns
flip the sort-indicator with flex-row-reverse so the label stays
closest to the data. aria-sort attributes set on the buttons for
screen readers.
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