diff --git a/client/src/pages/AnalyticsPage.tsx b/client/src/pages/AnalyticsPage.tsx
index cc1b42715..7bc53c55e 100644
--- a/client/src/pages/AnalyticsPage.tsx
+++ b/client/src/pages/AnalyticsPage.tsx
@@ -1,4 +1,5 @@
-import { useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
@@ -14,6 +15,84 @@ import { useI18n } from '@/i18n'
type TimeRange = '24h' | '7d' | '30d'
+const TIME_RANGES: readonly TimeRange[] = ['24h', '7d', '30d']
+const STORAGE_KEY = 'freellmapi.analytics.range'
+const DEFAULT_RANGE: TimeRange = '7d'
+
+// Read the previously-selected range from localStorage. Invalid or missing
+// values fall back to the 7d default so a corrupted entry never bricks the
+// page; SSR-safety follows the same try/catch shape as `lib/api.ts`.
+function loadStoredRange(): TimeRange {
+ if (typeof window === 'undefined') return DEFAULT_RANGE
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ if (stored && (TIME_RANGES as readonly string[]).includes(stored)) {
+ return stored as TimeRange
+ }
+ } catch {
+ /* localStorage unavailable (private mode, etc.) — use default */
+ }
+ return DEFAULT_RANGE
+}
+
+// Per-model table sort state. The `pinned` column is intentionally NOT
+// sortable — it renders "—" for zero-pinned rows and the unsortable `0`/`>0`
+// distinction would confuse the indicator. 3-state cycle per column:
+// null → asc → desc → null. Switching to a new column resets to asc.
+type SortColumn = 'model' | 'provider' | 'requests' | 'success' | 'latency' | 'inTokens' | 'outTokens' | 'saved'
+type SortDirection = 'asc' | 'desc'
+type SortState = { column: SortColumn; direction: SortDirection } | null
+
+const SORT_COLUMNS: readonly SortColumn[] = [
+ 'model', 'provider', 'requests', 'success', 'latency', 'inTokens', 'outTokens', 'saved',
+]
+const SORT_STORAGE_KEY = 'freellmapi.analytics.byModelSort'
+
+function loadStoredSort(): SortState {
+ if (typeof window === 'undefined') return null
+ try {
+ const raw = window.localStorage.getItem(SORT_STORAGE_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as { column?: unknown; direction?: unknown }
+ if (
+ typeof parsed.column === 'string' &&
+ (SORT_COLUMNS as readonly string[]).includes(parsed.column) &&
+ (parsed.direction === 'asc' || parsed.direction === 'desc')
+ ) {
+ return { column: parsed.column as SortColumn, direction: parsed.direction }
+ }
+ } catch {
+ /* corrupted JSON or storage unavailable — fall through to null */
+ }
+ return null
+}
+
+// Numeric accessor for a sortable column. Returns null for values the API
+// didn't include so they sort to the bottom on asc and top on desc.
+function sortValue(row: any, col: SortColumn): number | string | null {
+ switch (col) {
+ case 'model': return row.displayName ?? null
+ case 'provider': return row.platform ?? null
+ case 'requests': return row.requests ?? null
+ case 'success': return row.successRate ?? null
+ case 'latency': return row.avgLatencyMs ?? null
+ case 'inTokens': return row.totalInputTokens ?? null
+ case 'outTokens': return row.totalOutputTokens ?? null
+ case 'saved': return row.estimatedCost ?? null
+ }
+}
+
+function compareRows(a: any, b: any, col: SortColumn): number {
+ const av = sortValue(a, col)
+ const bv = sortValue(b, col)
+ // Nulls always last regardless of direction (Excel/Sheets convention).
+ if (av === null && bv === null) return 0
+ if (av === null) return 1
+ if (bv === null) return -1
+ if (typeof av === 'number' && typeof bv === 'number') return av - bv
+ return String(av).localeCompare(String(bv))
+}
+
function formatTokens(n?: number): string {
if (!n) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
@@ -48,9 +127,92 @@ const axisStyle = { fontSize: 11, fill: 'var(--muted-foreground)' } as const
const gridStyle = 'var(--border)'
const primaryFill = 'var(--foreground)'
+// Sortable header cell. Renders the label + a state-aware indicator:
+// unsorted → ChevronsUpDown (faded), asc → ArrowUp, desc → ArrowDown.
+// Right-aligned columns flip the indicator order so it sits to the LEFT
+// of the label, keeping the label closest to the data.
+function SortableHeader({
+ column,
+ label,
+ align,
+ extraClass,
+ sort,
+ onClick,
+}: {
+ column: SortColumn
+ label: string
+ align: 'left' | 'right'
+ extraClass?: string
+ sort: SortState
+ onClick: (col: SortColumn) => void
+}) {
+ const active = sort?.column === column
+ const direction = active ? sort.direction : null
+ const indicator = direction === 'asc'
+ ?
+ : direction === 'desc'
+ ?
+ :
+ const alignClass = align === 'right' ? 'text-right' : ''
+ const headClass = [alignClass, extraClass].filter(Boolean).join(' ')
+ return (
+
+
+
+ )
+}
+
export default function AnalyticsPage() {
const { t } = useI18n()
- const [range, setRange] = useState('7d')
+ const [range, setRange] = useState(loadStoredRange)
+
+ // Remember the last-selected range across page reloads / new sessions so
+ // the user lands back on the window they were inspecting. Same
+ // localStorage shape as `theme` and `freellmapi.locale`.
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(STORAGE_KEY, range)
+ } catch {
+ /* ignore — storage quota / private mode */
+ }
+ }, [range])
+
+ // Per-model table sort. Cycle: null → asc → desc → null. Switching column
+ // starts at asc on the new column. Persisted so the next visit lands on
+ // the user's preferred sort.
+ const [sort, setSort] = useState(loadStoredSort)
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ if (sort === null) window.localStorage.removeItem(SORT_STORAGE_KEY)
+ else window.localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sort))
+ } catch {
+ /* ignore */
+ }
+ }, [sort])
+
+ const onHeaderClick = (col: SortColumn) => {
+ setSort((current) => {
+ if (!current || current.column !== col) return { column: col, direction: 'asc' }
+ if (current.direction === 'asc') return { column: col, direction: 'desc' }
+ return null // third click on the same column → restore API order
+ })
+ }
const { data: summary } = useQuery({
queryKey: ['analytics', 'summary', range],
@@ -72,6 +234,21 @@ export default function AnalyticsPage() {
queryFn: () => apiFetch(`/api/analytics/by-model?range=${range}`),
})
+ // Apply the user's sort to byModel. When sort is null we render the rows
+ // in API-returned order; the API's natural ordering (requests DESC) is
+ // the right default. The sort is stable within the comparator because we
+ // fall back to insertion order for equal values (Array.prototype.sort is
+ // stable in all modern engines).
+ const sortedByModel = useMemo(() => {
+ if (!sort) return byModel
+ const copy = byModel.slice()
+ copy.sort((a, b) => {
+ const primary = compareRows(a, b, sort.column)
+ return sort.direction === 'asc' ? primary : -primary
+ })
+ return copy
+ }, [byModel, sort])
+
const { data: errors = [] } = useQuery({
queryKey: ['analytics', 'errors', range],
queryFn: () => apiFetch(`/api/analytics/errors?range=${range}`),
@@ -217,19 +394,19 @@ export default function AnalyticsPage() {
- {t('common.model')}
- {t('common.provider')}
- {t('analytics.requests')}
+
+
+
{t('analytics.pinned')}
- {t('common.success')}
- {t('analytics.latency')}
- {t('analytics.inTokens')}
- {t('analytics.outTokens')}
- {t('analytics.saved')}
+
+
+
+
+
- {byModel.map((m: any, i: number) => (
+ {sortedByModel.map((m: any, i: number) => (
{m.displayName}
{m.platform}