diff --git a/app/(main)/analytics/page.tsx b/app/(main)/analytics/page.tsx
new file mode 100644
index 00000000..c16f7c28
--- /dev/null
+++ b/app/(main)/analytics/page.tsx
@@ -0,0 +1,251 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { PageHeader, Sidebar } from "@/app/components";
+import { InfoTooltip, Select } from "@/app/components/ui";
+import { toSelectOptions } from "@/app/lib/utils/selectOptions";
+import { useApp } from "@/app/lib/context/AppContext";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart";
+import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals";
+import {
+ AnalyticsChartCard,
+ AnalyticsTotalsRow,
+ MonthYearPicker,
+} from "@/app/components/analytics";
+import {
+ ANALYTICS_GROUP_BY_OPTIONS,
+ ANALYTICS_METRIC_OPTIONS,
+ ANALYTICS_MODALITY_OPTIONS,
+ PROVIDES_OPTIONS,
+} from "@/app/lib/constants";
+import {
+ AnalyticsChartFilters,
+ AnalyticsGroupBy,
+ AnalyticsMetric,
+ AnalyticsModality,
+} from "@/app/lib/types/analytics";
+
+function FilterField({
+ label,
+ className,
+ info,
+ children,
+}: {
+ label: string;
+ className?: string;
+ info?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {info && }
+
+ {children}
+
+ );
+}
+
+export default function AnalyticsPage() {
+ const { sidebarCollapsed } = useApp();
+ const { isAuthenticated, isHydrated } = useAuth();
+ const [metric, setMetric] = useState("cost_all");
+ const [groupBy, setGroupBy] = useState("provider");
+ const [modality, setModality] = useState("");
+ const [provider, setProvider] = useState("");
+ const [fromMonth, setFromMonth] = useState("");
+ const [toMonth, setToMonth] = useState("");
+
+ const filters: AnalyticsChartFilters = useMemo(
+ () => ({
+ metric,
+ group_by: groupBy,
+ modality: modality || undefined,
+ provider: provider || undefined,
+ from_month: fromMonth || undefined,
+ to_month: toMonth || undefined,
+ }),
+ [metric, groupBy, modality, provider, fromMonth, toMonth],
+ );
+
+ const { data, isLoading, error } = useAnalyticsChart(filters);
+
+ const totalsFilters = useMemo(
+ () => ({
+ modality: modality || undefined,
+ provider: provider || undefined,
+ from_month: fromMonth || undefined,
+ to_month: toMonth || undefined,
+ }),
+ [modality, provider, fromMonth, toMonth],
+ );
+ const {
+ totals,
+ isLoading: isTotalsLoading,
+ error: totalsError,
+ } = useAnalyticsTotals(totalsFilters);
+
+ const metricLabel =
+ ANALYTICS_METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric;
+
+ const isReady = isHydrated;
+
+ return (
+
+
+
+
+
+
+
+ {!isReady ? null : !isAuthenticated ? (
+
+
+ Log in to view analytics.
+
+
+ ) : (
+
+
+
+
+ What the chart shows.
+
+ Cost = money spent on AI (real users +
+ quality checks combined). Volume = how
+ busy your AI was (number of requests).
+ >
+ }
+ >
+
+ setMetric(e.target.value as AnalyticsMetric)
+ }
+ options={toSelectOptions(ANALYTICS_METRIC_OPTIONS)}
+ />
+
+
+ How the chart is split up.
+
+ Total = everything as one line. Pick{" "}
+ Provider (who supplies the AI) or{" "}
+ Request type (what the AI was used for)
+ to see one line per group.
+ >
+ }
+ >
+
+ setGroupBy(e.target.value as AnalyticsGroupBy)
+ }
+ options={toSelectOptions(ANALYTICS_GROUP_BY_OPTIONS)}
+ />
+
+
+ What the AI was used for. Pick one to see numbers for
+ just that kind of work:
+
+ Text → Text (chat / written replies),{" "}
+ Speech → Speech (voice conversations),{" "}
+ Speech → Text (turning audio into
+ written text), Text → Speech (reading
+ text aloud), or Other . Leave as{" "}
+ All to count everything.
+ >
+ }
+ >
+
+ setModality(e.target.value as AnalyticsModality | "")
+ }
+ options={toSelectOptions(ANALYTICS_MODALITY_OPTIONS)}
+ placeholder="All"
+ />
+
+
+ setProvider(e.target.value)}
+ options={PROVIDES_OPTIONS}
+ placeholder="All"
+ />
+
+
+
+
+
+
+
+ {(fromMonth || toMonth) && (
+ {
+ setFromMonth("");
+ setToMonth("");
+ }}
+ className="text-[12px] font-medium text-status-error-text hover:underline cursor-pointer pb-1.5 shrink-0"
+ >
+ Clear dates
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/api/analytics/monthly/chart/route.ts b/app/api/analytics/monthly/chart/route.ts
new file mode 100644
index 00000000..127ae73d
--- /dev/null
+++ b/app/api/analytics/monthly/chart/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { apiClient } from "@/app/lib/apiClient";
+
+export async function GET(request: Request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const qs = searchParams.toString();
+ const endpoint = `/api/v1/analytics/monthly/chart${qs ? `?${qs}` : ""}`;
+ const { status, data } = await apiClient(request, endpoint);
+ return NextResponse.json(data, { status });
+ } catch (error: unknown) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ data: null,
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx
index afb30f12..34029af7 100644
--- a/app/components/Sidebar.tsx
+++ b/app/components/Sidebar.tsx
@@ -20,6 +20,7 @@ import {
ChevronRightIcon,
ChevronLeftIcon,
ChatIcon,
+ ChartBarIcon,
} from "@/app/components/icons";
import { LoginModal } from "@/app/components/auth";
import { Branding, UserMenuPopover } from "@/app/components/user-menu";
@@ -119,6 +120,7 @@ export default function Sidebar({
gear: ,
shield: ,
sliders: ,
+ chart: ,
};
const navItems: MenuItem[] = NAV_ITEMS.filter(
diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx
new file mode 100644
index 00000000..52438b40
--- /dev/null
+++ b/app/components/analytics/AnalyticsChartCard.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { useMemo } from "react";
+import { InfoTooltip } from "@/app/components/ui";
+import {
+ AnalyticsChartData,
+ AnalyticsChartRow,
+} from "@/app/lib/types/analytics";
+import { normalizeAndMergeSeries } from "@/app/lib/utils/analytics/normalizeSeries";
+import {
+ formatMonthLabel,
+ formatTokens,
+} from "@/app/lib/utils/analytics/formatValue";
+import BreakdownPanel from "./BreakdownPanel";
+import LineTrendChart from "./LineTrendChart";
+
+const SERIES_COLORS = [
+ "#2563eb",
+ "#e11d48",
+ "#16a34a",
+ "#f59e0b",
+ "#7c3aed",
+ "#0891b2",
+ "#ea580c",
+ "#db2777",
+ "#65a30d",
+ "#4f46e5",
+ "#dc2626",
+ "#0d9488",
+ "#c026d3",
+ "#ca8a04",
+ "#475569",
+ "#84cc16",
+];
+
+function buildRows(chart: AnalyticsChartData): AnalyticsChartRow[] {
+ return chart.labels.map((label, i) => {
+ const values = chart.series.map((s) => {
+ const raw = s.data[i];
+ const num = raw === undefined || raw === null ? 0 : Number(raw);
+ return Number.isFinite(num) ? num : 0;
+ });
+ const total = values.reduce((acc, v) => acc + v, 0);
+ const min = values.length ? Math.min(...values) : 0;
+ const max = values.length ? Math.max(...values) : 0;
+ const row: AnalyticsChartRow = {
+ month: formatMonthLabel(label),
+ monthIso: label,
+ __total: total,
+ __range: [min, max],
+ };
+ chart.series.forEach((s, idx) => {
+ row[s.name] = values[idx];
+ });
+ return row;
+ });
+}
+
+interface AnalyticsChartCardProps {
+ data: AnalyticsChartData | null;
+ isLoading: boolean;
+ error: string | null;
+ metricLabel: string;
+}
+
+export default function AnalyticsChartCard({
+ data,
+ isLoading,
+ error,
+ metricLabel,
+}: AnalyticsChartCardProps) {
+ const activeData = useMemo(() => {
+ if (!data) return null;
+ const merged = normalizeAndMergeSeries(data.series);
+ const filtered = merged.filter((s) => {
+ const dataSum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0);
+ const tokenSum =
+ (s.total_input_tokens ?? 0) +
+ (s.total_output_tokens ?? 0) +
+ (s.total_tokens ?? 0);
+ return dataSum > 0 || tokenSum > 0;
+ });
+ return { ...data, series: filtered };
+ }, [data]);
+
+ const rows = useMemo(() => {
+ if (!activeData) return [];
+ const all = buildRows(activeData);
+ // Auto-trim leading + trailing months where every series is zero
+ let start = 0;
+ let end = all.length - 1;
+ const isEmpty = (r: AnalyticsChartRow) => r.__total === 0;
+ while (start <= end && isEmpty(all[start])) start++;
+ while (end >= start && isEmpty(all[end])) end--;
+ return all.slice(start, end + 1);
+ }, [activeData]);
+ const hasSeries = !!activeData && activeData.series.length > 0;
+
+ const totals = useMemo(() => {
+ if (!activeData) return [];
+ const raw = activeData.series.map((s, i) => {
+ const sum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0);
+ return {
+ name: s.name,
+ total: sum,
+ color: SERIES_COLORS[i % SERIES_COLORS.length],
+ inputTokens: s.total_input_tokens,
+ outputTokens: s.total_output_tokens,
+ totalTokens: s.total_tokens,
+ };
+ });
+ const grandTotal = raw.reduce((acc, t) => acc + t.total, 0);
+ return raw
+ .map((t) => ({
+ ...t,
+ sharePct: grandTotal > 0 ? (t.total / grandTotal) * 100 : 0,
+ }))
+ .sort((a, b) => b.total - a.total);
+ }, [activeData]);
+
+ const tokenSummary = useMemo(() => {
+ if (!activeData) return null;
+ let inputSum = 0;
+ let outputSum = 0;
+ let totalSum = 0;
+ let anyDefined = false;
+ for (const s of activeData.series) {
+ if (
+ s.total_input_tokens !== undefined ||
+ s.total_output_tokens !== undefined ||
+ s.total_tokens !== undefined
+ ) {
+ anyDefined = true;
+ }
+ inputSum += s.total_input_tokens ?? 0;
+ outputSum += s.total_output_tokens ?? 0;
+ totalSum += s.total_tokens ?? 0;
+ }
+ if (!anyDefined) return null;
+ return { inputSum, outputSum, totalSum };
+ }, [activeData]);
+
+ return (
+
+
+
+
+ {metricLabel}
+
+ Shows {metricLabel} month by month, split by
+ what you chose under Group by (for example, one line
+ per provider).
+
+
+ The dotted line is the overall total — adding all the coloured
+ lines together each month.
+ >
+ }
+ />
+
+
+ Monthly trend
+ {activeData ? ` · grouped by ${activeData.group_by}` : ""}
+
+
+
+
+ {totals.length > 0 && (
+
+ )}
+
+
+
+ {tokenSummary && (
+
+
+
+
+ Tokens for this chart
+
+ The amount of text the AI processed to produce the numbers
+ above. Tokens are how AI providers measure work and bill
+ you — roughly one word ≈ 1.3 tokens.
+
+
+ This only counts the data shown in the chart. For a full
+ real-users-vs-quality-checks split, see the "All-time
+ totals" section at the top.
+ >
+ }
+ />
+
+
+ Aggregated across the groups shown in the chart above
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function TokenStat({
+ label,
+ value,
+ tooltip,
+}: {
+ label: string;
+ value: number;
+ tooltip: string;
+}) {
+ return (
+
+
+ {label}
+
+
+
+ {formatTokens(value)}
+
+
+ {value.toLocaleString("en-US")} exact
+
+
+ );
+}
diff --git a/app/components/analytics/AnalyticsTotalsRow.tsx b/app/components/analytics/AnalyticsTotalsRow.tsx
new file mode 100644
index 00000000..9eb24e9b
--- /dev/null
+++ b/app/components/analytics/AnalyticsTotalsRow.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { ReactNode } from "react";
+import { InfoTooltip, Loader } from "@/app/components/ui";
+import { AnalyticsTotalsRowProps } from "@/app/lib/types/analytics";
+
+function formatTokens(n: number): string {
+ if (!Number.isFinite(n)) return "0";
+ const abs = Math.abs(n);
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
+}
+
+function formatCurrency(n: number): string {
+ return n.toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+}
+
+function formatCount(n: number): string {
+ return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
+}
+
+export default function AnalyticsTotalsRow({
+ totals,
+ isLoading,
+ error,
+}: AnalyticsTotalsRowProps) {
+ if (isLoading && !totals) {
+ return (
+
+
+
+ );
+ }
+ if (error) {
+ return (
+
+ );
+ }
+ if (!totals) return null;
+
+ return (
+
+
+
+ All-time totals
+
+
+ Real users on the left, quality checks on the right.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type Accent = "cost" | "usage" | "activity";
+
+const ACCENT_CARD: Record = {
+ cost: "bg-status-success-bg/40 border-status-success-border/40",
+ usage: "bg-accent-primary/5 border-accent-primary/20",
+ activity: "bg-status-warning-bg/40 border-status-warning-border/40",
+};
+
+function StatCard({
+ label,
+ value,
+ hint,
+ tooltip,
+ accent,
+}: {
+ label: string;
+ value: string;
+ hint?: string;
+ tooltip?: ReactNode;
+ accent: Accent;
+}) {
+ return (
+
+
+ {label}
+ {tooltip && }
+
+
+ {value}
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+}
diff --git a/app/components/analytics/BreakdownPanel.tsx b/app/components/analytics/BreakdownPanel.tsx
new file mode 100644
index 00000000..1a8d0f3a
--- /dev/null
+++ b/app/components/analytics/BreakdownPanel.tsx
@@ -0,0 +1,271 @@
+"use client";
+
+import { useState } from "react";
+import { Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
+import { AnalyticsGroupBy, AnalyticsMetric } from "@/app/lib/types/analytics";
+import {
+ formatCompactMetric,
+ formatMetricValue,
+ formatTokens,
+} from "@/app/lib/utils/analytics/formatValue";
+
+export interface BreakdownEntry {
+ name: string;
+ total: number;
+ color: string;
+ inputTokens?: number;
+ outputTokens?: number;
+ totalTokens?: number;
+ sharePct: number;
+}
+
+const GROUP_BY_HEADER: Record = {
+ total: "Series",
+ provider: "Provider",
+ modality: "Request type",
+ modality_provider: "Request type · Provider",
+};
+
+interface SegmentLabelProps {
+ cx?: number;
+ cy?: number;
+ midAngle?: number;
+ innerRadius?: number;
+ outerRadius?: number;
+ percent?: number;
+}
+
+function renderSegmentLabel(props: SegmentLabelProps) {
+ const {
+ cx = 0,
+ cy = 0,
+ midAngle = 0,
+ innerRadius = 0,
+ outerRadius = 0,
+ percent = 0,
+ } = props;
+ if (percent < 0.05) return null;
+ const r = innerRadius + (outerRadius - innerRadius) * 0.55;
+ const x = cx + r * Math.cos((-midAngle * Math.PI) / 180);
+ const y = cy + r * Math.sin((-midAngle * Math.PI) / 180);
+ return (
+
+ {`${(percent * 100).toFixed(0)}%`}
+
+ );
+}
+
+interface PieTooltipPayload {
+ payload?: { name?: string; value?: number; fill?: string };
+}
+
+interface PieTooltipProps {
+ active?: boolean;
+ payload?: PieTooltipPayload[];
+ metric: AnalyticsMetric;
+ grandTotal: number;
+}
+
+function PieTooltip({ active, payload, metric, grandTotal }: PieTooltipProps) {
+ if (!active || !payload?.length) return null;
+ const item = payload[0]?.payload;
+ if (!item) return null;
+ const share = grandTotal > 0 ? ((item.value ?? 0) / grandTotal) * 100 : 0;
+ return (
+
+
+
+
+ {item.name}
+
+
+
+ Total
+
+ {formatMetricValue(item.value ?? 0, metric)}
+
+
+
+ Share
+ {share.toFixed(1)}%
+
+
+ );
+}
+
+interface BreakdownPanelProps {
+ totals: BreakdownEntry[];
+ metric: AnalyticsMetric;
+ metricLabel: string;
+ groupBy: AnalyticsGroupBy;
+}
+
+export default function BreakdownPanel({
+ totals,
+ metric,
+ metricLabel,
+ groupBy,
+}: BreakdownPanelProps) {
+ const [isSliceHovered, setIsSliceHovered] = useState(false);
+ const hasAnyTokens = totals.some(
+ (t) =>
+ (t.totalTokens ?? 0) > 0 ||
+ (t.inputTokens ?? 0) > 0 ||
+ (t.outputTokens ?? 0) > 0,
+ );
+ const grandTotal = totals.reduce((acc, t) => acc + t.total, 0);
+ const donutData = totals
+ .filter((t) => t.total > 0)
+ .map((t) => ({ name: t.name, value: t.total, fill: t.color }));
+ const groupHeader = GROUP_BY_HEADER[groupBy];
+
+ return (
+
+
+
+
+ {groupHeader}-wise {metricLabel}
+
+
+
+
+
+
+
+ {groupHeader}
+
+
+ {metricLabel}
+
+ Share
+ {hasAnyTokens && (
+
+ Tokens
+
+ )}
+
+
+
+ {totals.map((t, i) => {
+ const isEmpty = t.total === 0;
+ return (
+
+
+
+
+
+ {t.name}
+
+
+
+
+ {formatMetricValue(t.total, metric)}
+
+
+ {!isEmpty ? `${t.sharePct.toFixed(1)}%` : "—"}
+
+ {hasAnyTokens && (
+
+ {(t.totalTokens ?? 0) > 0
+ ? formatTokens(t.totalTokens ?? 0)
+ : "—"}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Share of {metricLabel}
+
+
+
+
+ {donutData.length > 0 ? (
+ <>
+
+
+ setIsSliceHovered(true)}
+ onMouseLeave={() => setIsSliceHovered(false)}
+ />
+
+ }
+ />
+
+
+ {!isSliceHovered && (
+
+
+ {formatCompactMetric(grandTotal, metric)}
+
+
+ Total
+
+
+ )}
+ >
+ ) : (
+
+ No data
+
+ )}
+
+ {donutData.length > 0 && (
+
+ {donutData.map((d) => (
+
+
+ {d.name}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/analytics/ChartTooltip.tsx b/app/components/analytics/ChartTooltip.tsx
new file mode 100644
index 00000000..531ef4e3
--- /dev/null
+++ b/app/components/analytics/ChartTooltip.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { AnalyticsTooltipRenderProps } from "@/app/lib/types/analytics";
+import { formatMetricValue } from "@/app/lib/utils/analytics/formatValue";
+
+export default function ChartTooltip({
+ active,
+ payload,
+ label,
+ metric,
+}: AnalyticsTooltipRenderProps) {
+ if (!active || !payload?.length) return null;
+ const totalEntry = payload.find((e) => e.dataKey === "__total");
+ const seriesEntries = payload.filter(
+ (e) => e.dataKey !== "__total" && e.dataKey !== "__range",
+ );
+ return (
+
+
{label}
+ {totalEntry && (
+
+ Total
+
+ {formatMetricValue(Number(totalEntry.value ?? 0), metric)}
+
+
+ )}
+ {seriesEntries.length > 0 && (
+
+ {seriesEntries.map((entry) => (
+
+
+
+ {String(entry.dataKey)}
+
+
+ {formatMetricValue(Number(entry.value ?? 0), metric)}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/components/analytics/LineTrendChart.tsx b/app/components/analytics/LineTrendChart.tsx
new file mode 100644
index 00000000..42521923
--- /dev/null
+++ b/app/components/analytics/LineTrendChart.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import {
+ CartesianGrid,
+ ComposedChart,
+ Line,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { Loader } from "@/app/components/ui";
+import {
+ AnalyticsChartData,
+ AnalyticsChartRow,
+} from "@/app/lib/types/analytics";
+import { formatCompactMetric } from "@/app/lib/utils/analytics/formatValue";
+import ChartTooltip from "./ChartTooltip";
+
+interface LineTrendChartProps {
+ rows: AnalyticsChartRow[];
+ activeData: AnalyticsChartData | null;
+ hasSeries: boolean;
+ hasInitialData: boolean;
+ isLoading: boolean;
+ error: string | null;
+ seriesColors: string[];
+}
+
+export default function LineTrendChart({
+ rows,
+ activeData,
+ hasSeries,
+ hasInitialData,
+ isLoading,
+ error,
+ seriesColors,
+}: LineTrendChartProps) {
+ return (
+ <>
+
+ {isLoading && !hasInitialData ? (
+
+
+
+ ) : error ? (
+
+ ) : !hasSeries || !activeData ? (
+
+
+ No data for the selected filters yet.
+
+
+ ) : (
+
+
+
+
+
+ formatCompactMetric(Number(v), activeData.metric)
+ }
+ />
+ }
+ cursor={{
+ stroke: "#a3a3a3",
+ strokeWidth: 1,
+ strokeDasharray: "3 3",
+ }}
+ />
+ {activeData.series.length > 1 && (
+
+ )}
+ {activeData.series.map((s, i) => {
+ const color = seriesColors[i % seriesColors.length];
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {hasSeries && activeData && (
+
+ {activeData.series.map((s, i) => (
+
+
+ {s.name}
+
+ ))}
+ {activeData.series.length > 1 && (
+
+
+ Total
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/app/components/analytics/MonthYearPicker.tsx b/app/components/analytics/MonthYearPicker.tsx
new file mode 100644
index 00000000..93a69d03
--- /dev/null
+++ b/app/components/analytics/MonthYearPicker.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { ChevronDownIcon, ChevronLeftIcon } from "@/app/components/icons";
+import { MonthYearAllowedRange } from "@/app/lib/types/analytics";
+
+interface MonthYearPickerProps {
+ label?: string;
+ value: string; // ISO date in `YYYY-MM-01` form
+ onChange: (iso: string) => void;
+ monthsBack?: number;
+ placeholder?: string;
+}
+
+const MONTH_LABELS_SHORT = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+];
+
+function buildAllowedRange(monthsBack: number): MonthYearAllowedRange {
+ const now = new Date();
+ const startYear = now.getFullYear();
+ const startMonth = now.getMonth();
+ const pairs: { year: number; monthIdx: number }[] = [];
+ for (let i = 0; i < monthsBack; i++) {
+ const monthIdx = (((startMonth - i) % 12) + 12) % 12;
+ const yearDelta = Math.floor((startMonth - i) / 12);
+ pairs.push({ year: startYear + yearDelta, monthIdx });
+ }
+ const monthsByYear = new Map>();
+ for (const { year, monthIdx } of pairs) {
+ if (!monthsByYear.has(year)) monthsByYear.set(year, new Set());
+ monthsByYear.get(year)!.add(monthIdx);
+ }
+ const years = Array.from(monthsByYear.keys()).sort((a, b) => b - a);
+ return { pairs, years, monthsByYear };
+}
+
+function formatLabel(iso: string): string {
+ const y = iso.slice(0, 4);
+ const m = parseInt(iso.slice(5, 7), 10) - 1;
+ if (Number.isNaN(m) || m < 0 || m > 11) return iso;
+ return `${MONTH_LABELS_SHORT[m]} ${y}`;
+}
+
+export default function MonthYearPicker({
+ label,
+ value,
+ onChange,
+ monthsBack = 24,
+ placeholder = "Select month",
+}: MonthYearPickerProps) {
+ const containerRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [view, setView] = useState<"year" | "month">("year");
+ const [pendingYear, setPendingYear] = useState(null);
+
+ const range = useMemo(() => buildAllowedRange(monthsBack), [monthsBack]);
+
+ const selectedYear = value ? parseInt(value.slice(0, 4), 10) : null;
+ const selectedMonthIdx = value ? parseInt(value.slice(5, 7), 10) - 1 : null;
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const openPopover = () => {
+ setOpen(true);
+ if (selectedYear != null) {
+ setPendingYear(selectedYear);
+ setView("month");
+ } else {
+ setPendingYear(null);
+ setView("year");
+ }
+ };
+
+ const handleYearPick = (year: number) => {
+ setPendingYear(year);
+ setView("month");
+ };
+
+ const handleMonthPick = (monthIdx: number) => {
+ if (pendingYear == null) return;
+ const mm = String(monthIdx + 1).padStart(2, "0");
+ onChange(`${pendingYear}-${mm}-01`);
+ setOpen(false);
+ };
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
(open ? setOpen(false) : openPopover())}
+ aria-haspopup="dialog"
+ aria-expanded={open}
+ className={`w-full flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-sm bg-bg-primary text-left transition-colors cursor-pointer ${
+ open ? "border-accent-primary" : "border-border"
+ }`}
+ >
+
+ {value ? formatLabel(value) : placeholder}
+
+
+
+
+ {open && (
+
+ {view === "year" ? (
+
+ ) : (
+ setView("year")}
+ onSelect={handleMonthPick}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+function YearGrid({
+ years,
+ selectedYear,
+ onSelect,
+}: {
+ years: number[];
+ selectedYear: number | null;
+ onSelect: (year: number) => void;
+}) {
+ return (
+
+
+ Select year
+
+
+ {years.map((y) => {
+ const isSelected = y === selectedYear;
+ return (
+ onSelect(y)}
+ className={`px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer ${
+ isSelected
+ ? "bg-accent-primary text-white"
+ : "text-text-primary hover:bg-bg-secondary"
+ }`}
+ >
+ {y}
+
+ );
+ })}
+
+
+ );
+}
+
+function MonthGrid({
+ year,
+ allowedMonths,
+ selectedMonth,
+ onBack,
+ onSelect,
+}: {
+ year: number;
+ allowedMonths: Set;
+ selectedMonth: number | null;
+ onBack: () => void;
+ onSelect: (monthIdx: number) => void;
+}) {
+ return (
+
+
+
+
+
+
+ {year}
+
+
+
+ {MONTH_LABELS_SHORT.map((label, monthIdx) => {
+ const allowed = allowedMonths.has(monthIdx);
+ const isSelected = allowed && selectedMonth === monthIdx;
+ return (
+ allowed && onSelect(monthIdx)}
+ disabled={!allowed}
+ className={`px-2 py-1.5 text-sm rounded-md transition-colors ${
+ isSelected
+ ? "bg-accent-primary text-white cursor-pointer"
+ : allowed
+ ? "text-text-primary hover:bg-bg-secondary cursor-pointer"
+ : "text-text-secondary/40 cursor-not-allowed"
+ }`}
+ >
+ {label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/components/analytics/index.ts b/app/components/analytics/index.ts
new file mode 100644
index 00000000..2d5050d5
--- /dev/null
+++ b/app/components/analytics/index.ts
@@ -0,0 +1,4 @@
+export { default as AnalyticsChartCard } from "./AnalyticsChartCard";
+export { default as AnalyticsTotalsRow } from "./AnalyticsTotalsRow";
+export { default as ChartTooltip } from "./ChartTooltip";
+export { default as MonthYearPicker } from "./MonthYearPicker";
diff --git a/app/components/icons/common/TooltipIcon.tsx b/app/components/icons/common/TooltipIcon.tsx
new file mode 100644
index 00000000..70624f14
--- /dev/null
+++ b/app/components/icons/common/TooltipIcon.tsx
@@ -0,0 +1,19 @@
+interface IconProps {
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+export default function TooltipIcon({ className, style }: IconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/components/icons/index.tsx b/app/components/icons/index.tsx
index 15abcf21..0ed575e2 100644
--- a/app/components/icons/index.tsx
+++ b/app/components/icons/index.tsx
@@ -1,5 +1,6 @@
// Common Icons (shared across multiple pages)
export { default as ArrowLeftIcon } from "./common/ArrowLeftIcon";
+export { default as TooltipIcon } from "./common/TooltipIcon";
export { default as ChevronDownIcon } from "./common/ChevronDownIcon";
export { default as CheckIcon } from "./common/CheckIcon";
export { default as CheckLineIcon } from "./common/CheckLineIcon";
@@ -48,6 +49,7 @@ export { default as ShieldCheckIcon } from "./sidebar/ShieldCheckIcon";
export { default as LogoutIcon } from "./sidebar/LogoutIcon";
export { default as ChatIcon } from "./sidebar/ChatIcon";
export { default as SendIcon } from "./sidebar/SendIcon";
+export { default as ChartBarIcon } from "./sidebar/ChartBarIcon";
// Prompt Editor Icons
export { default as ChevronRightIcon } from "./prompt-editor/ChevronRightIcon";
diff --git a/app/components/icons/sidebar/ChartBarIcon.tsx b/app/components/icons/sidebar/ChartBarIcon.tsx
new file mode 100644
index 00000000..021e0d75
--- /dev/null
+++ b/app/components/icons/sidebar/ChartBarIcon.tsx
@@ -0,0 +1,21 @@
+interface IconProps {
+ className?: string;
+}
+
+export default function ChartBarIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx
index 13fef4ae..bae4ee63 100644
--- a/app/components/prompt-editor/DiffView.tsx
+++ b/app/components/prompt-editor/DiffView.tsx
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import PromptDiffPane from "./PromptDiffPane";
import ConfigDiffPane from "./ConfigDiffPane";
-import { Button, Select, type SelectOption } from "@/app/components/ui";
+import { Button, Select } from "@/app/components/ui";
+import type { SelectOption } from "@/app/lib/types/ui";
import { VersionPill } from "@/app/components";
import { ArrowLeftIcon, ChevronRightIcon } from "@/app/components/icons";
import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs";
diff --git a/app/components/ui/InfoTooltip.tsx b/app/components/ui/InfoTooltip.tsx
index eda02b78..eeb711b7 100644
--- a/app/components/ui/InfoTooltip.tsx
+++ b/app/components/ui/InfoTooltip.tsx
@@ -8,6 +8,7 @@ import {
useCallback,
} from "react";
import { createPortal } from "react-dom";
+import { TooltipIcon } from "@/app/components/icons";
interface InfoTooltipProps {
text: ReactNode;
@@ -62,9 +63,9 @@ export default function InfoTooltip({ text }: InfoTooltipProps) {
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
- className="w-4 h-4 rounded-full text-[10px] font-bold flex items-center justify-center leading-none select-none bg-neutral-200 text-neutral-600 border border-neutral-300 cursor-pointer hover:bg-neutral-300 hover:text-neutral-700 transition-colors"
+ className="w-4 h-4 rounded-full text-[10px] font-bold flex items-center justify-center leading-none select-none cursor-pointer"
>
- i
+
{visible &&
createPortal(
diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx
index 86824a0d..2ad57555 100644
--- a/app/components/ui/Select.tsx
+++ b/app/components/ui/Select.tsx
@@ -1,11 +1,7 @@
"use client";
import { SelectHTMLAttributes } from "react";
-
-export interface SelectOption {
- value: string;
- label: string;
-}
+import { SelectOption } from "@/app/lib/types/ui";
interface SelectProps extends SelectHTMLAttributes {
options: SelectOption[];
@@ -17,17 +13,51 @@ export default function Select({
placeholder,
...props
}: SelectProps) {
+ const isGrouped = options.some((o) => o.group !== undefined);
+
+ const groups: { label: string | null; options: SelectOption[] }[] = [];
+ if (isGrouped) {
+ const seen = new Map();
+ for (const opt of options) {
+ const key = opt.group ?? null;
+ if (!seen.has(key)) {
+ const bucket: SelectOption[] = [];
+ seen.set(key, bucket);
+ groups.push({ label: key, options: bucket });
+ }
+ seen.get(key)!.push(opt);
+ }
+ }
+
return (
{placeholder && {placeholder} }
- {options.map((opt) => (
-
- {opt.label}
-
- ))}
+ {isGrouped
+ ? groups.map((g, i) =>
+ g.label ? (
+
+ {g.options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ ) : (
+ g.options.map((opt) => (
+
+ {opt.label}
+
+ ))
+ ),
+ )
+ : options.map((opt) => (
+
+ {opt.label}
+
+ ))}
);
}
diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts
index 28588b81..de2e2c67 100644
--- a/app/components/ui/index.ts
+++ b/app/components/ui/index.ts
@@ -1,7 +1,6 @@
export { default as Button } from "./Button";
export { default as Field } from "./Field";
export { default as Select } from "./Select";
-export type { SelectOption } from "./Select";
export { default as MultiSelect } from "./MultiSelect";
export { default as Modal } from "./Modal";
export { default as Loader, LoaderBox } from "./Loader";
diff --git a/app/globals.css b/app/globals.css
index 5726abae..66fb0d6f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -249,3 +249,27 @@ a {
opacity: 1;
cursor: not-allowed;
}
+
+/**
+ * Accent-colored thin scrollbar for compact data tables.
+ * Applied via className="custom-scroll-accent" on a scroll container.
+ */
+.custom-scroll-accent::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+.custom-scroll-accent::-webkit-scrollbar-track {
+ background: transparent;
+}
+.custom-scroll-accent::-webkit-scrollbar-thumb {
+ background-color: color-mix(in srgb, var(--color-accent-primary) 60%, white);
+ border-radius: 3px;
+}
+.custom-scroll-accent::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-accent-primary);
+}
+.custom-scroll-accent {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--color-accent-primary) 60%, white)
+ transparent;
+}
diff --git a/app/hooks/useAnalyticsChart.ts b/app/hooks/useAnalyticsChart.ts
new file mode 100644
index 00000000..6a5b5332
--- /dev/null
+++ b/app/hooks/useAnalyticsChart.ts
@@ -0,0 +1,88 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { apiFetch } from "@/app/lib/apiClient";
+import {
+ AnalyticsChartData,
+ AnalyticsChartFilters,
+ AnalyticsChartResponse,
+ AnalyticsMetric,
+ UseAnalyticsChartResult,
+} from "@/app/lib/types/analytics";
+import { mergeChartData } from "@/app/lib/utils/analytics/mergeChartData";
+
+/**
+ * Virtual metrics live in the frontend only — the backend doesn't know about
+ * them. The hook expands each into the atomic metrics shown here, fires one
+ * request per atom in parallel, and merges the responses before returning.
+ */
+const VIRTUAL_METRICS: Partial> = {
+ cost_all: ["cost", "eval_cost"],
+ volume: ["requests", "eval_runs"],
+};
+
+function buildQuery(filters: AnalyticsChartFilters): string {
+ const params = new URLSearchParams();
+ params.set("metric", filters.metric);
+ params.set("group_by", filters.group_by);
+ if (filters.modality) params.set("modality", filters.modality);
+ if (filters.provider) params.set("provider", filters.provider);
+ if (filters.project_id !== undefined)
+ params.set("project_id", String(filters.project_id));
+ if (filters.from_month) params.set("from_month", filters.from_month);
+ if (filters.to_month) params.set("to_month", filters.to_month);
+ return params.toString();
+}
+
+async function fetchSingleChart(
+ filters: AnalyticsChartFilters,
+ apiKey: string,
+): Promise {
+ const result = await apiFetch(
+ `/api/analytics/monthly/chart?${buildQuery(filters)}`,
+ apiKey,
+ );
+ if (!result.success || !result.data) {
+ throw new Error(result.error ?? "Failed to load analytics");
+ }
+ return result.data;
+}
+
+export function useAnalyticsChart(
+ filters: AnalyticsChartFilters,
+): UseAnalyticsChartResult {
+ const { activeKey, isAuthenticated } = useAuth();
+ const apiKey = activeKey?.key ?? "";
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
+
+ const fetchChart = useCallback(async () => {
+ if (!isAuthenticated) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const parts = VIRTUAL_METRICS[filters.metric];
+ if (parts) {
+ const [first, second] = await Promise.all(
+ parts.map((m) => fetchSingleChart({ ...filters, metric: m }, apiKey)),
+ );
+ setData(mergeChartData(first, second, filters.metric));
+ } else {
+ setData(await fetchSingleChart(filters, apiKey));
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load analytics");
+ setData(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiKey, isAuthenticated, filtersKey]);
+
+ useEffect(() => {
+ void fetchChart();
+ }, [fetchChart]);
+
+ return { data, isLoading, error, refetch: fetchChart };
+}
diff --git a/app/hooks/useAnalyticsTotals.ts b/app/hooks/useAnalyticsTotals.ts
new file mode 100644
index 00000000..232bd4cd
--- /dev/null
+++ b/app/hooks/useAnalyticsTotals.ts
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useState } from "react";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { apiFetch } from "@/app/lib/apiClient";
+import {
+ AnalyticsChartFilters,
+ AnalyticsChartResponse,
+ AnalyticsMetric,
+ AnalyticsTotalsMap,
+ AnalyticsTotalsValue,
+ UseAnalyticsTotalsResult,
+} from "@/app/lib/types/analytics";
+
+const TOTAL_METRICS: AnalyticsMetric[] = [
+ "requests",
+ "cost",
+ "eval_runs",
+ "eval_cost",
+];
+
+function buildQuery(
+ metric: AnalyticsMetric,
+ filters: Omit,
+) {
+ const params = new URLSearchParams();
+ params.set("metric", metric);
+ params.set("group_by", "total");
+ if (filters.modality) params.set("modality", filters.modality);
+ if (filters.provider) params.set("provider", filters.provider);
+ if (filters.project_id !== undefined)
+ params.set("project_id", String(filters.project_id));
+ if (filters.from_month) params.set("from_month", filters.from_month);
+ if (filters.to_month) params.set("to_month", filters.to_month);
+ return params.toString();
+}
+
+function emptyValue(): AnalyticsTotalsValue {
+ return { value: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0 };
+}
+
+export function useAnalyticsTotals(
+ filters: Omit,
+): UseAnalyticsTotalsResult {
+ const { activeKey, isAuthenticated } = useAuth();
+ const apiKey = activeKey?.key ?? "";
+ const [totals, setTotals] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const filtersKey = JSON.stringify({
+ modality: filters.modality,
+ provider: filters.provider,
+ project_id: filters.project_id,
+ from_month: filters.from_month,
+ to_month: filters.to_month,
+ });
+
+ const fetchTotals = useCallback(async () => {
+ if (!isAuthenticated) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const results = await Promise.all(
+ TOTAL_METRICS.map(async (metric) => {
+ const query = buildQuery(metric, filters);
+ const res = await apiFetch(
+ `/api/analytics/monthly/chart?${query}`,
+ apiKey,
+ );
+ if (!res.success || !res.data) return { metric, value: emptyValue() };
+ let sum = 0;
+ let totalTokens = 0;
+ let inputTokens = 0;
+ let outputTokens = 0;
+ for (const s of res.data.series) {
+ sum += s.data.reduce((a, v) => a + (Number(v) || 0), 0);
+ totalTokens += s.total_tokens ?? 0;
+ inputTokens += s.total_input_tokens ?? 0;
+ outputTokens += s.total_output_tokens ?? 0;
+ }
+ return {
+ metric,
+ value: { value: sum, totalTokens, inputTokens, outputTokens },
+ };
+ }),
+ );
+ const map: AnalyticsTotalsMap = {
+ requests: emptyValue(),
+ cost: emptyValue(),
+ eval_runs: emptyValue(),
+ eval_cost: emptyValue(),
+ };
+ for (const { metric, value } of results) map[metric] = value;
+ setTotals(map);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load totals");
+ setTotals(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiKey, isAuthenticated, filtersKey]);
+
+ useEffect(() => {
+ void fetchTotals();
+ }, [fetchTotals]);
+
+ return { totals, isLoading, error };
+}
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
index a359e6b0..4b4f392c 100644
--- a/app/lib/constants.ts
+++ b/app/lib/constants.ts
@@ -4,6 +4,11 @@
import { ConfigBlob } from "@/app/lib/types/promptEditor";
import { ToastType } from "@/app/components/ui";
+import {
+ AnalyticsGroupBy,
+ AnalyticsMetric,
+ AnalyticsModality,
+} from "@/app/lib/types/analytics";
export const APP_NAME = "Kaapi Konsole";
@@ -50,6 +55,60 @@ export const PROVIDES_OPTIONS = [
{ value: "google", label: "Google" },
];
+export const ANALYTICS_METRIC_OPTIONS: {
+ value: AnalyticsMetric;
+ label: string;
+}[] = [
+ { value: "cost_all", label: "Cost (USD)" },
+ { value: "volume", label: "Volume" },
+];
+
+export const ANALYTICS_GROUP_BY_OPTIONS: {
+ value: AnalyticsGroupBy;
+ label: string;
+}[] = [
+ { value: "total", label: "Total (no breakdown)" },
+ { value: "provider", label: "Provider" },
+ { value: "modality", label: "Request type" },
+ { value: "modality_provider", label: "Request type + Provider" },
+];
+
+export const ANALYTICS_MODALITY_OPTIONS: {
+ value: AnalyticsModality;
+ label: string;
+}[] = [
+ { value: "T-FS-T", label: "Text → Text" },
+ { value: "S-FS-S", label: "Speech → Speech" },
+ { value: "STT", label: "Speech → Text" },
+ { value: "TTS", label: "Text → Speech" },
+ { value: "OTHER", label: "Other" },
+];
+
+export const MONTH_OPTIONS: { value: string; label: string }[] = [
+ { value: "01", label: "January" },
+ { value: "02", label: "February" },
+ { value: "03", label: "March" },
+ { value: "04", label: "April" },
+ { value: "05", label: "May" },
+ { value: "06", label: "June" },
+ { value: "07", label: "July" },
+ { value: "08", label: "August" },
+ { value: "09", label: "September" },
+ { value: "10", label: "October" },
+ { value: "11", label: "November" },
+ { value: "12", label: "December" },
+];
+
+export function getRecentYearOptions(
+ count = 2,
+): { value: string; label: string }[] {
+ const now = new Date().getFullYear();
+ return Array.from({ length: count }, (_, i) => {
+ const y = String(now - i);
+ return { value: y, label: y };
+ });
+}
+
export const PROVIDER_TYPES = [
{
value: "text",
diff --git a/app/lib/navConfig.ts b/app/lib/navConfig.ts
index f68d5a9a..03730755 100644
--- a/app/lib/navConfig.ts
+++ b/app/lib/navConfig.ts
@@ -56,4 +56,10 @@ export const NAV_ITEMS: NavItemConfig[] = [
gateDescription:
"Log in to compare model response quality across different configs.",
},
+ {
+ name: "Analytics",
+ route: "/analytics",
+ icon: "chart",
+ gateDescription: "Log in to view the analytics dashboard.",
+ },
];
diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts
new file mode 100644
index 00000000..7577ec47
--- /dev/null
+++ b/app/lib/types/analytics.ts
@@ -0,0 +1,123 @@
+import { APIEnvelope } from "@/app/lib/types/chat";
+
+/**
+ * Atomic backend metrics map 1:1 to a single `/analytics/monthly/chart` call.
+ * Virtual metrics (`cost_all`, `volume`) are frontend-only: the chart hook
+ * fans them out into two atomic calls and sums the responses before plotting.
+ * - `cost_all` = `cost` + `eval_cost` (production + eval spend)
+ * - `volume` = `requests` + `eval_runs` (production calls + eval batches)
+ */
+export type AnalyticsMetric =
+ | "requests"
+ | "cost"
+ | "eval_runs"
+ | "eval_cost"
+ | "cost_all"
+ | "volume";
+
+export type AnalyticsGroupBy =
+ | "total"
+ | "provider"
+ | "modality"
+ | "modality_provider";
+
+export type AnalyticsModality = "T-FS-T" | "S-FS-S" | "STT" | "TTS" | "OTHER";
+
+export interface AnalyticsSeriesPoint {
+ name: string;
+ data: string[];
+ total_input_tokens?: number;
+ total_output_tokens?: number;
+ total_tokens?: number;
+}
+
+export interface AnalyticsChartData {
+ metric: AnalyticsMetric;
+ group_by: AnalyticsGroupBy;
+ labels: string[];
+ series: AnalyticsSeriesPoint[];
+}
+
+export interface AnalyticsChartFilters {
+ metric: AnalyticsMetric;
+ group_by: AnalyticsGroupBy;
+ modality?: AnalyticsModality;
+ provider?: string;
+ project_id?: number;
+ from_month?: string;
+ to_month?: string;
+}
+
+export type AnalyticsChartResponse = APIEnvelope;
+
+export interface AnalyticsTooltipEntry {
+ dataKey?: string | number;
+ value?: number | string;
+ color?: string;
+}
+
+export interface AnalyticsTooltipRenderProps {
+ active?: boolean;
+ payload?: AnalyticsTooltipEntry[];
+ label?: string | number;
+ metric: AnalyticsMetric;
+}
+
+export interface AnalyticsTotalsValue {
+ value: number;
+ totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+}
+
+/** Atomic backend metrics — the ones that map 1:1 to a backend call. */
+export type AnalyticsBackendMetric = Exclude<
+ AnalyticsMetric,
+ "cost_all" | "volume"
+>;
+
+export type AnalyticsTotalsMap = Record<
+ AnalyticsBackendMetric,
+ AnalyticsTotalsValue
+>;
+
+export interface UseAnalyticsTotalsResult {
+ totals: AnalyticsTotalsMap | null;
+ isLoading: boolean;
+ error: string | null;
+}
+
+export interface UseAnalyticsChartResult {
+ data: AnalyticsChartData | null;
+ isLoading: boolean;
+ error: string | null;
+ refetch: () => void;
+}
+
+export interface AnalyticsTotalsRowProps {
+ totals: AnalyticsTotalsMap | null;
+ isLoading: boolean;
+ error: string | null;
+}
+
+export interface AnalyticsChartRow {
+ month: string;
+ monthIso: string;
+ __total: number;
+ __range: [number, number];
+ [seriesName: string]: string | number | [number, number];
+}
+
+export interface AnalyticsSeriesAggregate {
+ data: number[];
+ totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ hasTokens: boolean;
+}
+
+export interface MonthYearAllowedRange {
+ pairs: { year: number; monthIdx: number }[];
+ years: number[];
+ monthsByYear: Map>;
+}
diff --git a/app/lib/types/ui.ts b/app/lib/types/ui.ts
new file mode 100644
index 00000000..7dadc5ec
--- /dev/null
+++ b/app/lib/types/ui.ts
@@ -0,0 +1,10 @@
+export interface SelectOption {
+ value: string;
+ label: string;
+ /**
+ * Optional group label. When any option in a `` declares a group,
+ * the select renders `` blocks. Options without a group fall
+ * into an untitled top-level bucket.
+ */
+ group?: string;
+}
diff --git a/app/lib/utils/analytics/formatValue.ts b/app/lib/utils/analytics/formatValue.ts
new file mode 100644
index 00000000..b0288266
--- /dev/null
+++ b/app/lib/utils/analytics/formatValue.ts
@@ -0,0 +1,50 @@
+import { AnalyticsMetric } from "@/app/lib/types/analytics";
+
+export const CURRENCY_METRICS: AnalyticsMetric[] = [
+ "cost",
+ "eval_cost",
+ "cost_all",
+];
+
+export function formatMetricValue(
+ value: number,
+ metric: AnalyticsMetric,
+): string {
+ if (CURRENCY_METRICS.includes(metric)) {
+ return value.toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ }
+ return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
+}
+
+export function formatCompactMetric(
+ value: number,
+ metric: AnalyticsMetric,
+): string {
+ const abs = Math.abs(value);
+ const compact =
+ abs >= 1_000_000
+ ? `${(value / 1_000_000).toFixed(1)}M`
+ : abs >= 1_000
+ ? `${(value / 1_000).toFixed(1)}k`
+ : value.toLocaleString("en-US", { maximumFractionDigits: 0 });
+ return CURRENCY_METRICS.includes(metric) ? `$${compact}` : compact;
+}
+
+export function formatTokens(n: number): string {
+ if (!Number.isFinite(n)) return "0";
+ const abs = Math.abs(n);
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
+}
+
+export function formatMonthLabel(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return iso;
+ return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
+}
diff --git a/app/lib/utils/analytics/mergeChartData.ts b/app/lib/utils/analytics/mergeChartData.ts
new file mode 100644
index 00000000..e7e802fa
--- /dev/null
+++ b/app/lib/utils/analytics/mergeChartData.ts
@@ -0,0 +1,75 @@
+import {
+ AnalyticsChartData,
+ AnalyticsMetric,
+ AnalyticsSeriesAggregate,
+ AnalyticsSeriesPoint,
+} from "@/app/lib/types/analytics";
+
+/**
+ * Sums two `AnalyticsChartData` responses into one — labels unioned,
+ * series matched by name, values and token totals added per month.
+ * Backs the virtual metrics (`cost_all`, `volume`).
+ */
+export function mergeChartData(
+ a: AnalyticsChartData,
+ b: AnalyticsChartData,
+ outMetric: AnalyticsMetric,
+): AnalyticsChartData {
+ const labels = Array.from(new Set([...a.labels, ...b.labels])).sort();
+ const labelIndex = new Map(labels.map((l, i) => [l, i]));
+
+ const map = new Map();
+
+ const ingest = (src: AnalyticsChartData) => {
+ for (const s of src.series) {
+ const agg = map.get(s.name) ?? {
+ data: new Array(labels.length).fill(0),
+ totalTokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ hasTokens: false,
+ };
+ src.labels.forEach((label, i) => {
+ const idx = labelIndex.get(label);
+ if (idx == null) return;
+ const v = Number(s.data[i] ?? 0);
+ agg.data[idx] += Number.isFinite(v) ? v : 0;
+ });
+ if (
+ s.total_input_tokens !== undefined ||
+ s.total_output_tokens !== undefined ||
+ s.total_tokens !== undefined
+ ) {
+ agg.hasTokens = true;
+ }
+ agg.totalTokens += s.total_tokens ?? 0;
+ agg.inputTokens += s.total_input_tokens ?? 0;
+ agg.outputTokens += s.total_output_tokens ?? 0;
+ map.set(s.name, agg);
+ }
+ };
+
+ ingest(a);
+ ingest(b);
+
+ const series: AnalyticsSeriesPoint[] = Array.from(map.entries()).map(
+ ([name, agg]) => ({
+ name,
+ data: agg.data.map((n) => String(n)),
+ ...(agg.hasTokens
+ ? {
+ total_tokens: agg.totalTokens,
+ total_input_tokens: agg.inputTokens,
+ total_output_tokens: agg.outputTokens,
+ }
+ : {}),
+ }),
+ );
+
+ return {
+ metric: outMetric,
+ group_by: a.group_by,
+ labels,
+ series,
+ };
+}
diff --git a/app/lib/utils/analytics/normalizeSeries.ts b/app/lib/utils/analytics/normalizeSeries.ts
new file mode 100644
index 00000000..c212e1fe
--- /dev/null
+++ b/app/lib/utils/analytics/normalizeSeries.ts
@@ -0,0 +1,74 @@
+import { AnalyticsSeriesPoint } from "@/app/lib/types/analytics";
+
+const PROVIDER_DISPLAY: Record = {
+ openai: "OpenAI",
+ google: "Google",
+ anthropic: "Anthropic",
+ sarvamai: "Sarvam AI",
+ elevenlabs: "ElevenLabs",
+ azure: "Azure",
+ cohere: "Cohere",
+};
+
+function canonicalProviderKey(raw: string): string {
+ return raw
+ .toLowerCase()
+ .trim()
+ .replace(/[-_\s]?native$/i, "")
+ .replace(/[-_\s]+/g, "");
+}
+
+function formatProvider(raw: string): string {
+ const key = canonicalProviderKey(raw);
+ return PROVIDER_DISPLAY[key] ?? raw.trim();
+}
+
+function normalizeName(name: string): string {
+ const parts = name.split("·").map((p) => p.trim());
+ if (parts.length === 1) return formatProvider(parts[0]);
+ return parts
+ .map((p, i) => (i === parts.length - 1 ? formatProvider(p) : p))
+ .join(" · ");
+}
+
+function sumNumericArrays(a: string[], b: string[]): string[] {
+ const len = Math.max(a.length, b.length);
+ const out: string[] = [];
+ for (let i = 0; i < len; i++) {
+ const left = Number(a[i] ?? 0) || 0;
+ const right = Number(b[i] ?? 0) || 0;
+ out.push(String(left + right));
+ }
+ return out;
+}
+
+function mergeSeries(
+ a: AnalyticsSeriesPoint,
+ b: AnalyticsSeriesPoint,
+): AnalyticsSeriesPoint {
+ return {
+ name: a.name,
+ data: sumNumericArrays(a.data, b.data),
+ total_input_tokens:
+ (a.total_input_tokens ?? 0) + (b.total_input_tokens ?? 0),
+ total_output_tokens:
+ (a.total_output_tokens ?? 0) + (b.total_output_tokens ?? 0),
+ total_tokens: (a.total_tokens ?? 0) + (b.total_tokens ?? 0),
+ };
+}
+
+export function normalizeAndMergeSeries(
+ series: AnalyticsSeriesPoint[],
+): AnalyticsSeriesPoint[] {
+ const byName = new Map();
+ for (const s of series) {
+ const displayName = normalizeName(s.name);
+ const existing = byName.get(displayName);
+ if (!existing) {
+ byName.set(displayName, { ...s, name: displayName });
+ } else {
+ byName.set(displayName, mergeSeries(existing, s));
+ }
+ }
+ return Array.from(byName.values());
+}
diff --git a/app/lib/utils/selectOptions.ts b/app/lib/utils/selectOptions.ts
new file mode 100644
index 00000000..5b9ef1a2
--- /dev/null
+++ b/app/lib/utils/selectOptions.ts
@@ -0,0 +1,11 @@
+import { SelectOption } from "@/app/lib/types/ui";
+
+export function toSelectOptions(
+ items: { value: T; label: string; group?: string }[],
+): SelectOption[] {
+ return items.map((i) => ({
+ value: i.value,
+ label: i.label,
+ ...(i.group ? { group: i.group } : {}),
+ }));
+}
diff --git a/package-lock.json b/package-lock.json
index 6510ab32..28bf6022 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.5",
+ "recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",
@@ -1267,6 +1268,42 @@
"react-dom": ">=16.8.0"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
+ "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.8",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
+ "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1274,6 +1311,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1565,6 +1614,69 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -1662,6 +1774,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.49.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
@@ -2803,6 +2921,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2894,6 +3021,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2991,6 +3239,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -3325,6 +3579,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.46.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
+ "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3875,7 +4139,6 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
- "dev": true,
"license": "MIT"
},
"node_modules/extend": {
@@ -4464,6 +4727,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4512,6 +4785,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -7217,7 +7499,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/react-markdown": {
@@ -7247,6 +7528,29 @@
"react": ">=18"
}
},
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-router": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
@@ -7285,6 +7589,51 @@
"react-dom": ">=18"
}
},
+ "node_modules/recharts": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
+ "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/refa": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz",
@@ -7439,6 +7788,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -8242,6 +8597,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
@@ -8747,6 +9108,28 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index fcdd3765..47811637 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.5",
+ "recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",