diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 5eab4a7a11..57943eba6f 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,5 +1,14 @@ -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { BarChart3, Brain, Cpu, RefreshCw, TrendingUp } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + BarChart3, + Brain, + Cpu, + RefreshCw, + TrendingUp, +} from "lucide-react"; import { api } from "@/lib/api"; import type { AnalyticsResponse, @@ -40,6 +49,85 @@ function formatDate(day: string): string { } } +// --------------------------------------------------------------------------- +// Sorting +// --------------------------------------------------------------------------- + +function useTableSort( + data: T[], + defaultKey: keyof T & string, + defaultDir: "asc" | "desc" = "desc", +) { + const [sortKey, setSortKey] = useState(defaultKey); + const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir); + + const sorted = useMemo(() => { + return [...data].sort((a, b) => { + const aVal = a[sortKey as keyof T]; + const bVal = b[sortKey as keyof T]; + // Nulls always last regardless of direction + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + if (aVal === bVal) return 0; + const cmp = aVal > bVal ? 1 : -1; + return sortDir === "asc" ? cmp : -cmp; + }); + }, [data, sortKey, sortDir]); + + const toggle = useCallback( + (key: string) => { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("desc"); + } + }, + [sortKey], + ); + + return { sorted, sortKey, sortDir, toggle }; +} + +function SortHeader({ + label, + col, + sortKey, + sortDir, + toggle, + className, +}: { + label: string; + col: string; + sortKey: string; + sortDir: "asc" | "desc"; + toggle: (key: string) => void; + className?: string; +}) { + const active = col === sortKey; + return ( + toggle(col)} + className={`cursor-pointer select-none ${className ?? ""}`} + > + + {label} + {active ? ( + sortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + + ); +} + + + function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { const { t } = useI18n(); if (daily.length === 0) return null; @@ -135,9 +223,9 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { const { t } = useI18n(); - if (daily.length === 0) return null; + const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc"); - const sorted = [...daily].reverse(); + if (daily.length === 0) return null; return ( @@ -154,46 +242,36 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { - - - - + + + + - {sorted.map((d) => { - return ( - ( + - - - - + - - ); - })} + + + ))}
- {t.analytics.date} - - {t.sessions.title} - - {t.analytics.input} - - {t.analytics.output} -
+ {formatDate(d.day)} + {d.sessions} - + + {formatTokens(d.input_tokens)} - - + + {formatTokens(d.output_tokens)} -
@@ -204,12 +282,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { const { t } = useI18n(); - if (models.length === 0) return null; + const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc"); - const sorted = [...models].sort( - (a, b) => - b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens), - ); + if (models.length === 0) return null; return ( @@ -226,15 +301,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { - - - + + + @@ -270,6 +339,8 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) { const { t } = useI18n(); + const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc"); + if (skills.length === 0) return null; return ( @@ -285,25 +356,15 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
- {t.analytics.model} - - {t.sessions.title} - - {t.analytics.tokens} -
- - - - - + + + + + - {skills.map((skill) => ( + {sorted.map((skill) => (
- {t.analytics.skill} - - {t.analytics.loads} - - {t.analytics.edits} - - {t.analytics.total} - - {t.analytics.lastUsed} -