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 ( +
+ + {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). + + } + > + + 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. + + } + > + setProvider(e.target.value)} + options={PROVIDES_OPTIONS} + placeholder="All" + /> +
+ + + + + + + {(fromMonth || toMonth) && ( + + )} +
+
+ +
+ + + +
+
+ )} +
+
+
+ ); +} 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 ( +
+

{error}

+
+ ); + } + 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} +

+
+
+ + + + + + + {hasAnyTokens && ( + + )} + + + + {totals.map((t, i) => { + const isEmpty = t.total === 0; + return ( + + + + + {hasAnyTokens && ( + + )} + + ); + })} + +
+ {groupHeader} + + {metricLabel} + Share + Tokens +
+ + + + {t.name} + + + + {formatMetricValue(t.total, metric)} + + {!isEmpty ? `${t.sharePct.toFixed(1)}%` : "—"} + + {(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 ? ( +
+

{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 && ( + + + )} +
+ )} + + ); +} 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 && ( + + )} + + + {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 ( + + ); + })} +
+
+ ); +} + +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 ( + + ); + })} +
+
+ ); +} 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 ( ); } 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 `