From 26a0a1003cd438ebfe66154784c508e2ea6756d2 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 20:47:42 +0200 Subject: [PATCH 01/34] v6.1.6: Fix unused code and TS checks --- server.js | 17 ----------------- src/components/Dashboard.tsx | 2 +- .../charts/RequestCacheHitRateByModel.tsx | 2 +- .../features/limits/ProviderLimitsSection.tsx | 8 +++++++- src/components/ui/card.tsx | 4 +++- src/hooks/use-computed-metrics.ts | 4 ++-- src/lib/auto-import.ts | 7 +++++-- src/lib/help-content.ts | 2 +- tsconfig.json | 4 ++-- 9 files changed, 22 insertions(+), 28 deletions(-) diff --git a/server.js b/server.js index e449560..0cbab77 100755 --- a/server.js +++ b/server.js @@ -1331,23 +1331,6 @@ function json(res, status, data) { res.end(JSON.stringify(data)); } -function sendFile(res, status, headers, filePath) { - const stream = fs.createReadStream(filePath); - res.writeHead(status, { - ...headers, - ...SECURITY_HEADERS, - }); - stream.on('error', () => { - if (!res.headersSent) { - res.writeHead(500, SECURITY_HEADERS); - res.end('Internal Server Error'); - return; - } - res.destroy(); - }); - stream.pipe(res); -} - function sendBuffer(res, status, headers, buffer) { res.writeHead(status, { 'Content-Length': buffer.length, diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index cde1342..ef3bf21 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -212,7 +212,7 @@ export function Dashboard() { const { metrics, modelCosts, providerMetrics, costChartData, modelCostChartData, tokenChartData, requestChartData, weekdayData, allModels, modelPieData, tokenPieData, - } = useComputedMetrics(filteredData, viewMode) + } = useComputedMetrics(filteredData) // Full dataset with only model filter applied (no date/month filter) for PeriodComparison const comparisonData = filteredDailyData diff --git a/src/components/charts/RequestCacheHitRateByModel.tsx b/src/components/charts/RequestCacheHitRateByModel.tsx index 7e715d8..9aa1259 100644 --- a/src/components/charts/RequestCacheHitRateByModel.tsx +++ b/src/components/charts/RequestCacheHitRateByModel.tsx @@ -8,7 +8,7 @@ import { CHART_HELP } from '@/lib/help-content' import { computeCacheHitRateByModel, computeMovingAverage } from '@/lib/calculations' import { formatDateAxis, formatPercent, periodUnit } from '@/lib/formatters' import { getModelColor, normalizeModelName } from '@/lib/model-utils' -import type { CacheHitRateByModelChartDataPoint, DailyUsage, ViewMode } from '@/types' +import type { DailyUsage, ViewMode } from '@/types' interface RequestCacheHitRateByModelProps { timelineData: DailyUsage[] diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 6217dc9..34a2381 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -10,6 +10,7 @@ import { ReferenceLine, ResponsiveContainer, Tooltip, + type TooltipValueType, XAxis, YAxis, } from 'recharts' @@ -62,6 +63,11 @@ function subscriptionLabel(row: ProviderLimitRow) { return i18n.t('limits.statuses.belowSubscription') } +function toTooltipNumber(value: TooltipValueType | undefined) { + const numericValue = Array.isArray(value) ? Number(value[0] ?? 0) : Number(value ?? 0) + return Number.isFinite(numericValue) ? numericValue : 0 +} + export function ProviderLimitsSection({ data, providers, limits, selectedMonth }: ProviderLimitsSectionProps) { const { t } = useTranslation() const sectionRef = useRef(null) @@ -589,7 +595,7 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } formatCurrency(Math.abs(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> [formatCurrencyExact(Math.abs(value)), name]} + formatter={(value: TooltipValueType | undefined, name: string | number | undefined) => [formatCurrencyExact(Math.abs(toTooltipNumber(value))), name ?? '']} labelFormatter={(label) => formatMonthYear(String(label))} contentStyle={{ borderRadius: 12, borderColor: 'hsl(var(--border))', background: 'color-mix(in srgb, hsl(var(--popover)) 90%, transparent)' }} /> diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index caadc0c..6b3d103 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -2,7 +2,9 @@ import * as React from 'react' import { motion } from 'framer-motion' import { cn } from '@/lib/cn' -const Card = React.forwardRef>( +type CardProps = React.ComponentPropsWithoutRef + +const Card = React.forwardRef( ({ className, ...props }, ref) => ( computeMetrics(data), [data]) const modelCosts = useMemo(() => computeModelCosts(data), [data]) const providerMetrics = useMemo(() => computeProviderMetrics(data), [data]) diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 2f8d0e3..1c543d1 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -4,7 +4,10 @@ export interface StderrEvent { line: string } export interface SuccessEvent { days: number; totalCost: number } export interface ErrorEvent { message: string } -function translateAutoImportMessage(message, t) { +type AutoImportTranslationVars = Record +type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string + +function translateAutoImportMessage(message: string, t: AutoImportTranslator) { if (message === 'Starte lokalen toktrack-Import...') { return t('autoImportModal.startingLocalImport') } @@ -44,7 +47,7 @@ export function startAutoImport(callbacks: { onSuccess: (data: SuccessEvent) => void onError: (data: ErrorEvent) => void onDone: () => void -}, t = (key, vars) => key): { close: () => void } { +}, t: AutoImportTranslator = (key) => key): { close: () => void } { const es = new EventSource('/api/auto-import/stream') es.addEventListener('check', (e) => { diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 736e425..5b6812e 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -1,4 +1,4 @@ -import i18n, { getCurrentLanguage } from '@/lib/i18n' +import { getCurrentLanguage } from '@/lib/i18n' const HELP_CONTENT = { de: { diff --git a/tsconfig.json b/tsconfig.json index 3543da3..9657955 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,8 @@ "noEmit": true, "jsx": "react-jsx", "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"] } }, From 64391a544c709298e5bcf3d9f1d1c3526d299415 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 21:05:03 +0200 Subject: [PATCH 02/34] v6.1.6: Enable noImplicitOverride --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 9657955..956c5bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "noEmit": true, "jsx": "react-jsx", "strict": true, + "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, From 968781fcc657696a1f11f9e5b46c456d2a23029a Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 21:06:49 +0200 Subject: [PATCH 03/34] v6.1.6: Enable noUncheckedSideEffectImports --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 956c5bb..adffa74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "jsx": "react-jsx", "strict": true, "noImplicitOverride": true, + "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, From 55cc2ddaae45e535d37345df1f993f5f2c9d7174 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 21:08:23 +0200 Subject: [PATCH 04/34] v6.1.6: Enable noImplicitReturns --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index adffa74..59c3c6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "jsx": "react-jsx", "strict": true, "noImplicitOverride": true, + "noImplicitReturns": true, "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, From ae32a5aa31cdb88faf99b759335e376b3f7c52cb Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 21:20:11 +0200 Subject: [PATCH 05/34] v6.1.6: Enable noPropertyAccessFromIndexSignature --- src/lib/help-content.ts | 28 ++++++++++++++++++++-------- tsconfig.json | 1 + 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 5b6812e..17921f6 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -139,11 +139,23 @@ function current() { return HELP_CONTENT[getCurrentLanguage()] } -function dynamicMap>(selector: () => T): Record { - return new Proxy({} as Record, { - get: (_, key) => selector()[String(key)] ?? '', +type AppHelpContent = typeof HELP_CONTENT.en +type HelpMap> = { [K in keyof T]: string } +export type MetricHelp = HelpMap +export type ChartHelp = HelpMap +export type SectionHelp = HelpMap +export type FeatureHelp = HelpMap + +function dynamicMap>(selector: () => T): T { + return new Proxy({} as T, { + get: (_, key) => Reflect.get(selector(), key), + has: (_, key) => key in selector(), ownKeys: () => Reflect.ownKeys(selector()), - getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }), + getOwnPropertyDescriptor: (_, key) => ({ + value: Reflect.get(selector(), key), + enumerable: true, + configurable: true, + }), }) } @@ -151,7 +163,7 @@ export function getKeyboardShortcuts() { return current().keyboardShortcuts } -export const METRIC_HELP = dynamicMap(() => current().metric) -export const CHART_HELP = dynamicMap(() => current().chart) -export const SECTION_HELP = dynamicMap(() => current().section) -export const FEATURE_HELP = dynamicMap(() => current().feature) +export const METRIC_HELP: MetricHelp = dynamicMap(() => current().metric) +export const CHART_HELP: ChartHelp = dynamicMap(() => current().chart) +export const SECTION_HELP: SectionHelp = dynamicMap(() => current().section) +export const FEATURE_HELP: FeatureHelp = dynamicMap(() => current().feature) diff --git a/tsconfig.json b/tsconfig.json index 59c3c6b..c3b7aa8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "strict": true, "noImplicitOverride": true, "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, From df958d32c2bf0169a133ed9a63b5d08b17f6a3a3 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 21:27:17 +0200 Subject: [PATCH 06/34] v6.1.6: Enable exactOptionalPropertyTypes --- src/components/Dashboard.tsx | 28 +++++----- src/components/cards/MonthMetrics.tsx | 16 +++--- src/components/cards/PrimaryMetrics.tsx | 24 ++++++--- src/components/cards/SecondaryMetrics.tsx | 34 ++++++++----- src/components/cards/TodayMetrics.tsx | 26 ++++++---- src/components/charts/CorrelationAnalysis.tsx | 2 +- .../features/auto-import/AutoImportModal.tsx | 8 ++- .../command-palette/CommandPalette.tsx | 2 +- .../features/limits/ProviderLimitsSection.tsx | 2 +- src/components/layout/FilterBar.tsx | 4 +- src/components/tables/RecentDays.tsx | 9 ++-- src/lib/data-transforms.ts | 51 +++++++++++-------- src/types/index.ts | 4 ++ tsconfig.json | 1 + 14 files changed, 128 insertions(+), 83 deletions(-) diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ef3bf21..d60e925 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -49,7 +49,7 @@ import { applyTheme } from '@/lib/app-settings' import { downloadCSV } from '@/lib/csv-export' import { VERSION } from '@/lib/constants' import { SECTION_HELP } from '@/lib/help-content' -import { generatePdfReport, importSettings, importUsageData } from '@/lib/api' +import { generatePdfReport, importSettings, importUsageData, type PdfReportRequest } from '@/lib/api' import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' @@ -174,8 +174,8 @@ export function Dashboard() { return { type: 'stored' as const, - time: persistedLoadedTime, - title: persistedLoadedTitle, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + ...(persistedLoadedTitle ? { title: persistedLoadedTitle } : {}), } }, [hasData, persistedLoadedTime, persistedLoadedTitle]) const headerDataSource = dataSource ?? persistedDataSource @@ -183,7 +183,7 @@ export function Dashboard() { settings.cliAutoLoadActive ? { active: true, - time: persistedLoadedTime, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), title: settings.lastLoadedAt ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) : t('header.autoLoadActive'), @@ -333,15 +333,17 @@ export function Dashboard() { setReportGenerating(true) try { - const blob = await generatePdfReport({ + const requestLanguage: PdfReportRequest['language'] = i18n.language === 'en' ? 'en' : 'de' + const request: PdfReportRequest = { viewMode, selectedMonth, selectedProviders, selectedModels, - startDate, - endDate, - language: i18n.language === 'en' ? 'en' : 'de', - }) + language: requestLanguage, + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), + } + const blob = await generatePdfReport(request) const objectUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = objectUrl @@ -371,7 +373,7 @@ export function Dashboard() { const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) setDataSource({ type: 'auto-import', - time, + ...(time ? { time } : {}), title: t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) }), }) addToast(t('toasts.dataImported'), 'success') @@ -456,7 +458,7 @@ export function Dashboard() { setDataSource({ type: 'file', label: file.name, - time, + ...(time ? { time } : {}), title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, }) @@ -810,8 +812,8 @@ export function Dashboard() { selectedModels={selectedModels} onToggleModel={toggleModel} onClearModels={clearModels} - startDate={startDate} - endDate={endDate} + {...(startDate ? { startDate } : {})} + {...(endDate ? { endDate } : {})} onStartDateChange={setStartDate} onEndDateChange={setEndDate} onApplyPreset={applyPreset} diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index f8455a7..fd59082 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -84,6 +84,12 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { : null const ioTotal = agg.inputTokens + agg.outputTokens + const tokensSubtitle = agg.inputTokens > 0 && agg.outputTokens > 0 + ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + : null + const modelsSubtitle = agg.topModel ? t('metricCards.month.topModel', { value: agg.topModel.name }) : null + const costPerMillionSubtitle = metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : null + const thinkingSubtitle = agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%` }) : null return (
@@ -105,10 +111,8 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { } - subtitle={agg.inputTokens > 0 && agg.outputTokens > 0 - ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) - : undefined} icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> } + {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} /> } - subtitle={metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : undefined} icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> } - subtitle={agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%` }) : undefined} icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index e777403..f9c0d7a 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -22,6 +22,18 @@ export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' const coverageRate = totalCalendarDays && viewMode === 'daily' ? (metrics.activeDays / totalCalendarDays) * 100 : null + const topModelSubtitle = metrics.topModel + ? `${formatCurrency(metrics.topModel.cost)} · ${t('metricCards.primary.share', { value: formatPercent(metrics.topModelShare, 0) })}${metrics.topRequestModel ? ` · ${t('metricCards.primary.requestLead', { value: metrics.topRequestModel.name })}` : ''}` + : null + const cacheHitRateSubtitle = metrics.totalTokens > 0 + ? t('metricCards.primary.allTokensViaCacheRead', { value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100) }) + : null + const thinkingInsight = metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingShareOfVolume', { value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100) }) + : null + const thinkingSubtitle = metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingSubtitle', { share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)) }) + : null return (
@@ -52,18 +64,16 @@ export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' } info={METRIC_HELP.topModel} + {...(topModelSubtitle ? { subtitle: topModelSubtitle } : {})} /> } - subtitle={metrics.totalTokens > 0 ? t('metricCards.primary.allTokensViaCacheRead', { value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100) }) : undefined} icon={} info={METRIC_HELP.cacheHitRate} + {...(cacheHitRateSubtitle ? { subtitle: cacheHitRateSubtitle } : {})} /> 0 ? t('metricCards.primary.thinkingShareOfVolume', { value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100) }) : undefined} />} - subtitle={metrics.totalTokens > 0 - ? t('metricCards.primary.thinkingSubtitle', { share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)) }) - : undefined} + value={} icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
) diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index 93d5568..1fa2403 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -29,46 +29,52 @@ export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: Se const requestLeader = metrics.topRequestModel ? t('metricCards.secondary.requestLeader', { model: metrics.topRequestModel.name, requests: formatNumber(metrics.topRequestModel.requests) }) : null + const topDaySubtitle = metrics.topDay ? formatDate(metrics.topDay.date, 'long') : null + const topProviderSubtitle = metrics.topProvider + ? t('metricCards.secondary.dominantProviderSubtitle', { + share: formatPercent(metrics.topProvider.share, 0), + cost: formatCurrency(metrics.topProvider.cost), + requestLeader: requestLeader ? ` · ${requestLeader}` : '', + }) + : null + const peakSubtitle = viewMode === 'daily' && metrics.busiestWeek + ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` + : costSpread !== null + ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) + : null + const medianSubtitle = median !== null && metrics.avgDailyCost > 0 + ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` + : null return (
: '–'} - subtitle={metrics.topDay ? formatDate(metrics.topDay.date, 'long') : undefined} icon={} info={METRIC_HELP.mostExpensiveDay} + {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} /> } info={t('metricCards.secondary.medianInfo')} + {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} /> : } - subtitle={viewMode === 'daily' && metrics.busiestWeek - ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` - : costSpread !== null ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) : undefined} icon={} info={METRIC_HELP.avgCostPerDay} + {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} /> : '–'} - subtitle={median !== null && metrics.avgDailyCost > 0 - ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` - : undefined} icon={} info={t('metricCards.secondary.medianInfo')} + {...(medianSubtitle ? { subtitle: medianSubtitle } : {})} />
) diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index f728297..4c58813 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -27,6 +27,16 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { const diffToAvg = metrics.avgDailyCost > 0 ? ((today.totalCost - metrics.avgDailyCost) / metrics.avgDailyCost) * 100 : null + const costSubtitle = diffToAvg !== null ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) : null + const tokensSubtitle = today.inputTokens > 0 && today.outputTokens > 0 + ? t('metricCards.today.ioRatio', { value: (today.inputTokens / today.outputTokens).toFixed(1) }) + : null + const modelSubtitle = topModel ? t('metricCards.today.topModel', { value: normalizeModelName(topModel.modelName) }) : null + const costPerMillionSubtitle = metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : null + const requestsSubtitle = today.requestCount > 0 && today.modelsUsed.length > 0 + ? t('metricCards.today.requestsSubtitle', { value: (today.requestCount / today.modelsUsed.length).toFixed(1), cost: formatCurrency(today.totalCost / today.requestCount) }) + : t('metricCards.today.requestCountersMissing') + const thinkingSubtitle = today.totalTokens > 0 ? t('metricCards.today.thinkingSubtitle', { value: formatPercent((today.thinkingTokens / today.totalTokens) * 100) }) : null return (
@@ -40,29 +50,27 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { } - subtitle={diffToAvg !== null ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) : undefined} icon={} trend={diffToAvg !== null ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } : null} + {...(costSubtitle ? { subtitle: costSubtitle } : {})} /> 0 ? today.totalTokens / today.requestCount : 0)} / Request`} />} - subtitle={today.inputTokens > 0 && today.outputTokens > 0 - ? t('metricCards.today.ioRatio', { value: (today.inputTokens / today.outputTokens).toFixed(1) }) - : undefined} icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> } + {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} /> 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0} type="currency" />} - subtitle={metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : undefined} icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> 0 ? : t('common.notAvailable')} - subtitle={today.requestCount > 0 && today.modelsUsed?.length - ? t('metricCards.today.requestsSubtitle', { value: (today.requestCount / today.modelsUsed.length).toFixed(1), cost: formatCurrency(today.totalCost / today.requestCount) }) - : t('metricCards.today.requestCountersMissing')} + subtitle={requestsSubtitle} icon={} /> } - subtitle={today.totalTokens > 0 ? t('metricCards.today.thinkingSubtitle', { value: formatPercent((today.thinkingTokens / today.totalTokens) * 100) }) : undefined} icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 19b61fd..210f480 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -128,7 +128,7 @@ function CorrelationPanel({ - + } cursor={{ strokeDasharray: '4 4' }} /> diff --git a/src/components/features/auto-import/AutoImportModal.tsx b/src/components/features/auto-import/AutoImportModal.tsx index 34ca7a7..8810df2 100644 --- a/src/components/features/auto-import/AutoImportModal.tsx +++ b/src/components/features/auto-import/AutoImportModal.tsx @@ -39,6 +39,10 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const addLine = useCallback((type: LineType, text: string) => { setLines(prev => [...prev, { type, text }]) }, []) + const autoImportTranslator = useCallback( + (key: string, vars?: Record) => (vars ? t(key, vars) : t(key)), + [t], + ) useEffect(() => { if (!open) return @@ -77,7 +81,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod onDone: () => { closeRef.current = null }, - }, t) + }, autoImportTranslator) closeRef.current = handle @@ -85,7 +89,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod handle.close() closeRef.current = null } - }, [open, addLine, onSuccess, t]) + }, [open, addLine, autoImportTranslator, onSuccess, t]) // Auto-scroll useEffect(() => { diff --git a/src/components/features/command-palette/CommandPalette.tsx b/src/components/features/command-palette/CommandPalette.tsx index f8e2c3a..a5a1e5c 100644 --- a/src/components/features/command-palette/CommandPalette.tsx +++ b/src/components/features/command-palette/CommandPalette.tsx @@ -234,11 +234,11 @@ export function CommandPalette({ label: t('commandPalette.commands.goToSection.label', { section: sectionLabel }), description: t('commandPalette.commands.goToSection.description', { section: sectionLabel }), keywords: [sectionLabel, section.domId, ...SECTION_COMMAND_KEYWORDS[section.id]], - aliases: SECTION_COMMAND_ALIASES[section.id], icon: SECTION_COMMAND_ICON_MAP[section.id], action: () => onScrollTo(section.domId), group: t('commandPalette.groups.navigation'), testId: `command-section-${section.id}`, + ...(SECTION_COMMAND_ALIASES[section.id] ? { aliases: SECTION_COMMAND_ALIASES[section.id] } : {}), }] }) ), [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t]) diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 34a2381..c3d66d4 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -296,10 +296,10 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } {row.monthlyLimit > 0 ? ( ) : (
diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index 7a82c3c..ea5551e 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -388,9 +388,9 @@ export function FilterBar({
- + {t('filterBar.until')} - + diff --git a/src/hooks/use-usage-data.ts b/src/hooks/use-usage-data.ts index 70b2b72..5a84961 100644 --- a/src/hooks/use-usage-data.ts +++ b/src/hooks/use-usage-data.ts @@ -13,7 +13,7 @@ export function useUploadData() { return useMutation({ mutationFn: (data: unknown) => uploadData(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['usage'] }) + void queryClient.invalidateQueries({ queryKey: ['usage'] }) }, }) } @@ -23,7 +23,7 @@ export function useDeleteData() { return useMutation({ mutationFn: deleteUsage, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['usage'] }) + void queryClient.invalidateQueries({ queryKey: ['usage'] }) }, }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index ddd6ff4..9426abb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,10 +13,30 @@ import type { import i18n from '@/lib/i18n' import { normalizeAppSettings } from '@/lib/app-settings' +interface ApiErrorPayload { + message?: string +} + +async function parseResponseJson(response: Response): Promise { + const data: unknown = await response.json() + return data as T +} + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = await parseResponseJson(response) + return typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : fallback + } catch { + return fallback + } +} + export async function fetchUsage(): Promise { const res = await fetch('/api/usage') if (!res.ok) throw new Error(i18n.t('api.fetchUsageFailed')) - return res.json() + return parseResponseJson(res) } export async function uploadData(data: unknown): Promise<{ days: number; totalCost: number }> { @@ -26,10 +46,9 @@ export async function uploadData(data: unknown): Promise<{ days: number; totalCo body: JSON.stringify(data), }) if (!res.ok) { - const err = await res.json().catch(() => ({ message: i18n.t('api.uploadFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.uploadFailed'))) } - return res.json() + return parseResponseJson<{ days: number; totalCost: number }>(res) } export async function deleteUsage(): Promise { @@ -44,10 +63,9 @@ export async function importUsageData(data: unknown): Promise ({ message: i18n.t('api.importUsageFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.importUsageFailed'))) } - return res.json() + return parseResponseJson(res) } export interface UpdateSettingsRequest { @@ -62,7 +80,7 @@ export interface UpdateSettingsRequest { export async function fetchSettings(): Promise { const res = await fetch('/api/settings') if (!res.ok) throw new Error('Failed to load settings') - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export async function updateSettings(patch: UpdateSettingsRequest): Promise { @@ -72,10 +90,9 @@ export async function updateSettings(patch: UpdateSettingsRequest): Promise ({ message: 'Failed to save settings' })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, 'Failed to save settings')) } - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export async function importSettings(data: unknown): Promise { @@ -85,10 +102,9 @@ export async function importSettings(data: unknown): Promise { body: JSON.stringify(data), }) if (!res.ok) { - const err = await res.json().catch(() => ({ message: i18n.t('api.importSettingsFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.importSettingsFailed'))) } - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export interface PdfReportRequest { @@ -109,8 +125,7 @@ export async function generatePdfReport(request: PdfReportRequest): Promise ({ message: i18n.t('api.pdfFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.pdfFailed'))) } return res.blob() diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 76a96c9..8d226f0 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -7,6 +7,15 @@ export interface ErrorEvent { message: string } type AutoImportTranslationVars = Record type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string +function parseEventData(event: Event): T | null { + if (!(event instanceof MessageEvent) || typeof event.data !== 'string') { + return null + } + + const data: unknown = JSON.parse(event.data) + return data as T +} + function translateAutoImportMessage(message: string, t: AutoImportTranslator) { if (message === 'Starte lokalen toktrack-Import...') { return t('autoImportModal.startingLocalImport') @@ -50,24 +59,34 @@ export function startAutoImport(callbacks: { }, t: AutoImportTranslator = (key) => key): { close: () => void } { const es = new EventSource('/api/auto-import/stream') - es.addEventListener('check', (e) => { - callbacks.onCheck(JSON.parse(e.data)) + es.addEventListener('check', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onCheck(data) + } }) - es.addEventListener('progress', (e) => { - const data = JSON.parse(e.data) - callbacks.onProgress({ ...data, message: translateAutoImportMessage(data.message, t) }) + es.addEventListener('progress', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onProgress({ ...data, message: translateAutoImportMessage(data.message, t) }) + } }) - es.addEventListener('stderr', (e) => { - const data = JSON.parse(e.data) - callbacks.onStderr({ ...data, line: translateAutoImportMessage(data.line, t) }) + es.addEventListener('stderr', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onStderr({ ...data, line: translateAutoImportMessage(data.line, t) }) + } }) - es.addEventListener('success', (e) => { - callbacks.onSuccess(JSON.parse(e.data)) + es.addEventListener('success', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onSuccess(data) + } }) - es.addEventListener('error', (e) => { + es.addEventListener('error', (event) => { // SSE 'error' can be both our custom event and a connection error - if (e instanceof MessageEvent && e.data) { - const data = JSON.parse(e.data) + const data = parseEventData(event) + if (data) { callbacks.onError({ ...data, message: translateAutoImportMessage(data.message, t) }) } else { callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index fc5d24d..bb6e9ae 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -186,7 +186,7 @@ export function computeWeekOverWeekChange(data: DailyUsage[]): number | null { } export function computeMovingAverage(values: number[], window = 7): (number | undefined)[] { - const result: (number | undefined)[] = new Array(values.length) + const result = Array(values.length) let sum = 0 for (let i = 0; i < values.length; i++) { @@ -279,10 +279,20 @@ export function computeProviderMetrics(data: DailyUsage[]): Map { - const { _dates: _unusedDates, ...metrics } = value - return [provider, metrics] - })) + return new Map(Array.from(map.entries()).map(([provider, value]) => [ + provider, + { + cost: value.cost, + tokens: value.tokens, + input: value.input, + output: value.output, + cacheRead: value.cacheRead, + cacheCreate: value.cacheCreate, + thinking: value.thinking, + requests: value.requests, + days: value.days, + }, + ])) } function computeCacheHitRate(cacheRead: number, cacheCreate: number, input: number, output: number, thinking: number): number { diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 328623c..2356844 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -270,7 +270,7 @@ export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] point[`${name}_ma7`] = modelMA7[name]?.[i] } - return point as RequestChartDataPoint + return point }) } diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index faf9b93..4ecc99a 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -15,6 +15,19 @@ export function localMonth(): string { return localToday().slice(0, 7) } +export function coerceNumber(value: unknown): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0 + } + + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 + } + + return 0 +} + export function formatCurrency(value: number): string { if (value >= 1000) return `$${(value / 1000).toFixed(1)}k` if (value >= 100) return `$${Math.round(value)}` From 1d0e1fef1c3fd0276b265b2d4297da4191b722b0 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 22:43:45 +0200 Subject: [PATCH 14/34] v6.1.6: Add Prettier formatting --- .editorconfig | 12 + .prettierignore | 10 + .prettierrc.json | 15 + CHANGELOG.md | 67 ++ RELEASING.md | 6 +- eslint.config.mjs | 2 + examples/sample-usage.json | 26 +- index.html | 2 +- package-lock.json | 34 + package.json | 4 + scripts/report-smoke.js | 134 ++- scripts/start-test-server.js | 36 +- scripts/verify-main-ci.js | 23 +- scripts/verify-package.js | 29 +- scripts/verify-registry-install.js | 68 +- server.js | 292 +++-- server/report/charts.js | 181 ++- server/report/index.js | 30 +- server/report/utils.js | 218 +++- src/components/Dashboard.tsx | 1059 +++++++++++------ src/components/EmptyState.tsx | 54 +- src/components/cards/MetricCard.tsx | 37 +- src/components/cards/MonthMetrics.tsx | 102 +- src/components/cards/PrimaryMetrics.tsx | 138 ++- src/components/cards/SecondaryMetrics.tsx | 66 +- src/components/cards/TodayMetrics.tsx | 111 +- src/components/charts/ChartCard.tsx | 112 +- src/components/charts/CorrelationAnalysis.tsx | 148 ++- src/components/charts/CostByModel.tsx | 26 +- src/components/charts/CostByModelOverTime.tsx | 154 ++- src/components/charts/CostByWeekday.tsx | 130 +- src/components/charts/CostOverTime.tsx | 140 ++- src/components/charts/CumulativeCost.tsx | 120 +- src/components/charts/CustomTooltip.tsx | 41 +- .../charts/DistributionAnalysis.tsx | 75 +- src/components/charts/ModelMix.tsx | 110 +- .../charts/RequestCacheHitRateByModel.tsx | 234 +++- src/components/charts/RequestsOverTime.tsx | 213 +++- src/components/charts/TokenEfficiency.tsx | 110 +- src/components/charts/TokenTypes.tsx | 37 +- src/components/charts/TokensOverTime.tsx | 413 +++++-- src/components/features/animations/FadeIn.tsx | 8 +- .../features/anomaly/AnomalyDetection.tsx | 37 +- .../features/auto-import/AutoImportModal.tsx | 112 +- .../features/cache-roi/CacheROI.tsx | 87 +- .../command-palette/CommandPalette.tsx | 648 +++++++--- .../features/comparison/PeriodComparison.tsx | 107 +- .../features/drill-down/DrillDownModal.tsx | 283 ++++- .../features/forecast/CostForecast.tsx | 165 ++- .../features/heatmap/HeatmapCalendar.tsx | 200 ++-- src/components/features/help/HelpPanel.tsx | 146 ++- src/components/features/help/InfoButton.tsx | 5 +- .../features/insights/UsageInsights.tsx | 231 +++- .../features/limits/ProviderLimitsSection.tsx | 622 +++++++--- .../features/pdf-report/PDFReport.tsx | 6 +- .../request-quality/RequestQuality.tsx | 101 +- .../features/risk/ConcentrationRisk.tsx | 52 +- .../features/settings/SettingsModal.tsx | 285 +++-- src/components/layout/FilterBar.tsx | 372 +++--- src/components/layout/Header.tsx | 128 +- src/components/tables/ModelEfficiency.tsx | 262 +++- src/components/tables/ProviderEfficiency.tsx | 216 +++- src/components/tables/RecentDays.tsx | 300 +++-- src/components/ui/badge.tsx | 5 +- src/components/ui/button.tsx | 13 +- src/components/ui/card.tsx | 53 +- src/components/ui/dialog.tsx | 14 +- src/components/ui/expandable-card.tsx | 24 +- src/components/ui/formatted-value.tsx | 37 +- src/components/ui/select.tsx | 12 +- src/components/ui/skeleton.tsx | 23 +- src/components/ui/toast.tsx | 8 +- src/components/ui/tooltip.tsx | 2 +- src/hooks/use-app-settings.ts | 30 +- src/hooks/use-computed-metrics.ts | 27 +- src/hooks/use-dashboard-filters.ts | 98 +- src/hooks/use-provider-limits.ts | 2 +- src/hooks/use-theme.ts | 2 +- src/index.css | 34 +- src/lib/app-settings.ts | 6 +- src/lib/auto-import.ts | 47 +- src/lib/calculations.ts | 415 +++++-- src/lib/constants.ts | 13 +- src/lib/csv-export.ts | 7 +- src/lib/dashboard-preferences.ts | 80 +- src/lib/data-transforms.ts | 108 +- src/lib/formatters.ts | 3 +- src/lib/help-content.ts | 288 +++-- src/lib/i18n.ts | 28 +- src/lib/model-utils.ts | 102 +- src/lib/provider-limits.ts | 6 +- src/types/index.ts | 14 +- tests/e2e/dashboard.spec.ts | 388 ++++-- tests/frontend/use-dashboard-filters.test.tsx | 2 +- tests/integration/server.test.ts | 129 +- tests/unit/analytics.test.ts | 13 +- tests/unit/report-charts.test.ts | 54 +- tests/unit/report-utils.test.ts | 34 +- vitest.config.ts | 70 +- 99 files changed, 8044 insertions(+), 3339 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ec712f1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +coverage/ +dist/ +node_modules/ +playwright-report/ +test-results/ +.playwright-mcp/ +.tmp-playwright/ +.tmp-smoke-*/ +package-lock.json +bun.lock diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..db1bfeb --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "printWidth": 100, + "overrides": [ + { + "files": ["server.js", "usage-normalizer.js", "scripts/**/*.js", "server/**/*.js"], + "options": { + "semi": true + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 128ccec..6fee79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ ## [6.1.5] - 2026-04-12 ### Added + - **Report insight callouts** — the Typst PDF report now highlights key findings such as sparse data coverage, provider concentration, cache contribution, and the strongest rolling 7-day cost window - **Report chart test coverage** — dedicated unit tests now cover SVG chart formatting, localized axis rendering, and long-label truncation for the Typst report assets ### Improved + - **GitHub Actions Node 24 readiness** — the release workflow now pins `actions/create-github-app-token` to `v2.1.4`, aligning the release path with the current Node 24-compatible action runtime guidance - **Typst report structure** — the PDF layout now uses a clearer executive-summary flow with localized headings, prepared report text blocks, and more robust section rendering for filtered report scenarios - **Report localization and semantics** — peak-period labeling, interpretation text, filter summaries, and report-specific strings are now more precise and consistently localized in both German and English @@ -18,6 +20,7 @@ - **Build and test config loading** — the Vite version injection path now reads `package.json` asynchronously, and the Vitest config resolves the async Vite config cleanly before merging test settings ### Fixed + - **Report temp-file cleanup** — server-side PDF generation now cleans up Typst working directories internally even when compilation fails - **PDF response lifecycle** — the report API now returns the compiled PDF from memory instead of exposing temporary file paths, avoiding leaked temp directories and simplifying cleanup - **Chart formatting consistency** — token-axis labels and font fallbacks no longer depend on hardcoded locale/font assumptions that caused inconsistent PDF output across environments @@ -29,11 +32,13 @@ ## [6.1.4] - 2026-04-11 ### Added + - **GitHub-driven release flow** — releases can now be started manually from GitHub Actions with a target version input, instead of relying on a locally created tag on `main` - **CI release gate** — the release workflow now verifies that the latest `CI` run for the current `main` commit completed successfully before any version bump, tag, or npm publish step begins - **Release app verification** — a dedicated GitHub API helper now validates the `CI` precondition directly from the workflow, so release gating stays tied to the exact `main` SHA ### Improved + - **Single human-managed version source** — the frontend app version is now injected from `package.json` at build time instead of being maintained as a second manual version constant - **Protected-branch compatibility** — the release workflow now uses the dedicated `ttdash-release` GitHub App token for checkout, push, tag creation, and GitHub release creation, so the release path works cleanly with branch rules and ruleset bypasses - **Release recovery behavior** — rerunning a failed release with the same version now resumes cleanly when the version bump commit, tag, or npm publication already exists @@ -42,12 +47,14 @@ ## [6.1.0] - 2026-04-11 ### Added + - **Background CLI mode** — `--background` starts the local server as a detached background process, and `ttdash stop` lists running instances so the selected one can be stopped directly - **Settings backups and layout preferences** — the settings dialog now supports backup import/export, conservative usage-data restore, default dashboard filters, section visibility, and section ordering - **Packaged CLI verification** — `npm run verify:package` now builds the real tarball and verifies that the packaged `ttdash` CLI can install, print help, and start outside the repo checkout - **Scoped package release prep** — the package is now prepared for the first public scoped release as `@roastcodes/ttdash` ### Improved + - **Dashboard settings model** — provider limits, persisted filters, section visibility, and section order now behave as first-class stored settings across fresh starts and backup restore flows - **CLI and installer UX** — terminal output, help text, and installer guidance now use English-first release-facing messaging - **Metrics and report correctness** — aggregated dashboard metrics, provider day counting, filter-preset behavior, and PDF language handling were corrected and aligned with the current view state @@ -55,6 +62,7 @@ - **Repository documentation** — README, contribution, release, security, and conduct docs were rewritten for a public, maintainer-led npm project ### Fixed + - **Race-safe background registry** — parallel `--background` starts briefly lock the local instance registry so no running server gets dropped from the tracked list - **Conservative data import** — backup imports add missing days, skip identical days, and keep conflicting local days instead of silently overwriting them - **Playwright release validation** — the E2E configuration now supports an override port so local release verification does not fail when the default smoke-test port is already occupied @@ -62,164 +70,199 @@ ## [6.0.11] - 2026-04-10 ### Fixed + - **Idempotent Bun installer** — `install.sh` and `install.bat` now clean existing `ttdash` entries from Bun’s global manifest before `bun add -g file:...` and remove the broken global `bun.lock` when needed, so repeated upgrades do not create duplicate `package.json` keys ## [6.0.10] - 2026-04-09 ### Added + - **GitHub release workflow** — a dedicated `release.yml` now creates GitHub releases automatically on `v*` tags, verifies tests and build first, and only accepts tags on `main` ### Improved + - **README project context** — the documentation now points explicitly to `toktrack` as the primary data source and credits `mag123c` ## [6.0.9] - 2026-04-09 ### Added + - **Automated test pyramid** — Vitest now covers data normalization, calculations, hook behavior, and the local server path; Playwright verifies the upload-to-dashboard smoke flow with real browser reports - **CI test pipeline** — GitHub Actions now runs build, coverage, Playwright smoke tests, and report artifacts automatically on pushes and pull requests ### Improved + - **Public repo readiness** — package metadata, license, security/contribution docs, and publish surface were cleaned up for a public repository - **Test isolation** — the Playwright web server uses its own local app environment and does not overwrite normal user data - **Runtime hardening** — the local server now binds to `127.0.0.1` by default, returns stricter security headers, and avoids unnecessary external runtime requests ### Fixed + - **Bun/npm consistency** — lockfiles and published runtime contents now stay aligned so builds and installs remain reproducible ## [6.0.8] - 2026-04-08 ### Added + - **CLI flags for `ttdash`** — `--port` / `-p`, `--help` / `-h`, `--no-open` / `-no`, and `--auto-load` / `-al` are now supported directly by the global CLI command - **Persistent load metadata** — app settings now store when data was last loaded and from which path (`file`, `auto-import`, `cli-auto-load`) - **Visible load hints in the UI** — the header and limits dialog now show the last load time, and `-al` also adds a dedicated `Auto-load on start` badge ### Improved + - **Shared auto-import path** — UI auto-import and CLI auto-load now use the same server logic so runtime behavior, persistence, and error handling stay consistent ## [6.0.7] - 2026-04-08 ### Added + - **Cache-Hit-Rate in der Request-Analyse** — neue kombinierte Visualisierung mit Zeitverlauf links und Modell-Snapshot rechts, vollständig filterkompatibel und mit denselben Aufbauanimationen wie die übrigen Diagramme ### Improved + - **Modellabdeckung im Cache-Hit-Rate-Verlauf** — alle aktiven Modelle, inklusive `GPT-5` und `GPT-5.4`, erscheinen jetzt zuverlässig in der Zeitreihen-Legende und im Diagramm - **Snapshot-Animation & Tooltip-Klarheit** — horizontale Balken bauen sich sauber von links nach rechts auf; Tooltips im Zeitverlauf blenden irrelevante `0.0%`-Serien aus und zeigen die aktiven Modelle lesbarer an ## [6.0.6] - 2026-04-08 ### Added + - **Plattformgerechte Persistenz** — Nutzungsdaten und App-Einstellungen liegen jetzt in OS-konformen User-Verzeichnissen statt im Projekt- bzw. Installationsordner; bestehende `data.json` wird beim Start automatisch migriert ### Improved + - **Stabile Settings über Ports hinweg** — Sprache, Theme und Provider-Limits werden serverseitig in lokalen App-Settings gespeichert und bleiben dadurch auch bei automatischem Portwechsel erhalten - **Robustere Dateischreibvorgänge** — `data.json` und `settings.json` werden atomar geschrieben, damit lokale Persistenz bei Abbruch oder Neustart nicht inkonsistent wird ## [6.0.5] - 2026-04-04 ### Improved + - **Dependency-Updates** — `@tanstack/react-query`, `i18next` und `react-i18next` sind auf die jeweils aktuellen Registry-Versionen angehoben - **Kompatibilitätsprüfung** — Dashboard-Build sowie Browser-Smoketests für Jahresansicht, Filter, Datepicker, Command Palette und Sprachwechsel wurden nach dem Upgrade erneut verifiziert ## [6.0.4] - 2026-04-04 ### Added + - **Globaler Filter-Reset** — der Filterstatus enthält jetzt einen `Reset all`-Button, und die Command Palette bietet eine direkte Aktion zum Zurücksetzen aller Filter auf den Default-Zustand ### Improved + - **Eigener Datums-Kalender** — der Zeitraumfilter nutzt jetzt einen dunklen, portalbasierten Kalender statt des nativen Browser-Datepickers, damit Darstellung und Stacking im Dark Mode konsistent bleiben - **Datepicker-Stabilität** — der Kalender liegt jetzt zuverlässig über dem Dashboard und wird nicht mehr von nachfolgenden Sektionen oder Animationen überlagert ## [6.0.3] - 2026-04-04 ### Added + - **Dashboard-Mehrsprachigkeit** — das Dashboard und der PDF-Report unterstützen jetzt Deutsch und Englisch auf Basis von `i18next` und `react-i18next` - **Sprachwechsel in der Command Palette** — `cmd+k` enthält jetzt direkte Aktionen zum Wechseln zwischen Deutsch und Englisch ### Improved + - **Vollständige EN-Abdeckung** — Forecast, Cache-ROI, Vergleiche, Anomalien, Tabellen, Help-Panel, Auto-Import und ergänzende Dashboard-Stat-Karten sind vollständig in die neue Übersetzungsstruktur migriert - **Locale-sensitive UI-Formate** — Datums-, Zahlen- und Wochentagsdarstellungen reagieren jetzt konsistent auf die aktive Sprache ## [6.0.2] - 2026-04-03 ### Added + - **Limits & Subscriptions** — neues Provider-Limits-Modal mit lokaler Persistenz, Limits-Button im Header, eigener Dashboard-Sektion und Command-Palette-Einträgen für Konfiguration und Navigation ### Improved + - **Provider-Limits Visualisierung** — Budget- und Subscription-Status werden jetzt pro Anbieter in klar getrennten, animierten Tracks mit Break-even- bzw. Limit-Markierung dargestellt ### Fixed + - **Jahresansicht & Filterwechsel** — Tages-, Monats- und Jahresansicht bleiben bei Presets sowie Anbieter-, Modell- und Datumsfiltern stabil; Hook-Reihenfolgen in Analyse- und Forecast-Komponenten sind konsistent - **Provider-Limits Tooltip-Clipping** — Info-Labels im Limits-Dialog werden am oberen Rand nicht mehr abgeschnitten ## [6.0.1] - 2026-04-03 ### Added + - **PDF-Report in der Command Palette** — `cmd+k` enthält jetzt eine direkte Aktion zum Generieren des aktuell gefilterten PDF-Reports ### Fixed + - **Request-Qualität Info-Tooltip** — das Info-Label in der Karte wird nicht mehr am oberen Rand abgeschnitten - **Gemeinsame Report-Aktion** — Toolbar-Button und Command Palette verwenden jetzt denselben Exportpfad inklusive Ladezustand und Toast-Feedback ## [6.0.0] - 2026-04-03 ### Added + - **Typst-Report-Pipeline** — PDF-Reports werden jetzt serverseitig mit Typst kompiliert, inklusive sauberem Layout, eingebetteten SVG-Charts und filterkonsistenten Reportdaten statt DOM-Screenshot-Export - **Report-Smoke-Test** — neue Prüfmatrix deckt Tages-, Monats- und Jahresansicht sowie kombinierte Provider-, Modell-, Monats- und Datumsfilter für die PDF-Generierung ab ### Improved + - **Filtertreue im PDF** — Report-Downloads übernehmen jetzt dieselben aktiven UI-Filter wie das Dashboard, inklusive Monatsauswahl, Datumsbereich, Providern und Modellen - **Mobile/Responsive Report-Flow** — der Report-Button und der Downloadpfad funktionieren jetzt auch unter enger Viewport-Breite stabil ### Fixed + - **PDF-Layoutfehler** — Tabellenköpfe, Filterdarstellung und Einpunkt-Charts im Report verhalten sich jetzt robust auch bei extrem kleinen oder stark gefilterten Datensätzen - **Typst-CLI Fallback** — Systeme ohne installierte Typst-CLI erhalten eine klare macOS-Hinweismeldung mit `brew install typst` ## [5.3.6] - 2026-04-02 ### Added + - **Erweiterte Command Palette** — `cmd+k` bietet jetzt zusätzliche Sprungziele, Ansichtswechsel, Zeitraum-Presets sowie direkte Anbieter- und Modell-Filterbefehle auf Basis der aktuell verfügbaren Daten - **Kontextsprünge** — direkte Navigation zu `Heute` und `Monat`, wenn diese Bereiche im aktuellen Filterzustand vorhanden sind ### Improved + - **Favicon-Auslieferung** — Root-, `public/`- und `dist/`-Icons sind jetzt synchron; HTML enthält zusätzliche `shortcut icon`- und `apple-touch-icon`-Links für robustere Browser-Erkennung ## [5.3.5] - 2026-04-02 ### Improved + - **Filter-Konsistenz über alle Ansichten** — Tages-, Monats- und Jahressicht basieren jetzt auf derselben vollständig gefilterten Tagesbasis, damit Anbieter-, Modell- und Zeitraumfilter in KPIs, Header, Vergleichskarten und Tabellen übereinstimmen - **Favicon & App-Branding** — neues `TTDash`-Monogramm als optimiertes SVG/PNG mit klarerer Wiedererkennbarkeit und besserer Lesbarkeit bei kleinen Größen - **Release-Output im Terminal** — Installer und Server-Start zeigen die aktuelle App-Version jetzt dynamisch direkt aus `package.json` ### Fixed + - **Heute-/Monat-Karten bei Kombinationsfiltern** — Bereiche mit aktiven Anbieter-, Modell- und Datumsfiltern greifen nicht mehr auf unfiltrierte Rohdaten zurück - **Header-Zeitraum & Periodenvergleich** — Datumsbadge und Vergleichswerte folgen jetzt derselben Filterbasis wie die restlichen Dashboard-Metriken ## [5.3.4] - 2026-04-02 ### Added + - **Dashboard Insights** — neue verdichtete Analyse-Sektion mit Provider-Dominanz, Modell-Konzentration, Kosten- und Request-Ökonomie sowie Aktivitätsmustern - **Responsive Tabellen-Karten** — `Recent Days` und `Model Efficiency` liefern auf kleinen Screens jetzt echte Card-Layouts statt primär horizontaler Scrollflächen ### Improved + - **Dashboard-Informationsdichte** — KPI-Karten, Chart-Untertitel und Tabellen-Summaries zeigen mehr Kontext, abgeleitete Kennzahlen und klarere Hilfstexte - **Unbekannte Modellfamilien** — neue `toktrack`-Modelle werden robuster normalisiert, erhalten deterministische Farben und bleiben in Filtern, Charts und Tooltips sauber lesbar - **Zahlenformatierung & Tooltips** — lange Werte werden kompakt dargestellt; Tooltips zeigen exakte Zahlen, Labels und zusätzliche Insights - **Responsive Layouts** — Header, Filter-Bar, Karten, Zoom-Ansichten und Tabellen verhalten sich stabiler bei Resize, Tablet-Breite und Mobile ### Fixed + - **Windows Auto-Import** — Prozessstart für `toktrack`, `npx.cmd` und `bunx` ist unter Windows robuster, damit der Auto-Upload nicht mehr am `spawn`-Pfad scheitert - **Expanded Donut-Charts** — Donuts sitzen im Zoom-Dialog tiefer, nutzen die verfügbare Fläche besser und kollidieren weniger mit Legenden - **Request-Ökonomie ohne Request-Daten** — bei fehlenden `requestCount`-Feldern zeigt das UI jetzt `n/v` statt irreführender Nullwerte - **Numerische Ausreißer im UI** — rohe lange Float-Werte werden nicht mehr ungefiltert im Dashboard angezeigt - **Heuristik-Hinweise für Preis-Fallbacks** — Cache-ROI kennzeichnet fehlende Preisdefinitionen für unbekannte Modelle explizit statt stillschweigend - **Erweiterbarkeit für neue Anbieter** — Provider-Erkennung deckt zusätzliche Familien wie `xAI`, `Meta`, `Cohere`, `Mistral`, `DeepSeek` und `Alibaba` besser ab + ## [5.3.3] - 2026-04-02 ### Improved + - **Performance-Optimierungen** — PDF-Export und schwere Modals werden jetzt lazy geladen; Datenpfade für gleitende Durchschnitte, Metriken und Filter wurden effizienter gemacht - **Bundle-Splitting** — Vendor-Code ist in getrennte Chunks für React, Recharts, Motion und UI aufgeteilt, damit das Dashboard schneller initial lädt ### Fixed + - **Dashboard-Renderpfad** — Datenquellen-Initialisierung erfolgt nicht mehr während des Renderns, wodurch unnötige Renders und React-Warnungen vermieden werden - **PDF-Export Ladezustand** — Export-Button bleibt nach Abschluss nicht mehr fälschlich im aktiven Zustand hängen - **Server-Sicherheitsheader** — lokale Responses liefern jetzt grundlegende Schutz-Header wie `nosniff`, `DENY` und `same-origin` @@ -227,6 +270,7 @@ ## [5.3.2] - 2026-04-02 ### Added + - **Toktrack-Migration & Rebranding** — Dashboard, Paket und UI laufen jetzt unter `TTDash` mit `toktrack` als primärem Datenformat; Legacy-`ccusage`-JSON bleibt kompatibel - **Anbieter-Filter** — Filterung nach `OpenAI`, `Anthropic`, `Google` usw. mit passender Einschränkung der sichtbaren Modelle - **Anbieter-Badges** — farbige Provider-Labels in Tabellen, Drill-downs und Filtern für bessere Modell-Zuordnung @@ -234,6 +278,7 @@ - **Bun-aware Installation** — `install.sh` und `install.bat` nutzen Bun, wenn verfügbar, sonst npm ### Improved + - **Auto-Import Runner-Auswahl** — nutzt zuerst lokales `toktrack`, dann `bunx`, dann `npx --yes toktrack`; Statusmeldungen zeigen den tatsächlich verwendeten Pfad - **Monatsprognose** — Forecast basiert jetzt auf Kalender-Tageskosten, geglätteter Run-Rate und defensiverer Volatilitätsbewertung statt einfacher linearer Regression - **Kumulative Monatsprojektion** — verwendet dieselbe Shared-Forecast-Logik wie die Prognose-Karte @@ -241,6 +286,7 @@ - **Lokaler App-Start** — öffnet beim Start aus dem Terminal direkt den Browser ### Fixed + - **Heatmap-Tooltip** — Hover-Labels sitzen wieder direkt über der Zelle statt viewport-versetzt - **Dialog-A11y** — fehlende Beschreibungen für Radix-Dialoge ergänzt - **Favicon & Tab-Titel** — Branding auf `TTDash` aktualisiert @@ -249,12 +295,14 @@ ## [5.3.1] - 2026-04-01 ### Fixed + - **Datum in Heute/Monat-Sektion falsch** — `toISOString()` lieferte UTC-Datum statt Lokalzeit, wodurch zwischen Mitternacht und 02:00 MESZ das gestrige Datum angezeigt wurde. Betraf: Heute-KPIs, Monats-KPIs, Heatmap-Markierung, Streak-Berechnung, Datumsfilter-Presets, PDF/CSV-Dateinamen - Neue `toLocalDateStr()`, `localToday()`, `localMonth()` Hilfsfunktionen ersetzen alle 7 `toISOString().slice()`-Aufrufe durch korrekte lokale Datumsberechnung ## [5.3.0] - 2026-03-31 ### Fixed + - **Monatsansicht & Jahresansicht komplett überarbeitet** — alle Metriken, Diagramme und Tabellen zeigen jetzt korrekte Daten in der Monats- und Jahresansicht: - **Aktive Tage** — zeigt die tatsächliche Anzahl aktiver Tage (vorher: 1 pro Monat/Jahr wegen fehlender Aggregation) - **Ø Kosten** — korrekte Durchschnittsberechnung pro Tag (vorher: durch Anzahl Perioden geteilt statt Anzahl Tage) @@ -269,6 +317,7 @@ - **PeriodComparison Monat-Bug** — `setMonth()` Overflow behoben: März 31 → Feb 31 → März 3 (klassischer JS-Date-Bug bei Monaten mit weniger Tagen) ### Technical + - Neues `_aggregatedDays`-Feld in `DailyUsage` trackt die Anzahl aggregierter Tage pro Eintrag - `aggregateToDailyFormat()` setzt `date` auf Period-Key ("2026-03" / "2026") statt erstes Tagesdatum - `computeMetrics()` und `computeModelCosts()` nutzen `_aggregatedDays` für korrekte Berechnungen @@ -279,35 +328,42 @@ ## [5.2.1] - 2026-03-31 ### Fixed + - **install.sh `-e` Ausgabe** — `echo -e` durch `printf` ersetzt, damit das Script auch mit `sh install.sh` korrekt funktioniert (POSIX-Shell kennt `echo -e` nicht) ## [5.2.0] - 2026-03-31 ### Added + - **Monats-KPIs** — neue Sektion unter "Heute" zeigt 6 Kennzahlen des laufenden Monats: Kosten (mit Trend vs. Vormonat), Tokens, aktive Tage/Abdeckung, Modelle, $/1M Tokens, Cache-Hit-Rate. Wird automatisch ausgeblendet wenn keine Daten für den aktuellen Monat vorhanden sind ## [5.1.1] - 2026-03-31 ### Fixed + - **Browser Tab Titel** — zeigt jetzt "CCUsage — Claude Code Dashboard" statt "localhost:3000" ## [5.1.0] - 2026-03-31 ### Added + - **Datenquellen-Badge im Header** — zeigt woher die Daten stammen: "Gespeichert" (grau, bei App-Start), "Auto-Import · HH:MM" (grün, nach Import), oder "dateiname.json · HH:MM" (blau, nach Upload). Wird bei Löschen zurückgesetzt - **Graceful Shutdown** — Server fährt bei Ctrl+C (SIGINT) und kill (SIGTERM) sauber herunter, schliesst offene Verbindungen ordentlich mit 3s Force-Exit Fallback ### Improved + - **Header Responsive** — 2-Zeilen-Layout statt 1-Zeile: Zeile 1 = Branding + Meta-Badges + Utility-Icons, Zeile 2 = Action-Buttons. Funktioniert sauber auf Desktop (1440px), Tablet (768px) und Mobile (375px) ## [5.0.1] - 2026-03-31 ### Fixed + - **7-Tage Ø Linien unsichtbar** — Recharts 3 Line-Drawing-Animation überschrieb `stroke-dasharray` auf gestrichelten Linien, wodurch das Dash-Pattern zerstört wurde. Fix: `isAnimationActive={false}` auf allen 10 gestrichelten MA7/Prognose-Linien in 6 Chart-Komponenten ## [5.0.0] - 2026-03-31 ### Added + - **Token-Effizienz Chart** — $/1M Tokens über die Zeit mit 7-Tage Ø und Durchschnitts-Referenzlinie, zeigt ob Kosten-Optimierung (Cache, Modell-Wahl) wirkt - **Modell-Mix Chart** — Stacked percentage area chart zeigt Modell-Nutzungsanteile über die Zeit, visualisiert Migration-Muster (z.B. Wechsel von Opus 4.5 zu 4.6) - **Aktiv-Streak** — Header zeigt konsekutive aktive Tage als 🔥-Badge @@ -320,6 +376,7 @@ - **install.bat** — Windows-kompatibles Installationsscript ### Improved + - **FilterBar** — aktiver Preset-Button (7T, 30T, etc.) visuell hervorgehoben, Reset bei Filterwechsel - **SectionHeaders** — linker Akzent-Border (`border-l-2 border-primary/40`) für visuelle Hierarchie - **MetricCard Trends** — Badges mit farbigem Hintergrund-Pill statt reinem Text @@ -339,6 +396,7 @@ - **TodayMetrics** — "$/1M Tokens" statt redundantem "Top Modell Kosten", korrektes Icon ### Fixed + - **Keyboard Shortcuts** — nicht-implementierte Shortcuts (⌘E, ⌘U, ⌘D, ⌘↑) aus Hilfe entfernt, die mit Browser-Shortcuts kollidierten - **CustomTooltip Total** — MA7-Durchschnittswerte werden nicht mehr fälschlich ins Total eingerechnet - **Token-Linien Dash-Pattern** — 7-Tage Ø Linien in Tokens-Charts nutzen jetzt `"5 5"` wie Kosten-Charts @@ -346,6 +404,7 @@ ## [4.0.0] - 2026-03-31 ### Added + - **Auto-Import** — one-click data import directly from Claude Code usage logs via `ccusage` programmatic API, no manual file export needed - SSE streaming with real-time progress in a terminal-style modal - Fetches latest model pricing from LiteLLM for accurate cost calculation @@ -356,6 +415,7 @@ - **Install script** — `install.sh` for one-command setup (install, build, global install) ### Changed + - `ccusage` is now a production dependency instead of requiring external installation - EmptyState now shows Auto-Import as primary action, manual upload as secondary - Server no longer needs `child_process` for data import (uses programmatic API) @@ -363,6 +423,7 @@ ## [3.1.0] - 2026-03-31 ### Upgraded + - **React** 18.3.1 → 19.2.4 - **react-dom** 18.3.1 → 19.2.4 - **TypeScript** 5.9.3 → 6.0.2 @@ -376,6 +437,7 @@ - **@types/react-dom** 18.3.7 → 19.2.3 ### Changed + - Removed deprecated `baseUrl` from tsconfig.json (TypeScript 6 requirement) - Renamed deprecated lucide icons: `HelpCircle` → `CircleHelp`, `AlertTriangle` → `TriangleAlert`, `Loader2` → `LoaderCircle`, `BarChart3` → `ChartBar` - Adapted Recharts 3 type changes (`activeTooltipIndex`, deprecated `Cell`) @@ -385,6 +447,7 @@ ## [3.0.0] - 2026-03-31 ### Added + - **Date Range Filter** with preset buttons (7T, 30T, Monat, Jahr, Alle) - **Token-Analyse Redesign** — two separate charts for Cache and I/O tokens with independent Y-axes, solving the scale problem where Cache Read (4.5B) made Input/Output (3.2M) invisible - **Per-Type 7-Tage Durchschnitt** for all four token types (Cache Read, Cache Write, Input, Output) @@ -405,6 +468,7 @@ - **Light Mode** fully polished alongside dark mode ### Fixed + - **PDF Export** — resolved html2canvas crash with Tailwind CSS v4 `oklab()` colors via canvas-based RGB conversion - **Model Filter** — now correctly filters costs within each day (previously showed all models' costs if any matched) - **MA7 Line invisible** — switched from `AreaChart` to `ComposedChart` so `` components render correctly alongside `` @@ -424,6 +488,7 @@ - **Gradient ID conflicts** — unique IDs via `useId()` prevent SVG conflicts in zoom mode ### Changed + - **Forecast colors** — Prognose line is teal (distinct from blue Ist-Kosten), Konfidenzband is transparent teal - **CostByModelOverTime title** — removed misleading "7-Tage Ø" since chart shows individual model lines - **Token chart layout** — split into Cache Tokens (top) + I/O Tokens (bottom) with summary tiles @@ -434,6 +499,7 @@ ## [2.0.0] - 2026-03-30 ### Added + - Complete frontend rebuild with Vite + React + TypeScript + Tailwind CSS v4 - Interactive charts with Recharts (cost over time, model breakdown, tokens, heatmap, etc.) - Command Palette (Cmd+K) for keyboard navigation @@ -451,6 +517,7 @@ ## [1.0.0] - Initial Release ### Added + - Node.js HTTP server with static file serving - JSON data upload/download API - Basic dashboard functionality diff --git a/RELEASING.md b/RELEASING.md index dcea89c..e96316d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,8 +59,10 @@ On a manual `workflow_dispatch` run against `main`, the workflow: 9. publishes `@roastcodes/ttdash` to npm through Trusted Publishing 10. waits for npm registry propagation 11. verifies: - - `npx --yes @roastcodes/ttdash@ --help` - - `bunx @roastcodes/ttdash@ --help` + +- `npx --yes @roastcodes/ttdash@ --help` +- `bunx @roastcodes/ttdash@ --help` + 12. creates the GitHub release Note: the workflow reruns the release-critical test suite itself after the version bump. This is necessary because the workflow-created push back to `main` should not be relied on to trigger the normal `CI` workflow again. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2d549b9..5ff6928 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,6 @@ import { defineConfig } from 'eslint/config' import js from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' import reactHooks from 'eslint-plugin-react-hooks' import globals from 'globals' import tseslint from 'typescript-eslint' @@ -91,4 +92,5 @@ export default defineConfig( '@typescript-eslint/switch-exhaustiveness-check': 'error', }, }, + eslintConfigPrettier, ) diff --git a/examples/sample-usage.json b/examples/sample-usage.json index 3f895cf..05a4e42 100644 --- a/examples/sample-usage.json +++ b/examples/sample-usage.json @@ -10,10 +10,7 @@ "totalTokens": 449300, "totalCost": 4.83, "requestCount": 84, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -47,10 +44,7 @@ "totalTokens": 358000, "totalCost": 3.94, "requestCount": 73, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -84,11 +78,7 @@ "totalTokens": 512200, "totalCost": 5.52, "requestCount": 96, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5", - "gemini-2.5-pro" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5", "gemini-2.5-pro"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -132,10 +122,7 @@ "totalTokens": 302600, "totalCost": 3.11, "requestCount": 61, - "modelsUsed": [ - "gpt-5.4", - "gemini-2.5-pro" - ], + "modelsUsed": ["gpt-5.4", "gemini-2.5-pro"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -169,10 +156,7 @@ "totalTokens": 228200, "totalCost": 2.47, "requestCount": 49, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", diff --git a/index.html b/index.html index 67e43be..13d7e84 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + diff --git a/package-lock.json b/package-lock.json index 8b11fcb..84e78ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,13 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "framer-motion": "^12.6.5", "globals": "^17.5.0", "jsdom": "^29.0.2", "lucide-react": "^1.7.0", + "prettier": "^3.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", @@ -3729,6 +3731,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", @@ -5089,6 +5107,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index b1f215c..dd692ab 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "scripts": { "dev": "vite", "build": "vite build", + "format": "prettier . --write", + "format:check": "prettier . --check", "lint": "eslint .", "lint:fix": "eslint . --fix", "preview": "vite preview", @@ -80,11 +82,13 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "framer-motion": "^12.6.5", "globals": "^17.5.0", "jsdom": "^29.0.2", "lucide-react": "^1.7.0", + "prettier": "^3.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", diff --git a/scripts/report-smoke.js b/scripts/report-smoke.js index 5532209..a2a8837 100644 --- a/scripts/report-smoke.js +++ b/scripts/report-smoke.js @@ -16,19 +16,103 @@ const outDir = path.join('/tmp', 'ttdash-report-matrix'); fs.mkdirSync(outDir, { recursive: true }); const cases = [ - { name: 'daily-all-de', viewMode: 'daily', language: 'de', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'daily-all-en', viewMode: 'daily', language: 'en', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'monthly-all', viewMode: 'monthly', language: 'en', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'yearly-all', viewMode: 'yearly', language: 'de', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'daily-anthropic', viewMode: 'daily', selectedMonth: null, selectedProviders: ['Anthropic'], selectedModels: [] }, - { name: 'daily-openai', viewMode: 'daily', selectedMonth: null, selectedProviders: ['OpenAI'], selectedModels: [] }, - { name: 'daily-google', viewMode: 'daily', selectedMonth: null, selectedProviders: ['Google'], selectedModels: [] }, - { name: 'monthly-opus46', viewMode: 'monthly', selectedMonth: null, selectedProviders: [], selectedModels: ['Opus 4.6'] }, - { name: 'daily-gpt54', viewMode: 'daily', selectedMonth: null, selectedProviders: [], selectedModels: ['GPT-5.4'] }, - { name: 'daily-mar-2026', viewMode: 'daily', selectedMonth: '2026-03', selectedProviders: [], selectedModels: [] }, - { name: 'daily-last-week', viewMode: 'daily', selectedMonth: null, selectedProviders: [], selectedModels: [], startDate: '2026-03-28', endDate: '2026-04-03' }, - { name: 'monthly-mar-openai', viewMode: 'monthly', selectedMonth: '2026-03', selectedProviders: ['OpenAI'], selectedModels: [] }, - { name: 'yearly-anthropic-opus46', viewMode: 'yearly', selectedMonth: null, selectedProviders: ['Anthropic'], selectedModels: ['Opus 4.6'] }, + { + name: 'daily-all-de', + viewMode: 'daily', + language: 'de', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-all-en', + viewMode: 'daily', + language: 'en', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'monthly-all', + viewMode: 'monthly', + language: 'en', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'yearly-all', + viewMode: 'yearly', + language: 'de', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-anthropic', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['Anthropic'], + selectedModels: [], + }, + { + name: 'daily-openai', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['OpenAI'], + selectedModels: [], + }, + { + name: 'daily-google', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['Google'], + selectedModels: [], + }, + { + name: 'monthly-opus46', + viewMode: 'monthly', + selectedMonth: null, + selectedProviders: [], + selectedModels: ['Opus 4.6'], + }, + { + name: 'daily-gpt54', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: [], + selectedModels: ['GPT-5.4'], + }, + { + name: 'daily-mar-2026', + viewMode: 'daily', + selectedMonth: '2026-03', + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-last-week', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + startDate: '2026-03-28', + endDate: '2026-04-03', + }, + { + name: 'monthly-mar-openai', + viewMode: 'monthly', + selectedMonth: '2026-03', + selectedProviders: ['OpenAI'], + selectedModels: [], + }, + { + name: 'yearly-anthropic-opus46', + viewMode: 'yearly', + selectedMonth: null, + selectedProviders: ['Anthropic'], + selectedModels: ['Opus 4.6'], + }, ]; main().catch((error) => { @@ -57,22 +141,34 @@ async function main() { throw new Error('pdfinfo output missing page count'); } if (!text.includes(generated.meta.filterSummary.viewMode)) { - throw new Error(`PDF text does not contain view mode ${generated.meta.filterSummary.viewMode}`); + throw new Error( + `PDF text does not contain view mode ${generated.meta.filterSummary.viewMode}`, + ); } if (!text.includes(generated.text.sections.overview)) { - throw new Error(`PDF text does not contain overview heading ${generated.text.sections.overview}`); + throw new Error( + `PDF text does not contain overview heading ${generated.text.sections.overview}`, + ); } if (!text.includes(generated.text.sections.interpretation)) { - throw new Error(`PDF text does not contain interpretation heading ${generated.text.sections.interpretation}`); + throw new Error( + `PDF text does not contain interpretation heading ${generated.text.sections.interpretation}`, + ); } if (!text.includes(generated.summaryCards[0].label)) { - throw new Error(`PDF text does not contain summary label ${generated.summaryCards[0].label}`); + throw new Error( + `PDF text does not contain summary label ${generated.summaryCards[0].label}`, + ); } if (generated.insights.items.length > 0 && !text.includes(generated.text.sections.insights)) { - throw new Error(`PDF text does not contain insights heading ${generated.text.sections.insights}`); + throw new Error( + `PDF text does not contain insights heading ${generated.text.sections.insights}`, + ); } - console.log(`[ok] ${testCase.name}: ${generated.meta.days} days, ${generated.meta.periods} periods`); + console.log( + `[ok] ${testCase.name}: ${generated.meta.days} days, ${generated.meta.periods} periods`, + ); } catch (error) { failures += 1; console.error(`[fail] ${testCase.name}: ${error.message}`); diff --git a/scripts/start-test-server.js b/scripts/start-test-server.js index 05ca0bf..c273dd1 100644 --- a/scripts/start-test-server.js +++ b/scripts/start-test-server.js @@ -1,24 +1,24 @@ #!/usr/bin/env node -const fs = require('fs') -const path = require('path') +const fs = require('fs'); +const path = require('path'); -const root = path.resolve(__dirname, '..') -const runtimeRoot = path.join(root, '.tmp-playwright', 'app') +const root = path.resolve(__dirname, '..'); +const runtimeRoot = path.join(root, '.tmp-playwright', 'app'); -fs.rmSync(runtimeRoot, { recursive: true, force: true }) -fs.mkdirSync(path.join(runtimeRoot, 'cache'), { recursive: true }) -fs.mkdirSync(path.join(runtimeRoot, 'config'), { recursive: true }) -fs.mkdirSync(path.join(runtimeRoot, 'data'), { recursive: true }) +fs.rmSync(runtimeRoot, { recursive: true, force: true }); +fs.mkdirSync(path.join(runtimeRoot, 'cache'), { recursive: true }); +fs.mkdirSync(path.join(runtimeRoot, 'config'), { recursive: true }); +fs.mkdirSync(path.join(runtimeRoot, 'data'), { recursive: true }); -process.env.NO_OPEN_BROWSER = '1' -process.env.HOST = process.env.HOST || process.env.PLAYWRIGHT_TEST_HOST || '127.0.0.1' -process.env.PORT = process.env.PORT || process.env.PLAYWRIGHT_TEST_PORT || '3015' -process.env.TTDASH_DATA_DIR = path.join(runtimeRoot, 'data') -process.env.TTDASH_CONFIG_DIR = path.join(runtimeRoot, 'config') -process.env.TTDASH_CACHE_DIR = path.join(runtimeRoot, 'cache') -process.env.XDG_CACHE_HOME = path.join(runtimeRoot, 'cache') -process.env.XDG_CONFIG_HOME = path.join(runtimeRoot, 'config') -process.env.XDG_DATA_HOME = path.join(runtimeRoot, 'data') +process.env.NO_OPEN_BROWSER = '1'; +process.env.HOST = process.env.HOST || process.env.PLAYWRIGHT_TEST_HOST || '127.0.0.1'; +process.env.PORT = process.env.PORT || process.env.PLAYWRIGHT_TEST_PORT || '3015'; +process.env.TTDASH_DATA_DIR = path.join(runtimeRoot, 'data'); +process.env.TTDASH_CONFIG_DIR = path.join(runtimeRoot, 'config'); +process.env.TTDASH_CACHE_DIR = path.join(runtimeRoot, 'cache'); +process.env.XDG_CACHE_HOME = path.join(runtimeRoot, 'cache'); +process.env.XDG_CONFIG_HOME = path.join(runtimeRoot, 'config'); +process.env.XDG_DATA_HOME = path.join(runtimeRoot, 'data'); -require(path.join(root, 'server.js')) +require(path.join(root, 'server.js')); diff --git a/scripts/verify-main-ci.js b/scripts/verify-main-ci.js index 1c6bdfe..6eb1982 100644 --- a/scripts/verify-main-ci.js +++ b/scripts/verify-main-ci.js @@ -64,7 +64,9 @@ function parseArgs(argv) { } if (!options.repo || !options.workflow || !options.sha) { - fail('Usage: node scripts/verify-main-ci.js --repo --workflow --sha [--branch main] [--retries N] [--retry-delay-ms MS]'); + fail( + 'Usage: node scripts/verify-main-ci.js --repo --workflow --sha [--branch main] [--retries N] [--retry-delay-ms MS]', + ); } if (!Number.isInteger(options.retries) || options.retries <= 0) { @@ -87,7 +89,9 @@ async function sleep(ms) { } async function fetchWorkflowRuns(options, token) { - const url = new URL(`https://api.github.com/repos/${options.repo}/actions/workflows/${encodeURIComponent(options.workflow)}/runs`); + const url = new URL( + `https://api.github.com/repos/${options.repo}/actions/workflows/${encodeURIComponent(options.workflow)}/runs`, + ); url.searchParams.set('branch', options.branch); url.searchParams.set('event', 'push'); url.searchParams.set('per_page', '30'); @@ -105,9 +109,10 @@ async function fetchWorkflowRuns(options, token) { const body = await response.text(); const normalizedBody = body.replace(/\s+/g, ' ').trim(); const maxPreviewLength = 200; - const bodyPreview = normalizedBody.length > maxPreviewLength - ? `${normalizedBody.slice(0, maxPreviewLength)}…` - : normalizedBody; + const bodyPreview = + normalizedBody.length > maxPreviewLength + ? `${normalizedBody.slice(0, maxPreviewLength)}…` + : normalizedBody; const previewSuffix = bodyPreview ? ` Response preview: ${bodyPreview}` : ''; throw new Error(`GitHub API request failed with ${response.status}.${previewSuffix}`); } @@ -132,9 +137,13 @@ async function main() { const run = payload.workflow_runs.find((candidate) => candidate.head_sha === options.sha); if (!run) { - log(`CI workflow run for ${options.sha} not found yet (attempt ${attempt}/${options.retries}).`); + log( + `CI workflow run for ${options.sha} not found yet (attempt ${attempt}/${options.retries}).`, + ); } else if (run.status !== 'completed') { - log(`CI workflow run is still in progress: ${describeRun(run)} (attempt ${attempt}/${options.retries}).`); + log( + `CI workflow run is still in progress: ${describeRun(run)} (attempt ${attempt}/${options.retries}).`, + ); } else if (run.conclusion === 'success') { log(`Verified CI success for ${options.sha}: ${describeRun(run)}.`); return; diff --git a/scripts/verify-package.js b/scripts/verify-package.js index 80510e4..1ea8bf0 100644 --- a/scripts/verify-package.js +++ b/scripts/verify-package.js @@ -167,7 +167,10 @@ async function terminateChild(child, label) { function verifyInstalledCli(command, tarballPath, npmEnv) { const installDir = mktemp('ttdash-install-'); const installPackageJson = path.join(installDir, 'package.json'); - fs.writeFileSync(installPackageJson, JSON.stringify({ name: 'ttdash-package-smoke', private: true }, null, 2) + '\n'); + fs.writeFileSync( + installPackageJson, + JSON.stringify({ name: 'ttdash-package-smoke', private: true }, null, 2) + '\n', + ); run(command, ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], { cwd: installDir, @@ -185,7 +188,9 @@ function verifyInstalledCli(command, tarballPath, npmEnv) { }); if (!helpOutput.includes(`TTDash v${packageJson.version}`)) { - throw new Error('Installed tarball CLI help output did not contain the expected version banner.'); + throw new Error( + 'Installed tarball CLI help output did not contain the expected version banner.', + ); } log('Verified installed tarball CLI help output.'); @@ -207,9 +212,13 @@ async function main() { run(command, ['run', 'build'], { env: npmEnv }); } - const packJson = run(command, ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], { - env: npmEnv, - }); + const packJson = run( + command, + ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], + { + env: npmEnv, + }, + ); const [packInfo] = parsePackJson(packJson); if (!packInfo || !packInfo.filename) { @@ -226,9 +235,13 @@ async function main() { const { installDir, installedCliPath } = verifyInstalledCli(command, tarballPath, npmEnv); - const helpOutput = run(command, ['exec', '--yes', '--package', tarballPath, '--', 'ttdash', '--help'], { - env: npmEnv, - }); + const helpOutput = run( + command, + ['exec', '--yes', '--package', tarballPath, '--', 'ttdash', '--help'], + { + env: npmEnv, + }, + ); if (!helpOutput.includes(`TTDash v${packageJson.version}`)) { throw new Error('Packaged CLI help output did not contain the expected version banner.'); diff --git a/scripts/verify-registry-install.js b/scripts/verify-registry-install.js index bee5475..b7b52cf 100644 --- a/scripts/verify-registry-install.js +++ b/scripts/verify-registry-install.js @@ -56,7 +56,9 @@ function parseArgs(argv) { } if (!options.packageName || !options.version) { - fail('Usage: node scripts/verify-registry-install.js --package --version [--retries N] [--retry-delay-ms MS]'); + fail( + 'Usage: node scripts/verify-registry-install.js --package --version [--retries N] [--retry-delay-ms MS]', + ); } if (!Number.isInteger(options.retries) || options.retries <= 0) { @@ -76,10 +78,17 @@ function sleep(ms) { function createIsolatedWorkingDir(prefix) { const cwd = mktemp(prefix); - fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ - name: 'ttdash-registry-verify', - private: true, - }, null, 2) + '\n'); + fs.writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify( + { + name: 'ttdash-registry-verify', + private: true, + }, + null, + 2, + ) + '\n', + ); return cwd; } @@ -132,22 +141,26 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { const cacheDir = mktemp('ttdash-registry-npm-cache-'); try { - const output = runCommand('npm', [ - 'exec', - '--yes', - '--prefer-online', - '--package', - `${packageName}@${version}`, - '--', - 'ttdash', - '--help', - ], { - cwd, - env: buildEnv({ - npm_config_cache: cacheDir, - NPM_CONFIG_CACHE: cacheDir, - }), - }); + const output = runCommand( + 'npm', + [ + 'exec', + '--yes', + '--prefer-online', + '--package', + `${packageName}@${version}`, + '--', + 'ttdash', + '--help', + ], + { + cwd, + env: buildEnv({ + npm_config_cache: cacheDir, + NPM_CONFIG_CACHE: cacheDir, + }), + }, + ); verifyExpectedVersion(output, expected); log(`Verified npm exec install path on attempt ${attempt}.`); @@ -166,7 +179,9 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { } } - throw new Error(`npm exec install path did not become ready in time.\n${formatCommandError(lastError)}`); + throw new Error( + `npm exec install path did not become ready in time.\n${formatCommandError(lastError)}`, + ); } async function verifyBunx(packageName, version, retries, retryDelayMs) { @@ -179,10 +194,7 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { const bunCacheDir = mktemp('ttdash-registry-bun-cache-'); try { - const output = runCommand('bunx', [ - `${packageName}@${version}`, - '--help', - ], { + const output = runCommand('bunx', [`${packageName}@${version}`, '--help'], { cwd, env: buildEnv({ BUN_INSTALL: bunInstallDir, @@ -207,7 +219,9 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { } } - throw new Error(`bunx install path did not become ready in time.\n${formatCommandError(lastError)}`); + throw new Error( + `bunx install path did not become ready in time.\n${formatCommandError(lastError)}`, + ); } async function main() { diff --git a/server.js b/server.js index 00a6ad0..0f35ff4 100755 --- a/server.js +++ b/server.js @@ -26,13 +26,19 @@ const BIND_HOST = process.env.HOST || '127.0.0.1'; const API_PREFIX = '/port/5000/api'; const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB const IS_WINDOWS = process.platform === 'win32'; -const TOKTRACK_LOCAL_BIN = path.join(ROOT, 'node_modules', '.bin', IS_WINDOWS ? 'toktrack.cmd' : 'toktrack'); +const TOKTRACK_LOCAL_BIN = path.join( + ROOT, + 'node_modules', + '.bin', + IS_WINDOWS ? 'toktrack.cmd' : 'toktrack', +); const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'Cross-Origin-Opener-Policy': 'same-origin', - 'Content-Security-Policy': "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", + 'Content-Security-Policy': + "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }; const APP_LABEL = 'TTDash'; const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup'; @@ -66,7 +72,9 @@ const DEFAULT_SETTINGS = { providers: [], models: [], }, - sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])), + sectionVisibility: Object.fromEntries( + DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true]), + ), sectionOrder: DASHBOARD_SECTION_IDS, lastLoadedAt: null, lastLoadSource: null, @@ -223,14 +231,27 @@ function resolveAppPaths() { }; } else if (IS_WINDOWS) { platformPaths = { - dataDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME), - configDir: path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), APP_DIR_NAME), - cacheDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME, 'Cache'), + dataDir: path.join( + process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), + APP_DIR_NAME, + ), + configDir: path.join( + process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), + APP_DIR_NAME, + ), + cacheDir: path.join( + process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), + APP_DIR_NAME, + 'Cache', + ), }; } else { const appName = APP_DIR_NAME_LINUX; platformPaths = { - dataDir: path.join(process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), appName), + dataDir: path.join( + process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), + appName, + ), configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName), cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName), }; @@ -355,9 +376,9 @@ async function isBackgroundInstanceOwned(instance) { return false; } - return runtime.id === instance.id - && runtime.pid === instance.pid - && runtime.port === instance.port; + return ( + runtime.id === instance.id && runtime.pid === instance.pid && runtime.port === instance.port + ); } function normalizeBackgroundInstance(value) { @@ -368,17 +389,19 @@ function normalizeBackgroundInstance(value) { const pid = Number.parseInt(value.pid, 10); const port = Number.parseInt(value.port, 10); const startedAt = normalizeIsoTimestamp(value.startedAt); - const id = typeof value.id === 'string' && value.id.trim() - ? value.id.trim() - : null; - const url = typeof value.url === 'string' && value.url.trim() - ? value.url.trim() - : null; - const host = typeof value.host === 'string' && value.host.trim() - ? value.host.trim() - : BIND_HOST; - - if (!id || !url || !startedAt || !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0) { + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : null; + const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : null; + const host = typeof value.host === 'string' && value.host.trim() ? value.host.trim() : BIND_HOST; + + if ( + !id || + !url || + !startedAt || + !Number.isInteger(pid) || + pid <= 0 || + !Number.isInteger(port) || + port <= 0 + ) { return null; } @@ -389,9 +412,8 @@ function normalizeBackgroundInstance(value) { url, host, startedAt, - logFile: typeof value.logFile === 'string' && value.logFile.trim() - ? value.logFile.trim() - : null, + logFile: + typeof value.logFile === 'string' && value.logFile.trim() ? value.logFile.trim() : null, }; } @@ -413,9 +435,7 @@ function writeBackgroundInstances(instances) { } async function readBackgroundInstancesSnapshot() { - const normalized = readBackgroundInstancesRaw() - .map(normalizeBackgroundInstance) - .filter(Boolean); + const normalized = readBackgroundInstancesRaw().map(normalizeBackgroundInstance).filter(Boolean); const alive = []; for (const instance of normalized) { @@ -445,7 +465,10 @@ async function getBackgroundInstances() { return (await readBackgroundInstancesSnapshot()).alive; } -async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) { +async function withBackgroundInstancesLock( + callback, + timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS, +) { const startedAt = Date.now(); while (true) { @@ -460,7 +483,7 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST let lockIsStale = false; try { const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR); - lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS; + lockIsStale = Date.now() - stats.mtimeMs > BACKGROUND_INSTANCES_LOCK_STALE_MS; } catch { // Ignore stat races while the lock directory is changing. } @@ -612,7 +635,11 @@ async function promptForBackgroundInstance(instances) { try { while (true) { - const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim(); + const answer = ( + await rl.question( + `Which instance should be stopped? [1-${instances.length}, Enter=cancel] `, + ) + ).trim(); if (!answer) { return null; @@ -691,22 +718,30 @@ async function runStopCommand() { const result = await stopBackgroundInstance(selectedInstance); if (result.status === 'stopped') { - console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.log( + `Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`, + ); return; } if (result.status === 'already-stopped') { - console.log(`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.log( + `Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`, + ); return; } if (result.status === 'forbidden') { - console.error(`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.error( + `Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`, + ); process.exitCode = 1; return; } - console.error(`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.error( + `TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`, + ); if (selectedInstance.logFile) { console.error(`Log file: ${selectedInstance.logFile}`); } @@ -744,9 +779,7 @@ async function startInBackground() { const instance = await waitForBackgroundInstance(child.pid); if (!instance) { - const logOutput = fs.existsSync(logFile) - ? fs.readFileSync(logFile, 'utf-8').trim() - : ''; + const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf-8').trim() : ''; throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`); } @@ -797,9 +830,7 @@ function normalizeDashboardDatePreset(value) { } function normalizeLastLoadSource(value) { - return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' - ? value - : null; + return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null; } function normalizeIsoTimestamp(value) { @@ -825,30 +856,38 @@ function isPlainObject(value) { } function computeUsageTotals(daily) { - return daily.reduce((totals, day) => ({ - inputTokens: totals.inputTokens + (day.inputTokens || 0), - outputTokens: totals.outputTokens + (day.outputTokens || 0), - cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0), - cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0), - thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0), - totalCost: totals.totalCost + (day.totalCost || 0), - totalTokens: totals.totalTokens + (day.totalTokens || 0), - requestCount: totals.requestCount + (day.requestCount || 0), - }), { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }); + return daily.reduce( + (totals, day) => ({ + inputTokens: totals.inputTokens + (day.inputTokens || 0), + outputTokens: totals.outputTokens + (day.outputTokens || 0), + cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0), + cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0), + thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0), + totalCost: totals.totalCost + (day.totalCost || 0), + totalTokens: totals.totalTokens + (day.totalTokens || 0), + requestCount: totals.requestCount + (day.requestCount || 0), + }), + { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + ); } function sortStrings(values) { - return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.trim()))] - .sort((left, right) => left.localeCompare(right)); + return [ + ...new Set( + (Array.isArray(values) ? values : []).filter( + (value) => typeof value === 'string' && value.trim(), + ), + ), + ].sort((left, right) => left.localeCompare(right)); } function canonicalizeModelBreakdown(entry) { @@ -928,9 +967,10 @@ function extractUsageImportPayload(payload) { } function mergeUsageData(currentData, importedData) { - const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 - ? normalizeIncomingData(currentData) - : null; + const current = + currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 + ? normalizeIncomingData(currentData) + : null; if (!current) { return { @@ -966,7 +1006,9 @@ function mergeUsageData(currentData, importedData) { conflictingDays += 1; } - const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + const mergedDaily = [...currentByDate.values()].sort((left, right) => + left.date.localeCompare(right.date), + ); return { data: { @@ -1016,10 +1058,14 @@ function normalizeStringList(value) { return []; } - return [...new Set(value - .filter((entry) => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter(Boolean))]; + return [ + ...new Set( + value + .filter((entry) => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; } function normalizeDefaultFilters(value) { @@ -1038,9 +1084,7 @@ function normalizeSectionVisibility(value) { const next = {}; for (const sectionId of DASHBOARD_SECTION_IDS) { - next[sectionId] = typeof source[sectionId] === 'boolean' - ? source[sectionId] - : true; + next[sectionId] = typeof source[sectionId] === 'boolean' ? source[sectionId] : true; } return next; @@ -1051,9 +1095,9 @@ function normalizeSectionOrder(value) { return [...DASHBOARD_SECTION_IDS]; } - const incoming = value.filter((sectionId) => ( - typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId) - )); + const incoming = value.filter( + (sectionId) => typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId), + ); const uniqueIncoming = [...new Set(incoming)]; const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId)); @@ -1087,14 +1131,8 @@ function openBrowser(url) { } const platform = process.platform; - const command = platform === 'darwin' - ? 'open' - : platform === 'win32' - ? 'cmd' - : 'xdg-open'; - const args = platform === 'win32' - ? ['/c', 'start', '', url] - : [url]; + const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; const child = spawn(command, args, { detached: true, @@ -1150,15 +1188,9 @@ function describeDataFile() { } function printStartupSummary(url, port) { - const browserMode = shouldOpenBrowser() - ? 'enabled' - : 'disabled'; - const autoLoadMode = CLI_OPTIONS.autoLoad - ? 'enabled' - : 'disabled'; - const runtimeMode = IS_BACKGROUND_CHILD - ? 'background' - : 'foreground'; + const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled'; + const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled'; + const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground'; console.log(''); console.log(`${APP_LABEL} v${APP_VERSION} is ready`); @@ -1550,7 +1582,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { }); startupAutoLoadCompleted = true; - console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`); + console.log( + `Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`, + ); } catch (error) { console.error(`Auto-load failed: ${error.message}`); console.error('Dashboard will start without newly imported data.'); @@ -1569,19 +1603,23 @@ const server = http.createServer(async (req, res) => { if (apiPath === '/usage') { if (req.method === 'GET') { const data = readData(); - return json(res, 200, data || { - daily: [], - totals: { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, + return json( + res, + 200, + data || { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, }, - }); + ); } if (req.method === 'DELETE') { try { @@ -1663,9 +1701,10 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { days, totalCost }); } catch (e) { const status = e.message === 'Payload too large' ? 413 : 400; - const message = e.message === 'Payload too large' - ? 'File too large (max. 10 MB)' - : e.message || 'Invalid JSON'; + const message = + e.message === 'Payload too large' + ? 'File too large (max. 10 MB)' + : e.message || 'Invalid JSON'; return json(res, status, { message }); } } @@ -1698,13 +1737,15 @@ const server = http.createServer(async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'X-Accel-Buffering': 'no', ...SECURITY_HEADERS, }); let aborted = false; - req.on('close', () => { aborted = true; }); + req.on('close', () => { + aborted = true; + }); try { const result = await performAutoImport({ @@ -1729,13 +1770,17 @@ const server = http.createServer(async (req, res) => { }, }); - if (aborted) { return; } + if (aborted) { + return; + } sendSSE(res, 'success', result); sendSSE(res, 'done', {}); res.end(); } catch (err) { - if (aborted) { return; } + if (aborted) { + return; + } sendSSE(res, 'error', { message: `Error: ${err.message}` }); sendSSE(res, 'done', {}); res.end(); @@ -1758,15 +1803,23 @@ const server = http.createServer(async (req, res) => { body = await readBody(req); } catch (e) { const status = e.message === 'Payload too large' ? 413 : 400; - return json(res, status, { message: e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request' }); + return json(res, status, { + message: + e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request', + }); } try { const result = await generatePdfReport(data.daily, body || {}); - return sendBuffer(res, 200, { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${result.filename}"`, - }, result.buffer); + return sendBuffer( + res, + 200, + { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${result.filename}"`, + }, + result.buffer, + ); } catch (error) { const message = error && error.message ? error.message : 'PDF generation failed'; const status = error && error.code === 'TYPST_MISSING' ? 503 : 500; @@ -1782,7 +1835,10 @@ const server = http.createServer(async (req, res) => { const safePath = pathname === '/' ? '/index.html' : pathname; const filePath = path.resolve(STATIC_ROOT, `.${safePath}`); - if (!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && filePath !== path.resolve(STATIC_ROOT, 'index.html')) { + if ( + !filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && + filePath !== path.resolve(STATIC_ROOT, 'index.html') + ) { return json(res, 403, { message: 'Access denied' }); } diff --git a/server/report/charts.js b/server/report/charts.js index 2d86402..d85ebd1 100644 --- a/server/report/charts.js +++ b/server/report/charts.js @@ -25,7 +25,18 @@ function truncateSvgLabel(value, maxLength = 28) { return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; } -function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fill = 'rgba(31, 111, 235, 0.14)', formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) { +function lineChart( + data, + { + valueKey, + secondaryKey, + title, + stroke = '#1f6feb', + fill = 'rgba(31, 111, 235, 0.14)', + formatter = (value) => String(value), + fontFamily = DEFAULT_FONT_FAMILY, + }, +) { const width = 980; const height = 360; const margin = { top: 42, right: 28, bottom: 54, left: 74 }; @@ -39,16 +50,18 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi const y = (value) => margin.top + plotHeight - (value / maxValue) * plotHeight; const linePoints = values.map((value, index) => `${x(index)},${y(value)}`).join(' '); - const areaPoints = data.length > 1 - ? [ - `${margin.left},${margin.top + plotHeight}`, - ...values.map((value, index) => `${x(index)},${y(value)}`), - `${margin.left + plotWidth},${margin.top + plotHeight}`, - ].join(' ') - : ''; - const secondaryPoints = secondaryValues.length > 0 - ? secondaryValues.map((value, index) => `${x(index)},${y(value)}`).join(' ') - : ''; + const areaPoints = + data.length > 1 + ? [ + `${margin.left},${margin.top + plotHeight}`, + ...values.map((value, index) => `${x(index)},${y(value)}`), + `${margin.left + plotWidth},${margin.top + plotHeight}`, + ].join(' ') + : ''; + const secondaryPoints = + secondaryValues.length > 0 + ? secondaryValues.map((value, index) => `${x(index)},${y(value)}`).join(' ') + : ''; const tickCount = 4; const yTicks = Array.from({ length: tickCount + 1 }, (_, index) => { const value = (maxValue / tickCount) * index; @@ -59,33 +72,66 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi }); const labelStep = Math.max(1, Math.ceil(data.length / 6)); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} - ${yTicks.map((tick) => ` + ${yTicks + .map( + (tick) => ` ${escapeXml(formatter(tick.value))} - `).join('')} + `, + ) + .join('')} ${areaPoints ? `` : ''} ${secondaryPoints ? `` : ''} - ${data.length > 1 - ? `` - : ``} - ${values.map((value, index) => ` + ${ + data.length > 1 + ? `` + : `` + } + ${values + .map( + (value, index) => ` - `).join('')} - ${data.map((entry, index) => index % labelStep === 0 || index === data.length - 1 ? ` + `, + ) + .join('')} + ${data + .map((entry, index) => + index % labelStep === 0 || index === data.length - 1 + ? ` ${escapeXml(entry.label)} - ` : '').join('')} - `); + ` + : '', + ) + .join('')} + `, + ); } -function horizontalBarChart(data, { title, formatter = (value) => String(value), getValue, getLabel, getColor, fontFamily = DEFAULT_FONT_FAMILY }) { +function horizontalBarChart( + data, + { + title, + formatter = (value) => String(value), + getValue, + getLabel, + getColor, + fontFamily = DEFAULT_FONT_FAMILY, + }, +) { const width = 980; const height = 360; - const longestLabelLength = data.reduce((max, entry) => Math.max(max, String(getLabel(entry) || '').length), 0); + const longestLabelLength = data.reduce( + (max, entry) => Math.max(max, String(getLabel(entry) || '').length), + 0, + ); const margin = { top: 46, right: 100, @@ -94,39 +140,56 @@ function horizontalBarChart(data, { title, formatter = (value) => String(value), }; const plotWidth = width - margin.left - margin.right; const barGap = 18; - const barHeight = Math.min(28, (height - margin.top - margin.bottom - barGap * (data.length - 1)) / Math.max(data.length, 1)); + const barHeight = Math.min( + 28, + (height - margin.top - margin.bottom - barGap * (data.length - 1)) / Math.max(data.length, 1), + ); const maxValue = Math.max(...data.map(getValue), 1); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} - ${data.map((entry, index) => { - const y = margin.top + index * (barHeight + barGap); - const value = getValue(entry); - const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); - return ` + ${data + .map((entry, index) => { + const y = margin.top + index * (barHeight + barGap); + const value = getValue(entry); + const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); + return ` ${escapeXml(truncateSvgLabel(getLabel(entry), 30))} ${escapeXml(formatter(value))} `; - }).join('')} - `); + }) + .join('')} + `, + ); } -function stackedBarChart(data, { title, segments, formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) { +function stackedBarChart( + data, + { title, segments, formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }, +) { const width = 980; const height = 380; const margin = { top: 52, right: 30, bottom: 56, left: 74 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; - const totals = data.map((entry) => segments.reduce((sum, segment) => sum + (Number(entry[segment.key]) || 0), 0)); + const totals = data.map((entry) => + segments.reduce((sum, segment) => sum + (Number(entry[segment.key]) || 0), 0), + ); const maxValue = Math.max(...totals, 1); const barWidth = Math.max(10, plotWidth / Math.max(data.length * 1.8, 1)); const gap = data.length > 1 ? (plotWidth - data.length * barWidth) / (data.length - 1) : 0; const labelStep = Math.max(1, Math.ceil(data.length / 7)); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} ${Array.from({ length: 5 }, (_, index) => { @@ -137,26 +200,36 @@ function stackedBarChart(data, { title, segments, formatter = (value) => String( ${escapeXml(formatter(value))} `; }).join('')} - ${data.map((entry, index) => { - const x = margin.left + index * (barWidth + gap); - let offset = 0; - const rects = segments.map((segment) => { - const value = Number(entry[segment.key]) || 0; - const h = maxValue > 0 ? (value / maxValue) * plotHeight : 0; - const y = margin.top + plotHeight - offset - h; - offset += h; - return ``; - }).join(''); - const label = index % labelStep === 0 || index === data.length - 1 - ? `${escapeXml(entry.label)}` - : ''; - return `${rects}${label}`; - }).join('')} - ${segments.map((segment, index) => ` + ${data + .map((entry, index) => { + const x = margin.left + index * (barWidth + gap); + let offset = 0; + const rects = segments + .map((segment) => { + const value = Number(entry[segment.key]) || 0; + const h = maxValue > 0 ? (value / maxValue) * plotHeight : 0; + const y = margin.top + plotHeight - offset - h; + offset += h; + return ``; + }) + .join(''); + const label = + index % labelStep === 0 || index === data.length - 1 + ? `${escapeXml(entry.label)}` + : ''; + return `${rects}${label}`; + }) + .join('')} + ${segments + .map( + (segment, index) => ` ${escapeXml(segment.label)} - `).join('')} - `); + `, + ) + .join('')} + `, + ); } module.exports = { diff --git a/server/report/index.js b/server/report/index.js index 3256c07..477025a 100644 --- a/server/report/index.js +++ b/server/report/index.js @@ -292,11 +292,31 @@ function createChartAssets(reportData) { title: reportData.text.charts.tokenTrend, formatter: (value) => formatCompactAxis(value, reportData.meta.language), segments: [ - { key: 'input', label: translate(reportData.meta.language, 'common.input'), color: '#0f766e' }, - { key: 'output', label: translate(reportData.meta.language, 'common.output'), color: '#1d4ed8' }, - { key: 'cacheWrite', label: translate(reportData.meta.language, 'common.cacheWrite'), color: '#b45309' }, - { key: 'cacheRead', label: translate(reportData.meta.language, 'common.cacheRead'), color: '#7c3aed' }, - { key: 'thinking', label: translate(reportData.meta.language, 'common.thinking'), color: '#be185d' }, + { + key: 'input', + label: translate(reportData.meta.language, 'common.input'), + color: '#0f766e', + }, + { + key: 'output', + label: translate(reportData.meta.language, 'common.output'), + color: '#1d4ed8', + }, + { + key: 'cacheWrite', + label: translate(reportData.meta.language, 'common.cacheWrite'), + color: '#b45309', + }, + { + key: 'cacheRead', + label: translate(reportData.meta.language, 'common.cacheRead'), + color: '#7c3aed', + }, + { + key: 'thinking', + label: translate(reportData.meta.language, 'common.thinking'), + color: '#be185d', + }, ], }), }; diff --git a/server/report/utils.js b/server/report/utils.js index 7ca7b27..e1b1458 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -10,8 +10,8 @@ const MODEL_COLORS = { 'GPT-5.4': 'rgb(230, 98, 56)', 'GPT-5': 'rgb(230, 98, 56)', 'Gemini 3 Flash Preview': 'rgb(237, 188, 8)', - 'Gemini': 'rgb(237, 188, 8)', - 'OpenCode': 'rgb(51, 181, 193)', + Gemini: 'rgb(237, 188, 8)', + OpenCode: 'rgb(51, 181, 193)', }; const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; @@ -24,7 +24,9 @@ function titleCaseSegment(segment) { } function normalizeModelName(raw) { - const lower = String(raw || '').toLowerCase().trim(); + const lower = String(raw || '') + .toLowerCase() + .trim(); if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4'; if (lower.includes('gpt-5')) return 'GPT-5'; if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6'; @@ -47,7 +49,9 @@ function normalizeModelName(raw) { .replace(/-{2,}/g, '-') .replace(/^-|-$/g, ''); - const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i); + const familyMatch = stripped.match( + /(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i, + ); if (familyMatch) { const family = familyMatch[1]; const suffix = familyMatch[2] ? familyMatch[2].replace(/-/g, '.') : ''; @@ -56,20 +60,30 @@ function normalizeModelName(raw) { return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim(); } - return stripped - .split('-') - .filter(Boolean) - .map(titleCaseSegment) - .join(' ') || String(raw || ''); + return stripped.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); } function getModelProvider(raw) { const lower = String(raw || '').toLowerCase(); - if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI'; - if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic'; + if ( + lower.includes('gpt') || + lower.includes('openai') || + lower.includes('/o1') || + lower.includes('/o3') || + /\bo\d\b/.test(lower) + ) + return 'OpenAI'; + if ( + lower.includes('claude') || + lower.includes('opus') || + lower.includes('sonnet') || + lower.includes('haiku') + ) + return 'Anthropic'; if (lower.includes('gemini')) return 'Google'; if (lower.includes('grok') || lower.includes('xai')) return 'xAI'; - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta'; + if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) + return 'Meta'; if (lower.includes('command') || lower.includes('cohere')) return 'Cohere'; if (lower.includes('mistral')) return 'Mistral'; if (lower.includes('deepseek')) return 'DeepSeek'; @@ -127,7 +141,8 @@ function recalculateDayFromBreakdowns(day, modelBreakdowns) { thinkingTokens, totalCost, requestCount, - totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, modelsUsed: modelBreakdowns.map((item) => item.modelName), modelBreakdowns, }; @@ -138,8 +153,12 @@ function filterByProviders(data, selectedProviders) { const selected = new Set(selectedProviders); return data .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(getModelProvider(entry.modelName))); - return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null; + const filteredBreakdowns = day.modelBreakdowns.filter((entry) => + selected.has(getModelProvider(entry.modelName)), + ); + return filteredBreakdowns.length > 0 + ? recalculateDayFromBreakdowns(day, filteredBreakdowns) + : null; }) .filter(Boolean); } @@ -149,17 +168,19 @@ function filterByModels(data, selectedModels) { const selected = new Set(selectedModels); return data .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(normalizeModelName(entry.modelName))); - return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null; + const filteredBreakdowns = day.modelBreakdowns.filter((entry) => + selected.has(normalizeModelName(entry.modelName)), + ); + return filteredBreakdowns.length > 0 + ? recalculateDayFromBreakdowns(day, filteredBreakdowns) + : null; }) .filter(Boolean); } function aggregateToDailyFormat(data, viewMode) { if (viewMode === 'daily') return data; - const groupKey = viewMode === 'monthly' - ? (date) => date.slice(0, 7) - : (date) => date.slice(0, 4); + const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4); const groups = new Map(); for (const day of data) { @@ -239,7 +260,8 @@ function toWeekdayData(data) { } return WEEKDAYS.map((label, index) => { const values = weekdayCosts[index]; - const average = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; + const average = + values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; return { day: label, cost: average }; }); } @@ -338,7 +360,8 @@ function computeMetrics(data) { totalCacheCreate += day.cacheCreationTokens; totalThinking += day.thinkingTokens; activeDays += day._aggregatedDays || 1; - if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) hasRequestData = true; + if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) + hasRequestData = true; if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost }; if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost }; @@ -416,7 +439,12 @@ function computeModelRows(data) { _dates: new Set(), }; current.cost += breakdown.cost; - current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; + current.tokens += + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens; current.requests += breakdown.requestCount; if (!current._dates.has(day.date)) { current._dates.add(day.date); @@ -454,7 +482,12 @@ function computeProviderRows(data) { _dates: new Set(), }; current.cost += breakdown.cost; - current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; + current.tokens += + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens; current.requests += breakdown.requestCount; if (!current._dates.has(day.date)) { current._dates.add(day.date); @@ -497,7 +530,12 @@ function formatDate(dateStr, mode = 'short', language = 'de') { } const date = new Date(`${dateStr}T00:00:00`); if (mode === 'long') { - return date.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); + return date.toLocaleDateString(locale, { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); } return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' }); } @@ -510,7 +548,10 @@ function formatDateAxis(dateStr, language = 'de') { const date = new Date(Number(year), Number(month) - 1); return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' }); } - return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { day: '2-digit', month: '2-digit' }); + return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + }); } function formatFilterValue(value, language = 'de') { @@ -581,10 +622,12 @@ function formatCompactAxis(value, language = 'de') { return formatCompactNumber(value, language); } -function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normalize = (value) => value } = {}) { - const normalized = (values || []) - .map(normalize) - .filter(Boolean); +function summarizeSelection( + values, + language, + { emptyKey, maxVisible = 3, normalize = (value) => value } = {}, +) { + const normalized = (values || []).map(normalize).filter(Boolean); if (normalized.length === 0) { return translate(language, emptyKey); @@ -592,9 +635,8 @@ function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normal const visible = normalized.slice(0, maxVisible); const hidden = normalized.length - visible.length; - const suffix = hidden > 0 - ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` - : ''; + const suffix = + hidden > 0 ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` : ''; return `${visible.join(', ')}${suffix}`; } @@ -663,7 +705,10 @@ function periodUnit(viewMode, language = 'de') { function applyReportFilters(allDailyData, filters) { const sorted = sortByDate(allDailyData); - const preProvider = filterByMonth(filterByDateRange(sorted, filters.startDate, filters.endDate), filters.selectedMonth); + const preProvider = filterByMonth( + filterByDateRange(sorted, filters.startDate, filters.endDate), + filters.selectedMonth, + ); const preModel = filterByProviders(preProvider, filters.selectedProviders || []); const filteredDaily = filterByModels(preModel, filters.selectedModels || []); const filtered = aggregateToDailyFormat(filteredDaily, filters.viewMode || 'daily'); @@ -698,32 +743,76 @@ function buildReportData(allDailyData, options = {}) { emptyKey: 'report.filters.all', normalize: normalizeModelName, }); - const monthLabel = formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all'); - const startDateLabel = formatFilterValue(filters.startDate || null, language) || translate(language, 'report.filters.noFilter'); - const endDateLabel = formatFilterValue(filters.endDate || null, language) || translate(language, 'report.filters.noFilter'); - const peakPeriodLabel = metrics.topDay ? formatDate(metrics.topDay.date, 'long', language) : notAvailable; + const monthLabel = + formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all'); + const startDateLabel = + formatFilterValue(filters.startDate || null, language) || + translate(language, 'report.filters.noFilter'); + const endDateLabel = + formatFilterValue(filters.endDate || null, language) || + translate(language, 'report.filters.noFilter'); + const peakPeriodLabel = metrics.topDay + ? formatDate(metrics.topDay.date, 'long', language) + : notAvailable; const topModelValue = metrics.topModel ? metrics.topModel.name : notAvailable; const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable; const insights = buildInsights(metrics, { filteredDaily, filtered, language }); const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0; - const recentRows = sortByDate(filtered).slice(-12).reverse().map((entry) => ({ - period: entry.date, - label: formatDate(entry.date, 'long', language), - cost: entry.totalCost, - costLabel: formatCurrency(entry.totalCost, language), - tokens: entry.totalTokens, - tokensLabel: formatCompact(entry.totalTokens, language), - requests: entry.requestCount, - requestsLabel: formatInteger(entry.requestCount, language), - })); + const recentRows = sortByDate(filtered) + .slice(-12) + .reverse() + .map((entry) => ({ + period: entry.date, + label: formatDate(entry.date, 'long', language), + cost: entry.totalCost, + costLabel: formatCurrency(entry.totalCost, language), + tokens: entry.totalTokens, + tokensLabel: formatCompact(entry.totalTokens, language), + requests: entry.requestCount, + requestsLabel: formatInteger(entry.requestCount, language), + })); const summaryCards = [ - { label: translate(language, 'common.costs'), value: formatCurrency(metrics.totalCost, language), note: metrics.topProvider ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}` : notAvailable, tone: 'accent' }, - { label: translate(language, 'common.tokens'), value: formatCompact(metrics.totalTokens, language), note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, tone: 'accent' }, - { label: translate(language, 'common.requests'), value: formatInteger(metrics.totalRequests, language), note: metrics.hasRequestData ? `${formatPercent(metrics.cacheHitRate, language)} Cache` : notAvailable, tone: 'good' }, - { label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`, value: formatCurrency(avgPeriodCost, language), note: `${reportDataLabel(filters.viewMode, language)}`, tone: 'accent' }, - { label: translate(language, 'common.model'), value: topModelValue, note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable, tone: 'warn' }, - { label: translate(language, 'report.summary.peakPeriod'), value: peakPeriodLabel, note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable, tone: 'warn' }, + { + label: translate(language, 'common.costs'), + value: formatCurrency(metrics.totalCost, language), + note: metrics.topProvider + ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}` + : notAvailable, + tone: 'accent', + }, + { + label: translate(language, 'common.tokens'), + value: formatCompact(metrics.totalTokens, language), + note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, + tone: 'accent', + }, + { + label: translate(language, 'common.requests'), + value: formatInteger(metrics.totalRequests, language), + note: metrics.hasRequestData + ? `${formatPercent(metrics.cacheHitRate, language)} Cache` + : notAvailable, + tone: 'good', + }, + { + label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`, + value: formatCurrency(avgPeriodCost, language), + note: `${reportDataLabel(filters.viewMode, language)}`, + tone: 'accent', + }, + { + label: translate(language, 'common.model'), + value: topModelValue, + note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable, + tone: 'warn', + }, + { + label: translate(language, 'report.summary.peakPeriod'), + value: peakPeriodLabel, + note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable, + tone: 'warn', + }, ]; const interpretationSummary = translate(language, 'report.interpretation.summary', { @@ -791,10 +880,18 @@ function buildReportData(allDailyData, options = {}) { })), recentPeriods: recentRows, labels: { - dateRangeText: dateRange ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` : translate(language, 'common.noData'), - topModel: metrics.topModel ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})` : notAvailable, - topProvider: metrics.topProvider ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})` : notAvailable, - topDay: metrics.topDay ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})` : notAvailable, + dateRangeText: dateRange + ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` + : translate(language, 'common.noData'), + topModel: metrics.topModel + ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})` + : notAvailable, + topProvider: metrics.topProvider + ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})` + : notAvailable, + topDay: metrics.topDay + ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})` + : notAvailable, }, interpretation: { summary: interpretationSummary, @@ -842,7 +939,10 @@ function buildReportData(allDailyData, options = {}) { }, }, formatting: { - axisDates: filtered.map((entry) => ({ date: entry.date, label: formatDateAxis(entry.date, language) })), + axisDates: filtered.map((entry) => ({ + date: entry.date, + label: formatDateAxis(entry.date, language), + })), }, }; } diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 7ae7bf9..3c2d5eb 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -49,16 +49,45 @@ import { applyTheme } from '@/lib/app-settings' import { downloadCSV } from '@/lib/csv-export' import { VERSION } from '@/lib/constants' import { SECTION_HELP } from '@/lib/help-content' -import { generatePdfReport, importSettings, importUsageData, type PdfReportRequest } from '@/lib/api' -import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' +import { + generatePdfReport, + importSettings, + importUsageData, + type PdfReportRequest, +} from '@/lib/api' +import { + formatCurrency, + formatDateTimeCompact, + formatDateTimeFull, + formatTokens, + formatPercent, + periodUnit, + localToday, + toLocalDateStr, +} from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' import { SettingsModal } from './features/settings/SettingsModal' import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { AppLanguage, DashboardDefaultFilters, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ProviderLimits } from '@/types' +import type { + AppLanguage, + DashboardDefaultFilters, + DashboardSectionId, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, +} from '@/types' -const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then(module => ({ default: module.DrillDownModal }))) -const AutoImportModal = lazy(() => import('./features/auto-import/AutoImportModal').then(module => ({ default: module.AutoImportModal }))) +const DrillDownModal = lazy(() => + import('./features/drill-down/DrillDownModal').then((module) => ({ + default: module.DrillDownModal, + })), +) +const AutoImportModal = lazy(() => + import('./features/auto-import/AutoImportModal').then((module) => ({ + default: module.AutoImportModal, + })), +) const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup' const USAGE_BACKUP_KIND = 'ttdash-usage-backup' const BACKUP_FORMAT_VERSION = 1 @@ -114,21 +143,20 @@ export function Dashboard() { const [reportGenerating, setReportGenerating] = useState(false) const [settingsTransferBusy, setSettingsTransferBusy] = useState(false) const [dataTransferBusy, setDataTransferBusy] = useState(false) - const [dataSource, setDataSource] = useState<{ type: 'stored' | 'auto-import' | 'file'; label?: string; time?: string; title?: string } | null>(null) + const [dataSource, setDataSource] = useState<{ + type: 'stored' | 'auto-import' | 'file' + label?: string + time?: string + title?: string + } | null>(null) const [animationSeed, setAnimationSeed] = useState(0) const daily = useMemo(() => usageData?.daily ?? [], [usageData]) const hasData = daily.length > 0 - const allProviders = useMemo(() => getUniqueProviders(daily.map(d => d.modelsUsed)), [daily]) - const allModelsFromData = useMemo(() => getUniqueModels(daily.map(d => d.modelsUsed)), [daily]) - const { - settings, - providerLimits, - setTheme, - setLanguage, - saveSettings, - isSaving, - } = useAppSettings(allProviders) + const allProviders = useMemo(() => getUniqueProviders(daily.map((d) => d.modelsUsed)), [daily]) + const allModelsFromData = useMemo(() => getUniqueModels(daily.map((d) => d.modelsUsed)), [daily]) + const { settings, providerLimits, setTheme, setLanguage, saveSettings, isSaving } = + useAppSettings(allProviders) const isDark = settings.theme === 'dark' useEffect(() => { @@ -162,11 +190,14 @@ export function Dashboard() { }, []) const persistedLoadedTime = useMemo( - () => settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined, + () => (settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined), [settings.lastLoadedAt], ) const persistedLoadedTitle = useMemo( - () => settings.lastLoadedAt ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) : undefined, + () => + settings.lastLoadedAt + ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : undefined, [settings.lastLoadedAt, t], ) const persistedDataSource = useMemo(() => { @@ -179,25 +210,35 @@ export function Dashboard() { } }, [hasData, persistedLoadedTime, persistedLoadedTitle]) const headerDataSource = dataSource ?? persistedDataSource - const startupAutoLoadBadge = useMemo(() => ( - settings.cliAutoLoadActive - ? { - active: true, - ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), - title: settings.lastLoadedAt - ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : t('header.autoLoadActive'), - } - : null - ), [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t]) + const startupAutoLoadBadge = useMemo( + () => + settings.cliAutoLoadActive + ? { + active: true, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + title: settings.lastLoadedAt + ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : t('header.autoLoadActive'), + } + : null, + [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t], + ) const { - viewMode, setViewMode, - selectedMonth, setSelectedMonth, - selectedProviders, toggleProvider, clearProviders, - selectedModels, toggleModel, clearModels, - startDate, setStartDate, - endDate, setEndDate, + viewMode, + setViewMode, + selectedMonth, + setSelectedMonth, + selectedProviders, + toggleProvider, + clearProviders, + selectedModels, + toggleModel, + clearModels, + startDate, + setStartDate, + endDate, + setEndDate, resetAll, applyDefaultFilters, applyPreset, @@ -210,8 +251,17 @@ export function Dashboard() { } = useDashboardFilters(daily, settings.defaultFilters) const { - metrics, modelCosts, providerMetrics, costChartData, modelCostChartData, - tokenChartData, requestChartData, weekdayData, allModels, modelPieData, tokenPieData, + metrics, + modelCosts, + providerMetrics, + costChartData, + modelCostChartData, + tokenChartData, + requestChartData, + weekdayData, + allModels, + modelPieData, + tokenPieData, } = useComputedMetrics(filteredData) // Full dataset with only model filter applied (no date/month filter) for PeriodComparison @@ -226,17 +276,30 @@ export function Dashboard() { }, [dateRange, viewMode]) const todayStr = localToday() - const todayData = useMemo(() => filteredDailyData.find(d => d.date === todayStr) ?? null, [filteredDailyData, todayStr]) - const hasCurrentMonthData = useMemo(() => filteredDailyData.some(d => d.date.startsWith(todayStr.slice(0, 7))), [filteredDailyData, todayStr]) - const visibleLimitProviders = useMemo(() => ( - selectedProviders.length > 0 ? selectedProviders : allProviders - ), [selectedProviders, allProviders]) + const todayData = useMemo( + () => filteredDailyData.find((d) => d.date === todayStr) ?? null, + [filteredDailyData, todayStr], + ) + const hasCurrentMonthData = useMemo( + () => filteredDailyData.some((d) => d.date.startsWith(todayStr.slice(0, 7))), + [filteredDailyData, todayStr], + ) + const visibleLimitProviders = useMemo( + () => (selectedProviders.length > 0 ? selectedProviders : allProviders), + [selectedProviders, allProviders], + ) const settingsProviderOptions = useMemo( - () => [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => left.localeCompare(right)), + () => + [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => + left.localeCompare(right), + ), [allProviders, settings.defaultFilters.providers], ) const settingsModelOptions = useMemo( - () => [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => left.localeCompare(right)), + () => + [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => + left.localeCompare(right), + ), [allModelsFromData, settings.defaultFilters.models], ) const sectionVisibility = settings.sectionVisibility @@ -244,7 +307,7 @@ export function Dashboard() { // Compute active streak (consecutive days from today backwards) const streak = useMemo(() => { - const dates = new Set(filteredDailyData.map(d => d.date)) + const dates = new Set(filteredDailyData.map((d) => d.date)) let count = 0 const d = new Date(todayStr + 'T00:00:00') while (dates.has(toLocalDateStr(d))) { @@ -256,7 +319,7 @@ export function Dashboard() { const drillDownDay = useMemo(() => { if (!drillDownDate) return null - return filteredData.find(d => d.date === drillDownDate) ?? null + return filteredData.find((d) => d.date === drillDownDate) ?? null }, [drillDownDate, filteredData]) const handleUpload = useCallback(() => { @@ -271,54 +334,66 @@ export function Dashboard() { void setTheme(isDark ? 'light' : 'dark') }, [isDark, setTheme]) - const handleSaveSettings = useCallback(async (nextSettings: { - providerLimits: ProviderLimits - defaultFilters: DashboardDefaultFilters - sectionVisibility: DashboardSectionVisibility - sectionOrder: DashboardSectionOrder - }) => { - const updatedSettings = await saveSettings(nextSettings) - applyDefaultFilters(updatedSettings.defaultFilters) - addToast(t('toasts.settingsSaved'), 'success') - }, [saveSettings, applyDefaultFilters, addToast, t]) - - const handleLanguageChange = useCallback((language: AppLanguage) => { - if (settings.language !== language) { - void setLanguage(language) - } - if (i18n.resolvedLanguage !== language) { - void i18n.changeLanguage(language) - } - }, [i18n, setLanguage, settings.language]) + const handleSaveSettings = useCallback( + async (nextSettings: { + providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder + }) => { + const updatedSettings = await saveSettings(nextSettings) + applyDefaultFilters(updatedSettings.defaultFilters) + addToast(t('toasts.settingsSaved'), 'success') + }, + [saveSettings, applyDefaultFilters, addToast, t], + ) - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - try { - const text = await file.text() - const json: unknown = JSON.parse(text) - await uploadMutation.mutateAsync(json) - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) - setDataSource({ - type: 'file', - label: file.name, - time, - title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, - }) - addToast(t('toasts.fileLoaded', { name: file.name }), 'success') - } catch { - addToast(t('toasts.fileReadFailed'), 'error') - } - e.target.value = '' - }, [uploadMutation, queryClient, addToast, t]) + const handleLanguageChange = useCallback( + (language: AppLanguage) => { + if (settings.language !== language) { + void setLanguage(language) + } + if (i18n.resolvedLanguage !== language) { + void i18n.changeLanguage(language) + } + }, + [i18n, setLanguage, settings.language], + ) + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + try { + const text = await file.text() + const json: unknown = JSON.parse(text) + await uploadMutation.mutateAsync(json) + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((prev) => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { + hour: '2-digit', + minute: '2-digit', + }) + setDataSource({ + type: 'file', + label: file.name, + time, + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + addToast(t('toasts.fileLoaded', { name: file.name }), 'success') + } catch { + addToast(t('toasts.fileReadFailed'), 'error') + } + e.target.value = '' + }, + [uploadMutation, queryClient, addToast, t], + ) const handleDelete = useCallback(async () => { await deleteMutation.mutateAsync() void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) + setAnimationSeed((prev) => prev + 1) setDataSource(null) addToast(t('toasts.dataDeleted'), 'info') }, [deleteMutation, queryClient, addToast, t]) @@ -355,11 +430,25 @@ export function Dashboard() { addToast(t('commandPalette.commands.generateReport.label'), 'success') } catch (error) { console.error('PDF generation failed:', error) - addToast(`${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') + addToast( + `${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error', + ) } finally { setReportGenerating(false) } - }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast, i18n.language, t]) + }, [ + reportGenerating, + viewMode, + selectedMonth, + selectedProviders, + selectedModels, + startDate, + endDate, + addToast, + i18n.language, + t, + ]) const handleAutoImport = useCallback(() => { setAutoImportOpen(true) @@ -368,7 +457,7 @@ export function Dashboard() { const handleAutoImportSuccess = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ['usage'] }) void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) + setAnimationSeed((prev) => prev + 1) const now = new Date() const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) setDataSource({ @@ -423,298 +512,426 @@ export function Dashboard() { dataImportInputRef.current?.click() }, []) - const handleSettingsImportChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + const handleSettingsImportChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return - setSettingsTransferBusy(true) - try { - const parsed: unknown = JSON.parse(await file.text()) - const imported = await importSettings(parsed) - queryClient.setQueryData(['settings'], imported) - applyDefaultFilters(imported.defaultFilters) - addToast(t('toasts.settingsImported', { name: file.name }), 'success') - } catch (error) { - addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') - } finally { - setSettingsTransferBusy(false) - e.target.value = '' - } - }, [queryClient, applyDefaultFilters, addToast, t]) + setSettingsTransferBusy(true) + try { + const parsed: unknown = JSON.parse(await file.text()) + const imported = await importSettings(parsed) + queryClient.setQueryData(['settings'], imported) + applyDefaultFilters(imported.defaultFilters) + addToast(t('toasts.settingsImported', { name: file.name }), 'success') + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setSettingsTransferBusy(false) + e.target.value = '' + } + }, + [queryClient, applyDefaultFilters, addToast, t], + ) - const handleDataImportChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + const handleDataImportChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return - setDataTransferBusy(true) - try { - const parsed: unknown = JSON.parse(await file.text()) - const summary = await importUsageData(parsed) - await queryClient.invalidateQueries({ queryKey: ['usage'] }) - await queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) - setDataSource({ - type: 'file', - label: file.name, - ...(time ? { time } : {}), - title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, - }) - - const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' - const toastKey = summary.conflictingDays > 0 ? 'toasts.dataBackupImportedWithConflicts' : 'toasts.dataBackupImported' - addToast(t(toastKey, { - added: summary.addedDays, - unchanged: summary.unchangedDays, - conflicts: summary.conflictingDays, - }), toastType) - } catch (error) { - addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') - } finally { - setDataTransferBusy(false) - e.target.value = '' - } - }, [queryClient, addToast, t]) + setDataTransferBusy(true) + try { + const parsed: unknown = JSON.parse(await file.text()) + const summary = await importUsageData(parsed) + await queryClient.invalidateQueries({ queryKey: ['usage'] }) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((prev) => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { + hour: '2-digit', + minute: '2-digit', + }) + setDataSource({ + type: 'file', + label: file.name, + ...(time ? { time } : {}), + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + + const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' + const toastKey = + summary.conflictingDays > 0 + ? 'toasts.dataBackupImportedWithConflicts' + : 'toasts.dataBackupImported' + addToast( + t(toastKey, { + added: summary.addedDays, + unchanged: summary.unchangedDays, + conflicts: summary.conflictingDays, + }), + toastType, + ) + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setDataTransferBusy(false) + e.target.value = '' + } + }, + [queryClient, addToast, t], + ) const handleScrollTo = useCallback((section: string) => { const el = document.getElementById(section) el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, []) - const renderSection = useCallback((sectionId: DashboardSectionId) => { - switch (sectionId) { - case 'insights': - return sectionVisibility.insights ? ( -
- -
- ) : null - case 'metrics': - return sectionVisibility.metrics ? ( -
- - - - - -
- d.totalCost)} viewMode={viewMode} /> -
-
-
- ) : null - case 'today': - return sectionVisibility.today && todayData ? ( -
- -
- ) : null - case 'currentMonth': - return sectionVisibility.currentMonth && hasCurrentMonthData ? ( -
- -
- ) : null - case 'activity': - return sectionVisibility.activity ? ( -
- - -
- - - -
-
-
- ) : null - case 'forecastCache': - return sectionVisibility.forecastCache ? ( -
- - -
- - - - - - -
-
-
- ) : null - case 'limits': - return sectionVisibility.limits ? ( -
- - { + switch (sectionId) { + case 'insights': + return sectionVisibility.insights ? ( +
+ - -
- ) : null - case 'costAnalysis': - return sectionVisibility.costAnalysis ? ( -
- - -
-
- +
+ ) : null + case 'metrics': + return sectionVisibility.metrics ? ( +
+ + + + + +
+ d.totalCost)} + viewMode={viewMode} + />
- -
- - -
- -
-
- -
- - -
-
- -
- - -
-
-
- ) : null - case 'tokenAnalysis': - return sectionVisibility.tokenAnalysis ? ( -
- - -
- - -
-
-
- ) : null - case 'requestAnalysis': - return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( -
- - - - - -
- -
-
- -
- -
-
-
- ) : null - case 'advancedAnalysis': - return sectionVisibility.advancedAnalysis ? ( -
- - -
- - +
+ ) : null + case 'today': + return sectionVisibility.today && todayData ? ( +
+ +
+ ) : null + case 'currentMonth': + return sectionVisibility.currentMonth && hasCurrentMonthData ? ( +
+ +
+ ) : null + case 'activity': + return sectionVisibility.activity ? ( +
+ + +
+ + + +
+
+
+ ) : null + case 'forecastCache': + return sectionVisibility.forecastCache ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'limits': + return sectionVisibility.limits ? ( +
+ + -
-
- -
- -
-
-
- ) : null - case 'comparisons': - return sectionVisibility.comparisons ? ( -
- - -
- - - - - - -
-
-
- ) : null - case 'tables': - return sectionVisibility.tables ? ( -
- - - - - -
- -
-
- -
- -
-
-
- ) : null - default: - return null - } - }, [ - allModels, - comparisonData, - costChartData, - filteredDailyData, - filteredData, - hasCurrentMonthData, - metrics, - modelCostChartData, - modelCosts, - modelPieData, - providerLimits, - providerMetrics, - requestChartData, - sectionVisibility, - selectedMonth, - t, - todayData, - tokenChartData, - tokenPieData, - totalCalendarDays, - viewMode, - visibleLimitProviders, - weekdayData, - ]) +
+
+ ) : null + case 'costAnalysis': + return sectionVisibility.costAnalysis ? ( +
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ ) : null + case 'tokenAnalysis': + return sectionVisibility.tokenAnalysis ? ( +
+ + +
+ + +
+
+
+ ) : null + case 'requestAnalysis': + return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + case 'advancedAnalysis': + return sectionVisibility.advancedAnalysis ? ( +
+ + +
+ + +
+
+ +
+ +
+
+
+ ) : null + case 'comparisons': + return sectionVisibility.comparisons ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'tables': + return sectionVisibility.tables ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + default: + return null + } + }, + [ + allModels, + comparisonData, + costChartData, + filteredDailyData, + filteredData, + hasCurrentMonthData, + metrics, + modelCostChartData, + modelCosts, + modelPieData, + providerLimits, + providerMetrics, + requestChartData, + sectionVisibility, + selectedMonth, + t, + todayData, + tokenChartData, + tokenPieData, + totalCalendarDays, + viewMode, + visibleLimitProviders, + weekdayData, + ], + ) if (isLoading) { return @@ -723,12 +940,43 @@ export function Dashboard() { if (!hasData) { return ( <> - - - - + + + + - {autoImportOpen && } + {autoImportOpen && ( + + )} - - - + + +
{t('header.settings')} - )} - pdfButton={( - - )} + } + pdfButton={ + + } />
@@ -821,11 +1087,12 @@ export function Dashboard() { />
-
+
{sectionOrder.map((sectionId) => ( - - {renderSection(sectionId)} - + {renderSection(sectionId)} ))}
@@ -878,7 +1145,13 @@ export function Dashboard() { /> - {autoImportOpen && } + {autoImportOpen && ( + + )} - -
- -
-
-

- TTDash -

-

v{VERSION}

-
-

- {t('emptyState.description')} -

- -

{t('emptyState.or')}

- - -
+ +
+ +
+
+

+ TTDash +

+

v{VERSION}

+
+

+ {t('emptyState.description')} +

+ +

{t('emptyState.or')}

+ + +
) diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx index 57fc36f..5286b28 100644 --- a/src/components/cards/MetricCard.tsx +++ b/src/components/cards/MetricCard.tsx @@ -13,9 +13,22 @@ interface MetricCardProps { className?: string } -export function MetricCard({ label, value, subtitle, icon, trend, info, className }: MetricCardProps) { +export function MetricCard({ + label, + value, + subtitle, + icon, + trend, + info, + className, +}: MetricCardProps) { return ( - +
{label} @@ -25,18 +38,16 @@ export function MetricCard({ label, value, subtitle, icon, trend, info, classNam
{value}
- {subtitle && ( - {subtitle} - )} + {subtitle && {subtitle}} {trend && trend.value !== 0 && ( - 0 - ? 'text-red-400 bg-red-400/10' - : 'text-green-400 bg-green-400/10' - )}> - {trend.value > 0 ? '↑' : '↓'}{Math.abs(trend.value).toFixed(1)}% - {trend.label && ` ${trend.label}`} + 0 ? 'text-red-400 bg-red-400/10' : 'text-green-400 bg-green-400/10', + )} + > + {trend.value > 0 ? '↑' : '↓'} + {Math.abs(trend.value).toFixed(1)}%{trend.label && ` ${trend.label}`} )}
diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index fc9a4ce..d68ce7b 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -1,6 +1,15 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { TrendingDown, DollarSign, Coins, Cpu, Database, CalendarDays, Activity, BrainCircuit } from 'lucide-react' +import { + TrendingDown, + DollarSign, + Coins, + Cpu, + Database, + CalendarDays, + Activity, + BrainCircuit, +} from 'lucide-react' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' @@ -20,14 +29,14 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const currentMonth = localMonth() const monthData = useMemo( - () => daily.filter(d => d.date.startsWith(currentMonth)), + () => daily.filter((d) => d.date.startsWith(currentMonth)), [daily, currentMonth], ) const prevMonth = useMemo(() => { const [y = 0, m = 1] = currentMonth.split('-').map(Number) const pm = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}` - return daily.filter(d => d.date.startsWith(pm)) + return daily.filter((d) => d.date.startsWith(pm)) }, [daily, currentMonth]) const agg = useMemo(() => { @@ -65,31 +74,48 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const dayOfMonth = today.getDate() return { - totalCost, totalTokens, inputTokens, outputTokens, - cacheRead, cacheCreate, thinkingTokens, requestCount, cacheHitRate, costPerMillion, - activeDays: monthData.length, dayOfMonth, - modelCount: models.size, topModel, + totalCost, + totalTokens, + inputTokens, + outputTokens, + cacheRead, + cacheCreate, + thinkingTokens, + requestCount, + cacheHitRate, + costPerMillion, + activeDays: monthData.length, + dayOfMonth, + modelCount: models.size, + topModel, } }, [monthData]) - const prevMonthCost = useMemo( - () => prevMonth.reduce((s, d) => s + d.totalCost, 0), - [prevMonth], - ) + const prevMonthCost = useMemo(() => prevMonth.reduce((s, d) => s + d.totalCost, 0), [prevMonth]) if (!agg) return null - const diffToPrev = prevMonthCost > 0 - ? ((agg.totalCost - prevMonthCost) / prevMonthCost) * 100 - : null + const diffToPrev = + prevMonthCost > 0 ? ((agg.totalCost - prevMonthCost) / prevMonthCost) * 100 : null const ioTotal = agg.inputTokens + agg.outputTokens - const tokensSubtitle = agg.inputTokens > 0 && agg.outputTokens > 0 - ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + const tokensSubtitle = + agg.inputTokens > 0 && agg.outputTokens > 0 + ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + : null + const modelsSubtitle = agg.topModel + ? t('metricCards.month.topModel', { value: agg.topModel.name }) : null - const modelsSubtitle = agg.topModel ? t('metricCards.month.topModel', { value: agg.topModel.name }) : null - const costPerMillionSubtitle = metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : null - const thinkingSubtitle = agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%` }) : null + const costPerMillionSubtitle = + metrics.costPerMillion > 0 + ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) + : null + const thinkingSubtitle = + agg.totalTokens > 0 + ? t('metricCards.month.thinkingSubtitle', { + value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%`, + }) + : null return (
@@ -104,9 +130,15 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { } - subtitle={t('metricCards.month.avgPerDay', { value: formatCurrency(agg.totalCost / agg.activeDays) })} + subtitle={t('metricCards.month.avgPerDay', { + value: formatCurrency(agg.totalCost / agg.activeDays), + })} icon={} - trend={diffToPrev !== null ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } : null} + trend={ + diffToPrev !== null + ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } + : null + } /> } /> 0 ? : t('common.notAvailable')} - subtitle={agg.requestCount > 0 - ? t('metricCards.month.requestsSubtitle', { value: (agg.requestCount / agg.activeDays).toFixed(1), cost: formatCurrency(agg.totalCost / agg.requestCount) }) - : t('metricCards.month.requestCountersMissing')} + value={ + agg.requestCount > 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + agg.requestCount > 0 + ? t('metricCards.month.requestsSubtitle', { + value: (agg.requestCount / agg.activeDays).toFixed(1), + cost: formatCurrency(agg.totalCost / agg.requestCount), + }) + : t('metricCards.month.requestCountersMissing') + } icon={} /> 0 && metrics.totalOutput > 0 - ? (metrics.totalInput / metrics.totalOutput).toFixed(1) - : null + const ioRatio = + metrics.totalInput > 0 && metrics.totalOutput > 0 + ? (metrics.totalInput / metrics.totalOutput).toFixed(1) + : null - const coverageRate = totalCalendarDays && viewMode === 'daily' - ? (metrics.activeDays / totalCalendarDays) * 100 - : null + const coverageRate = + totalCalendarDays && viewMode === 'daily' + ? (metrics.activeDays / totalCalendarDays) * 100 + : null const topModelSubtitle = metrics.topModel ? `${formatCurrency(metrics.topModel.cost)} · ${t('metricCards.primary.share', { value: formatPercent(metrics.topModelShare, 0) })}${metrics.topRequestModel ? ` · ${t('metricCards.primary.requestLead', { value: metrics.topRequestModel.name })}` : ''}` : null - const cacheHitRateSubtitle = metrics.totalTokens > 0 - ? t('metricCards.primary.allTokensViaCacheRead', { value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100) }) - : null - const thinkingInsight = metrics.totalTokens > 0 - ? t('metricCards.primary.thinkingShareOfVolume', { value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100) }) - : null - const thinkingSubtitle = metrics.totalTokens > 0 - ? t('metricCards.primary.thinkingSubtitle', { share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)) }) - : null + const cacheHitRateSubtitle = + metrics.totalTokens > 0 + ? t('metricCards.primary.allTokensViaCacheRead', { + value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100), + }) + : null + const thinkingInsight = + metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingShareOfVolume', { + value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), + }) + : null + const thinkingSubtitle = + metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingSubtitle', { + share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), + tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)), + }) + : null return (
} + value={ + + } subtitle={`Ø ${formatCurrency(metrics.avgDailyCost)}/${periodUnit(viewMode)} · ${formatCurrency(metrics.avgCostPerRequest)}/Req`} icon={} trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} @@ -47,17 +82,35 @@ export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' /> } - subtitle={ioRatio ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` : `${formatTokens(metrics.avgTokensPerRequest)} / Request`} + value={ + + } + subtitle={ + ioRatio + ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` + : `${formatTokens(metrics.avgTokensPerRequest)} / Request` + } icon={} info={METRIC_HELP.totalTokens} /> } info={METRIC_HELP.activeDays} /> @@ -83,15 +136,44 @@ export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' /> : t('common.notAvailable')} - subtitle={metrics.hasRequestData - ? t('metricCards.primary.requestsSubtitle', { requests: metrics.avgRequestsPerDay.toFixed(1), unit: periodUnit(viewMode), cost: formatCurrency(metrics.avgCostPerRequest), volatility: Math.round(metrics.requestVolatility) }) - : t('metricCards.primary.requestCountersMissing')} + value={ + metrics.hasRequestData ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + metrics.hasRequestData + ? t('metricCards.primary.requestsSubtitle', { + requests: metrics.avgRequestsPerDay.toFixed(1), + unit: periodUnit(viewMode), + cost: formatCurrency(metrics.avgCostPerRequest), + volatility: Math.round(metrics.requestVolatility), + }) + : t('metricCards.primary.requestCountersMissing') + } icon={} /> } + value={ + + } icon={} {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} /> diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index f5357e3..b08f36b 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -2,7 +2,13 @@ import { TrendingUp, ChartBar, Sigma, Building2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' -import { formatDate, formatCurrency, formatNumber, formatPercent, periodUnit } from '@/lib/formatters' +import { + formatDate, + formatCurrency, + formatNumber, + formatPercent, + periodUnit, +} from '@/lib/formatters' import { METRIC_HELP } from '@/lib/help-content' import type { DashboardMetrics, ViewMode } from '@/types' @@ -12,12 +18,15 @@ interface SecondaryMetricsProps { viewMode?: ViewMode } -export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: SecondaryMetricsProps) { +export function SecondaryMetrics({ + metrics, + dailyCosts, + viewMode = 'daily', +}: SecondaryMetricsProps) { const { t } = useTranslation() // Calculate spread between most and least expensive days - const costSpread = metrics.topDay && metrics.cheapestDay - ? metrics.topDay.cost - metrics.cheapestDay.cost - : null + const costSpread = + metrics.topDay && metrics.cheapestDay ? metrics.topDay.cost - metrics.cheapestDay.cost : null // Calculate median const median = (() => { @@ -32,7 +41,10 @@ export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: Se return (previousValue + midValue) / 2 })() const requestLeader = metrics.topRequestModel - ? t('metricCards.secondary.requestLeader', { model: metrics.topRequestModel.name, requests: formatNumber(metrics.topRequestModel.requests) }) + ? t('metricCards.secondary.requestLeader', { + model: metrics.topRequestModel.name, + requests: formatNumber(metrics.topRequestModel.requests), + }) : null const topDaySubtitle = metrics.topDay ? formatDate(metrics.topDay.date, 'long') : null const topProviderSubtitle = metrics.topProvider @@ -42,20 +54,30 @@ export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: Se requestLeader: requestLeader ? ` · ${requestLeader}` : '', }) : null - const peakSubtitle = viewMode === 'daily' && metrics.busiestWeek - ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` - : costSpread !== null - ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) + const peakSubtitle = + viewMode === 'daily' && metrics.busiestWeek + ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` + : costSpread !== null + ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) + : null + const medianSubtitle = + median !== null && metrics.avgDailyCost > 0 + ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` : null - const medianSubtitle = median !== null && metrics.avgDailyCost > 0 - ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` - : null return (
: '–'} + label={ + viewMode === 'yearly' + ? t('metricCards.secondary.mostExpensiveYear') + : viewMode === 'monthly' + ? t('metricCards.secondary.mostExpensiveMonth') + : t('metricCards.secondary.mostExpensiveDay') + } + value={ + metrics.topDay ? : '–' + } icon={} info={METRIC_HELP.mostExpensiveDay} {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} @@ -68,8 +90,18 @@ export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: Se {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} /> : } + label={ + viewMode === 'daily' + ? t('metricCards.secondary.peak7Days') + : t('metricCards.secondary.avgCostPerUnit', { unit: periodUnit(viewMode) }) + } + value={ + viewMode === 'daily' && metrics.busiestWeek ? ( + + ) : ( + + ) + } icon={} info={METRIC_HELP.avgCostPerDay} {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index 4c58813..b9dbe4d 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -1,4 +1,12 @@ -import { TrendingDown, DollarSign, Coins, Cpu, Database, Activity, BrainCircuit } from 'lucide-react' +import { + TrendingDown, + DollarSign, + Coins, + Cpu, + Database, + Activity, + BrainCircuit, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' @@ -16,27 +24,55 @@ interface TodayMetricsProps { export function TodayMetrics({ today, metrics }: TodayMetricsProps) { const { t } = useTranslation() - const cacheHitRate = (today.cacheReadTokens + today.cacheCreationTokens) > 0 - ? (today.cacheReadTokens / (today.cacheReadTokens + today.cacheCreationTokens + today.inputTokens + today.outputTokens + today.thinkingTokens)) * 100 - : 0 + const cacheHitRate = + today.cacheReadTokens + today.cacheCreationTokens > 0 + ? (today.cacheReadTokens / + (today.cacheReadTokens + + today.cacheCreationTokens + + today.inputTokens + + today.outputTokens + + today.thinkingTokens)) * + 100 + : 0 const topModel = today.modelBreakdowns?.length - ? today.modelBreakdowns.reduce((a, b) => a.cost > b.cost ? a : b) + ? today.modelBreakdowns.reduce((a, b) => (a.cost > b.cost ? a : b)) : null - const diffToAvg = metrics.avgDailyCost > 0 - ? ((today.totalCost - metrics.avgDailyCost) / metrics.avgDailyCost) * 100 + const diffToAvg = + metrics.avgDailyCost > 0 + ? ((today.totalCost - metrics.avgDailyCost) / metrics.avgDailyCost) * 100 + : null + const costSubtitle = + diffToAvg !== null + ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) + : null + const tokensSubtitle = + today.inputTokens > 0 && today.outputTokens > 0 + ? t('metricCards.today.ioRatio', { + value: (today.inputTokens / today.outputTokens).toFixed(1), + }) + : null + const modelSubtitle = topModel + ? t('metricCards.today.topModel', { value: normalizeModelName(topModel.modelName) }) : null - const costSubtitle = diffToAvg !== null ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) : null - const tokensSubtitle = today.inputTokens > 0 && today.outputTokens > 0 - ? t('metricCards.today.ioRatio', { value: (today.inputTokens / today.outputTokens).toFixed(1) }) - : null - const modelSubtitle = topModel ? t('metricCards.today.topModel', { value: normalizeModelName(topModel.modelName) }) : null - const costPerMillionSubtitle = metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : null - const requestsSubtitle = today.requestCount > 0 && today.modelsUsed.length > 0 - ? t('metricCards.today.requestsSubtitle', { value: (today.requestCount / today.modelsUsed.length).toFixed(1), cost: formatCurrency(today.totalCost / today.requestCount) }) - : t('metricCards.today.requestCountersMissing') - const thinkingSubtitle = today.totalTokens > 0 ? t('metricCards.today.thinkingSubtitle', { value: formatPercent((today.thinkingTokens / today.totalTokens) * 100) }) : null + const costPerMillionSubtitle = + metrics.costPerMillion > 0 + ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) + : null + const requestsSubtitle = + today.requestCount > 0 && today.modelsUsed.length > 0 + ? t('metricCards.today.requestsSubtitle', { + value: (today.requestCount / today.modelsUsed.length).toFixed(1), + cost: formatCurrency(today.totalCost / today.requestCount), + }) + : t('metricCards.today.requestCountersMissing') + const thinkingSubtitle = + today.totalTokens > 0 + ? t('metricCards.today.thinkingSubtitle', { + value: formatPercent((today.thinkingTokens / today.totalTokens) * 100), + }) + : null return (
@@ -51,12 +87,23 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { label={t('metricCards.today.costToday')} value={} icon={} - trend={diffToAvg !== null ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } : null} + trend={ + diffToAvg !== null + ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } + : null + } {...(costSubtitle ? { subtitle: costSubtitle } : {})} /> 0 ? today.totalTokens / today.requestCount : 0)} / Request`} />} + value={ + 0 ? today.totalTokens / today.requestCount : 0)} / Request`} + /> + } icon={} {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> @@ -68,19 +115,39 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { /> 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0} type="currency" />} + value={ + 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0 + } + type="currency" + /> + } icon={} {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> } - subtitle={t('metricCards.today.cacheShare', { value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100) })} + subtitle={t('metricCards.today.cacheShare', { + value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), + })} icon={} /> 0 ? : t('common.notAvailable')} + value={ + today.requestCount > 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } subtitle={requestsSubtitle} icon={} /> diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 6978656..ffb02c8 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -1,4 +1,12 @@ -import { createContext, useState, useMemo, useCallback, useContext, useRef, type ReactNode } from 'react' +import { + createContext, + useState, + useMemo, + useCallback, + useContext, + useRef, + type ReactNode, +} from 'react' import { useTranslation } from 'react-i18next' import { motion, useInView } from 'framer-motion' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' @@ -24,7 +32,12 @@ interface ChartCardProps { function stringifyCsvCell(value: unknown): string { if (value == null) return '' - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { return String(value) } @@ -52,21 +65,28 @@ interface ChartRevealProps { duration?: number } -export function ChartReveal({ children, variant = 'line', delay = 0, duration = 0.7 }: ChartRevealProps) { +export function ChartReveal({ + children, + variant = 'line', + delay = 0, + duration = 0.7, +}: ChartRevealProps) { const active = useChartAnimationActive() const resolvedDuration = variant === 'radial' ? Math.max(duration, 0.95) : Math.max(duration, 0.9) - const hidden = variant === 'bar' - ? { opacity: 0, clipPath: 'inset(100% 0 0 0 round 16px)', y: 10 } - : variant === 'radial' - ? { opacity: 0, scale: 0.82, rotate: -18 } - : { opacity: 0, clipPath: 'inset(0 100% 0 0 round 16px)', x: -8 } + const hidden = + variant === 'bar' + ? { opacity: 0, clipPath: 'inset(100% 0 0 0 round 16px)', y: 10 } + : variant === 'radial' + ? { opacity: 0, scale: 0.82, rotate: -18 } + : { opacity: 0, clipPath: 'inset(0 100% 0 0 round 16px)', x: -8 } - const visible = variant === 'bar' - ? { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', y: 0 } - : variant === 'radial' - ? { opacity: 1, scale: 1, rotate: 0 } - : { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', x: 0 } + const visible = + variant === 'bar' + ? { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', y: 0 } + : variant === 'radial' + ? { opacity: 1, scale: 1, rotate: 0 } + : { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', x: 0 } return ( (null) @@ -97,7 +129,9 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c const stats = useMemo(() => { if (!chartData || !valueKey) return null - const values = chartData.map(d => d[valueKey]).filter((v): v is number => typeof v === 'number' && !isNaN(v)) + const values = chartData + .map((d) => d[valueKey]) + .filter((v): v is number => typeof v === 'number' && !isNaN(v)) if (values.length === 0) return null const sum = values.reduce((s, v) => s + v, 0) return { @@ -110,20 +144,24 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c }, [chartData, valueKey]) const fmt = valueFormatter ?? formatCurrency - const renderChildren = (isExpanded: boolean) => typeof children === 'function' - ? children(isExpanded) - : children + const renderChildren = (isExpanded: boolean) => + typeof children === 'function' ? children(isExpanded) : children const handleExport = useCallback(() => { if (!chartData || chartData.length === 0) return const firstRow = chartData[0] if (!firstRow) return const keys = Object.keys(firstRow) - const csv = [keys.join(','), ...chartData.map(row => keys.map(k => stringifyCsvCell(row[k])).join(','))].join('\n') + const csv = [ + keys.join(','), + ...chartData.map((row) => keys.map((k) => stringifyCsvCell(row[k])).join(',')), + ].join('\n') const blob = new Blob([csv], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') - a.href = url; a.download = `${title}.csv`; a.click() + a.href = url + a.download = `${title}.csv` + a.click() URL.revokeObjectURL(url) }, [chartData, title]) @@ -135,14 +173,10 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c {info && }
- {summary && ( - {summary} - )} + {summary && {summary}}
- {subtitle && ( - {subtitle} - )} + {subtitle && {subtitle}} ) @@ -174,7 +208,7 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c
-
+

{title}

@@ -192,23 +226,35 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c {stats && (
-
Min
+
+ Min +
{fmt(stats.min)}
-
Max
+
+ Max +
{fmt(stats.max)}
-
Avg
+
+ Avg +
{fmt(stats.avg)}
-
Gesamt
-
{fmt(stats.total)}
+
+ Gesamt +
+
+ {fmt(stats.total)} +
-
Datenpunkte
+
+ Datenpunkte +
{stats.count}
diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 0f97c4c..fcd3a90 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -1,12 +1,27 @@ import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { motion, useInView } from 'framer-motion' -import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis } from 'recharts' +import { + ResponsiveContainer, + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ZAxis, +} from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' -import { formatCurrency, formatDate, formatNumber, formatPercent, formatTokens } from '@/lib/formatters' +import { + formatCurrency, + formatDate, + formatNumber, + formatPercent, + formatTokens, +} from '@/lib/formatters' import type { DailyUsage } from '@/types' interface CorrelationAnalysisProps { @@ -37,7 +52,15 @@ function correlation(valuesA: number[], valuesB: number[]) { return covariance / Math.sqrt(varianceA * varianceB) } -function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?: Array<{ payload: ScatterPoint }>; mode: 'requestCost' | 'cacheEfficiency' }) { +function ScatterTooltip({ + active, + payload, + mode, +}: { + active?: boolean + payload?: Array<{ payload: ScatterPoint }> + mode: 'requestCost' | 'cacheEfficiency' +}) { const { t } = useTranslation() if (!active || !payload?.length) return null @@ -52,7 +75,9 @@ function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?: <>
{t('charts.correlation.requestsLabel')} - {point.requests !== undefined ? formatNumber(point.requests) : '–'} + + {point.requests !== undefined ? formatNumber(point.requests) : '–'} +
{t('charts.correlation.cost')} @@ -60,22 +85,30 @@ function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?:
{t('charts.correlation.tokensLabel')} - {point.tokens ? formatTokens(point.tokens) : '–'} + + {point.tokens ? formatTokens(point.tokens) : '–'} +
) : ( <>
{t('charts.correlation.cacheRate')} - {point.cacheRate !== undefined ? formatPercent(point.cacheRate, 1) : '–'} + + {point.cacheRate !== undefined ? formatPercent(point.cacheRate, 1) : '–'} +
- {t('charts.correlation.costPerRequest')} + + {t('charts.correlation.costPerRequest')} + {formatCurrency(point.y)}
{t('charts.correlation.requestsLabel')} - {point.requests !== undefined ? formatNumber(point.requests) : '–'} + + {point.requests !== undefined ? formatNumber(point.requests) : '–'} +
)} @@ -126,14 +159,33 @@ function CorrelationPanel({ >
-
{title}
+
+ {title} +
{subtitle}
- - + + } cursor={{ strokeDasharray: '4 4' }} /> (() => data.map(entry => ({ - x: entry.requestCount, - y: entry.totalCost, - z: Math.max(5, Math.sqrt(entry.totalTokens / 1000)), - label: entry.date, - tokens: entry.totalTokens, - requests: entry.requestCount, - })), [data]) - - const cacheVsCostPerRequest = useMemo(() => data - .filter(entry => entry.requestCount > 0 && entry.totalTokens > 0) - .map(entry => { - const cacheShare = (entry.cacheReadTokens / entry.totalTokens) * 100 - return { - x: cacheShare, - y: entry.totalCost / entry.requestCount, - z: Math.max(5, Math.sqrt(entry.requestCount)), + const requestVsCost = useMemo( + () => + data.map((entry) => ({ + x: entry.requestCount, + y: entry.totalCost, + z: Math.max(5, Math.sqrt(entry.totalTokens / 1000)), label: entry.date, - cacheRate: cacheShare, + tokens: entry.totalTokens, requests: entry.requestCount, - } - }), [data]) + })), + [data], + ) + + const cacheVsCostPerRequest = useMemo( + () => + data + .filter((entry) => entry.requestCount > 0 && entry.totalTokens > 0) + .map((entry) => { + const cacheShare = (entry.cacheReadTokens / entry.totalTokens) * 100 + return { + x: cacheShare, + y: entry.totalCost / entry.requestCount, + z: Math.max(5, Math.sqrt(entry.requestCount)), + label: entry.date, + cacheRate: cacheShare, + requests: entry.requestCount, + } + }), + [data], + ) - const requestCostCorrelation = correlation(requestVsCost.map(point => point.x), requestVsCost.map(point => point.y)) - const cacheEfficiencyCorrelation = correlation(cacheVsCostPerRequest.map(point => point.x), cacheVsCostPerRequest.map(point => point.y)) + const requestCostCorrelation = correlation( + requestVsCost.map((point) => point.x), + requestVsCost.map((point) => point.y), + ) + const cacheEfficiencyCorrelation = correlation( + cacheVsCostPerRequest.map((point) => point.x), + cacheVsCostPerRequest.map((point) => point.y), + ) if (data.length < 2) { return ( @@ -216,7 +282,13 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { color={CHART_COLORS.cost} xAxisName={t('charts.correlation.requestsAxis')} yAxisName={t('charts.correlation.cost')} - footer={requestCostCorrelation >= 0.6 ? t('charts.correlation.strongRequestCost') : requestCostCorrelation >= 0.3 ? t('charts.correlation.mediumRequestCost') : t('charts.correlation.weakRequestCost')} + footer={ + requestCostCorrelation >= 0.6 + ? t('charts.correlation.strongRequestCost') + : requestCostCorrelation >= 0.3 + ? t('charts.correlation.mediumRequestCost') + : t('charts.correlation.weakRequestCost') + } delay={0.02} /> @@ -230,7 +302,13 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { xAxisName={t('charts.correlation.cacheRate')} xTickFormatter={(value) => formatPercent(value, 0)} yAxisName={t('charts.correlation.costPerRequestAxis')} - footer={cacheEfficiencyCorrelation <= -0.3 ? t('charts.correlation.negativeCache') : cacheEfficiencyCorrelation < 0.2 ? t('charts.correlation.neutralCache') : t('charts.correlation.positiveCache')} + footer={ + cacheEfficiencyCorrelation <= -0.3 + ? t('charts.correlation.negativeCache') + : cacheEfficiencyCorrelation < 0.2 + ? t('charts.correlation.neutralCache') + : t('charts.correlation.positiveCache') + } delay={0.08} /> diff --git a/src/components/charts/CostByModel.tsx b/src/components/charts/CostByModel.tsx index cc547ae..2a4e994 100644 --- a/src/components/charts/CostByModel.tsx +++ b/src/components/charts/CostByModel.tsx @@ -20,7 +20,14 @@ function CenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; {t('charts.costByModel.total')} - + {total} @@ -32,7 +39,14 @@ export function CostByModel({ data }: CostByModelProps) { const total = data.reduce((sum, d) => sum + d.value, 0) return ( - []} valueKey="value" valueFormatter={formatCurrency}> + []} + valueKey="value" + valueFormatter={formatCurrency} + > {(expanded) => { const chartHeight = expanded ? 560 : 320 const pieCenterY = expanded ? '66%' : '57%' @@ -69,8 +83,12 @@ export function CostByModel({ data }: CostByModelProps) { { - const entry = data.find(d => d.name === value) - return {value} ({entry ? formatCurrency(entry.value) : ''}) + const entry = data.find((d) => d.name === value) + return ( + + {value} ({entry ? formatCurrency(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index e63d917..7b5054d 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -1,4 +1,13 @@ -import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' @@ -15,46 +24,64 @@ interface CostByModelOverTimeProps { export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) { const { t } = useTranslation() - const topModel = models - .map(model => ({ - model, - total: data.reduce((sum, point) => sum + (typeof point[model] === 'number' ? point[model] : 0), 0), - })) - .sort((a, b) => b.total - a.total)[0] ?? null + const topModel = + models + .map((model) => ({ + model, + total: data.reduce( + (sum, point) => sum + (typeof point[model] === 'number' ? point[model] : 0), + 0, + ), + })) + .sort((a, b) => b.total - a.total)[0] ?? null // Expanded extra: taller chart with per-model 7-day MA lines const expandedChart = ( {(animate) => (
-
{t('charts.costByModelOverTime.movingAverageHeading')}
+
+ {t('charts.costByModelOverTime.movingAverageHeading')} +
- - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} - /> - - {models.map(model => ( - + - ))} + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} + /> + + {models.map((model) => ( + + ))} @@ -66,7 +93,14 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) return ( []} @@ -79,29 +113,41 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} - /> - - {models.map(model => ( - + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> - ))} + + {models.map((model) => ( + + ))} diff --git a/src/components/charts/CostByWeekday.tsx b/src/components/charts/CostByWeekday.tsx index 05ab59d..13cfc14 100644 --- a/src/components/charts/CostByWeekday.tsx +++ b/src/components/charts/CostByWeekday.tsx @@ -1,4 +1,13 @@ -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts' +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Cell, +} from 'recharts' import { useState, useId } from 'react' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' @@ -18,12 +27,12 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { const uid = useId() const gid = (n: string) => `${uid}-${n}`.replace(/:/g, '') - const maxCost = Math.max(...data.map(d => d.cost)) - const minCost = Math.min(...data.map(d => d.cost)) - const peakIndex = data.findIndex(d => d.cost === maxCost) - const lowIndex = data.findIndex(d => d.cost === minCost) + const maxCost = Math.max(...data.map((d) => d.cost)) + const minCost = Math.min(...data.map((d) => d.cost)) + const peakIndex = data.findIndex((d) => d.cost === maxCost) + const lowIndex = data.findIndex((d) => d.cost === minCost) const weekendCost = data - .filter(entry => entry.day === 'Sa' || entry.day === 'So') + .filter((entry) => entry.day === 'Sa' || entry.day === 'So') .reduce((sum, entry) => sum + entry.cost, 0) const weekTotal = data.reduce((sum, entry) => sum + entry.cost, 0) @@ -45,57 +54,66 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { { - if (state?.activeTooltipIndex !== undefined && typeof state.activeTooltipIndex === 'number') { - setActiveIndex(state.activeTooltipIndex) - } - }} - onMouseLeave={() => setActiveIndex(null)} - > - - - - - - - - - - - - - - - - - - - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} - /> - { + if ( + state?.activeTooltipIndex !== undefined && + typeof state.activeTooltipIndex === 'number' + ) { + setActiveIndex(state.activeTooltipIndex) + } + }} + onMouseLeave={() => setActiveIndex(null)} > - {data.map((_, index) => { - let fill = `url(#${gid('weekday')})` - if (activeIndex === index) fill = `url(#${gid('weekdayActive')})` - else if (index === peakIndex) fill = `url(#${gid('weekdayPeak')})` - else if (index === lowIndex) fill = `url(#${gid('weekdayLow')})` - return - })} - + + + + + + + + + + + + + + + + + + + + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + {data.map((_, index) => { + let fill = `url(#${gid('weekday')})` + if (activeIndex === index) fill = `url(#${gid('weekdayActive')})` + else if (index === peakIndex) fill = `url(#${gid('weekdayPeak')})` + else if (index === lowIndex) fill = `url(#${gid('weekdayLow')})` + return + })} + diff --git a/src/components/charts/CostOverTime.tsx b/src/components/charts/CostOverTime.tsx index c732ce3..2706885 100644 --- a/src/components/charts/CostOverTime.tsx +++ b/src/components/charts/CostOverTime.tsx @@ -1,6 +1,16 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -31,7 +41,15 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { return ( []} valueKey="cost" @@ -41,52 +59,80 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { {(animate) => ( - { - if (onClickDay && e?.activeTooltipIndex != null && typeof e.activeTooltipIndex === 'number') { - const point = data[e.activeTooltipIndex] - if (point?.date) { - onClickDay(point.date) + { + if ( + onClickDay && + e?.activeTooltipIndex != null && + typeof e.activeTooltipIndex === 'number' + ) { + const point = data[e.activeTooltipIndex] + if (point?.date) { + onClickDay(point.date) + } } - } - }}> - - - - - - - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - + }} + > + + + + + + + + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + diff --git a/src/components/charts/CumulativeCost.tsx b/src/components/charts/CumulativeCost.tsx index 421284a..38bf48f 100644 --- a/src/components/charts/CumulativeCost.tsx +++ b/src/components/charts/CumulativeCost.tsx @@ -1,6 +1,15 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -36,7 +45,7 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { const endDate = `${currentMonth}-${String(daysInMonth).padStart(2, '0')}` return [ - ...data.map(d => ({ ...d, projected: undefined as number | undefined })), + ...data.map((d) => ({ ...d, projected: undefined as number | undefined })), // Bridge point on last actual date { ...data[data.length - 1], projected: last.cumulative }, // Projected end-of-month point @@ -47,51 +56,78 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { const lastCumulative = data[data.length - 1]?.cumulative ?? 0 return ( - []} valueKey="cumulative" valueFormatter={formatCurrency}> + []} + valueKey="cumulative" + valueFormatter={formatCurrency} + > {(animate) => ( []} margin={CHART_MARGIN}> - - - - - - - - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + diff --git a/src/components/charts/CustomTooltip.tsx b/src/components/charts/CustomTooltip.tsx index c4861c1..6a09419 100644 --- a/src/components/charts/CustomTooltip.tsx +++ b/src/components/charts/CustomTooltip.tsx @@ -29,33 +29,40 @@ export function CustomTooltip({ // Separate actual values from moving average (Ø) lines const isMA = (entry: TooltipPayloadEntry) => - entry.name.includes('Ø') || entry.dataKey?.toString().includes('MA7') || entry.dataKey?.toString().includes('_ma7') + entry.name.includes('Ø') || + entry.dataKey?.toString().includes('MA7') || + entry.dataKey?.toString().includes('_ma7') const isPinned = (entry: TooltipPayloadEntry) => pinnedEntryNames.includes(entry.name) - const hasNonZeroValue = (entry: TooltipPayloadEntry) => !hideZeroValues || Math.abs(entry.value ?? 0) > 0.0001 + const hasNonZeroValue = (entry: TooltipPayloadEntry) => + !hideZeroValues || Math.abs(entry.value ?? 0) > 0.0001 const actualEntries = payload - .filter(e => !isMA(e) && !isPinned(e) && hasNonZeroValue(e)) + .filter((e) => !isMA(e) && !isPinned(e) && hasNonZeroValue(e)) .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)) - const pinnedEntries = payload.filter(e => !isMA(e) && isPinned(e) && hasNonZeroValue(e)) - const maEntries = payload.filter(e => isMA(e)) + const pinnedEntries = payload.filter((e) => !isMA(e) && isPinned(e) && hasNonZeroValue(e)) + const maEntries = payload.filter((e) => isMA(e)) const total = actualEntries.reduce((sum, entry) => sum + (entry.value ?? 0), 0) const showTotal = showComputedTotal && actualEntries.length >= 2 const point = payload[0]?.payload ?? {} - const focusEntry = actualEntries.length === 1 ? actualEntries[0] : pinnedEntries.length === 1 ? pinnedEntries[0] : null + const focusEntry = + actualEntries.length === 1 + ? actualEntries[0] + : pinnedEntries.length === 1 + ? pinnedEntries[0] + : null const prevValueRaw = focusEntry ? point[`${focusEntry.dataKey}Prev`] : undefined const prevValue = typeof prevValueRaw === 'number' ? prevValueRaw : null const matchingMA = focusEntry - ? maEntries.find(entry => entry.dataKey === `${focusEntry.dataKey}MA7` || entry.dataKey === `${focusEntry.dataKey.toString().toLowerCase()}MA7`) - ?? (maEntries.length === 1 ? maEntries[0] : null) - : null - const deltaVsPrevious = focusEntry && prevValue !== null - ? focusEntry.value - prevValue - : null - const deltaVsAverage = focusEntry && matchingMA - ? focusEntry.value - matchingMA.value + ? (maEntries.find( + (entry) => + entry.dataKey === `${focusEntry.dataKey}MA7` || + entry.dataKey === `${focusEntry.dataKey.toString().toLowerCase()}MA7`, + ) ?? (maEntries.length === 1 ? maEntries[0] : null)) : null + const deltaVsPrevious = focusEntry && prevValue !== null ? focusEntry.value - prevValue : null + const deltaVsAverage = focusEntry && matchingMA ? focusEntry.value - matchingMA.value : null return (
@@ -136,7 +143,8 @@ export function CustomTooltip({ vs. vorher: - {(deltaVsPrevious >= 0 ? '+' : '')}{formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious} + {deltaVsPrevious >= 0 ? '+' : ''} + {formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious}
)} @@ -145,7 +153,8 @@ export function CustomTooltip({ vs. Ø: - {(deltaVsAverage >= 0 ? '+' : '')}{formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage} + {deltaVsAverage >= 0 ? '+' : ''} + {formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage}
)} diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index e6061f1..7f4f5ee 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -1,7 +1,16 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { motion } from 'framer-motion' -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts' +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Cell, +} from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -51,7 +60,13 @@ function toBins(values: number[], formatter: (value: number) => string): Distrib return bins } -function DistributionTooltip({ active, payload }: { active?: boolean; payload?: Array<{ value: number; payload: DistributionBin }> }) { +function DistributionTooltip({ + active, + payload, +}: { + active?: boolean + payload?: Array<{ value: number; payload: DistributionBin }> +}) { const { t } = useTranslation() if (!active || !payload?.length) return null @@ -82,14 +97,25 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA const distributions = useMemo(() => { if (data.length < 2) return [] - const costs = data.map(entry => entry.totalCost) - const requests = data.map(entry => entry.requestCount) - const tokensPerRequest = data.map(entry => entry.requestCount > 0 ? entry.totalTokens / entry.requestCount : 0) + const costs = data.map((entry) => entry.totalCost) + const requests = data.map((entry) => entry.requestCount) + const tokensPerRequest = data.map((entry) => + entry.requestCount > 0 ? entry.totalTokens / entry.requestCount : 0, + ) return [ - { title: t('charts.distribution.costPerPeriod', { period: periodLabel(viewMode) }), data: toBins(costs, formatCurrency) }, - { title: t('charts.distribution.requestsPerPeriod', { period: periodLabel(viewMode) }), data: toBins(requests, formatNumber) }, - { title: t('charts.distribution.tokensPerRequest'), data: toBins(tokensPerRequest, formatTokens) }, + { + title: t('charts.distribution.costPerPeriod', { period: periodLabel(viewMode) }), + data: toBins(costs, formatCurrency), + }, + { + title: t('charts.distribution.requestsPerPeriod', { period: periodLabel(viewMode) }), + data: toBins(requests, formatNumber), + }, + { + title: t('charts.distribution.tokensPerRequest'), + data: toBins(tokensPerRequest, formatTokens), + }, ] }, [data, viewMode, t]) @@ -112,7 +138,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA } return ( - + {t('charts.distribution.title')} @@ -130,8 +156,12 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA >
-
{distribution.title}
-
{distribution.data.length} {t('charts.distribution.buckets')}
+
+ {distribution.title} +
+
+ {distribution.data.length} {t('charts.distribution.buckets')} +
@@ -152,8 +182,17 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA textAnchor={distribution.data.length > 5 ? 'end' : 'middle'} height={distribution.data.length > 5 ? 48 : 30} /> - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> {distribution.data.map((_, binIndex) => { - const intensity = distribution.data.length > 1 ? binIndex / (distribution.data.length - 1) : 0 + const intensity = + distribution.data.length > 1 ? binIndex / (distribution.data.length - 1) : 0 const opacity = 0.45 + intensity * 0.35 - return + return ( + + ) })} diff --git a/src/components/charts/ModelMix.tsx b/src/components/charts/ModelMix.tsx index 704c600..ffe5142 100644 --- a/src/components/charts/ModelMix.tsx +++ b/src/components/charts/ModelMix.tsx @@ -1,6 +1,14 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts' +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' @@ -27,9 +35,14 @@ function MixTooltip({ active, payload, label }: MixTooltipProps) {
{sorted.map((entry, i) => (
- + {entry.name}: - {formatPercent(entry.value)} + + {formatPercent(entry.value)} +
))}
@@ -47,7 +60,7 @@ export function ModelMix({ data }: ModelMixProps) { } const models = Array.from(modelSet).sort() - const chartData = sorted.map(d => { + const chartData = sorted.map((d) => { const total = d.totalCost const point: Record = { date: d.date } const costs: Record = {} @@ -77,43 +90,60 @@ export function ModelMix({ data }: ModelMixProps) { - - {models.map(model => { - const color = getModelColor(model) - const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` - return ( - - - - - ) - })} - - - - formatPercent(Math.round(coerceNumber(value)), 0)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} domain={[0, 100]} ticks={[0, 25, 50, 75, 100]} /> - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - {models.map(model => { - const color = getModelColor(model) - const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` - return ( - + {models.map((model) => { + const color = getModelColor(model) + const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` + return ( + + + + + ) + })} + + + - ) - })} + formatPercent(Math.round(coerceNumber(value)), 0)} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + domain={[0, 100]} + ticks={[0, 25, 50, 75, 100]} + /> + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + {models.map((model) => { + const color = getModelColor(model) + const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` + return ( + + ) + })} diff --git a/src/components/charts/RequestCacheHitRateByModel.tsx b/src/components/charts/RequestCacheHitRateByModel.tsx index 2c698ce..ff999e8 100644 --- a/src/components/charts/RequestCacheHitRateByModel.tsx +++ b/src/components/charts/RequestCacheHitRateByModel.tsx @@ -1,6 +1,19 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, BarChart, Bar, Cell } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + BarChart, + Bar, + Cell, +} from 'recharts' import { ChartAnimationAware, ChartCard, ChartReveal } from './ChartCard' import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from './chart-theme' import { CustomTooltip } from './CustomTooltip' @@ -20,22 +33,36 @@ function formatRate(value: number) { return formatPercent(value, 1) } -function computePointRate(input: number, output: number, cacheCreate: number, cacheRead: number, thinking: number) { +function computePointRate( + input: number, + output: number, + cacheCreate: number, + cacheRead: number, + thinking: number, +) { const base = input + output + cacheCreate + cacheRead + thinking return base > 0 ? (cacheRead / base) * 100 : 0 } -export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode }: RequestCacheHitRateByModelProps) { +export function RequestCacheHitRateByModel({ + timelineData, + summaryData, + viewMode, +}: RequestCacheHitRateByModelProps) { const { t } = useTranslation() const uid = useId().replace(/:/g, '') const totalLabel = t('charts.requestCacheHitRate.total') - const trendLabel = viewMode === 'daily' ? t('charts.requestCacheHitRate.trailing7Rate') : t('charts.requestCacheHitRate.trendRate') + const trendLabel = + viewMode === 'daily' + ? t('charts.requestCacheHitRate.trailing7Rate') + : t('charts.requestCacheHitRate.trendRate') const barData = useMemo( - () => computeCacheHitRateByModel(summaryData).map(entry => ({ - ...entry, - model: entry.model === 'Total' ? totalLabel : entry.model, - })), + () => + computeCacheHitRateByModel(summaryData).map((entry) => ({ + ...entry, + model: entry.model === 'Total' ? totalLabel : entry.model, + })), [summaryData, totalLabel], ) @@ -44,7 +71,8 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode const total = barData[0] if (!total) return null const topModel = barData.slice(1).sort((a, b) => b.totalRate - a.totalRate)[0] ?? null - const dominantModel = barData.slice(1).sort((a, b) => b.totalBaseTokens - a.totalBaseTokens)[0] ?? null + const dominantModel = + barData.slice(1).sort((a, b) => b.totalBaseTokens - a.totalBaseTokens)[0] ?? null return { total, @@ -57,13 +85,17 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode const lineData = useMemo(() => { if (timelineData.length === 0) return [] - const topModels = barData - .slice(1) - .map(entry => entry.model) + const topModels = barData.slice(1).map((entry) => entry.model) const sorted = [...timelineData].sort((a, b) => a.date.localeCompare(b.date)) - const totalRates = sorted.map(point => - computePointRate(point.inputTokens, point.outputTokens, point.cacheCreationTokens, point.cacheReadTokens, point.thinkingTokens), + const totalRates = sorted.map((point) => + computePointRate( + point.inputTokens, + point.outputTokens, + point.cacheCreationTokens, + point.cacheReadTokens, + point.thinkingTokens, + ), ) const totalTrend = computeMovingAverage(totalRates, Math.min(7, sorted.length)) @@ -71,12 +103,21 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode for (const model of topModels) modelSeries[model] = [] for (const point of sorted) { - const byModel = new Map() + const byModel = new Map< + string, + { input: number; output: number; cacheCreate: number; cacheRead: number; thinking: number } + >() for (const breakdown of point.modelBreakdowns) { const name = normalizeModelName(breakdown.modelName) if (!topModels.includes(name)) continue - const current = byModel.get(name) ?? { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0 } + const current = byModel.get(name) ?? { + input: 0, + output: 0, + cacheCreate: 0, + cacheRead: 0, + thinking: 0, + } current.input += breakdown.inputTokens current.output += breakdown.outputTokens current.cacheCreate += breakdown.cacheCreationTokens @@ -91,7 +132,13 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode if (series) { series.push( current - ? computePointRate(current.input, current.output, current.cacheCreate, current.cacheRead, current.thinking) + ? computePointRate( + current.input, + current.output, + current.cacheCreate, + current.cacheRead, + current.thinking, + ) : 0, ) } @@ -120,7 +167,9 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode const lineHeight = viewMode === 'daily' ? 280 : 250 const expandedLineHeight = viewMode === 'daily' ? 360 : 320 - const lineSeries = Object.keys(lineData[0] ?? {}).filter(key => key !== 'date' && key !== 'totalRate' && key !== 'totalRate_ma7') + const lineSeries = Object.keys(lineData[0] ?? {}).filter( + (key) => key !== 'date' && key !== 'totalRate' && key !== 'totalRate_ma7', + ) const expandedExtra = (
@@ -129,12 +178,20 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode
{entry.model}
-
{t('charts.requestCacheHitRate.totalRate')}
-
{formatRate(entry.totalRate)}
+
+ {t('charts.requestCacheHitRate.totalRate')} +
+
+ {formatRate(entry.totalRate)} +
-
{t('charts.requestCacheHitRate.trailing7Rate')}
-
{formatRate(entry.trailing7Rate)}
+
+ {t('charts.requestCacheHitRate.trailing7Rate')} +
+
+ {formatRate(entry.trailing7Rate)} +
@@ -159,24 +216,38 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode <>
-
{t('charts.requestCacheHitRate.totalRate')}
-
{formatRate(summary.total.totalRate)}
+
+ {t('charts.requestCacheHitRate.totalRate')} +
+
+ {formatRate(summary.total.totalRate)} +
-
{t('charts.requestCacheHitRate.trailing7Rate')}
-
{formatRate(summary.total.trailing7Rate)}
+
+ {t('charts.requestCacheHitRate.trailing7Rate')} +
+
+ {formatRate(summary.total.trailing7Rate)} +
-
{t('charts.requestCacheHitRate.topModel')}
+
+ {t('charts.requestCacheHitRate.topModel')} +
{summary.topModel?.model ?? '–'}
-
{t('charts.requestCacheHitRate.models')}
+
+ {t('charts.requestCacheHitRate.models')} +
{summary.models}
-
+
{t('charts.requestCacheHitRate.timelineHeading', { unit: periodUnit(viewMode) })} @@ -184,7 +255,10 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {(animate) => ( - + @@ -192,18 +266,36 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode - - - + + + formatRate(value)} pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate')]} showComputedTotal={false} hideZeroValues /> - )} + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -215,7 +307,12 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode name={t('charts.requestCacheHitRate.totalRate')} strokeWidth={2} dot={false} - activeDot={{ r: 5, strokeWidth: 2, stroke: CHART_COLORS.cost, fill: 'hsl(var(--background))' }} + activeDot={{ + r: 5, + strokeWidth: 2, + stroke: CHART_COLORS.cost, + fill: 'hsl(var(--background))', + }} isAnimationActive={animate} animationDuration={CHART_ANIMATION.duration} animationEasing={CHART_ANIMATION.easing} @@ -263,15 +360,23 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode
{(animate) => ( - - - - + + + + - + formatRate(value)} - pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate'), t('charts.requestCacheHitRate.trailing7Rate')]} + pinnedEntryNames={[ + t('charts.requestCacheHitRate.totalRate'), + t('charts.requestCacheHitRate.trailing7Rate'), + ]} showComputedTotal={false} /> - )} + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -313,7 +421,11 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {barData.map((entry) => ( ))} @@ -331,7 +443,11 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {barData.map((entry) => ( ))} diff --git a/src/components/charts/RequestsOverTime.tsx b/src/components/charts/RequestsOverTime.tsx index b199ed0..f6696a8 100644 --- a/src/components/charts/RequestsOverTime.tsx +++ b/src/components/charts/RequestsOverTime.tsx @@ -1,6 +1,19 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, PieChart, Pie, Cell } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + PieChart, + Pie, + Cell, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -24,7 +37,13 @@ function formatRequests(value: number) { }).format(value) } -function RequestCenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; total: string }) { +function RequestCenterLabel({ + viewBox, + total, +}: { + viewBox?: { cx: number; cy: number } + total: string +}) { const { t } = useTranslation() if (!viewBox) return null const { cx, cy } = viewBox @@ -34,7 +53,14 @@ function RequestCenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: nu {t('charts.requestsOverTime.total')} - + {total} @@ -45,8 +71,14 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque const { t } = useTranslation() const uid = useId().replace(/:/g, '') const averageLabel = t('charts.requestsOverTime.averagePerUnit', { unit: periodUnit(viewMode) }) - const trendLabel = viewMode === 'daily' ? t('charts.requestsOverTime.movingAverage') : t('charts.requestsOverTime.trend') - const trendHeading = viewMode === 'daily' ? t('charts.requestsOverTime.movingAverageHeading') : t('charts.requestsOverTime.trendHeading') + const trendLabel = + viewMode === 'daily' + ? t('charts.requestsOverTime.movingAverage') + : t('charts.requestsOverTime.trend') + const trendHeading = + viewMode === 'daily' + ? t('charts.requestsOverTime.movingAverageHeading') + : t('charts.requestsOverTime.trendHeading') const summary = useMemo(() => { if (data.length === 0) return null @@ -61,15 +93,15 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque key === 'totalRequestsPrev' || key.endsWith('_ma7') || key.endsWith('Prev') - ) continue + ) + continue if (typeof value === 'number') { modelTotals.set(key, (modelTotals.get(key) ?? 0) + value) } } } - const topModels = Array.from(modelTotals.entries()) - .sort((a, b) => b[1] - a[1]) + const topModels = Array.from(modelTotals.entries()).sort((a, b) => b[1] - a[1]) const totalRequests = data.reduce((sum, point) => sum + point.totalRequests, 0) const peak = [...data].sort((a, b) => b.totalRequests - a.totalRequests)[0] @@ -103,14 +135,31 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque {(animate) => (
-
{trendHeading}
+
+ {trendHeading} +
- - - formatRequests(v)} />} cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> + + + formatRequests(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} + />
-
{t('charts.requestsOverTime.requestsByModelTotal')}
+
+ {t('charts.requestsOverTime.requestsByModelTotal')} +
{(summary?.topModels ?? []).map(([model, total]) => { - const share = summary && summary.totalRequests > 0 ? (total / summary.totalRequests) * 100 : 0 + const share = + summary && summary.totalRequests > 0 ? (total / summary.totalRequests) * 100 : 0 return (
- +
{model}
{share.toFixed(1)}%
-
{formatRequests(total)}
-
{t('charts.requestsOverTime.requestsInRange')}
+
+ {formatRequests(total)} +
+
+ {t('charts.requestsOverTime.requestsInRange')} +
) })} @@ -175,7 +234,15 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque return ( : undefined} chartData={data as unknown as Record[]} @@ -194,26 +261,46 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque <>
-
{t('charts.requestsOverTime.total')}
-
{summary ? formatRequests(summary.totalRequests) : '0'}
+
+ {t('charts.requestsOverTime.total')} +
+
+ {summary ? formatRequests(summary.totalRequests) : '0'} +
-
{averageLabel}
-
{summary && data.length > 0 ? formatRequests(summary.totalRequests / data.length) : '0'}
+
+ {averageLabel} +
+
+ {summary && data.length > 0 + ? formatRequests(summary.totalRequests / data.length) + : '0'} +
-
{t('charts.requestsOverTime.topModel')}
-
{summary?.topModels[0]?.[0] ?? '–'}
+
+ {t('charts.requestsOverTime.topModel')} +
+
+ {summary?.topModels[0]?.[0] ?? '–'} +
-
{t('charts.requestsOverTime.topShare')}
+
+ {t('charts.requestsOverTime.topShare')} +
- {summary && summary.totalRequests > 0 && summary.topModels[0] ? `${((summary.topModels[0][1] / summary.totalRequests) * 100).toFixed(1)}%` : '–'} + {summary && summary.totalRequests > 0 && summary.topModels[0] + ? `${((summary.topModels[0][1] / summary.totalRequests) * 100).toFixed(1)}%` + : '–'}
-
+
{(animate) => ( @@ -222,15 +309,47 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque - - + + - - - + + + formatRequests(v)} pinnedEntryNames={[t('charts.requestsOverTime.totalRequestsSeries')]} showComputedTotal={false} />} + content={ + formatRequests(v)} + pinnedEntryNames={[ + t('charts.requestsOverTime.totalRequestsSeries'), + ]} + showComputedTotal={false} + /> + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -242,7 +361,12 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque name={t('charts.requestsOverTime.totalRequestsSeries')} strokeWidth={1.8} dot={false} - activeDot={{ r: 5, strokeWidth: 2, stroke: CHART_COLORS.cumulative, fill: 'hsl(var(--background))' }} + activeDot={{ + r: 5, + strokeWidth: 2, + stroke: CHART_COLORS.cumulative, + fill: 'hsl(var(--background))', + }} isAnimationActive={animate} animationDuration={CHART_ANIMATION.duration} /> @@ -303,14 +427,25 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque {donutData.map((entry) => ( ))} - + - formatRequests(v)} />} /> + formatRequests(v)} />} + /> { - const entry = donutData.find(d => d.name === value) - return {value} ({entry ? formatRequests(entry.value) : ''}) + const entry = donutData.find((d) => d.name === value) + return ( + + {value} ({entry ? formatRequests(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/TokenEfficiency.tsx b/src/components/charts/TokenEfficiency.tsx index e961784..21f3ee5 100644 --- a/src/components/charts/TokenEfficiency.tsx +++ b/src/components/charts/TokenEfficiency.tsx @@ -1,6 +1,16 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -19,13 +29,11 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { const { chartData, avg } = useMemo(() => { const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) - const effValues = sorted.map(d => - d.totalTokens > 0 ? d.totalCost / (d.totalTokens / 1_000_000) : 0 + const effValues = sorted.map((d) => + d.totalTokens > 0 ? d.totalCost / (d.totalTokens / 1_000_000) : 0, ) const ma7 = computeMovingAverage(effValues) - const avg = effValues.length > 0 - ? effValues.reduce((s, v) => s + v, 0) / effValues.length - : 0 + const avg = effValues.length > 0 ? effValues.reduce((s, v) => s + v, 0) / effValues.length : 0 return { chartData: sorted.map((d, i) => ({ @@ -53,41 +61,61 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { - - - - - - - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - + + + + + + + + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + diff --git a/src/components/charts/TokenTypes.tsx b/src/components/charts/TokenTypes.tsx index 1629a3e..5c599a0 100644 --- a/src/components/charts/TokenTypes.tsx +++ b/src/components/charts/TokenTypes.tsx @@ -7,11 +7,11 @@ import { formatTokens } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' const TOKEN_COLORS: Record = { - 'Input': CHART_COLORS.input, - 'Output': CHART_COLORS.output, + Input: CHART_COLORS.input, + Output: CHART_COLORS.output, 'Cache Write': CHART_COLORS.cacheWrite, 'Cache Read': CHART_COLORS.cacheRead, - 'Thinking': CHART_COLORS.cost, + Thinking: CHART_COLORS.cost, } interface TokenTypesProps { @@ -27,7 +27,14 @@ function CenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; {t('charts.tokenTypes.total')} - + {total} @@ -39,7 +46,14 @@ export function TokenTypes({ data }: TokenTypesProps) { const total = data.reduce((sum, d) => sum + d.value, 0) return ( - []} valueKey="value" valueFormatter={formatTokens}> + []} + valueKey="value" + valueFormatter={formatTokens} + > {(expanded) => { const chartHeight = expanded ? 560 : 320 const pieCenterY = expanded ? '66%' : '57%' @@ -67,7 +81,10 @@ export function TokenTypes({ data }: TokenTypesProps) { animationEasing={CHART_ANIMATION.easing} > {data.map((entry) => ( - + ))} @@ -75,8 +92,12 @@ export function TokenTypes({ data }: TokenTypesProps) { { - const entry = data.find(d => d.name === value) - return {value} ({entry ? formatTokens(entry.value) : ''}) + const entry = data.find((d) => d.name === value) + return ( + + {value} ({entry ? formatTokens(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/TokensOverTime.tsx b/src/components/charts/TokensOverTime.tsx index 6a0a1fa..e00f656 100644 --- a/src/components/charts/TokensOverTime.tsx +++ b/src/components/charts/TokensOverTime.tsx @@ -1,6 +1,15 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Line } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Line, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -20,7 +29,11 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { const gid = (name: string) => `${uid}-${name}`.replace(/:/g, '') const totals = useMemo(() => { - let cacheRead = 0, cacheWrite = 0, input = 0, output = 0, thinking = 0 + let cacheRead = 0, + cacheWrite = 0, + input = 0, + output = 0, + thinking = 0 for (const d of data) { cacheRead += d['Cache Read'] cacheWrite += d['Cache Write'] @@ -28,22 +41,34 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { output += d.Output thinking += d.Thinking } - return { cacheRead, cacheWrite, input, output, thinking, total: cacheRead + cacheWrite + input + output + thinking } + return { + cacheRead, + cacheWrite, + input, + output, + thinking, + total: cacheRead + cacheWrite + input + output + thinking, + } }, [data]) // Total tokens per day for the expanded extra chart - const totalPerDay = useMemo(() => - data.map((d, i) => ({ - date: d.date, - total: d.Input + d.Output + d['Cache Write'] + d['Cache Read'] + d.Thinking, - totalPrev: (() => { - const previousDay = i > 0 ? data[i - 1] : undefined - return previousDay - ? previousDay.Input + previousDay.Output + previousDay['Cache Write'] + previousDay['Cache Read'] + previousDay.Thinking - : undefined - })(), - tokenMA7: d.tokenMA7, - })), + const totalPerDay = useMemo( + () => + data.map((d, i) => ({ + date: d.date, + total: d.Input + d.Output + d['Cache Write'] + d['Cache Read'] + d.Thinking, + totalPrev: (() => { + const previousDay = i > 0 ? data[i - 1] : undefined + return previousDay + ? previousDay.Input + + previousDay.Output + + previousDay['Cache Write'] + + previousDay['Cache Read'] + + previousDay.Thinking + : undefined + })(), + tokenMA7: d.tokenMA7, + })), [data], ) @@ -58,22 +83,61 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {(animate) => (
-
{t('charts.tokensOverTime.allTypes')}
+
+ {t('charts.tokensOverTime.allTypes')} +
- - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + @@ -96,16 +160,22 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { > {/* Summary row with totals per type */}
- {([ - { label: 'Cache Read', value: totals.cacheRead, color: CHART_COLORS.cacheRead }, - { label: 'Cache Write', value: totals.cacheWrite, color: CHART_COLORS.cacheWrite }, - { label: 'Output', value: totals.output, color: CHART_COLORS.output }, - { label: 'Input', value: totals.input, color: CHART_COLORS.input }, - { label: 'Thinking', value: totals.thinking, color: CHART_COLORS.cost }, - ] as const).map(item => ( + {( + [ + { label: 'Cache Read', value: totals.cacheRead, color: CHART_COLORS.cacheRead }, + { label: 'Cache Write', value: totals.cacheWrite, color: CHART_COLORS.cacheWrite }, + { label: 'Output', value: totals.output, color: CHART_COLORS.output }, + { label: 'Input', value: totals.input, color: CHART_COLORS.input }, + { label: 'Thinking', value: totals.thinking, color: CHART_COLORS.cost }, + ] as const + ).map((item) => (
-
{item.label}
-
{formatTokens(item.value)}
+
+ {item.label} +
+
+ {formatTokens(item.value)} +
{totals.total > 0 ? `${((item.value / totals.total) * 100).toFixed(1)}%` : '–'}
@@ -115,30 +185,95 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {/* Chart 1: Cache Tokens (large scale) with per-type MA7 */}
-
{t('charts.tokensOverTime.cacheTokens')}
+
+ {t('charts.tokensOverTime.cacheTokens')} +
{(animate) => ( - - - - - - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - - + + + + + + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + + @@ -148,30 +283,90 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {/* Chart 2: I/O Tokens (small scale) with per-type MA7 */}
-
{t('charts.tokensOverTime.inputOutputTokens')}
+
+ {t('charts.tokensOverTime.inputOutputTokens')} +
{(animate) => ( - - - - - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - - + + + + + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + + @@ -180,24 +375,64 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) {
-
{t('charts.tokensOverTime.thinkingTokens')}
+
+ {t('charts.tokensOverTime.thinkingTokens')} +
{(animate) => ( - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + diff --git a/src/components/features/animations/FadeIn.tsx b/src/components/features/animations/FadeIn.tsx index c801065..29cc150 100644 --- a/src/components/features/animations/FadeIn.tsx +++ b/src/components/features/animations/FadeIn.tsx @@ -9,7 +9,13 @@ interface FadeInProps { direction?: 'up' | 'down' | 'left' | 'right' | 'none' } -export function FadeIn({ children, delay = 0, duration = 0.5, className, direction = 'up' }: FadeInProps) { +export function FadeIn({ + children, + delay = 0, + duration = 0.5, + className, + direction = 'up', +}: FadeInProps) { const offsets = { up: { y: 20 }, down: { y: -20 }, diff --git a/src/components/features/anomaly/AnomalyDetection.tsx b/src/components/features/anomaly/AnomalyDetection.tsx index 0a073a2..c47b191 100644 --- a/src/components/features/anomaly/AnomalyDetection.tsx +++ b/src/components/features/anomaly/AnomalyDetection.tsx @@ -19,7 +19,7 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma const { t } = useTranslation() const { anomalies, mean, stdDev } = useMemo(() => { if (data.length < 3) return { anomalies: [], mean: 0, stdDev: 0 } - const costs = data.map(d => d.totalCost) + const costs = data.map((d) => d.totalCost) const m = costs.reduce((s, v) => s + v, 0) / costs.length const sd = Math.sqrt(costs.reduce((s, v) => s + (v - m) ** 2, 0) / costs.length) return { anomalies: computeAnomalies(data), mean: m, stdDev: sd } @@ -55,12 +55,16 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma

- {t('anomaly.description', { period: periodLabel(viewMode, true), mean: formatCurrency(mean), stdDev: formatCurrency(stdDev) })} + {t('anomaly.description', { + period: periodLabel(viewMode, true), + mean: formatCurrency(mean), + stdDev: formatCurrency(stdDev), + })}

{anomalies .sort((a, b) => b.totalCost - a.totalCost) - .map(day => { + .map((day) => { const zScoreNum = stdDev > 0 ? (day.totalCost - mean) / stdDev : 0 const zScore = zScoreNum.toFixed(1) const isHigh = day.totalCost > mean @@ -76,22 +80,31 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma onClick={() => onClickDay?.(day.date)} >
-
+
{formatDate(day.date, 'long')} {severity === 'critical' && ( - {t('anomaly.critical')} + + {t('anomaly.critical')} + )}
- + {formatCurrency(day.totalCost)} - - {isHigh ? '+' : ''}{zScore}σ + + {isHigh ? '+' : ''} + {zScore}σ
diff --git a/src/components/features/auto-import/AutoImportModal.tsx b/src/components/features/auto-import/AutoImportModal.tsx index 8810df2..654194c 100644 --- a/src/components/features/auto-import/AutoImportModal.tsx +++ b/src/components/features/auto-import/AutoImportModal.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { LoaderCircle, CheckCircle2, XCircle, Terminal } from 'lucide-react' import { startAutoImport } from '@/lib/auto-import' @@ -37,7 +43,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const closeRef = useRef<{ close: () => void } | null>(null) const addLine = useCallback((type: LineType, text: string) => { - setLines(prev => [...prev, { type, text }]) + setLines((prev) => [...prev, { type, text }]) }, []) const autoImportTranslator = useCallback( (key: string, vars?: Record) => (vars ? t(key, vars) : t(key)), @@ -51,37 +57,50 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod setLines([]) setSummary(null) - const handle = startAutoImport({ - onCheck: (data: CheckEvent) => { - if (data.status === 'checking') { - addLine('check', t('autoImportModal.checkingTool', { tool: data.tool })) - } else if (data.status === 'found') { - addLine('check', t('autoImportModal.toolFound', { tool: data.tool, method: data.method, version: data.version })) - setStatus('running') - } else if (data.status === 'not_found') { - addLine('check', t('autoImportModal.toolNotFound', { tool: data.tool })) - } + const handle = startAutoImport( + { + onCheck: (data: CheckEvent) => { + if (data.status === 'checking') { + addLine('check', t('autoImportModal.checkingTool', { tool: data.tool })) + } else if (data.status === 'found') { + addLine( + 'check', + t('autoImportModal.toolFound', { + tool: data.tool, + method: data.method, + version: data.version, + }), + ) + setStatus('running') + } else if (data.status === 'not_found') { + addLine('check', t('autoImportModal.toolNotFound', { tool: data.tool })) + } + }, + onProgress: (data) => { + addLine('progress', data.message) + }, + onStderr: (data) => { + addLine('stderr', data.line) + }, + onSuccess: (data: SuccessEvent) => { + addLine( + 'success', + t('autoImportModal.importedDays', { days: data.days, cost: data.totalCost.toFixed(2) }), + ) + setSummary(data) + setStatus('success') + onSuccess() + }, + onError: (data) => { + addLine('error', data.message) + setStatus('error') + }, + onDone: () => { + closeRef.current = null + }, }, - onProgress: (data) => { - addLine('progress', data.message) - }, - onStderr: (data) => { - addLine('stderr', data.line) - }, - onSuccess: (data: SuccessEvent) => { - addLine('success', t('autoImportModal.importedDays', { days: data.days, cost: data.totalCost.toFixed(2) })) - setSummary(data) - setStatus('success') - onSuccess() - }, - onError: (data) => { - addLine('error', data.message) - setStatus('error') - }, - onDone: () => { - closeRef.current = null - }, - }, autoImportTranslator) + autoImportTranslator, + ) closeRef.current = handle @@ -101,16 +120,24 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const isRunning = status === 'checking' || status === 'running' return ( - { if (!isRunning) onOpenChange(v) }}> - { if (isRunning) e.preventDefault() }}> + { + if (!isRunning) onOpenChange(v) + }} + > + { + if (isRunning) e.preventDefault() + }} + > {t('autoImportModal.title')} - - {t('autoImportModal.description')} - + {t('autoImportModal.description')} {/* Terminal output */} @@ -137,7 +164,9 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod <> - {status === 'checking' ? t('autoImportModal.checkingPrerequisites') : t('autoImportModal.importingData')} + {status === 'checking' + ? t('autoImportModal.checkingPrerequisites') + : t('autoImportModal.importingData')} )} @@ -145,7 +174,10 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod <> - {t('autoImportModal.loadedDays', { days: summary.days, cost: summary.totalCost.toFixed(2) })} + {t('autoImportModal.loadedDays', { + days: summary.days, + cost: summary.totalCost.toFixed(2), + })} )} @@ -158,7 +190,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod
{!isRunning && ( - )} diff --git a/src/components/features/cache-roi/CacheROI.tsx b/src/components/features/cache-roi/CacheROI.tsx index 4face90..2fee727 100644 --- a/src/components/features/cache-roi/CacheROI.tsx +++ b/src/components/features/cache-roi/CacheROI.tsx @@ -18,37 +18,45 @@ interface CacheROIProps { export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) { const { t } = useTranslation() - const { actualCost, hypotheticalCost, savings, savingsPercent, dailyAvg, heuristicModels } = useMemo(() => { - let actual = 0 - let hypothetical = 0 - const heuristicModels = new Set() + const { actualCost, hypotheticalCost, savings, savingsPercent, dailyAvg, heuristicModels } = + useMemo(() => { + let actual = 0 + let hypothetical = 0 + const heuristicModels = new Set() - for (const d of data) { - actual += d.totalCost + for (const d of data) { + actual += d.totalCost - for (const mb of d.modelBreakdowns) { - const name = normalizeModelName(mb.modelName) - const prices = MODEL_PRICES[name] - if (!prices) { - // If no pricing info, assume cache read saves ~90% vs input - heuristicModels.add(name) - hypothetical += mb.cost + (mb.cacheReadTokens / 1_000_000) * 10 - continue + for (const mb of d.modelBreakdowns) { + const name = normalizeModelName(mb.modelName) + const prices = MODEL_PRICES[name] + if (!prices) { + // If no pricing info, assume cache read saves ~90% vs input + heuristicModels.add(name) + hypothetical += mb.cost + (mb.cacheReadTokens / 1_000_000) * 10 + continue + } + // What it would have cost if cache reads were regular input tokens + const cacheReadAsInput = (mb.cacheReadTokens / 1_000_000) * prices.input + const actualCacheReadCost = (mb.cacheReadTokens / 1_000_000) * prices.cacheRead + hypothetical += mb.cost - actualCacheReadCost + cacheReadAsInput } - // What it would have cost if cache reads were regular input tokens - const cacheReadAsInput = (mb.cacheReadTokens / 1_000_000) * prices.input - const actualCacheReadCost = (mb.cacheReadTokens / 1_000_000) * prices.cacheRead - hypothetical += mb.cost - actualCacheReadCost + cacheReadAsInput } - } - const saved = hypothetical - actual - const pct = hypothetical > 0 ? (saved / hypothetical) * 100 : 0 - const totalPeriods = data.reduce((s, d) => s + (d._aggregatedDays ?? 1), 0) - const dailyAvg = totalPeriods > 0 ? actual / totalPeriods : 0 + const saved = hypothetical - actual + const pct = hypothetical > 0 ? (saved / hypothetical) * 100 : 0 + const totalPeriods = data.reduce((s, d) => s + (d._aggregatedDays ?? 1), 0) + const dailyAvg = totalPeriods > 0 ? actual / totalPeriods : 0 - return { actualCost: actual, hypotheticalCost: hypothetical, savings: saved, savingsPercent: pct, dailyAvg, heuristicModels: Array.from(heuristicModels).sort() } - }, [data]) + return { + actualCost: actual, + hypotheticalCost: hypothetical, + savings: saved, + savingsPercent: pct, + dailyAvg, + heuristicModels: Array.from(heuristicModels).sort(), + } + }, [data]) if (data.length === 0) { return ( @@ -82,18 +90,23 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.heuristicFallback', { count: heuristicModels.length, - modelsLabel: heuristicModels.length === 1 ? t('cacheRoi.model') : t('cacheRoi.models'), + modelsLabel: + heuristicModels.length === 1 ? t('cacheRoi.model') : t('cacheRoi.models'), })}
)}
{t('cacheRoi.withoutCache')}
-
+
+ +
{t('cacheRoi.withCacheActual')}
-
+
+ +
{t('cacheRoi.savings')}
@@ -103,7 +116,9 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
-
{t('cacheRoi.avgCostPerUnit', { unit: periodUnit(viewMode) })}
+
+ {t('cacheRoi.avgCostPerUnit', { unit: periodUnit(viewMode) })} +
@@ -121,13 +136,21 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.withCache')}
-
+
- {t('cacheRoi.paid')} - {t('cacheRoi.saved')} + + {t('cacheRoi.paid')} + + + {' '} + {t('cacheRoi.saved')} +
diff --git a/src/components/features/command-palette/CommandPalette.tsx b/src/components/features/command-palette/CommandPalette.tsx index a5a1e5c..e21a3dd 100644 --- a/src/components/features/command-palette/CommandPalette.tsx +++ b/src/components/features/command-palette/CommandPalette.tsx @@ -3,12 +3,37 @@ import { useTranslation } from 'react-i18next' import { Command } from 'cmdk' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { - Download, Trash2, Upload, Sun, Moon, Calendar, ChartBar, - Table, Search, ArrowUp, CircleHelp, Zap, Filter, BarChart3, - LineChart, Sigma, CalendarRange, Layers3, ArrowDown, RefreshCcw, SlidersHorizontal, Languages + Download, + Trash2, + Upload, + Sun, + Moon, + Calendar, + ChartBar, + Table, + Search, + ArrowUp, + CircleHelp, + Zap, + Filter, + BarChart3, + LineChart, + Sigma, + CalendarRange, + Layers3, + ArrowDown, + RefreshCcw, + SlidersHorizontal, + Languages, } from 'lucide-react' import { DASHBOARD_SECTION_DEFINITION_MAP } from '@/lib/dashboard-preferences' -import type { AppLanguage, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ViewMode } from '@/types' +import type { + AppLanguage, + DashboardSectionId, + DashboardSectionOrder, + DashboardSectionVisibility, + ViewMode, +} from '@/types' interface CommandPaletteProps { isDark: boolean @@ -103,12 +128,11 @@ function normalizeSearchValue(value: string) { } function getCommandSearchText(cmd: CommandItem) { - return normalizeSearchValue([ - cmd.label, - cmd.description, - ...(cmd.keywords ?? []), - ...(cmd.aliases ?? []), - ].filter(Boolean).join(' ')) + return normalizeSearchValue( + [cmd.label, cmd.description, ...(cmd.keywords ?? []), ...(cmd.aliases ?? [])] + .filter(Boolean) + .join(' '), + ) } function getCommandSearchScore(cmd: CommandItem, query: string) { @@ -136,12 +160,12 @@ function getCommandSearchScore(cmd: CommandItem, query: string) { continue } - if ((cmd.aliases ?? []).some(alias => normalizeSearchValue(alias) === term)) { + if ((cmd.aliases ?? []).some((alias) => normalizeSearchValue(alias) === term)) { score += 70 continue } - if ((cmd.keywords ?? []).some(keyword => normalizeSearchValue(keyword).startsWith(term))) { + if ((cmd.keywords ?? []).some((keyword) => normalizeSearchValue(keyword).startsWith(term))) { score += 55 continue } @@ -192,158 +216,409 @@ export function CommandPalette({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') - const sectionAvailability = useMemo>(() => ({ - insights: true, - metrics: true, - today: hasTodaySection, - currentMonth: hasMonthSection, - activity: true, - forecastCache: true, - limits: true, - costAnalysis: true, - tokenAnalysis: true, - requestAnalysis: hasRequestSection, - advancedAnalysis: true, - comparisons: true, - tables: true, - }), [hasMonthSection, hasRequestSection, hasTodaySection]) + const sectionAvailability = useMemo>( + () => ({ + insights: true, + metrics: true, + today: hasTodaySection, + currentMonth: hasMonthSection, + activity: true, + forecastCache: true, + limits: true, + costAnalysis: true, + tokenAnalysis: true, + requestAnalysis: hasRequestSection, + advancedAnalysis: true, + comparisons: true, + tables: true, + }), + [hasMonthSection, hasRequestSection, hasTodaySection], + ) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() - setOpen(prev => !prev) + setOpen((prev) => !prev) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, []) - const sectionNavigationCommands = useMemo(() => ( - sectionOrder.flatMap((sectionId) => { - const section = DASHBOARD_SECTION_DEFINITION_MAP[sectionId] - - if (!sectionVisibility[sectionId] || !sectionAvailability[sectionId]) { - return [] - } - - const sectionLabel = t(section.labelKey) + const sectionNavigationCommands = useMemo( + () => + sectionOrder.flatMap((sectionId) => { + const section = DASHBOARD_SECTION_DEFINITION_MAP[sectionId] + + if (!sectionVisibility[sectionId] || !sectionAvailability[sectionId]) { + return [] + } + + const sectionLabel = t(section.labelKey) + + return [ + { + id: `section-${section.id}`, + label: t('commandPalette.commands.goToSection.label', { section: sectionLabel }), + description: t('commandPalette.commands.goToSection.description', { + section: sectionLabel, + }), + keywords: [sectionLabel, section.domId, ...SECTION_COMMAND_KEYWORDS[section.id]], + icon: SECTION_COMMAND_ICON_MAP[section.id], + action: () => onScrollTo(section.domId), + group: t('commandPalette.groups.navigation'), + testId: `command-section-${section.id}`, + ...(SECTION_COMMAND_ALIASES[section.id] + ? { aliases: SECTION_COMMAND_ALIASES[section.id] } + : {}), + }, + ] + }), + [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t], + ) - return [{ - id: `section-${section.id}`, - label: t('commandPalette.commands.goToSection.label', { section: sectionLabel }), - description: t('commandPalette.commands.goToSection.description', { section: sectionLabel }), - keywords: [sectionLabel, section.domId, ...SECTION_COMMAND_KEYWORDS[section.id]], - icon: SECTION_COMMAND_ICON_MAP[section.id], - action: () => onScrollTo(section.domId), + const baseCommands = useMemo( + () => [ + { + id: 'auto-import', + label: t('commandPalette.commands.autoImport.label'), + description: t('commandPalette.commands.autoImport.description'), + keywords: ['toktrack', 'import', 'load', 'sync'], + aliases: ['auto import', 'daten importieren'], + icon: , + action: onAutoImport, + group: t('commandPalette.groups.actions'), + }, + { + id: 'settings-open', + label: t('commandPalette.commands.openSettings.label'), + description: t('commandPalette.commands.openSettings.description'), + keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], + aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], + icon: , + action: onOpenSettings, + group: t('commandPalette.groups.actions'), + }, + { + id: 'csv', + label: t('commandPalette.commands.exportCsv.label'), + description: t('commandPalette.commands.exportCsv.description'), + keywords: ['download', 'export', 'csv'], + aliases: ['csv download', 'daten exportieren'], + shortcut: '⌘E', + icon: , + action: onExportCSV, + group: t('commandPalette.groups.actions'), + }, + { + id: 'report', + label: reportGenerating + ? t('commandPalette.commands.generateReport.labelLoading') + : t('commandPalette.commands.generateReport.label'), + description: t('commandPalette.commands.generateReport.description'), + keywords: ['pdf', 'report', 'bericht', 'export'], + aliases: ['report export', 'pdf export', 'bericht generieren'], + icon: , + action: onGenerateReport, + group: t('commandPalette.groups.actions'), + }, + { + id: 'upload', + label: t('commandPalette.commands.upload.label'), + description: t('commandPalette.commands.upload.description'), + keywords: ['upload', 'file', 'json', 'import'], + aliases: ['datei laden', 'json import'], + shortcut: '⌘U', + icon: , + action: onUpload, + group: t('commandPalette.groups.actions'), + }, + { + id: 'delete', + label: t('commandPalette.commands.delete.label'), + description: t('commandPalette.commands.delete.description'), + keywords: ['reset data', 'clear data', 'delete'], + aliases: ['daten reset', 'alles loeschen'], + icon: , + action: onDelete, + group: t('commandPalette.groups.actions'), + }, + + { + id: 'view-daily', + label: t('commandPalette.commands.viewDaily.label'), + description: t('commandPalette.commands.viewDaily.description'), + keywords: ['daily', 'tage', 'tag', 'tagesansicht'], + aliases: ['daily view'], + icon: , + action: () => onViewModeChange('daily'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'view-monthly', + label: t('commandPalette.commands.viewMonthly.label'), + description: t('commandPalette.commands.viewMonthly.description'), + keywords: ['monthly', 'monate', 'monat', 'monatsansicht'], + aliases: ['monthly view'], + icon: , + action: () => onViewModeChange('monthly'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'view-yearly', + label: t('commandPalette.commands.viewYearly.label'), + description: t('commandPalette.commands.viewYearly.description'), + keywords: ['yearly', 'jahre', 'jahr', 'jahresansicht'], + aliases: ['yearly view'], + icon: , + action: () => onViewModeChange('yearly'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-7d', + label: t('commandPalette.commands.preset7d.label'), + description: t('commandPalette.commands.preset7d.description'), + keywords: ['7d', '7 tage'], + icon: , + action: () => onApplyPreset('7d'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-30d', + label: t('commandPalette.commands.preset30d.label'), + description: t('commandPalette.commands.preset30d.description'), + keywords: ['30d', '30 tage'], + icon: , + action: () => onApplyPreset('30d'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-month', + label: t('commandPalette.commands.presetMonth.label'), + description: t('commandPalette.commands.presetMonth.description'), + keywords: ['current month', 'monat'], + icon: , + action: () => onApplyPreset('month'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-year', + label: t('commandPalette.commands.presetYear.label'), + description: t('commandPalette.commands.presetYear.description'), + keywords: ['current year', 'jahr'], + icon: , + action: () => onApplyPreset('year'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-all', + label: t('commandPalette.commands.presetAll.label'), + description: t('commandPalette.commands.presetAll.description'), + keywords: ['all', 'alles'], + icon: , + action: () => onApplyPreset('all'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-providers', + label: t('commandPalette.commands.clearProviders.label'), + description: t('commandPalette.commands.clearProviders.description'), + keywords: ['provider', 'anbieter', 'clear'], + icon: , + action: onClearProviders, + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-models', + label: t('commandPalette.commands.clearModels.label'), + description: t('commandPalette.commands.clearModels.description'), + keywords: ['models', 'modelle', 'clear'], + icon: , + action: onClearModels, + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-dates', + label: t('commandPalette.commands.clearDates.label'), + description: t('commandPalette.commands.clearDates.description'), + keywords: ['date', 'datum', 'range', 'clear'], + icon: , + action: onClearDateRange, + group: t('commandPalette.groups.filters'), + }, + { + id: 'reset-all', + label: t('commandPalette.commands.resetAll.label'), + description: t('commandPalette.commands.resetAll.description'), + keywords: ['reset all', 'alles zurücksetzen', 'default', 'clear filters'], + aliases: ['reset dashboard', 'alles reset', 'filter reset'], + icon: , + action: onResetAll, + group: t('commandPalette.groups.filters'), + }, + + { + id: 'top', + label: t('commandPalette.commands.scrollTop.label'), + description: t('commandPalette.commands.scrollTop.description'), + keywords: ['top', 'start', 'anfang'], + shortcut: '⌘↑', + icon: , + action: () => window.scrollTo({ top: 0, behavior: 'smooth' }), group: t('commandPalette.groups.navigation'), - testId: `command-section-${section.id}`, - ...(SECTION_COMMAND_ALIASES[section.id] ? { aliases: SECTION_COMMAND_ALIASES[section.id] } : {}), - }] - }) - ), [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t]) - - const baseCommands = useMemo(() => [ - { id: 'auto-import', label: t('commandPalette.commands.autoImport.label'), description: t('commandPalette.commands.autoImport.description'), keywords: ['toktrack', 'import', 'load', 'sync'], aliases: ['auto import', 'daten importieren'], icon: , action: onAutoImport, group: t('commandPalette.groups.actions') }, - { id: 'settings-open', label: t('commandPalette.commands.openSettings.label'), description: t('commandPalette.commands.openSettings.description'), keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], icon: , action: onOpenSettings, group: t('commandPalette.groups.actions') }, - { id: 'csv', label: t('commandPalette.commands.exportCsv.label'), description: t('commandPalette.commands.exportCsv.description'), keywords: ['download', 'export', 'csv'], aliases: ['csv download', 'daten exportieren'], shortcut: '⌘E', icon: , action: onExportCSV, group: t('commandPalette.groups.actions') }, - { id: 'report', label: reportGenerating ? t('commandPalette.commands.generateReport.labelLoading') : t('commandPalette.commands.generateReport.label'), description: t('commandPalette.commands.generateReport.description'), keywords: ['pdf', 'report', 'bericht', 'export'], aliases: ['report export', 'pdf export', 'bericht generieren'], icon: , action: onGenerateReport, group: t('commandPalette.groups.actions') }, - { id: 'upload', label: t('commandPalette.commands.upload.label'), description: t('commandPalette.commands.upload.description'), keywords: ['upload', 'file', 'json', 'import'], aliases: ['datei laden', 'json import'], shortcut: '⌘U', icon: , action: onUpload, group: t('commandPalette.groups.actions') }, - { id: 'delete', label: t('commandPalette.commands.delete.label'), description: t('commandPalette.commands.delete.description'), keywords: ['reset data', 'clear data', 'delete'], aliases: ['daten reset', 'alles loeschen'], icon: , action: onDelete, group: t('commandPalette.groups.actions') }, - - { id: 'view-daily', label: t('commandPalette.commands.viewDaily.label'), description: t('commandPalette.commands.viewDaily.description'), keywords: ['daily', 'tage', 'tag', 'tagesansicht'], aliases: ['daily view'], icon: , action: () => onViewModeChange('daily'), group: t('commandPalette.groups.filters') }, - { id: 'view-monthly', label: t('commandPalette.commands.viewMonthly.label'), description: t('commandPalette.commands.viewMonthly.description'), keywords: ['monthly', 'monate', 'monat', 'monatsansicht'], aliases: ['monthly view'], icon: , action: () => onViewModeChange('monthly'), group: t('commandPalette.groups.filters') }, - { id: 'view-yearly', label: t('commandPalette.commands.viewYearly.label'), description: t('commandPalette.commands.viewYearly.description'), keywords: ['yearly', 'jahre', 'jahr', 'jahresansicht'], aliases: ['yearly view'], icon: , action: () => onViewModeChange('yearly'), group: t('commandPalette.groups.filters') }, - { id: 'preset-7d', label: t('commandPalette.commands.preset7d.label'), description: t('commandPalette.commands.preset7d.description'), keywords: ['7d', '7 tage'], icon: , action: () => onApplyPreset('7d'), group: t('commandPalette.groups.filters') }, - { id: 'preset-30d', label: t('commandPalette.commands.preset30d.label'), description: t('commandPalette.commands.preset30d.description'), keywords: ['30d', '30 tage'], icon: , action: () => onApplyPreset('30d'), group: t('commandPalette.groups.filters') }, - { id: 'preset-month', label: t('commandPalette.commands.presetMonth.label'), description: t('commandPalette.commands.presetMonth.description'), keywords: ['current month', 'monat'], icon: , action: () => onApplyPreset('month'), group: t('commandPalette.groups.filters') }, - { id: 'preset-year', label: t('commandPalette.commands.presetYear.label'), description: t('commandPalette.commands.presetYear.description'), keywords: ['current year', 'jahr'], icon: , action: () => onApplyPreset('year'), group: t('commandPalette.groups.filters') }, - { id: 'preset-all', label: t('commandPalette.commands.presetAll.label'), description: t('commandPalette.commands.presetAll.description'), keywords: ['all', 'alles'], icon: , action: () => onApplyPreset('all'), group: t('commandPalette.groups.filters') }, - { id: 'clear-providers', label: t('commandPalette.commands.clearProviders.label'), description: t('commandPalette.commands.clearProviders.description'), keywords: ['provider', 'anbieter', 'clear'], icon: , action: onClearProviders, group: t('commandPalette.groups.filters') }, - { id: 'clear-models', label: t('commandPalette.commands.clearModels.label'), description: t('commandPalette.commands.clearModels.description'), keywords: ['models', 'modelle', 'clear'], icon: , action: onClearModels, group: t('commandPalette.groups.filters') }, - { id: 'clear-dates', label: t('commandPalette.commands.clearDates.label'), description: t('commandPalette.commands.clearDates.description'), keywords: ['date', 'datum', 'range', 'clear'], icon: , action: onClearDateRange, group: t('commandPalette.groups.filters') }, - { id: 'reset-all', label: t('commandPalette.commands.resetAll.label'), description: t('commandPalette.commands.resetAll.description'), keywords: ['reset all', 'alles zurücksetzen', 'default', 'clear filters'], aliases: ['reset dashboard', 'alles reset', 'filter reset'], icon: , action: onResetAll, group: t('commandPalette.groups.filters') }, - - { id: 'top', label: t('commandPalette.commands.scrollTop.label'), description: t('commandPalette.commands.scrollTop.description'), keywords: ['top', 'start', 'anfang'], shortcut: '⌘↑', icon: , action: () => window.scrollTo({ top: 0, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, - { id: 'bottom', label: t('commandPalette.commands.scrollBottom.label'), description: t('commandPalette.commands.scrollBottom.description'), keywords: ['bottom', 'ende'], icon: , action: () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, - { id: 'filters', label: t('commandPalette.commands.filters.label'), description: t('commandPalette.commands.filters.description'), keywords: ['filterbar', 'filter'], icon: , action: () => onScrollTo('filters'), group: t('commandPalette.groups.navigation') }, - ...sectionNavigationCommands, - - { id: 'theme', label: isDark ? t('commandPalette.commands.themeLight.label') : t('commandPalette.commands.themeDark.label'), description: t('commandPalette.commands.themeDark.description'), keywords: ['theme', 'dark', 'light'], shortcut: '⌘D', icon: isDark ? : , action: onToggleTheme, group: t('commandPalette.groups.view') }, - { id: 'language-de', label: t('commandPalette.commands.languageGerman.label'), description: t('commandPalette.commands.languageGerman.description'), keywords: ['language', 'sprache', 'deutsch', 'german', 'locale'], aliases: ['switch german', 'auf deutsch', 'sprache deutsch'], icon: , action: () => onLanguageChange('de'), group: t('commandPalette.groups.language') }, - { id: 'language-en', label: t('commandPalette.commands.languageEnglish.label'), description: t('commandPalette.commands.languageEnglish.description'), keywords: ['language', 'sprache', 'english', 'englisch', 'locale'], aliases: ['switch english', 'auf englisch', 'sprache english'], icon: , action: () => onLanguageChange('en'), group: t('commandPalette.groups.language') }, - { id: 'help', label: t('commandPalette.commands.help.label'), description: t('commandPalette.commands.help.description'), keywords: ['shortcut', 'hilfe'], shortcut: '?', icon: , action: onHelp, group: t('commandPalette.groups.help') }, - ], [ - isDark, - onAutoImport, - onOpenSettings, - onExportCSV, - onGenerateReport, - onUpload, - onDelete, - onViewModeChange, - onApplyPreset, - onClearProviders, - onClearModels, - onClearDateRange, - onResetAll, - onScrollTo, - sectionNavigationCommands, - reportGenerating, - onToggleTheme, - onLanguageChange, - onHelp, - t, - ]) - - const providerCommands = useMemo(() => ( - availableProviders.map(provider => { - const selected = selectedProviders.includes(provider) - return { - id: `provider-${provider}`, - label: `${selected ? t('commandPalette.commands.clearProviders.label') : t('common.provider')}: ${provider}`, - description: selected ? `${t('commandPalette.commands.clearProviders.description')}: ${provider}` : `${t('common.provider')} ${provider}`, - keywords: ['anbieter', 'provider', provider.toLowerCase()], - aliases: [`filter ${provider.toLowerCase()}`, `${provider.toLowerCase()} daten`], + }, + { + id: 'bottom', + label: t('commandPalette.commands.scrollBottom.label'), + description: t('commandPalette.commands.scrollBottom.description'), + keywords: ['bottom', 'ende'], + icon: , + action: () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }), + group: t('commandPalette.groups.navigation'), + }, + { + id: 'filters', + label: t('commandPalette.commands.filters.label'), + description: t('commandPalette.commands.filters.description'), + keywords: ['filterbar', 'filter'], icon: , - action: () => onToggleProvider(provider), - group: t('commandPalette.groups.providers'), - } - }) - ), [availableProviders, selectedProviders, onToggleProvider, t]) - - const modelCommands = useMemo(() => ( - availableModels.map(model => { - const selected = selectedModels.includes(model) - return { - id: `model-${model}`, - label: `${selected ? t('commandPalette.commands.clearModels.label') : t('common.model')}: ${model}`, - description: selected ? `${t('commandPalette.commands.clearModels.description')}: ${model}` : `${t('common.model')} ${model}`, - keywords: ['modell', 'model', model.toLowerCase()], - aliases: [`filter ${model.toLowerCase()}`, `${model.toLowerCase()} requests`, `${model.toLowerCase()} kosten`], - icon: , - action: () => onToggleModel(model), - group: t('commandPalette.groups.models'), - } - }) - ), [availableModels, selectedModels, onToggleModel, t]) - - const commands = useMemo(() => [ - ...baseCommands, - ...providerCommands, - ...modelCommands, - ], [baseCommands, providerCommands, modelCommands]) - - const filteredCommands = useMemo(() => ( - commands - .map((cmd, index) => ({ cmd, score: getCommandSearchScore(cmd, search), index })) - .filter(entry => entry.score > 0) - .sort((a, b) => b.score - a.score || a.index - b.index) - .map(entry => entry.cmd) - ), [commands, search]) + action: () => onScrollTo('filters'), + group: t('commandPalette.groups.navigation'), + }, + ...sectionNavigationCommands, + + { + id: 'theme', + label: isDark + ? t('commandPalette.commands.themeLight.label') + : t('commandPalette.commands.themeDark.label'), + description: t('commandPalette.commands.themeDark.description'), + keywords: ['theme', 'dark', 'light'], + shortcut: '⌘D', + icon: isDark ? : , + action: onToggleTheme, + group: t('commandPalette.groups.view'), + }, + { + id: 'language-de', + label: t('commandPalette.commands.languageGerman.label'), + description: t('commandPalette.commands.languageGerman.description'), + keywords: ['language', 'sprache', 'deutsch', 'german', 'locale'], + aliases: ['switch german', 'auf deutsch', 'sprache deutsch'], + icon: , + action: () => onLanguageChange('de'), + group: t('commandPalette.groups.language'), + }, + { + id: 'language-en', + label: t('commandPalette.commands.languageEnglish.label'), + description: t('commandPalette.commands.languageEnglish.description'), + keywords: ['language', 'sprache', 'english', 'englisch', 'locale'], + aliases: ['switch english', 'auf englisch', 'sprache english'], + icon: , + action: () => onLanguageChange('en'), + group: t('commandPalette.groups.language'), + }, + { + id: 'help', + label: t('commandPalette.commands.help.label'), + description: t('commandPalette.commands.help.description'), + keywords: ['shortcut', 'hilfe'], + shortcut: '?', + icon: , + action: onHelp, + group: t('commandPalette.groups.help'), + }, + ], + [ + isDark, + onAutoImport, + onOpenSettings, + onExportCSV, + onGenerateReport, + onUpload, + onDelete, + onViewModeChange, + onApplyPreset, + onClearProviders, + onClearModels, + onClearDateRange, + onResetAll, + onScrollTo, + sectionNavigationCommands, + reportGenerating, + onToggleTheme, + onLanguageChange, + onHelp, + t, + ], + ) + + const providerCommands = useMemo( + () => + availableProviders.map((provider) => { + const selected = selectedProviders.includes(provider) + return { + id: `provider-${provider}`, + label: `${selected ? t('commandPalette.commands.clearProviders.label') : t('common.provider')}: ${provider}`, + description: selected + ? `${t('commandPalette.commands.clearProviders.description')}: ${provider}` + : `${t('common.provider')} ${provider}`, + keywords: ['anbieter', 'provider', provider.toLowerCase()], + aliases: [`filter ${provider.toLowerCase()}`, `${provider.toLowerCase()} daten`], + icon: , + action: () => onToggleProvider(provider), + group: t('commandPalette.groups.providers'), + } + }), + [availableProviders, selectedProviders, onToggleProvider, t], + ) + + const modelCommands = useMemo( + () => + availableModels.map((model) => { + const selected = selectedModels.includes(model) + return { + id: `model-${model}`, + label: `${selected ? t('commandPalette.commands.clearModels.label') : t('common.model')}: ${model}`, + description: selected + ? `${t('commandPalette.commands.clearModels.description')}: ${model}` + : `${t('common.model')} ${model}`, + keywords: ['modell', 'model', model.toLowerCase()], + aliases: [ + `filter ${model.toLowerCase()}`, + `${model.toLowerCase()} requests`, + `${model.toLowerCase()} kosten`, + ], + icon: , + action: () => onToggleModel(model), + group: t('commandPalette.groups.models'), + } + }), + [availableModels, selectedModels, onToggleModel, t], + ) + + const commands = useMemo( + () => [...baseCommands, ...providerCommands, ...modelCommands], + [baseCommands, providerCommands, modelCommands], + ) + + const filteredCommands = useMemo( + () => + commands + .map((cmd, index) => ({ cmd, score: getCommandSearchScore(cmd, search), index })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.cmd), + [commands, search], + ) const visibleCommands = useMemo(() => filteredCommands.slice(0, 9), [filteredCommands]) - const groups = useMemo(() => Array.from(new Set(filteredCommands.map(c => c.group))), [filteredCommands]) + const groups = useMemo( + () => Array.from(new Set(filteredCommands.map((c) => c.group))), + [filteredCommands], + ) const runCommand = (cmd: CommandItem) => { setOpen(false) @@ -380,9 +655,7 @@ export function CommandPalette({ {t('commandPalette.title')} - - {t('commandPalette.description')} - + {t('commandPalette.description')}
@@ -397,38 +670,47 @@ export function CommandPalette({ {t('commandPalette.empty')} - {groups.map(group => ( - - {filteredCommands.filter(c => c.group === group).map(cmd => { - const quickIndex = visibleCommands.findIndex(visible => visible.id === cmd.id) - - return ( - runCommand(cmd)} - className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" - > - {cmd.icon} -
-
{cmd.label}
- {cmd.description && ( -
{cmd.description}
- )} -
- {cmd.shortcut && ( - - {cmd.shortcut} - - )} - {quickIndex >= 0 && ( - - {quickIndex + 1} - - )} -
- )})} + {groups.map((group) => ( + + {filteredCommands + .filter((c) => c.group === group) + .map((cmd) => { + const quickIndex = visibleCommands.findIndex((visible) => visible.id === cmd.id) + + return ( + runCommand(cmd)} + className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" + > + {cmd.icon} +
+
{cmd.label}
+ {cmd.description && ( +
+ {cmd.description} +
+ )} +
+ {cmd.shortcut && ( + + {cmd.shortcut} + + )} + {quickIndex >= 0 && ( + + {quickIndex + 1} + + )} +
+ ) + })}
))} diff --git a/src/components/features/comparison/PeriodComparison.tsx b/src/components/features/comparison/PeriodComparison.tsx index ba361ec..8c412e4 100644 --- a/src/components/features/comparison/PeriodComparison.tsx +++ b/src/components/features/comparison/PeriodComparison.tsx @@ -15,14 +15,23 @@ interface PeriodComparisonProps { type Preset = 'week' | 'month' | 'custom' -function getDelta(a: number, b: number, higherIsGood = false): { value: number; color: string; arrow: string; hasData: boolean } { - if (b === 0 && a === 0) return { value: 0, color: 'text-muted-foreground', arrow: '', hasData: false } +function getDelta( + a: number, + b: number, + higherIsGood = false, +): { value: number; color: string; arrow: string; hasData: boolean } { + if (b === 0 && a === 0) + return { value: 0, color: 'text-muted-foreground', arrow: '', hasData: false } if (b === 0) return { value: 0, color: 'text-muted-foreground', arrow: '↑', hasData: false } const pct = ((a - b) / b) * 100 const isPositive = pct > 0 // For costs: higher is bad (red). For cache-rate: higher is good (green). - const color = pct === 0 ? 'text-muted-foreground' - : (isPositive === higherIsGood) ? 'text-green-400' : 'text-red-400' + const color = + pct === 0 + ? 'text-muted-foreground' + : isPositive === higherIsGood + ? 'text-green-400' + : 'text-red-400' const arrow = pct > 0 ? '↑' : pct < 0 ? '↓' : '' return { value: Math.abs(pct), color, arrow, hasData: true } } @@ -67,8 +76,8 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { const twoWeeksAgoStr = fmtLocal(lastMonday) return { - periodA: sorted.filter(d => d.date >= weekAgoStr && d.date <= lastStr), - periodB: sorted.filter(d => d.date >= twoWeeksAgoStr && d.date < weekAgoStr), + periodA: sorted.filter((d) => d.date >= weekAgoStr && d.date <= lastStr), + periodB: sorted.filter((d) => d.date >= twoWeeksAgoStr && d.date < weekAgoStr), labelA: t('comparison.thisWeek'), labelB: t('comparison.lastWeek'), } @@ -82,8 +91,8 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { const prevMonth = fmtLocal(prevDate).slice(0, 7) return { - periodA: sorted.filter(d => d.date.startsWith(currentMonth)), - periodB: sorted.filter(d => d.date.startsWith(prevMonth)), + periodA: sorted.filter((d) => d.date.startsWith(currentMonth)), + periodB: sorted.filter((d) => d.date.startsWith(prevMonth)), labelA: t('comparison.thisMonth'), labelB: t('comparison.lastMonth'), } @@ -104,7 +113,9 @@ export function PeriodComparison({ data }: PeriodComparisonProps) {

{t('comparison.notEnoughData')}

-

{t('comparison.requiresDays', { count: data.length })}

+

+ {t('comparison.requiresDays', { count: data.length })} +

@@ -112,15 +123,45 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { } const hasPrevData = periodB.length > 0 - const fmtB = (val: string) => hasPrevData ? val : '–' + const fmtB = (val: string) => (hasPrevData ? val : '–') const comparisons = [ - { label: t('comparison.cost'), a: formatCurrency(metricsA.totalCost), b: fmtB(formatCurrency(metricsB.totalCost)), delta: getDelta(metricsA.totalCost, metricsB.totalCost) }, - { label: t('comparison.tokens'), a: formatTokens(metricsA.totalTokens), b: fmtB(formatTokens(metricsB.totalTokens)), delta: getDelta(metricsA.totalTokens, metricsB.totalTokens) }, - { label: '$/1M', a: `$${metricsA.costPerMillion.toFixed(2)}`, b: fmtB(`$${metricsB.costPerMillion.toFixed(2)}`), delta: getDelta(metricsA.costPerMillion, metricsB.costPerMillion) }, - { label: t('comparison.avgPerDay'), a: formatCurrency(metricsA.avgDailyCost), b: fmtB(formatCurrency(metricsB.avgDailyCost)), delta: getDelta(metricsA.avgDailyCost, metricsB.avgDailyCost) }, - { label: t('comparison.cacheRate'), a: formatPercent(metricsA.cacheHitRate), b: fmtB(formatPercent(metricsB.cacheHitRate)), delta: getDelta(metricsA.cacheHitRate, metricsB.cacheHitRate, true) }, - { label: t('comparison.days'), a: String(metricsA.activeDays), b: fmtB(String(metricsB.activeDays)), delta: getDelta(metricsA.activeDays, metricsB.activeDays) }, + { + label: t('comparison.cost'), + a: formatCurrency(metricsA.totalCost), + b: fmtB(formatCurrency(metricsB.totalCost)), + delta: getDelta(metricsA.totalCost, metricsB.totalCost), + }, + { + label: t('comparison.tokens'), + a: formatTokens(metricsA.totalTokens), + b: fmtB(formatTokens(metricsB.totalTokens)), + delta: getDelta(metricsA.totalTokens, metricsB.totalTokens), + }, + { + label: '$/1M', + a: `$${metricsA.costPerMillion.toFixed(2)}`, + b: fmtB(`$${metricsB.costPerMillion.toFixed(2)}`), + delta: getDelta(metricsA.costPerMillion, metricsB.costPerMillion), + }, + { + label: t('comparison.avgPerDay'), + a: formatCurrency(metricsA.avgDailyCost), + b: fmtB(formatCurrency(metricsB.avgDailyCost)), + delta: getDelta(metricsA.avgDailyCost, metricsB.avgDailyCost), + }, + { + label: t('comparison.cacheRate'), + a: formatPercent(metricsA.cacheHitRate), + b: fmtB(formatPercent(metricsB.cacheHitRate)), + delta: getDelta(metricsA.cacheHitRate, metricsB.cacheHitRate, true), + }, + { + label: t('comparison.days'), + a: String(metricsA.activeDays), + b: fmtB(String(metricsB.activeDays)), + delta: getDelta(metricsA.activeDays, metricsB.activeDays), + }, ] return ( @@ -156,29 +197,43 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { - + - + - {comparisons.map(row => ( + {comparisons.map((row) => ( - + ))} diff --git a/src/components/features/drill-down/DrillDownModal.tsx b/src/components/features/drill-down/DrillDownModal.tsx index 0aa8a3c..4a3dc70 100644 --- a/src/components/features/drill-down/DrillDownModal.tsx +++ b/src/components/features/drill-down/DrillDownModal.tsx @@ -1,10 +1,21 @@ import { useMemo } from 'react' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts' import { CustomTooltip } from '@/components/charts/CustomTooltip' import { formatCurrency, formatTokens, formatPercent, formatDate } from '@/lib/formatters' import { FormattedValue } from '@/components/ui/formatted-value' -import { normalizeModelName, getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' +import { + normalizeModelName, + getModelColor, + getModelProvider, + getProviderBadgeClasses, +} from '@/lib/model-utils' import { cn } from '@/lib/cn' import type { DailyUsage } from '@/types' @@ -18,12 +29,38 @@ interface DrillDownModalProps { export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDownModalProps) { const modelData = useMemo(() => { if (!day) return [] - const map = new Map() + const map = new Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + } + >() for (const mb of day.modelBreakdowns) { const name = normalizeModelName(mb.modelName) - const ex = map.get(name) ?? { cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0, requests: 0 } + const ex = map.get(name) ?? { + cost: 0, + tokens: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + thinking: 0, + requests: 0, + } ex.cost += mb.cost - ex.tokens += mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens + ex.tokens += + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens ex.input += mb.inputTokens ex.output += mb.outputTokens ex.cacheRead += mb.cacheReadTokens @@ -39,48 +76,87 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo if (!day) return null - const cacheRate = (day.cacheReadTokens + day.cacheCreationTokens + day.inputTokens + day.outputTokens + day.thinkingTokens) > 0 - ? (day.cacheReadTokens / (day.cacheReadTokens + day.cacheCreationTokens + day.inputTokens + day.outputTokens + day.thinkingTokens)) * 100 - : 0 + const cacheRate = + day.cacheReadTokens + + day.cacheCreationTokens + + day.inputTokens + + day.outputTokens + + day.thinkingTokens > + 0 + ? (day.cacheReadTokens / + (day.cacheReadTokens + + day.cacheCreationTokens + + day.inputTokens + + day.outputTokens + + day.thinkingTokens)) * + 100 + : 0 - const pieData = modelData.map(m => ({ name: m.name, value: m.cost })) + const pieData = modelData.map((m) => ({ name: m.name, value: m.cost })) const avgTokensPerRequest = day.requestCount > 0 ? day.totalTokens / day.requestCount : 0 const avgCostPerRequest = day.requestCount > 0 ? day.totalCost / day.requestCount : 0 - const costRanking = [...contextData].sort((a, b) => b.totalCost - a.totalCost).findIndex(entry => entry.date === day.date) + 1 - const requestRanking = [...contextData].sort((a, b) => b.requestCount - a.requestCount).findIndex(entry => entry.date === day.date) + 1 + const costRanking = + [...contextData] + .sort((a, b) => b.totalCost - a.totalCost) + .findIndex((entry) => entry.date === day.date) + 1 + const requestRanking = + [...contextData] + .sort((a, b) => b.requestCount - a.requestCount) + .findIndex((entry) => entry.date === day.date) + 1 const previousSeven = [...contextData] - .filter(entry => entry.date < day.date) + .filter((entry) => entry.date < day.date) .sort((a, b) => a.date.localeCompare(b.date)) .slice(-7) - const avgCost7 = previousSeven.length > 0 ? previousSeven.reduce((sum, entry) => sum + entry.totalCost, 0) / previousSeven.length : null - const avgRequests7 = previousSeven.length > 0 ? previousSeven.reduce((sum, entry) => sum + entry.requestCount, 0) / previousSeven.length : null - const topRequestModel = modelData.reduce((best, current) => { - if (!best || current.requests > best.requests) return current - return best - }, null as (typeof modelData)[number] | null) + const avgCost7 = + previousSeven.length > 0 + ? previousSeven.reduce((sum, entry) => sum + entry.totalCost, 0) / previousSeven.length + : null + const avgRequests7 = + previousSeven.length > 0 + ? previousSeven.reduce((sum, entry) => sum + entry.requestCount, 0) / previousSeven.length + : null + const topRequestModel = modelData.reduce( + (best, current) => { + if (!best || current.requests > best.requests) return current + return best + }, + null as (typeof modelData)[number] | null, + ) return ( !o && onClose()}> - {formatDate(day.date, 'long')} — {formatCurrency(day.totalCost)} + + {formatDate(day.date, 'long')} — {formatCurrency(day.totalCost)} + - Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking Tokens. + Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking + Tokens.
Tokens
-
+
+ +
$/1M
-
+
+ +
Cache-Rate
-
+
+ +
Modelle
@@ -88,19 +164,27 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Requests
-
+
+ +
Thinking
-
+
+ +
Tokens / Req
-
+
+ +
Kosten / Req
-
+
+ +
Kosten-Rang
@@ -108,7 +192,9 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Request-Rang
-
{requestRanking > 0 ? `#${requestRanking}` : '–'}
+
+ {requestRanking > 0 ? `#${requestRanking}` : '–'} +
@@ -119,11 +205,19 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Kosten vs. 7T-Ø
-
{avgCost7 !== null ? `${day.totalCost >= avgCost7 ? '↑' : '↓'} ${formatCurrency(Math.abs(day.totalCost - avgCost7))}` : '–'}
+
+ {avgCost7 !== null + ? `${day.totalCost >= avgCost7 ? '↑' : '↓'} ${formatCurrency(Math.abs(day.totalCost - avgCost7))}` + : '–'} +
Requests vs. 7T-Ø
-
{avgRequests7 !== null ? `${day.requestCount >= avgRequests7 ? '↑' : '↓'} ${Math.abs(day.requestCount - avgRequests7).toFixed(0)}` : '–'}
+
+ {avgRequests7 !== null + ? `${day.requestCount >= avgRequests7 ? '↑' : '↓'} ${Math.abs(day.requestCount - avgRequests7).toFixed(0)}` + : '–'} +
@@ -131,27 +225,67 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Token-Verteilung
- {day.totalTokens > 0 && ([ - { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, - { value: day.cacheCreationTokens, color: 'hsl(262, 60%, 55%)', label: 'Cache Write' }, - { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: 'Input' }, - { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: 'Output' }, - { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: 'Thinking' }, - ] as const).map(seg => ( -
- ))} + {day.totalTokens > 0 && + ( + [ + { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, + { + value: day.cacheCreationTokens, + color: 'hsl(262, 60%, 55%)', + label: 'Cache Write', + }, + { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: 'Input' }, + { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: 'Output' }, + { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: 'Thinking' }, + ] as const + ).map((seg) => ( +
+ ))}
- Cache Read {formatPercent((day.cacheReadTokens / day.totalTokens) * 100)} - Cache Write {formatPercent((day.cacheCreationTokens / day.totalTokens) * 100)} - Input {formatPercent((day.inputTokens / day.totalTokens) * 100)} - Output {formatPercent((day.outputTokens / day.totalTokens) * 100)} - Thinking {formatPercent((day.thinkingTokens / day.totalTokens) * 100)} + + + Cache Read {formatPercent((day.cacheReadTokens / day.totalTokens) * 100)} + + + + Cache Write {formatPercent((day.cacheCreationTokens / day.totalTokens) * 100)} + + + + Input {formatPercent((day.inputTokens / day.totalTokens) * 100)} + + + + Output {formatPercent((day.outputTokens / day.totalTokens) * 100)} + + + + Thinking {formatPercent((day.thinkingTokens / day.totalTokens) * 100)} +
@@ -159,8 +293,16 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
- - {pieData.map(entry => ( + + {pieData.map((entry) => ( ))} @@ -170,26 +312,47 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
- {modelData.map(model => { + {modelData.map((model) => { const share = day.totalCost > 0 ? (model.cost / day.totalCost) * 100 : 0 return ( -
+
- + {model.name} - + {getModelProvider(model.name)} - {formatPercent(share)} + + {formatPercent(share)} +
- - - {model.requests} Req + + + + + + + + {model.requests} Req +
- {model.requests > 0 ? `${formatCurrency(model.cost / model.requests)}/Req · ${formatTokens(model.tokens / model.requests)}/Req` : 'Keine Requests'} + {model.requests > 0 + ? `${formatCurrency(model.cost / model.requests)}/Req · ${formatTokens(model.tokens / model.requests)}/Req` + : 'Keine Requests'}
diff --git a/src/components/features/forecast/CostForecast.tsx b/src/components/features/forecast/CostForecast.tsx index 9ce3211..272eed4 100644 --- a/src/components/features/forecast/CostForecast.tsx +++ b/src/components/features/forecast/CostForecast.tsx @@ -1,6 +1,16 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from '@/components/charts/ChartCard' import { CustomTooltip } from '@/components/charts/CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from '@/components/charts/chart-theme' @@ -19,7 +29,16 @@ interface CostForecastProps { export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { const { t } = useTranslation() - const { chartData, forecastTotal, currentMonthTotal, dailyAvgTrend, projectedDailyBurn, remainingDays, confidence, confidenceColor } = useMemo(() => { + const { + chartData, + forecastTotal, + currentMonthTotal, + dailyAvgTrend, + projectedDailyBurn, + remainingDays, + confidence, + confidenceColor, + } = useMemo(() => { const forecast = computeCurrentMonthForecast(data) if (!forecast) { @@ -50,13 +69,21 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { daysInMonth, } = forecast - const confidenceColor = confidence === 'high' - ? 'text-green-400 bg-green-400/10' - : confidence === 'medium' - ? 'text-yellow-400 bg-yellow-400/10' - : 'text-red-400 bg-red-400/10' + const confidenceColor = + confidence === 'high' + ? 'text-green-400 bg-green-400/10' + : confidence === 'medium' + ? 'text-yellow-400 bg-yellow-400/10' + : 'text-red-400 bg-red-400/10' - const points: { date: string; cost?: number; forecast?: number; lower?: number; upper?: number; band?: number }[] = [] + const points: { + date: string + cost?: number + forecast?: number + lower?: number + upper?: number + band?: number + }[] = [] for (const point of elapsedCalendarSeries) { points.push({ date: point.date, cost: point.cost }) @@ -116,9 +143,15 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { return (
} - subtitle={t('forecast.totalOverPeriods', { total: formatCurrency(total), count: data.length, unit: viewMode === 'monthly' ? t('periods.months') : t('periods.years') })} + subtitle={t('forecast.totalOverPeriods', { + total: formatCurrency(total), + count: data.length, + unit: viewMode === 'monthly' ? t('periods.months') : t('periods.years'), + })} icon={} />
@@ -131,9 +164,7 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) {

{t('forecast.noForecast')}

-

- {t('forecast.requiresTwoDays')} -

+

{t('forecast.requiresTwoDays')}

) @@ -142,11 +173,28 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { return (
{t('forecast.monthEndForecast')} {t(`forecast.${confidence}`)}} - value={<>~} + label={ + + {t('forecast.monthEndForecast')}{' '} + + {t(`forecast.${confidence}`)} + + + } + value={ + <> + ~ + + } subtitle={`${t('forecast.soFar', { value: formatCurrency(currentMonthTotal) })} · ${t('forecast.remainingDays', { count: remainingDays })}${dailyAvgTrend ? ` · ${t('forecast.projectedPerDay', { value: formatCurrency(projectedDailyBurn) })}` : ''}`} icon={} - trend={dailyAvgTrend && dailyAvgTrend.change !== 0 ? { value: dailyAvgTrend.change, label: t('forecast.vsLastWeek') } : null} + trend={ + dailyAvgTrend && dailyAvgTrend.change !== 0 + ? { value: dailyAvgTrend.change, label: t('forecast.vsLastWeek') } + : null + } /> - - - - - - - - - formatCurrency(coerceNumber(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} /> - - - - - + + + + + + + + + formatCurrency(coerceNumber(value))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} /> + + + + + diff --git a/src/components/features/heatmap/HeatmapCalendar.tsx b/src/components/features/heatmap/HeatmapCalendar.tsx index 0007b71..c89fe40 100644 --- a/src/components/features/heatmap/HeatmapCalendar.tsx +++ b/src/components/features/heatmap/HeatmapCalendar.tsx @@ -3,7 +3,13 @@ import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_HELP } from '@/lib/help-content' -import { formatCurrency, formatNumber, formatTokens, localToday, toLocalDateStr } from '@/lib/formatters' +import { + formatCurrency, + formatNumber, + formatTokens, + localToday, + toLocalDateStr, +} from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import type { DailyUsage, ViewMode } from '@/types' @@ -23,32 +29,67 @@ function getColor(value: number, maxValue: number, hue: number): string { if (value === 0) return 'hsl(224, 12%, 14%)' const intensity = Math.min(value / maxValue, 1) if (intensity < 0.15) return `hsl(${hue}, 70%, 18%)` - if (intensity < 0.30) return `hsl(${hue}, 70%, 26%)` + if (intensity < 0.3) return `hsl(${hue}, 70%, 26%)` if (intensity < 0.45) return `hsl(${hue}, 70%, 34%)` - if (intensity < 0.60) return `hsl(${hue}, 70%, 42%)` + if (intensity < 0.6) return `hsl(${hue}, 70%, 42%)` if (intensity < 0.75) return `hsl(${hue}, 70%, 52%)` - if (intensity < 0.90) return `hsl(${hue}, 70%, 60%)` + if (intensity < 0.9) return `hsl(${hue}, 70%, 60%)` return `hsl(${hue}, 70%, 70%)` } -export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: HeatmapCalendarProps) { +export function HeatmapCalendar({ + data, + viewMode = 'daily', + metric = 'cost', +}: HeatmapCalendarProps) { const { t } = useTranslation() - const [tooltip, setTooltip] = useState<{ x: number; y: number; date: string; value: number } | null>(null) + const [tooltip, setTooltip] = useState<{ + x: number + y: number + date: string + value: number + } | null>(null) const overlayRef = useRef(null) const dayLabels = useMemo( - () => Array.from({ length: 7 }, (_, index) => index).map((index) => index % 2 === 1 ? '' : new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }).format(new Date(Date.UTC(2024, 0, 1 + index))).slice(0, 2)), - [] + () => + Array.from({ length: 7 }, (_, index) => index).map((index) => + index % 2 === 1 + ? '' + : new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + .format(new Date(Date.UTC(2024, 0, 1 + index))) + .slice(0, 2), + ), + [], ) const config = { - cost: { title: t('charts.heatmap.costTitle'), empty: t('charts.heatmap.costEmpty'), formatter: formatCurrency, accessor: (entry: DailyUsage) => entry.totalCost, hue: 215 }, - requests: { title: t('charts.heatmap.requestsTitle'), empty: t('charts.heatmap.requestsEmpty'), formatter: formatNumber, accessor: (entry: DailyUsage) => entry.requestCount, hue: 160 }, - tokens: { title: t('charts.heatmap.tokensTitle'), empty: t('charts.heatmap.tokensEmpty'), formatter: formatTokens, accessor: (entry: DailyUsage) => entry.totalTokens, hue: 35 }, + cost: { + title: t('charts.heatmap.costTitle'), + empty: t('charts.heatmap.costEmpty'), + formatter: formatCurrency, + accessor: (entry: DailyUsage) => entry.totalCost, + hue: 215, + }, + requests: { + title: t('charts.heatmap.requestsTitle'), + empty: t('charts.heatmap.requestsEmpty'), + formatter: formatNumber, + accessor: (entry: DailyUsage) => entry.requestCount, + hue: 160, + }, + tokens: { + title: t('charts.heatmap.tokensTitle'), + empty: t('charts.heatmap.tokensEmpty'), + formatter: formatTokens, + accessor: (entry: DailyUsage) => entry.totalTokens, + hue: 35, + }, }[metric] - const infoText = metric === 'cost' - ? CHART_HELP.heatmap - : metric === 'requests' - ? CHART_HELP.requestHeatmap - : CHART_HELP.tokenHeatmap + const infoText = + metric === 'cost' + ? CHART_HELP.heatmap + : metric === 'requests' + ? CHART_HELP.requestHeatmap + : CHART_HELP.tokenHeatmap const { cells, weeks, months, maxValue } = useMemo(() => { if (data.length === 0) return { cells: [], weeks: 0, months: [], maxValue: 0 } @@ -121,7 +162,9 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H

{config.empty}

-

{t('charts.heatmap.switchToDaily')}

+

+ {t('charts.heatmap.switchToDaily')} +

@@ -145,76 +188,77 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H
- {/* Day labels */} - {dayLabels.map((label, i) => ( - label && ( + {/* Day labels */} + {dayLabels.map( + (label, i) => + label && ( + + {label} + + ), + )} + + {/* Month labels */} + {months.map((m, i) => ( - {label} + {m.label} - ) - ))} - - {/* Month labels */} - {months.map((m, i) => ( - - {m.label} - - ))} + ))} - {/* Cells */} - {cells.map((cell, i) => { - const isToday = cell.date === todayStr - return ( - - { - const bounds = overlayRef.current?.getBoundingClientRect() - if (!bounds) return - setTooltip({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - 12, - date: cell.date, - value: cell.value, - }) - }} - onMouseLeave={() => setTooltip(null)} - /> - {isToday && ( + {/* Cells */} + {cells.map((cell, i) => { + const isToday = cell.date === todayStr + return ( + { + const bounds = overlayRef.current?.getBoundingClientRect() + if (!bounds) return + setTooltip({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top - 12, + date: cell.date, + value: cell.value, + }) + }} + onMouseLeave={() => setTooltip(null)} /> - )} - - ) - })} + {isToday && ( + + )} + + ) + })}
@@ -231,7 +275,7 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H {/* Legend */}
{t('charts.heatmap.less')} - {[0, 0.15, 0.30, 0.45, 0.60, 0.75, 0.90, 1].map((level, i) => ( + {[0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9, 1].map((level, i) => (
>(() => ({ - totalCost: t('helpPanel.metricLabels.totalCost'), - totalTokens: t('helpPanel.metricLabels.totalTokens'), - activeDays: t('helpPanel.metricLabels.activeDays'), - topModel: t('helpPanel.metricLabels.topModel'), - cacheHitRate: t('helpPanel.metricLabels.cacheHitRate'), - costPerMillion: t('helpPanel.metricLabels.costPerMillion'), - mostExpensiveDay: t('helpPanel.metricLabels.mostExpensiveDay'), - cheapestDay: t('helpPanel.metricLabels.cheapestDay'), - avgCostPerDay: t('helpPanel.metricLabels.avgCostPerDay'), - outputTokens: t('helpPanel.metricLabels.outputTokens'), - }), [t]) - const chartLabels = useMemo>(() => ({ - costOverTime: t('helpPanel.chartLabels.costOverTime'), - costByModel: t('helpPanel.chartLabels.costByModel'), - costByModelOverTime: t('helpPanel.chartLabels.costByModelOverTime'), - cumulativeCost: t('helpPanel.chartLabels.cumulativeCost'), - costByWeekday: t('helpPanel.chartLabels.costByWeekday'), - tokensOverTime: t('helpPanel.chartLabels.tokensOverTime'), - requestsOverTime: t('helpPanel.chartLabels.requestsOverTime'), - requestCacheHitRate: t('helpPanel.chartLabels.requestCacheHitRate'), - tokenTypes: t('helpPanel.chartLabels.tokenTypes'), - tokenEfficiency: t('helpPanel.chartLabels.tokenEfficiency'), - modelMix: t('helpPanel.chartLabels.modelMix'), - distributionAnalysis: t('helpPanel.chartLabels.distributionAnalysis'), - correlationAnalysis: t('helpPanel.chartLabels.correlationAnalysis'), - heatmap: t('helpPanel.chartLabels.heatmap'), - requestHeatmap: t('helpPanel.chartLabels.requestHeatmap'), - tokenHeatmap: t('helpPanel.chartLabels.tokenHeatmap'), - forecast: t('helpPanel.chartLabels.forecast'), - cacheROI: t('helpPanel.chartLabels.cacheROI'), - periodComparison: t('helpPanel.chartLabels.periodComparison'), - anomalyDetection: t('helpPanel.chartLabels.anomalyDetection'), - }), [t]) - const sectionLabels = useMemo>(() => ({ - insights: t('helpPanel.sectionLabels.insights'), - metrics: t('helpPanel.sectionLabels.metrics'), - today: t('helpPanel.sectionLabels.today'), - currentMonth: t('helpPanel.sectionLabels.currentMonth'), - activity: t('helpPanel.sectionLabels.activity'), - forecastCache: t('helpPanel.sectionLabels.forecastCache'), - costAnalysis: t('helpPanel.sectionLabels.costAnalysis'), - tokenAnalysis: t('helpPanel.sectionLabels.tokenAnalysis'), - requestAnalysis: t('helpPanel.sectionLabels.requestAnalysis'), - advancedAnalysis: t('helpPanel.sectionLabels.advancedAnalysis'), - comparisons: t('helpPanel.sectionLabels.comparisons'), - tables: t('helpPanel.sectionLabels.tables'), - limits: t('helpPanel.sectionLabels.limits'), - }), [t]) - const featureLabels = useMemo>(() => ({ - requestQuality: t('helpPanel.featureLabels.requestQuality'), - providerLimits: t('helpPanel.featureLabels.providerLimits'), - concentrationRisk: t('helpPanel.featureLabels.concentrationRisk'), - providerEfficiency: t('helpPanel.featureLabels.providerEfficiency'), - modelEfficiency: t('helpPanel.featureLabels.modelEfficiency'), - recentDays: t('helpPanel.featureLabels.recentDays'), - }), [t]) + const metricLabels = useMemo>( + () => ({ + totalCost: t('helpPanel.metricLabels.totalCost'), + totalTokens: t('helpPanel.metricLabels.totalTokens'), + activeDays: t('helpPanel.metricLabels.activeDays'), + topModel: t('helpPanel.metricLabels.topModel'), + cacheHitRate: t('helpPanel.metricLabels.cacheHitRate'), + costPerMillion: t('helpPanel.metricLabels.costPerMillion'), + mostExpensiveDay: t('helpPanel.metricLabels.mostExpensiveDay'), + cheapestDay: t('helpPanel.metricLabels.cheapestDay'), + avgCostPerDay: t('helpPanel.metricLabels.avgCostPerDay'), + outputTokens: t('helpPanel.metricLabels.outputTokens'), + }), + [t], + ) + const chartLabels = useMemo>( + () => ({ + costOverTime: t('helpPanel.chartLabels.costOverTime'), + costByModel: t('helpPanel.chartLabels.costByModel'), + costByModelOverTime: t('helpPanel.chartLabels.costByModelOverTime'), + cumulativeCost: t('helpPanel.chartLabels.cumulativeCost'), + costByWeekday: t('helpPanel.chartLabels.costByWeekday'), + tokensOverTime: t('helpPanel.chartLabels.tokensOverTime'), + requestsOverTime: t('helpPanel.chartLabels.requestsOverTime'), + requestCacheHitRate: t('helpPanel.chartLabels.requestCacheHitRate'), + tokenTypes: t('helpPanel.chartLabels.tokenTypes'), + tokenEfficiency: t('helpPanel.chartLabels.tokenEfficiency'), + modelMix: t('helpPanel.chartLabels.modelMix'), + distributionAnalysis: t('helpPanel.chartLabels.distributionAnalysis'), + correlationAnalysis: t('helpPanel.chartLabels.correlationAnalysis'), + heatmap: t('helpPanel.chartLabels.heatmap'), + requestHeatmap: t('helpPanel.chartLabels.requestHeatmap'), + tokenHeatmap: t('helpPanel.chartLabels.tokenHeatmap'), + forecast: t('helpPanel.chartLabels.forecast'), + cacheROI: t('helpPanel.chartLabels.cacheROI'), + periodComparison: t('helpPanel.chartLabels.periodComparison'), + anomalyDetection: t('helpPanel.chartLabels.anomalyDetection'), + }), + [t], + ) + const sectionLabels = useMemo>( + () => ({ + insights: t('helpPanel.sectionLabels.insights'), + metrics: t('helpPanel.sectionLabels.metrics'), + today: t('helpPanel.sectionLabels.today'), + currentMonth: t('helpPanel.sectionLabels.currentMonth'), + activity: t('helpPanel.sectionLabels.activity'), + forecastCache: t('helpPanel.sectionLabels.forecastCache'), + costAnalysis: t('helpPanel.sectionLabels.costAnalysis'), + tokenAnalysis: t('helpPanel.sectionLabels.tokenAnalysis'), + requestAnalysis: t('helpPanel.sectionLabels.requestAnalysis'), + advancedAnalysis: t('helpPanel.sectionLabels.advancedAnalysis'), + comparisons: t('helpPanel.sectionLabels.comparisons'), + tables: t('helpPanel.sectionLabels.tables'), + limits: t('helpPanel.sectionLabels.limits'), + }), + [t], + ) + const featureLabels = useMemo>( + () => ({ + requestQuality: t('helpPanel.featureLabels.requestQuality'), + providerLimits: t('helpPanel.featureLabels.providerLimits'), + concentrationRisk: t('helpPanel.featureLabels.concentrationRisk'), + providerEfficiency: t('helpPanel.featureLabels.providerEfficiency'), + modelEfficiency: t('helpPanel.featureLabels.modelEfficiency'), + recentDays: t('helpPanel.featureLabels.recentDays'), + }), + [t], + ) return ( {t('header.help')} - - {t('commandPalette.description')} - + {t('commandPalette.description')} {/* Keyboard shortcuts */} diff --git a/src/components/features/help/InfoButton.tsx b/src/components/features/help/InfoButton.tsx index 5208bfc..1c4b968 100644 --- a/src/components/features/help/InfoButton.tsx +++ b/src/components/features/help/InfoButton.tsx @@ -17,7 +17,10 @@ export function InfoButton({ text, className }: InfoButtonProps) { type="button" aria-label={t('common.showInfo')} data-info-button="true" - className={cn('inline-flex items-center justify-center text-muted-foreground/50 hover:text-muted-foreground transition-colors', className)} + className={cn( + 'inline-flex items-center justify-center text-muted-foreground/50 hover:text-muted-foreground transition-colors', + className, + )} > diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index 2a4ebc2..70e84d4 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -6,7 +6,14 @@ import { SectionHeader } from '@/components/ui/section-header' import { FadeIn } from '@/components/features/animations/FadeIn' import { FormattedValue } from '@/components/ui/formatted-value' import { SECTION_HELP } from '@/lib/help-content' -import { formatCurrency, formatDate, formatNumber, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' +import { + formatCurrency, + formatDate, + formatNumber, + formatPercent, + formatTokens, + periodUnit, +} from '@/lib/formatters' import type { DashboardMetrics, ViewMode } from '@/types' interface UsageInsightsProps { @@ -29,7 +36,9 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps)
-
{title}
+
+ {title} +
{value}
@@ -39,8 +48,13 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps)

{summary}

{details.map((detail) => ( -
-
{detail.label}
+
+
+ {detail.label} +
{detail.value}
))} @@ -51,16 +65,23 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps) export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageInsightsProps) { const { t } = useTranslation() - const coverageRate = totalCalendarDays && viewMode === 'daily' - ? (metrics.activeDays / totalCalendarDays) * 100 - : null + const coverageRate = + totalCalendarDays && viewMode === 'daily' + ? (metrics.activeDays / totalCalendarDays) * 100 + : null - const usageUnit = viewMode === 'yearly' ? t('periods.years') : viewMode === 'monthly' ? t('periods.months') : t('periods.days') - const peakSignal = metrics.topThreeModelsShare >= 80 - ? t('insights.peakWindow.signalStrong') - : metrics.topThreeModelsShare >= 55 - ? t('insights.peakWindow.signalModerate') - : t('insights.peakWindow.signalWide') + const usageUnit = + viewMode === 'yearly' + ? t('periods.years') + : viewMode === 'monthly' + ? t('periods.months') + : t('periods.days') + const peakSignal = + metrics.topThreeModelsShare >= 80 + ? t('insights.peakWindow.signalStrong') + : metrics.topThreeModelsShare >= 55 + ? t('insights.peakWindow.signalModerate') + : t('insights.peakWindow.signalWide') return (
@@ -76,14 +97,28 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns title={t('insights.concentration.title')} icon={} value={metrics.topProvider ? formatPercent(metrics.topProvider.share, 0) : '–'} - summary={metrics.topProvider - ? t('insights.concentration.summary', { provider: metrics.topProvider.name, model: metrics.topModel?.name ?? t('metricCards.primary.topModel') }) - : t('insights.concentration.fallback')} + summary={ + metrics.topProvider + ? t('insights.concentration.summary', { + provider: metrics.topProvider.name, + model: metrics.topModel?.name ?? t('metricCards.primary.topModel'), + }) + : t('insights.concentration.fallback') + } details={[ - { label: t('insights.concentration.topProvider'), value: metrics.topProvider?.name ?? '–' }, + { + label: t('insights.concentration.topProvider'), + value: metrics.topProvider?.name ?? '–', + }, { label: t('insights.concentration.topModel'), value: metrics.topModel?.name ?? '–' }, - { label: t('insights.concentration.topModelShare'), value: formatPercent(metrics.topModelShare, 0) }, - { label: t('insights.concentration.topThreeModels'), value: formatPercent(metrics.topThreeModelsShare, 0) }, + { + label: t('insights.concentration.topModelShare'), + value: formatPercent(metrics.topModelShare, 0), + }, + { + label: t('insights.concentration.topThreeModels'), + value: formatPercent(metrics.topThreeModelsShare, 0), + }, ]} /> @@ -92,19 +127,54 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={metrics.hasRequestData ? : t('common.notAvailable')} - summary={metrics.hasRequestData - ? t('insights.requestEconomy.summary', { - cost: formatCurrency(metrics.avgCostPerRequest), - tokens: formatTokens(metrics.avgTokensPerRequest), - leader: metrics.topRequestModel ? t('insights.requestEconomy.leader', { model: metrics.topRequestModel.name }) : '', - }).trim() - : t('insights.requestEconomy.fallback')} + value={ + metrics.hasRequestData ? ( + + ) : ( + t('common.notAvailable') + ) + } + summary={ + metrics.hasRequestData + ? t('insights.requestEconomy.summary', { + cost: formatCurrency(metrics.avgCostPerRequest), + tokens: formatTokens(metrics.avgTokensPerRequest), + leader: metrics.topRequestModel + ? t('insights.requestEconomy.leader', { model: metrics.topRequestModel.name }) + : '', + }).trim() + : t('insights.requestEconomy.fallback') + } details={[ - { label: t('insights.requestEconomy.avgRequests', { unit: periodUnit(viewMode) }), value: metrics.hasRequestData ? metrics.avgRequestsPerDay.toFixed(1) : t('common.notAvailable') }, - { label: t('insights.requestEconomy.avgTokensPerRequest'), value: metrics.hasRequestData ? formatTokens(metrics.avgTokensPerRequest) : t('common.notAvailable') }, - { label: t('insights.requestEconomy.costPerMillion'), value: formatCurrency(metrics.costPerMillion) }, - { label: t('insights.requestEconomy.totalRequests'), value: metrics.hasRequestData ? formatNumber(metrics.totalRequests) : t('common.notAvailable') }, + { + label: t('insights.requestEconomy.avgRequests', { unit: periodUnit(viewMode) }), + value: metrics.hasRequestData + ? metrics.avgRequestsPerDay.toFixed(1) + : t('common.notAvailable'), + }, + { + label: t('insights.requestEconomy.avgTokensPerRequest'), + value: metrics.hasRequestData + ? formatTokens(metrics.avgTokensPerRequest) + : t('common.notAvailable'), + }, + { + label: t('insights.requestEconomy.costPerMillion'), + value: formatCurrency(metrics.costPerMillion), + }, + { + label: t('insights.requestEconomy.totalRequests'), + value: metrics.hasRequestData + ? formatNumber(metrics.totalRequests) + : t('common.notAvailable'), + }, ]} /> @@ -113,15 +183,46 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={coverageRate !== null ? formatPercent(coverageRate, 0) : formatNumber(metrics.activeDays)} - summary={coverageRate !== null - ? t('insights.usagePatterns.summaryWithCoverage', { activeDays: metrics.activeDays, totalDays: totalCalendarDays, volatility: formatNumber(Math.round(metrics.requestVolatility)) }) - : t('insights.usagePatterns.summaryWithoutCoverage', { activeDays: metrics.activeDays, unit: usageUnit })} + value={ + coverageRate !== null + ? formatPercent(coverageRate, 0) + : formatNumber(metrics.activeDays) + } + summary={ + coverageRate !== null + ? t('insights.usagePatterns.summaryWithCoverage', { + activeDays: metrics.activeDays, + totalDays: totalCalendarDays, + volatility: formatNumber(Math.round(metrics.requestVolatility)), + }) + : t('insights.usagePatterns.summaryWithoutCoverage', { + activeDays: metrics.activeDays, + unit: usageUnit, + }) + } details={[ - { label: t('insights.usagePatterns.avgModels'), value: metrics.avgModelsPerEntry.toFixed(1) }, - { label: t('insights.usagePatterns.providersActive'), value: formatNumber(metrics.providerCount) }, - { label: t('insights.usagePatterns.weekendShare'), value: metrics.weekendCostShare !== null ? formatPercent(metrics.weekendCostShare, 0) : '–' }, - { label: t('insights.usagePatterns.thinkingShare'), value: metrics.totalTokens > 0 ? formatPercent((metrics.totalThinking / metrics.totalTokens) * 100, 1) : '–' }, + { + label: t('insights.usagePatterns.avgModels'), + value: metrics.avgModelsPerEntry.toFixed(1), + }, + { + label: t('insights.usagePatterns.providersActive'), + value: formatNumber(metrics.providerCount), + }, + { + label: t('insights.usagePatterns.weekendShare'), + value: + metrics.weekendCostShare !== null + ? formatPercent(metrics.weekendCostShare, 0) + : '–', + }, + { + label: t('insights.usagePatterns.thinkingShare'), + value: + metrics.totalTokens > 0 + ? formatPercent((metrics.totalThinking / metrics.totalTokens) * 100, 1) + : '–', + }, ]} /> @@ -130,14 +231,34 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost) : formatCurrency(metrics.topDay?.cost ?? 0)} - summary={metrics.busiestWeek - ? t('insights.peakWindow.summary', { start: formatDate(metrics.busiestWeek.start), end: formatDate(metrics.busiestWeek.end) }) - : t('insights.peakWindow.fallback')} + value={ + metrics.busiestWeek + ? formatCurrency(metrics.busiestWeek.cost) + : formatCurrency(metrics.topDay?.cost ?? 0) + } + summary={ + metrics.busiestWeek + ? t('insights.peakWindow.summary', { + start: formatDate(metrics.busiestWeek.start), + end: formatDate(metrics.busiestWeek.end), + }) + : t('insights.peakWindow.fallback') + } details={[ - { label: t('insights.peakWindow.peakDay'), value: metrics.topDay ? `${formatDate(metrics.topDay.date)} · ${formatCurrency(metrics.topDay.cost)}` : '–' }, - { label: t('insights.peakWindow.avgPerUnit', { unit: periodUnit(viewMode) }), value: formatCurrency(metrics.avgDailyCost) }, - { label: t('insights.peakWindow.peak7DayAverage'), value: metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost / 7) : '–' }, + { + label: t('insights.peakWindow.peakDay'), + value: metrics.topDay + ? `${formatDate(metrics.topDay.date)} · ${formatCurrency(metrics.topDay.cost)}` + : '–', + }, + { + label: t('insights.peakWindow.avgPerUnit', { unit: periodUnit(viewMode) }), + value: formatCurrency(metrics.avgDailyCost), + }, + { + label: t('insights.peakWindow.peak7DayAverage'), + value: metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost / 7) : '–', + }, { label: t('insights.peakWindow.signal'), value: peakSignal }, ]} /> @@ -153,13 +274,17 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns {metrics.topProvider ? t('insights.quickRead.summary', { - provider: metrics.topProvider.name, - providerShare: formatPercent(metrics.topProvider.share, 0), - topThreeShare: formatPercent(metrics.topThreeModelsShare, 0), - requestLeader: metrics.topRequestModel - ? t('insights.quickRead.requestLeader', { requestModel: metrics.topRequestModel.name, tokenModel: metrics.topTokenModel?.name ?? t('metricCards.primary.topModel') }) - : '', - }).trim() + provider: metrics.topProvider.name, + providerShare: formatPercent(metrics.topProvider.share, 0), + topThreeShare: formatPercent(metrics.topThreeModelsShare, 0), + requestLeader: metrics.topRequestModel + ? t('insights.quickRead.requestLeader', { + requestModel: metrics.topRequestModel.name, + tokenModel: + metrics.topTokenModel?.name ?? t('metricCards.primary.topModel'), + }) + : '', + }).trim() : t('insights.quickRead.fallback')}
diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index c679207..aabd48f 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -22,7 +22,12 @@ import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from '@/components/charts import { buildProviderMonthlyCosts, getLatestMonth } from '@/lib/provider-limits' import i18n from '@/lib/i18n' import { CHART_HELP, SECTION_HELP } from '@/lib/help-content' -import { coerceNumber, formatCurrency, formatCurrencyExact, formatMonthYear } from '@/lib/formatters' +import { + coerceNumber, + formatCurrency, + formatCurrencyExact, + formatMonthYear, +} from '@/lib/formatters' import { getProviderBadgeStyle } from '@/lib/model-utils' import type { DailyUsage, ProviderLimits } from '@/types' @@ -68,7 +73,12 @@ function toTooltipNumber(value: TooltipValueType | undefined) { return Number.isFinite(numericValue) ? numericValue : 0 } -export function ProviderLimitsSection({ data, providers, limits, selectedMonth }: ProviderLimitsSectionProps) { +export function ProviderLimitsSection({ + data, + providers, + limits, + selectedMonth, +}: ProviderLimitsSectionProps) { const { t } = useTranslation() const sectionRef = useRef(null) const inView = useInView(sectionRef, { once: true, amount: 0.2 }) @@ -86,58 +96,63 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } const latestMonth = getLatestMonth(data) const resolvedFocusMonth = selectedMonth ?? latestMonth - const nextRows: ProviderLimitRow[] = providers.map((provider) => { - const config = limits[provider] - const cost = resolvedFocusMonth ? (monthMap.get(resolvedFocusMonth)?.get(provider) ?? 0) : 0 - const totalCost = providerTotals.get(provider) ?? 0 - const monthlyLimit = config?.monthlyLimit ?? 0 - const hasSubscription = Boolean(config?.hasSubscription) - const subscriptionPrice = hasSubscription ? (config?.subscriptionPrice ?? 0) : 0 - const overrun = monthlyLimit > 0 ? Math.max(cost - monthlyLimit, 0) : 0 - const remaining = monthlyLimit > 0 ? Math.max(monthlyLimit - cost, 0) : null - const utilization = monthlyLimit > 0 ? (cost / monthlyLimit) * 100 : null - const subscriptionDelta = hasSubscription ? cost - subscriptionPrice : null - const subscriptionGain = subscriptionDelta !== null ? Math.max(subscriptionDelta, 0) : 0 - const subscriptionGap = subscriptionDelta !== null ? Math.max(-subscriptionDelta, 0) : 0 - - let riskStatus: ProviderLimitRow['riskStatus'] = 'none' - if (monthlyLimit > 0 && cost >= monthlyLimit) riskStatus = 'limit' - else if (monthlyLimit > 0 && cost >= monthlyLimit * 0.8) riskStatus = 'warning' - else if (monthlyLimit > 0) riskStatus = 'ok' - - const subscriptionStatus: ProviderLimitRow['subscriptionStatus'] = !hasSubscription - ? 'none' - : subscriptionGain > 0 - ? 'gain' - : 'gap' - - return { - provider, - cost, - totalCost, - monthlyLimit, - subscriptionPrice, - hasSubscription, - remaining, - overrun, - utilization, - subscriptionDelta, - subscriptionGain, - subscriptionGap, - riskStatus, - subscriptionStatus, - } - }).sort((a, b) => { - if (a.riskStatus === 'limit' && b.riskStatus !== 'limit') return -1 - if (a.riskStatus !== 'limit' && b.riskStatus === 'limit') return 1 - if (a.subscriptionStatus === 'gain' && b.subscriptionStatus !== 'gain') return -1 - if (a.subscriptionStatus !== 'gain' && b.subscriptionStatus === 'gain') return 1 - return b.cost - a.cost - }) + const nextRows: ProviderLimitRow[] = providers + .map((provider) => { + const config = limits[provider] + const cost = resolvedFocusMonth ? (monthMap.get(resolvedFocusMonth)?.get(provider) ?? 0) : 0 + const totalCost = providerTotals.get(provider) ?? 0 + const monthlyLimit = config?.monthlyLimit ?? 0 + const hasSubscription = Boolean(config?.hasSubscription) + const subscriptionPrice = hasSubscription ? (config?.subscriptionPrice ?? 0) : 0 + const overrun = monthlyLimit > 0 ? Math.max(cost - monthlyLimit, 0) : 0 + const remaining = monthlyLimit > 0 ? Math.max(monthlyLimit - cost, 0) : null + const utilization = monthlyLimit > 0 ? (cost / monthlyLimit) * 100 : null + const subscriptionDelta = hasSubscription ? cost - subscriptionPrice : null + const subscriptionGain = subscriptionDelta !== null ? Math.max(subscriptionDelta, 0) : 0 + const subscriptionGap = subscriptionDelta !== null ? Math.max(-subscriptionDelta, 0) : 0 + + let riskStatus: ProviderLimitRow['riskStatus'] = 'none' + if (monthlyLimit > 0 && cost >= monthlyLimit) riskStatus = 'limit' + else if (monthlyLimit > 0 && cost >= monthlyLimit * 0.8) riskStatus = 'warning' + else if (monthlyLimit > 0) riskStatus = 'ok' + + const subscriptionStatus: ProviderLimitRow['subscriptionStatus'] = !hasSubscription + ? 'none' + : subscriptionGain > 0 + ? 'gain' + : 'gap' + + return { + provider, + cost, + totalCost, + monthlyLimit, + subscriptionPrice, + hasSubscription, + remaining, + overrun, + utilization, + subscriptionDelta, + subscriptionGain, + subscriptionGap, + riskStatus, + subscriptionStatus, + } + }) + .sort((a, b) => { + if (a.riskStatus === 'limit' && b.riskStatus !== 'limit') return -1 + if (a.riskStatus !== 'limit' && b.riskStatus === 'limit') return 1 + if (a.subscriptionStatus === 'gain' && b.subscriptionStatus !== 'gain') return -1 + if (a.subscriptionStatus !== 'gain' && b.subscriptionStatus === 'gain') return 1 + return b.cost - a.cost + }) const nextTimeline = months.map((month) => { const monthCosts = monthMap.get(month) ?? new Map() - const totalCost = providers.reduce((sum, provider) => sum + (monthCosts.get(provider) ?? 0), 0) + const totalCost = providers.reduce( + (sum, provider) => sum + (monthCosts.get(provider) ?? 0), + 0, + ) const totalLimit = providers.reduce((sum, provider) => { const limit = limits[provider]?.monthlyLimit ?? 0 return sum + (limit > 0 ? limit : 0) @@ -179,8 +194,8 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } rows: nextRows, focusMonth: resolvedFocusMonth, timelineData: nextTimeline, - atLimitCount: nextRows.filter(row => row.riskStatus === 'limit').length, - nearLimitCount: nextRows.filter(row => row.riskStatus === 'warning').length, + atLimitCount: nextRows.filter((row) => row.riskStatus === 'limit').length, + nearLimitCount: nextRows.filter((row) => row.riskStatus === 'warning').length, subscriptionTotal: nextRows.reduce((sum, row) => sum + row.subscriptionPrice, 0), subscriptionGainTotal: nextRows.reduce((sum, row) => sum + row.subscriptionGain, 0), } @@ -213,10 +228,30 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{[ - { label: t('limits.cards.atLimit'), value: String(atLimitCount), hint: focusMonth ? formatMonthYear(focusMonth) : t('limits.cards.noMonth'), icon: }, - { label: t('limits.cards.nearLimit'), value: String(nearLimitCount), hint: t('limits.cards.nearLimitHint'), icon: }, - { label: t('limits.cards.subscriptionVolume'), value: formatCurrency(subscriptionTotal), hint: t('limits.cards.subscriptionVolumeHint'), icon: }, - { label: t('limits.cards.subscriptionValue'), value: formatCurrency(subscriptionGainTotal), hint: t('limits.cards.subscriptionValueHint'), icon: }, + { + label: t('limits.cards.atLimit'), + value: String(atLimitCount), + hint: focusMonth ? formatMonthYear(focusMonth) : t('limits.cards.noMonth'), + icon: , + }, + { + label: t('limits.cards.nearLimit'), + value: String(nearLimitCount), + hint: t('limits.cards.nearLimitHint'), + icon: , + }, + { + label: t('limits.cards.subscriptionVolume'), + value: formatCurrency(subscriptionTotal), + hint: t('limits.cards.subscriptionVolumeHint'), + icon: , + }, + { + label: t('limits.cards.subscriptionValue'), + value: formatCurrency(subscriptionGainTotal), + hint: t('limits.cards.subscriptionValueHint'), + icon: , + }, ].map((item, index) => (
-
{item.label}
+
+ {item.label} +
{item.value}
{item.hint}
@@ -245,10 +282,12 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{rows.map((row, index) => { const providerStyle = getProviderBadgeStyle(row.provider) - const riskProgress = row.monthlyLimit > 0 ? Math.min((row.cost / row.monthlyLimit) * 100, 100) : 0 - const subscriptionProgress = row.hasSubscription && row.subscriptionPrice > 0 - ? Math.min((row.cost / row.subscriptionPrice) * 100, 100) - : 0 + const riskProgress = + row.monthlyLimit > 0 ? Math.min((row.cost / row.monthlyLimit) * 100, 100) : 0 + const subscriptionProgress = + row.hasSubscription && row.subscriptionPrice > 0 + ? Math.min((row.cost / row.subscriptionPrice) * 100, 100) + : 0 return ( - +
{row.provider}
{riskLabel(row)}
-
{subscriptionLabel(row)}
+
+ {subscriptionLabel(row)} +
- {row.monthlyLimit > 0 ? `${row.utilization?.toFixed(0)}% Limit` : row.hasSubscription ? `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` : 'Offen'} + {row.monthlyLimit > 0 + ? `${row.utilization?.toFixed(0)}% Limit` + : row.hasSubscription + ? `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` + : 'Offen'}
-
{t('limits.tracks.usageFocusMonth')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.usageFocusMonth')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.limitSubscription')}
+
+ {t('limits.tracks.limitSubscription')} +
- {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : t('limits.statuses.noLimit')} / {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'} + {row.monthlyLimit > 0 + ? formatCurrency(row.monthlyLimit) + : t('limits.statuses.noLimit')}{' '} + / {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'}
@@ -290,16 +352,34 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{t('limits.tracks.budgetRisk')} - {row.monthlyLimit > 0 ? (row.overrun > 0 ? `+${formatCurrency(row.overrun)}` : formatCurrency(row.remaining ?? 0)) : t('limits.statuses.noLimit')} + + {row.monthlyLimit > 0 + ? row.overrun > 0 + ? `+${formatCurrency(row.overrun)}` + : formatCurrency(row.remaining ?? 0) + : t('limits.statuses.noLimit')} +
{row.monthlyLimit > 0 ? ( ) : (
@@ -310,17 +390,41 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{t('limits.tracks.subscriptionEffect')} - - {!row.hasSubscription ? t('limits.statuses.noSubscription') : row.subscriptionStatus === 'gain' ? `+${formatCurrency(row.subscriptionGain)}` : formatCurrency(row.subscriptionGap)} + + {!row.hasSubscription + ? t('limits.statuses.noSubscription') + : row.subscriptionStatus === 'gain' + ? `+${formatCurrency(row.subscriptionGain)}` + : formatCurrency(row.subscriptionGap)}
{row.hasSubscription ? ( ) : (
@@ -338,7 +442,9 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
[]} valueKey="cost" @@ -346,16 +452,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } >
{rows.map((row, index) => { - const maxValue = row.monthlyLimit > 0 - ? Math.max(row.monthlyLimit, row.cost, 1) - : Math.max(row.cost, 1) + const maxValue = + row.monthlyLimit > 0 + ? Math.max(row.monthlyLimit, row.cost, 1) + : Math.max(row.cost, 1) const scaleMax = maxValue * 1.15 const costWidth = `${(row.cost / scaleMax) * 100}%` - const limitPosition = row.monthlyLimit > 0 ? `${(row.monthlyLimit / scaleMax) * 100}%` : '0%' - const withinLimitWidth = row.monthlyLimit > 0 ? `${(Math.min(row.cost, row.monthlyLimit) / scaleMax) * 100}%` : costWidth - const overLimitWidth = row.monthlyLimit > 0 && row.overrun > 0 - ? `${(row.overrun / scaleMax) * 100}%` - : '0%' + const limitPosition = + row.monthlyLimit > 0 ? `${(row.monthlyLimit / scaleMax) * 100}%` : '0%' + const withinLimitWidth = + row.monthlyLimit > 0 + ? `${(Math.min(row.cost, row.monthlyLimit) / scaleMax) * 100}%` + : costWidth + const overLimitWidth = + row.monthlyLimit > 0 && row.overrun > 0 + ? `${(row.overrun / scaleMax) * 100}%` + : '0%' return ( 0 - ? t('limits.tracks.alreadyAboveLimit', { value: formatCurrency(row.overrun) }) - : t('limits.tracks.stillToLimit', { value: formatCurrency(row.remaining ?? 0) })} + ? t('limits.tracks.alreadyAboveLimit', { + value: formatCurrency(row.overrun), + }) + : t('limits.tracks.stillToLimit', { + value: formatCurrency(row.remaining ?? 0), + })}
-
{t('limits.tracks.usage')} {formatCurrency(row.cost)}
-
{t('limits.tracks.limit')} {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : '–'}
+
+ {t('limits.tracks.usage')} {formatCurrency(row.cost)} +
+
+ {t('limits.tracks.limit')}{' '} + {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : '–'} +
@@ -388,14 +509,28 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } {row.monthlyLimit > 0 ? ( <> -
-
+
+
{row.overrun > 0 && ( @@ -404,12 +539,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } style={{ left: limitPosition }} initial={{ width: 0 }} animate={inView ? { width: overLimitWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.14 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.14 + index * 0.04, + ease: 'easeOut', + }} /> )} -
-
+
+
{t('limits.tracks.limit')}
@@ -418,30 +563,66 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } className="absolute left-0 top-5 h-4 rounded-full bg-muted-foreground/40" initial={{ width: 0 }} animate={inView ? { width: costWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.08 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.08 + index * 0.04, + ease: 'easeOut', + }} /> )} -
$0
-
{formatCurrency(scaleMax)}
+
+ $0 +
+
+ {formatCurrency(scaleMax)} +
-
{t('limits.tracks.currentlyUsed')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.currentlyUsed')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.remainingToLimit')}
-
0 && row.overrun === 0 ? 'mt-1 font-medium text-sky-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.monthlyLimit > 0 ? (row.overrun === 0 ? formatCurrency(row.remaining ?? 0) : '$0.00') : '–'} +
+ {t('limits.tracks.remainingToLimit')} +
+
0 && row.overrun === 0 + ? 'mt-1 font-medium text-sky-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.monthlyLimit > 0 + ? row.overrun === 0 + ? formatCurrency(row.remaining ?? 0) + : '$0.00' + : '–'}
-
{t('limits.tracks.alreadyOverLimit')}
-
0 ? 'mt-1 font-medium text-red-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.monthlyLimit > 0 ? (row.overrun > 0 ? formatCurrency(row.overrun) : '$0.00') : '–'} +
+ {t('limits.tracks.alreadyOverLimit')} +
+
0 + ? 'mt-1 font-medium text-red-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.monthlyLimit > 0 + ? row.overrun > 0 + ? formatCurrency(row.overrun) + : '$0.00' + : '–'}
@@ -453,7 +634,11 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } []} valueKey="cost" @@ -466,11 +651,16 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } : Math.max(row.cost, 1) const scaleMax = maxValue * 1.15 const costWidth = `${(row.cost / scaleMax) * 100}%` - const subPosition = row.hasSubscription ? `${(row.subscriptionPrice / scaleMax) * 100}%` : '0%' - const withinSubscriptionWidth = row.hasSubscription ? `${(Math.min(row.cost, row.subscriptionPrice) / scaleMax) * 100}%` : costWidth - const overSubscriptionWidth = row.hasSubscription && row.subscriptionGain > 0 - ? `${(row.subscriptionGain / scaleMax) * 100}%` + const subPosition = row.hasSubscription + ? `${(row.subscriptionPrice / scaleMax) * 100}%` : '0%' + const withinSubscriptionWidth = row.hasSubscription + ? `${(Math.min(row.cost, row.subscriptionPrice) / scaleMax) * 100}%` + : costWidth + const overSubscriptionWidth = + row.hasSubscription && row.subscriptionGain > 0 + ? `${(row.subscriptionGain / scaleMax) * 100}%` + : '0%' return (
-
{t('limits.tracks.usage')} {formatCurrency(row.cost)}
-
{t('limits.tracks.subscription')} {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'}
+
+ {t('limits.tracks.usage')} {formatCurrency(row.cost)} +
+
+ {t('limits.tracks.subscription')}{' '} + {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'} +
@@ -503,14 +702,24 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } {row.hasSubscription ? ( <> -
-
+
+
{row.subscriptionGain > 0 && ( @@ -519,12 +728,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } style={{ left: subPosition }} initial={{ width: 0 }} animate={inView ? { width: overSubscriptionWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.14 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.14 + index * 0.04, + ease: 'easeOut', + }} /> )} -
-
+
+
{t('limits.tracks.breakEven')}
@@ -533,30 +752,66 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } className="absolute left-0 top-5 h-4 rounded-full bg-muted-foreground/40" initial={{ width: 0 }} animate={inView ? { width: costWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.08 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.08 + index * 0.04, + ease: 'easeOut', + }} /> )} -
$0
-
{formatCurrency(scaleMax)}
+
+ $0 +
+
+ {formatCurrency(scaleMax)} +
-
{t('limits.tracks.currentlyUsed')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.currentlyUsed')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.remainingToBreakEven')}
-
0 ? 'mt-1 font-medium text-amber-200' : 'mt-1 font-medium text-muted-foreground'}> - {row.hasSubscription ? (row.subscriptionGap > 0 ? formatCurrency(row.subscriptionGap) : '$0.00') : '–'} +
+ {t('limits.tracks.remainingToBreakEven')} +
+
0 + ? 'mt-1 font-medium text-amber-200' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.hasSubscription + ? row.subscriptionGap > 0 + ? formatCurrency(row.subscriptionGap) + : '$0.00' + : '–'}
-
{t('limits.tracks.alreadyAboveBreakEven')}
-
0 ? 'mt-1 font-medium text-emerald-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.hasSubscription ? (row.subscriptionGain > 0 ? formatCurrency(row.subscriptionGain) : '$0.00') : '–'} +
+ {t('limits.tracks.alreadyAboveBreakEven')} +
+
0 + ? 'mt-1 font-medium text-emerald-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.hasSubscription + ? row.subscriptionGain > 0 + ? formatCurrency(row.subscriptionGain) + : '$0.00' + : '–'}
@@ -591,21 +846,98 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } - - - formatCurrency(Math.abs(coerceNumber(value)))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> + + + formatCurrency(Math.abs(coerceNumber(value)))} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> [formatCurrencyExact(Math.abs(toTooltipNumber(value))), name ?? '']} + formatter={( + value: TooltipValueType | undefined, + name: string | number | undefined, + ) => [formatCurrencyExact(Math.abs(toTooltipNumber(value))), name ?? '']} labelFormatter={(label) => formatMonthYear(String(label))} - contentStyle={{ borderRadius: 12, borderColor: 'hsl(var(--border))', background: 'color-mix(in srgb, hsl(var(--popover)) 90%, transparent)' }} + contentStyle={{ + borderRadius: 12, + borderColor: 'hsl(var(--border))', + background: 'color-mix(in srgb, hsl(var(--popover)) 90%, transparent)', + }} /> - - - - - + + + + + diff --git a/src/components/features/pdf-report/PDFReport.tsx b/src/components/features/pdf-report/PDFReport.tsx index 491c63f..3e36512 100644 --- a/src/components/features/pdf-report/PDFReport.tsx +++ b/src/components/features/pdf-report/PDFReport.tsx @@ -19,7 +19,11 @@ export function PDFReportButton({ generating, onGenerate }: PDFReportProps) { title={t('commandPalette.commands.generateReport.label')} className="h-11 flex-col gap-1 px-0 text-[10px] sm:h-9 sm:flex-row sm:gap-2 sm:px-3 sm:text-sm" > - {generating ? : } + {generating ? ( + + ) : ( + + )} {t('header.report')} ) diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index 2ab7509..46805bf 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -16,22 +16,28 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { const { t } = useTranslation() const sectionRef = useRef(null) const inView = useInView(sectionRef, { once: true, amount: 0.25 }) - const cachePerRequest = metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 - const thinkingPerRequest = metrics.totalRequests > 0 ? metrics.totalThinking / metrics.totalRequests : 0 + const cachePerRequest = + metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 + const thinkingPerRequest = + metrics.totalRequests > 0 ? metrics.totalThinking / metrics.totalRequests : 0 const inputOutputRatio = metrics.totalOutput > 0 ? metrics.totalInput / metrics.totalOutput : 0 const requestDensity = metrics.activeDays > 0 ? metrics.totalRequests / metrics.activeDays : 0 const qualityMetrics = [ { label: t('requestQuality.tokensPerRequest'), - value: metrics.hasRequestData ? formatTokens(metrics.avgTokensPerRequest) : t('common.notAvailable'), + value: metrics.hasRequestData + ? formatTokens(metrics.avgTokensPerRequest) + : t('common.notAvailable'), accent: 'var(--chart-2)', hint: t('requestQuality.tokensHint'), progress: Math.min(metrics.avgTokensPerRequest / 200_000, 1), }, { label: t('requestQuality.costPerRequest'), - value: metrics.hasRequestData ? formatCurrency(metrics.avgCostPerRequest) : t('common.notAvailable'), + value: metrics.hasRequestData + ? formatCurrency(metrics.avgCostPerRequest) + : t('common.notAvailable'), accent: 'var(--chart-4)', hint: t('requestQuality.costHint'), progress: Math.min(metrics.avgCostPerRequest / 0.25, 1), @@ -70,7 +76,9 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }} transition={{ duration: 0.35, delay: 0.05 }} > -
{item.label}
+
+ {item.label} +
{item.value}
{item.hint}
@@ -78,7 +86,9 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { className="h-full rounded-full transition-all duration-500" style={{ backgroundColor: `hsl(${item.accent})` }} initial={{ width: 0 }} - animate={inView ? { width: `${Math.max(item.progress * 100, 6)}%` } : { width: 0 }} + animate={ + inView ? { width: `${Math.max(item.progress * 100, 6)}%` } : { width: 0 } + } transition={{ duration: 0.7, delay: 0.08 }} />
@@ -87,26 +97,75 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
- -
{t('requestQuality.requestDensity')}
-
{formatNumber(Math.round(requestDensity))}
-
{t('requestQuality.averagePerActiveUnit', { unit: viewMode === 'yearly' ? t('periods.year') : viewMode === 'monthly' ? t('periods.month') : t('periods.day') })}
+ +
+ {t('requestQuality.requestDensity')} +
+
+ {formatNumber(Math.round(requestDensity))} +
+
+ {t('requestQuality.averagePerActiveUnit', { + unit: + viewMode === 'yearly' + ? t('periods.year') + : viewMode === 'monthly' + ? t('periods.month') + : t('periods.day'), + })} +
- -
{t('requestQuality.cacheHitRate')}
-
{formatPercent(metrics.cacheHitRate, 1)}
+ +
+ {t('requestQuality.cacheHitRate')} +
+
+ {formatPercent(metrics.cacheHitRate, 1)} +
{t('requestQuality.cacheHitHint')}
- -
{t('requestQuality.inputOutput')}
-
{inputOutputRatio.toFixed(2)}:1
-
{t('requestQuality.inputOutputHint')}
+ +
+ {t('requestQuality.inputOutput')} +
+
+ {inputOutputRatio.toFixed(2)}:1 +
+
+ {t('requestQuality.inputOutputHint')} +
- -
{t('requestQuality.topRequestModel')}
-
{metrics.topRequestModel?.name ?? '–'}
+ +
+ {t('requestQuality.topRequestModel')} +
+
+ {metrics.topRequestModel?.name ?? '–'} +
- {metrics.topRequestModel ? `${formatNumber(metrics.topRequestModel.requests)} ${t('common.requests')}` : t('requestQuality.noRequestLeader')} + {metrics.topRequestModel + ? `${formatNumber(metrics.topRequestModel.requests)} ${t('common.requests')}` + : t('requestQuality.noRequestLeader')}
diff --git a/src/components/features/risk/ConcentrationRisk.tsx b/src/components/features/risk/ConcentrationRisk.tsx index 7ffb936..9a2f1b6 100644 --- a/src/components/features/risk/ConcentrationRisk.tsx +++ b/src/components/features/risk/ConcentrationRisk.tsx @@ -13,11 +13,17 @@ interface ConcentrationRiskProps { function describeRisk(value: number) { if (value >= 0.6) return { label: 'high', tone: 'text-red-400 bg-red-400/10 border-red-400/20' } - if (value >= 0.35) return { label: 'medium', tone: 'text-amber-300 bg-amber-400/10 border-amber-400/20' } + if (value >= 0.35) + return { label: 'medium', tone: 'text-amber-300 bg-amber-400/10 border-amber-400/20' } return { label: 'low', tone: 'text-green-400 bg-green-400/10 border-green-400/20' } } -export function ConcentrationRisk({ topModelShare, topProviderShare, modelConcentrationIndex, providerConcentrationIndex }: ConcentrationRiskProps) { +export function ConcentrationRisk({ + topModelShare, + topProviderShare, + modelConcentrationIndex, + providerConcentrationIndex, +}: ConcentrationRiskProps) { const { t } = useTranslation() const modelRisk = describeRisk(modelConcentrationIndex) const providerRisk = describeRisk(providerConcentrationIndex) @@ -35,28 +41,52 @@ export function ConcentrationRisk({ topModelShare, topProviderShare, modelConcen
-
{t('risk.modelDependency')}
+
+ {t('risk.modelDependency')} +
{formatPercent(topModelShare, 1)}
- {t(`risk.${modelRisk.label}`)} + + {t(`risk.${modelRisk.label}`)} +
-
+
+
+
+ {t('risk.modelHint', { value: modelConcentrationIndex.toFixed(2) })}
-
{t('risk.modelHint', { value: modelConcentrationIndex.toFixed(2) })}
-
{t('risk.providerDependency')}
-
{formatPercent(topProviderShare, 1)}
+
+ {t('risk.providerDependency')} +
+
+ {formatPercent(topProviderShare, 1)} +
- {t(`risk.${providerRisk.label}`)} + + {t(`risk.${providerRisk.label}`)} +
-
+
+
+
+ {t('risk.providerHint', { value: providerConcentrationIndex.toFixed(2) })}
-
{t('risk.providerHint', { value: providerConcentrationIndex.toFixed(2) })}
diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index 9667cfd..7713b8f 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -1,6 +1,12 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' @@ -16,7 +22,18 @@ import { getDefaultDashboardSectionVisibility, } from '@/lib/dashboard-preferences' import { cn } from '@/lib/cn' -import { ArrowDown, ArrowUp, Database, Download, Eye, Filter, GripVertical, LayoutPanelTop, Settings2, Upload } from 'lucide-react' +import { + ArrowDown, + ArrowUp, + Database, + Download, + Eye, + Filter, + GripVertical, + LayoutPanelTop, + Settings2, + Upload, +} from 'lucide-react' import type { DashboardDefaultFilters, DashboardSectionOrder, @@ -62,16 +79,20 @@ function parseNumberInput(value: string): number { } function toggleSelection(values: string[], value: string) { - return values.includes(value) - ? values.filter(entry => entry !== value) - : [...values, value] + return values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value] } function normalizeSelection(values: string[]) { - return [...new Set(values.map(value => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)) + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((left, right) => + left.localeCompare(right), + ) } -function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOrder[number], direction: -1 | 1) { +function moveSection( + order: DashboardSectionOrder, + sectionId: DashboardSectionOrder[number], + direction: -1 | 1, +) { const currentIndex = order.indexOf(sectionId) const targetIndex = currentIndex + direction @@ -86,7 +107,11 @@ function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOr return next } -function reorderSections(order: DashboardSectionOrder, sourceId: DashboardSectionOrder[number], targetId: DashboardSectionOrder[number]) { +function reorderSections( + order: DashboardSectionOrder, + sourceId: DashboardSectionOrder[number], + targetId: DashboardSectionOrder[number], +) { if (sourceId === targetId) return order const sourceIndex = order.indexOf(sourceId) @@ -126,12 +151,20 @@ export function SettingsModal({ dataBusy = false, }: SettingsModalProps) { const { t } = useTranslation() - const [limitDraft, setLimitDraft] = useState(() => syncProviderLimits(limitProviders, limits)) - const [defaultFilterDraft, setDefaultFilterDraft] = useState(defaultFilters) - const [sectionVisibilityDraft, setSectionVisibilityDraft] = useState(sectionVisibility) + const [limitDraft, setLimitDraft] = useState(() => + syncProviderLimits(limitProviders, limits), + ) + const [defaultFilterDraft, setDefaultFilterDraft] = + useState(defaultFilters) + const [sectionVisibilityDraft, setSectionVisibilityDraft] = + useState(sectionVisibility) const [sectionOrderDraft, setSectionOrderDraft] = useState(sectionOrder) - const [draggedSectionId, setDraggedSectionId] = useState(null) - const [dragOverSectionId, setDragOverSectionId] = useState(null) + const [draggedSectionId, setDraggedSectionId] = useState( + null, + ) + const [dragOverSectionId, setDragOverSectionId] = useState( + null, + ) useEffect(() => { if (!open) return @@ -154,7 +187,7 @@ export function SettingsModal({ ) const updateProvider = (provider: string, patch: Partial) => { - setLimitDraft(prev => ({ + setLimitDraft((prev) => ({ ...prev, [provider]: { ...(prev[provider] ?? DEFAULT_PROVIDER_LIMIT_CONFIG), @@ -208,9 +241,10 @@ export function SettingsModal({ ? t(`settings.modal.sources.${lastLoadSource}`) : t('settings.modal.sources.unknown') const orderedSections = useMemo( - () => sectionOrderDraft - .map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]) - .filter((section) => section !== undefined), + () => + sectionOrderDraft + .map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]) + .filter((section) => section !== undefined), [sectionOrderDraft], ) @@ -222,9 +256,7 @@ export function SettingsModal({ {t('settings.modal.title')} - - {t('settings.modal.description')} - + {t('settings.modal.description')}
@@ -233,17 +265,23 @@ export function SettingsModal({
-
{t('settings.modal.lastLoaded')}
+
+ {t('settings.modal.lastLoaded')} +
{lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')}
-
{t('settings.modal.loadedVia')}
+
+ {t('settings.modal.loadedVia')} +
{loadSourceLabel}
-
{t('settings.modal.cliAutoLoad')}
+
+ {t('settings.modal.cliAutoLoad')} +
{cliAutoLoadActive ? t('common.enabled') : t('common.disabled')}
@@ -259,8 +297,12 @@ export function SettingsModal({
-
{t('settings.modal.defaultFiltersTitle')}
-

{t('settings.modal.defaultFiltersDescription')}

+
+ {t('settings.modal.defaultFiltersTitle')} +
+

+ {t('settings.modal.defaultFiltersDescription')} +

@@ -294,7 +338,9 @@ export function SettingsModal({
-
{t('settings.modal.defaultDateRange')}
+
+ {t('settings.modal.defaultDateRange')} +
{DASHBOARD_DATE_PRESETS.map((preset) => ( @@ -311,7 +359,9 @@ export function SettingsModal({
-
{t('settings.modal.filterProviders')}
+
+ {t('settings.modal.filterProviders')} +
{providerOptions.length === 0 ? (
{t('settings.modal.noProviders')} @@ -325,12 +375,17 @@ export function SettingsModal({ key={provider} type="button" aria-pressed={selected} - onClick={() => setDefaultFilterDraft(prev => ({ ...prev, providers: toggleSelection(prev.providers, provider) }))} + onClick={() => + setDefaultFilterDraft((prev) => ({ + ...prev, + providers: toggleSelection(prev.providers, provider), + })) + } className={cn( 'inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors', selected ? 'border-primary/30 bg-primary text-primary-foreground' - : getProviderBadgeClasses(provider) + : getProviderBadgeClasses(provider), )} > {provider} @@ -342,7 +397,9 @@ export function SettingsModal({
-
{t('settings.modal.filterModels')}
+
+ {t('settings.modal.filterModels')} +
{modelOptions.length === 0 ? (
{t('settings.modal.noModels')} @@ -356,12 +413,17 @@ export function SettingsModal({ key={model} type="button" aria-pressed={selected} - onClick={() => setDefaultFilterDraft(prev => ({ ...prev, models: toggleSelection(prev.models, model) }))} + onClick={() => + setDefaultFilterDraft((prev) => ({ + ...prev, + models: toggleSelection(prev.models, model), + })) + } className={cn( 'inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors', selected ? 'border-primary/30 bg-primary text-primary-foreground' - : 'border-border bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground' + : 'border-border bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground', )} > {model} @@ -381,8 +443,12 @@ export function SettingsModal({
-
{t('settings.modal.sectionVisibilityTitle')}
-

{t('settings.modal.sectionVisibilityDescription')}

+
+ {t('settings.modal.sectionVisibilityTitle')} +
+

+ {t('settings.modal.sectionVisibilityDescription')} +

@@ -473,9 +551,13 @@ export function SettingsModal({ size="icon" className="h-8 w-8" data-testid={`move-section-down-${section.id}`} - onClick={() => setSectionOrderDraft((prev) => moveSection(prev, section.id, 1))} + onClick={() => + setSectionOrderDraft((prev) => moveSection(prev, section.id, 1)) + } disabled={index === orderedSections.length - 1} - aria-label={t('settings.modal.moveSectionDown', { section: t(section.labelKey) })} + aria-label={t('settings.modal.moveSectionDown', { + section: t(section.labelKey), + })} > @@ -483,10 +565,12 @@ export function SettingsModal({ type="button" data-testid={`toggle-section-visibility-${section.id}`} aria-pressed={visible} - onClick={() => setSectionVisibilityDraft(prev => ({ - ...prev, - [section.id]: !prev[section.id], - }))} + onClick={() => + setSectionVisibilityDraft((prev) => ({ + ...prev, + [section.id]: !prev[section.id], + })) + } className={cn( 'inline-flex min-w-[88px] items-center justify-center rounded-full border px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] transition-colors', visible @@ -511,16 +595,30 @@ export function SettingsModal({
-
{t('settings.modal.settingsBackupTitle')}
-

{t('settings.modal.settingsBackupDescription')}

+
+ {t('settings.modal.settingsBackupTitle')} +
+

+ {t('settings.modal.settingsBackupDescription')} +

- - @@ -533,8 +631,12 @@ export function SettingsModal({
-
{t('settings.modal.dataBackupTitle')}
-

{t('settings.modal.dataBackupDescription')}

+
+ {t('settings.modal.dataBackupTitle')} +
+

+ {t('settings.modal.dataBackupDescription')} +

@@ -544,11 +646,21 @@ export function SettingsModal({ {t('settings.modal.dataImportReplaceHint')}

- - @@ -563,8 +675,12 @@ export function SettingsModal({
-
{t('settings.modal.providerLimitsTitle')}
-

{t('settings.modal.providerLimitsDescription')}

+
+ {t('settings.modal.providerLimitsTitle')} +
+

+ {t('settings.modal.providerLimitsDescription')} +

@@ -657,8 +800,12 @@ export function SettingsModal({ {t('common.reset')}
- - + +
diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index ea5551e..86a31d1 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select' import { cn } from '@/lib/cn' import { getModelColor, getProviderBadgeClasses, getProviderBadgeStyle } from '@/lib/model-utils' import { formatDate, formatMonthYear, localToday, toLocalDateStr } from '@/lib/formatters' @@ -53,7 +59,11 @@ function buildCalendarDays(displayMonth: Date) { return cells } -function resolveActivePreset(selectedMonth: string | null, startDate?: string, endDate?: string): DashboardDatePreset | null { +function resolveActivePreset( + selectedMonth: string | null, + startDate?: string, + endDate?: string, +): DashboardDatePreset | null { if (selectedMonth) return null if (!startDate && !endDate) return 'all' if (!startDate || !endDate) return null @@ -109,23 +119,30 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { const containerRef = useRef(null) const triggerRef = useRef(null) const overlayRef = useRef(null) - const [overlayStyle, setOverlayStyle] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 292 }) + const [overlayStyle, setOverlayStyle] = useState<{ top: number; left: number; width: number }>({ + top: 0, + left: 0, + width: 292, + }) const selectedDate = useMemo(() => parseLocalDate(value), [value]) - const [displayMonth, setDisplayMonth] = useState(() => selectedDate ?? parseLocalDate(localToday()) ?? new Date()) + const [displayMonth, setDisplayMonth] = useState( + () => selectedDate ?? parseLocalDate(localToday()) ?? new Date(), + ) const weekdayLabels = useMemo( - () => Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) - .format(new Date(Date.UTC(2024, 0, 1 + index))) - .replace('.', '') - .slice(0, 2) - ), - [] + () => + Array.from({ length: 7 }, (_, index) => + new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + .format(new Date(Date.UTC(2024, 0, 1 + index))) + .replace('.', '') + .slice(0, 2), + ), + [], ) const monthLabel = useMemo( () => displayMonth.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }), - [displayMonth] + [displayMonth], ) const calendarDays = useMemo(() => buildCalendarDays(displayMonth), [displayMonth]) @@ -148,8 +165,11 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { const viewportHeight = window.innerHeight const estimatedHeight = 330 const left = Math.min(Math.max(12, rect.left), Math.max(12, viewportWidth - width - 12)) - const showAbove = rect.bottom + estimatedHeight > viewportHeight - 12 && rect.top > estimatedHeight - const top = showAbove ? Math.max(12, rect.top - estimatedHeight - 8) : Math.min(viewportHeight - estimatedHeight - 12, rect.bottom + 8) + const showAbove = + rect.bottom + estimatedHeight > viewportHeight - 12 && rect.top > estimatedHeight + const top = showAbove + ? Math.max(12, rect.top - estimatedHeight - 8) + : Math.min(viewportHeight - estimatedHeight - 12, rect.bottom + 8) setOverlayStyle({ top, left, width }) } @@ -183,7 +203,7 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { - {open && typeof document !== 'undefined' && createPortal( -
-
- -
{monthLabel}
- -
+ {open && + typeof document !== 'undefined' && + createPortal( +
+
+ +
{monthLabel}
+ +
-
- {weekdayLabels.map((day) => ( -
- {day} -
- ))} -
+
+ {weekdayLabels.map((day) => ( +
+ {day} +
+ ))} +
-
- {calendarDays.map((day, index) => { - if (!day) { - return
- } +
+ {calendarDays.map((day, index) => { + if (!day) { + return
+ } - const iso = toLocalDateStr(day) - const isSelected = value === iso - const isToday = iso === today + const iso = toLocalDateStr(day) + const isSelected = value === iso + const isToday = iso === today - return ( - - ) - })} -
+ return ( + + ) + })} +
-
- - -
-
, - document.body - )} +
+ + +
+
, + document.body, + )}
) } export function FilterBar({ - viewMode, onViewModeChange, - selectedMonth, onMonthChange, - availableMonths, availableProviders, selectedProviders, - onToggleProvider, onClearProviders, allModels, - selectedModels, onToggleModel, onClearModels, - startDate, endDate, - onStartDateChange, onEndDateChange, + viewMode, + onViewModeChange, + selectedMonth, + onMonthChange, + availableMonths, + availableProviders, + selectedProviders, + onToggleProvider, + onClearProviders, + allModels, + selectedModels, + onToggleModel, + onClearModels, + startDate, + endDate, + onStartDateChange, + onEndDateChange, onApplyPreset, onResetAll, }: FilterBarProps) { @@ -319,16 +358,33 @@ export function FilterBar({ [selectedMonth, startDate, endDate], ) - const hasCustomFilters = selectedMonth !== null || selectedProviders.length > 0 || selectedModels.length > 0 || Boolean(startDate || endDate) || viewMode !== 'daily' + const hasCustomFilters = + selectedMonth !== null || + selectedProviders.length > 0 || + selectedModels.length > 0 || + Boolean(startDate || endDate) || + viewMode !== 'daily' return (
{t('filterBar.status')} - {selectedProviders.length > 0 ? t('filterBar.providersActive', { count: selectedProviders.length }) : t('common.allProviders')} - {selectedModels.length > 0 ? t('filterBar.modelsActive', { count: selectedModels.length }) : t('common.allModels')} - {(startDate || endDate) && {t('filterBar.dateFilterActive')}} + + {selectedProviders.length > 0 + ? t('filterBar.providersActive', { count: selectedProviders.length }) + : t('common.allProviders')} + + + {selectedModels.length > 0 + ? t('filterBar.modelsActive', { count: selectedModels.length }) + : t('common.allModels')} + + {(startDate || endDate) && ( + + {t('filterBar.dateFilterActive')} + + )}
- - {t('filterBar.until')} - + + + {t('filterBar.until')} + + - ) - })} + {availableProviders.map((provider) => { + const isSelected = selectedProviders.includes(provider) + return ( + + ) + })} {selectedProviders.length > 0 && ( -
@@ -136,38 +188,70 @@ export function Header({ dateRange, isDark, currentLanguage, helpOpen, streak, d ))}
- - ⌘K -
- -
-
- {settingsButton} -
-
- {pdfButton} -
-
- diff --git a/src/components/tables/ModelEfficiency.tsx b/src/components/tables/ModelEfficiency.tsx index 3de46b1..9bb57dc 100644 --- a/src/components/tables/ModelEfficiency.tsx +++ b/src/components/tables/ModelEfficiency.tsx @@ -4,7 +4,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' -import { formatPercent, formatTokens, periodUnit, periodLabel, formatNumber } from '@/lib/formatters' +import { + formatPercent, + formatTokens, + periodUnit, + periodLabel, + formatNumber, +} from '@/lib/formatters' import { getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' @@ -27,49 +33,98 @@ interface ModelData { } interface ModelEfficiencyProps { - modelCosts: Map + modelCosts: Map< + string, + { + cost: number + tokens: number + input?: number + output?: number + cacheRead?: number + cacheCreate?: number + thinking?: number + days: number + requests: number + costPerDay?: number + } + > totalCost: number viewMode?: ViewMode } -type SortKey = 'cost' | 'tokens' | 'costPerMillion' | 'costPerRequest' | 'tokensPerRequest' | 'share' | 'requestShare' | 'cacheShare' | 'thinkingShare' | 'days' | 'requests' | 'costPerDay' +type SortKey = + | 'cost' + | 'tokens' + | 'costPerMillion' + | 'costPerRequest' + | 'tokensPerRequest' + | 'share' + | 'requestShare' + | 'cacheShare' + | 'thinkingShare' + | 'days' + | 'requests' + | 'costPerDay' -export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: ModelEfficiencyProps) { +export function ModelEfficiency({ + modelCosts, + totalCost, + viewMode = 'daily', +}: ModelEfficiencyProps) { const { t } = useTranslation() const [sortKey, setSortKey] = useState('cost') const [sortAsc, setSortAsc] = useState(false) - const models = useMemo(() => Array.from(modelCosts.entries()).map(([name, v]) => ({ - name, - cost: v.cost, - tokens: v.tokens, - costPerMillion: v.tokens > 0 ? v.cost / (v.tokens / 1_000_000) : 0, - costPerRequest: v.requests > 0 ? v.cost / v.requests : 0, - tokensPerRequest: v.requests > 0 ? v.tokens / v.requests : 0, - share: totalCost > 0 ? (v.cost / totalCost) * 100 : 0, - requestShare: 0, - cacheShare: v.tokens > 0 ? ((v.cacheRead ?? 0) / v.tokens) * 100 : 0, - thinkingShare: v.tokens > 0 ? ((v.thinking ?? 0) / v.tokens) * 100 : 0, - days: v.days, - requests: v.requests, - costPerDay: v.days > 0 ? v.cost / v.days : 0, - })), [modelCosts, totalCost]) + const models = useMemo( + () => + Array.from(modelCosts.entries()).map(([name, v]) => ({ + name, + cost: v.cost, + tokens: v.tokens, + costPerMillion: v.tokens > 0 ? v.cost / (v.tokens / 1_000_000) : 0, + costPerRequest: v.requests > 0 ? v.cost / v.requests : 0, + tokensPerRequest: v.requests > 0 ? v.tokens / v.requests : 0, + share: totalCost > 0 ? (v.cost / totalCost) * 100 : 0, + requestShare: 0, + cacheShare: v.tokens > 0 ? ((v.cacheRead ?? 0) / v.tokens) * 100 : 0, + thinkingShare: v.tokens > 0 ? ((v.thinking ?? 0) / v.tokens) * 100 : 0, + days: v.days, + requests: v.requests, + costPerDay: v.days > 0 ? v.cost / v.days : 0, + })), + [modelCosts, totalCost], + ) - const totalRequests = useMemo(() => models.reduce((sum, model) => sum + model.requests, 0), [models]) - const enrichedModels = useMemo(() => models.map(model => ({ - ...model, - requestShare: totalRequests > 0 ? (model.requests / totalRequests) * 100 : 0, - })), [models, totalRequests]) + const totalRequests = useMemo( + () => models.reduce((sum, model) => sum + model.requests, 0), + [models], + ) + const enrichedModels = useMemo( + () => + models.map((model) => ({ + ...model, + requestShare: totalRequests > 0 ? (model.requests / totalRequests) * 100 : 0, + })), + [models, totalRequests], + ) - const sorted = useMemo(() => [...enrichedModels].sort((a, b) => { - const diff = a[sortKey] - b[sortKey] - return sortAsc ? diff : -diff - }), [enrichedModels, sortAsc, sortKey]) + const sorted = useMemo( + () => + [...enrichedModels].sort((a, b) => { + const diff = a[sortKey] - b[sortKey] + return sortAsc ? diff : -diff + }), + [enrichedModels, sortAsc, sortKey], + ) const topModel = sorted[0] ?? null - const mostEfficient = useMemo(() => [...models] - .filter(model => model.tokens > 0) - .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, [models]) + const mostEfficient = useMemo( + () => + [...models] + .filter((model) => model.tokens > 0) + .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, + [models], + ) const handleSort = (key: SortKey) => { if (key === sortKey) { @@ -83,14 +138,14 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M const SortHeader = ({ label, field }: { label: string; field: SortKey }) => (
) @@ -104,40 +159,73 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M {t('tables.modelEfficiency.title')} - {t('tables.modelEfficiency.count', { count: models.length })} + + {t('tables.modelEfficiency.count', { count: models.length })} +
-
{t('tables.modelEfficiency.topModel')}
+
+ {t('tables.modelEfficiency.topModel')} +
{topModel?.name ?? '–'}
-
{topModel ? t('tables.modelEfficiency.share', { value: formatPercent(topModel.share, 0) }) : '–'}
+
+ {topModel + ? t('tables.modelEfficiency.share', { value: formatPercent(topModel.share, 0) }) + : '–'} +
-
{t('tables.modelEfficiency.mostEfficient')}
+
+ {t('tables.modelEfficiency.mostEfficient')} +
{mostEfficient?.name ?? '–'}
-
{mostEfficient ? t('tables.modelEfficiency.share', { value: formatPercent(mostEfficient.share, 0) }) : '–'}
+
+ {mostEfficient + ? t('tables.modelEfficiency.share', { + value: formatPercent(mostEfficient.share, 0), + }) + : '–'} +
-
{t('tables.modelEfficiency.totalRequests')}
+
+ {t('tables.modelEfficiency.totalRequests')} +
{formatNumber(totalRequests)}
-
{models.length > 0 ? t('tables.modelEfficiency.perModel', { value: (totalRequests / models.length).toFixed(0) }) : '–'}
+
+ {models.length > 0 + ? t('tables.modelEfficiency.perModel', { + value: (totalRequests / models.length).toFixed(0), + }) + : '–'} +
-
{t('tables.modelEfficiency.topModelTokens')}
-
{topModel ? formatTokens(topModel.tokens) : '–'}
-
{topModel ? `${topModel.days} ${periodLabel(viewMode, true)}` : '–'}
+
+ {t('tables.modelEfficiency.topModelTokens')} +
+
+ {topModel ? formatTokens(topModel.tokens) : '–'} +
+
+ {topModel ? `${topModel.days} ${periodLabel(viewMode, true)}` : '–'} +
- {sorted.map(model => ( + {sorted.map((model) => (
- + {model.name}
@@ -145,8 +233,12 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
-
-
{t('tables.modelEfficiency.share', { value: formatPercent(model.share, 1) })}
+
+ +
+
+ {t('tables.modelEfficiency.share', { value: formatPercent(model.share, 1) })} +
@@ -156,7 +248,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
$/1M
-
+
+ +
{t('common.requests')}
@@ -164,7 +258,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
$/Req
-
+
+ +
Tokens/Req
@@ -183,7 +279,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
{t('comparison.metric')} + {t('comparison.metric')} + {labelB} {labelA}{t('comparison.delta')} + {t('comparison.delta')} +
{row.label} {row.b} + + {row.a} {row.delta.hasData ? ( - - {row.delta.arrow}{formatPercent(row.delta.value)} + + {row.delta.arrow} + {formatPercent(row.delta.value)} - ) : '–'} + ) : ( + '–' + )}
handleSort(field)} > {label} - +
- + @@ -191,21 +289,41 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M - + - - + + - {sorted.map(model => ( - + {sorted.map((model) => ( + - - + + - - - + + + diff --git a/src/components/tables/ProviderEfficiency.tsx b/src/components/tables/ProviderEfficiency.tsx index 9b671fe..de5391e 100644 --- a/src/components/tables/ProviderEfficiency.tsx +++ b/src/components/tables/ProviderEfficiency.tsx @@ -4,7 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' -import { formatPercent, formatTokens, periodLabel, periodUnit, formatNumber } from '@/lib/formatters' +import { + formatPercent, + formatTokens, + periodLabel, + periodUnit, + formatNumber, +} from '@/lib/formatters' import { getProviderBadgeClasses } from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' @@ -24,31 +30,54 @@ interface ProviderEfficiencyProps { viewMode?: ViewMode } -type SortKey = 'cost' | 'share' | 'requests' | 'tokens' | 'costPerRequest' | 'costPerMillion' | 'cacheShare' +type SortKey = + | 'cost' + | 'share' + | 'requests' + | 'tokens' + | 'costPerRequest' + | 'costPerMillion' + | 'cacheShare' -export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'daily' }: ProviderEfficiencyProps) { +export function ProviderEfficiency({ + providerMetrics, + totalCost, + viewMode = 'daily', +}: ProviderEfficiencyProps) { const { t } = useTranslation() const [sortKey, setSortKey] = useState('cost') const [sortAsc, setSortAsc] = useState(false) - const rows = useMemo(() => ( - Array.from(providerMetrics.entries()).map(([name, value]) => ({ - name, - ...value, - share: totalCost > 0 ? (value.cost / totalCost) * 100 : 0, - costPerRequest: value.requests > 0 ? value.cost / value.requests : 0, - costPerMillion: value.tokens > 0 ? value.cost / (value.tokens / 1_000_000) : 0, - cacheShare: value.tokens > 0 ? (value.cacheRead / value.tokens) * 100 : 0, - })) - ), [providerMetrics, totalCost]) + const rows = useMemo( + () => + Array.from(providerMetrics.entries()).map(([name, value]) => ({ + name, + ...value, + share: totalCost > 0 ? (value.cost / totalCost) * 100 : 0, + costPerRequest: value.requests > 0 ? value.cost / value.requests : 0, + costPerMillion: value.tokens > 0 ? value.cost / (value.tokens / 1_000_000) : 0, + cacheShare: value.tokens > 0 ? (value.cacheRead / value.tokens) * 100 : 0, + })), + [providerMetrics, totalCost], + ) - const sorted = useMemo(() => [...rows].sort((a, b) => { - const diff = a[sortKey] - b[sortKey] - return sortAsc ? diff : -diff - }), [rows, sortAsc, sortKey]) + const sorted = useMemo( + () => + [...rows].sort((a, b) => { + const diff = a[sortKey] - b[sortKey] + return sortAsc ? diff : -diff + }), + [rows, sortAsc, sortKey], + ) const lead = sorted[0] ?? null - const efficient = useMemo(() => [...rows].filter(row => row.tokens > 0).sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, [rows]) + const efficient = useMemo( + () => + [...rows] + .filter((row) => row.tokens > 0) + .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, + [rows], + ) const totalRequests = useMemo(() => rows.reduce((sum, row) => sum + row.requests, 0), [rows]) const handleSort = (key: SortKey) => { @@ -60,7 +89,13 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai } const SortHeader = ({ label, field }: { label: string; field: SortKey }) => ( -
{t('tables.modelEfficiency.model')} + {t('tables.modelEfficiency.model')} +
- + {model.name} - + {getModelProvider(model.name)} @@ -220,17 +338,33 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M -
+
{formatPercent(model.share)}
{formatNumber(model.requests)}{formatPercent(model.requestShare, 1)} + {formatNumber(model.requests)} + + {formatPercent(model.requestShare, 1)} + {formatTokens(model.tokensPerRequest)}{formatPercent(model.cacheShare, 1)}{formatPercent(model.thinkingShare, 1)} + {formatTokens(model.tokensPerRequest)} + + {formatPercent(model.cacheShare, 1)} + + {formatPercent(model.thinkingShare, 1)} + handleSort(field)}> + handleSort(field)} + > {label} @@ -76,62 +111,111 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai {t('tables.providerEfficiency.title')} - {t('tables.providerEfficiency.count', { count: rows.length })} + + {t('tables.providerEfficiency.count', { count: rows.length })} +
-
{t('tables.providerEfficiency.leadProvider')}
+
+ {t('tables.providerEfficiency.leadProvider')} +
{lead?.name ?? '–'}
-
{lead ? t('tables.providerEfficiency.share', { value: formatPercent(lead.share, 0) }) : '–'}
+
+ {lead + ? t('tables.providerEfficiency.share', { value: formatPercent(lead.share, 0) }) + : '–'} +
-
{t('tables.providerEfficiency.mostEfficient')}
+
+ {t('tables.providerEfficiency.mostEfficient')} +
{efficient?.name ?? '–'}
-
{efficient ? `${efficient.costPerMillion.toFixed(2)} $/1M` : '–'}
+
+ {efficient ? `${efficient.costPerMillion.toFixed(2)} $/1M` : '–'} +
-
{t('tables.providerEfficiency.totalRequests')}
+
+ {t('tables.providerEfficiency.totalRequests')} +
{formatNumber(totalRequests)}
-
{rows.length > 0 ? t('tables.providerEfficiency.perProvider', { value: (totalRequests / rows.length).toFixed(0) }) : '–'}
+
+ {rows.length > 0 + ? t('tables.providerEfficiency.perProvider', { + value: (totalRequests / rows.length).toFixed(0), + }) + : '–'} +
-
{t('tables.providerEfficiency.avgPerUnit', { unit: periodUnit(viewMode) })}
-
{lead ? : '–'}
-
{lead ? `${lead.days} ${periodLabel(viewMode, true)}` : '–'}
+
+ {t('tables.providerEfficiency.avgPerUnit', { unit: periodUnit(viewMode) })} +
+
+ {lead ? ( + + ) : ( + '–' + )} +
+
+ {lead ? `${lead.days} ${periodLabel(viewMode, true)}` : '–'} +
- {sorted.map(row => ( + {sorted.map((row) => (
- + {row.name} -
{t('tables.providerEfficiency.share', { value: formatPercent(row.share, 1) })}
+
+ {t('tables.providerEfficiency.share', { value: formatPercent(row.share, 1) })} +
-
-
{formatNumber(row.requests)} {t('tables.providerEfficiency.req')}
+
+ +
+
+ {formatNumber(row.requests)} {t('tables.providerEfficiency.req')} +
-
{t('tables.providerEfficiency.tokens')}
+
+ {t('tables.providerEfficiency.tokens')} +
{formatTokens(row.tokens)}
$/Req
-
+
+ +
$/1M
-
+
+ +
-
{t('tables.providerEfficiency.cacheShare')}
+
+ {t('tables.providerEfficiency.cacheShare')} +
{formatPercent(row.cacheShare, 1)}
@@ -143,31 +227,61 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai - + - - + + - {sorted.map(row => ( - + {sorted.map((row) => ( + - - - - - - - + + + + + + + ))} diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index d1c3ff7..9b02008 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -6,7 +6,12 @@ import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' import { formatCurrency, formatDate, formatPercent, formatNumber } from '@/lib/formatters' -import { normalizeModelName, getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' +import { + normalizeModelName, + getModelColor, + getModelProvider, + getProviderBadgeClasses, +} from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' import { periodLabel } from '@/lib/formatters' @@ -30,9 +35,12 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP const items = [...data] items.sort((a, b) => { switch (sortKey) { - case 'date': return sortAsc ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date) - case 'cost': return sortAsc ? a.totalCost - b.totalCost : b.totalCost - a.totalCost - case 'tokens': return sortAsc ? a.totalTokens - b.totalTokens : b.totalTokens - a.totalTokens + case 'date': + return sortAsc ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date) + case 'cost': + return sortAsc ? a.totalCost - b.totalCost : b.totalCost - a.totalCost + case 'tokens': + return sortAsc ? a.totalTokens - b.totalTokens : b.totalTokens - a.totalTokens case 'costPerM': { const aPerM = a.totalTokens > 0 ? a.totalCost / (a.totalTokens / 1e6) : 0 const bPerM = b.totalTokens > 0 ? b.totalCost / (b.totalTokens / 1e6) : 0 @@ -44,15 +52,30 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP }, [data, sortKey, sortAsc]) const displayed = showAll ? sorted : sorted.slice(0, 30) - const chronological = useMemo(() => [...data].sort((a, b) => a.date.localeCompare(b.date)), [data]) + const chronological = useMemo( + () => [...data].sort((a, b) => a.date.localeCompare(b.date)), + [data], + ) const benchmarkMap = useMemo(() => { - const map = new Map() + const map = new Map< + string, + { prevCostDelta?: number; avgCost7?: number; avgRequests7?: number } + >() chronological.forEach((day, index) => { const previous = index > 0 ? chronological[index - 1] : null const window = chronological.slice(Math.max(0, index - 7), index) - const prevCostDelta = previous && previous.totalCost > 0 ? ((day.totalCost - previous.totalCost) / previous.totalCost) * 100 : null - const avgCost7 = window.length > 0 ? window.reduce((sum, item) => sum + item.totalCost, 0) / window.length : null - const avgRequests7 = window.length > 0 ? window.reduce((sum, item) => sum + item.requestCount, 0) / window.length : null + const prevCostDelta = + previous && previous.totalCost > 0 + ? ((day.totalCost - previous.totalCost) / previous.totalCost) * 100 + : null + const avgCost7 = + window.length > 0 + ? window.reduce((sum, item) => sum + item.totalCost, 0) / window.length + : null + const avgRequests7 = + window.length > 0 + ? window.reduce((sum, item) => sum + item.requestCount, 0) / window.length + : null map.set(day.date, { ...(prevCostDelta !== null ? { prevCostDelta } : {}), ...(avgCost7 !== null ? { avgCost7 } : {}), @@ -62,24 +85,27 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP return map }, [chronological]) - const maxCost = useMemo( - () => Math.max(...data.map(d => d.totalCost), 0), - [data] - ) + const maxCost = useMemo(() => Math.max(...data.map((d) => d.totalCost), 0), [data]) const summary = useMemo(() => { if (data.length === 0) return null const totalCost = data.reduce((sum, day) => sum + day.totalCost, 0) const totalTokens = data.reduce((sum, day) => sum + day.totalTokens, 0) const totalRequests = data.reduce((sum, day) => sum + day.requestCount, 0) - const cacheShare = totalTokens > 0 ? data.reduce((sum, day) => sum + day.cacheReadTokens, 0) / totalTokens * 100 : 0 + const cacheShare = + totalTokens > 0 + ? (data.reduce((sum, day) => sum + day.cacheReadTokens, 0) / totalTokens) * 100 + : 0 const top = [...data].sort((a, b) => b.totalCost - a.totalCost)[0] ?? null return { totalCost, totalTokens, totalRequests, cacheShare, top } }, [data]) const handleSort = (key: SortKey) => { if (key === sortKey) setSortAsc(!sortAsc) - else { setSortKey(key); setSortAsc(false) } + else { + setSortKey(key) + setSortAsc(false) + } } return ( @@ -88,12 +114,20 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
- {viewMode === 'monthly' ? t('tables.recentDays.monthsDetail') : viewMode === 'yearly' ? t('tables.recentDays.yearsDetail') : t('tables.recentDays.daysDetail')} + {viewMode === 'monthly' + ? t('tables.recentDays.monthsDetail') + : viewMode === 'yearly' + ? t('tables.recentDays.yearsDetail') + : t('tables.recentDays.daysDetail')} - {t('tables.recentDays.showing', { shown: displayed.length, total: sorted.length, unit: periodLabel(viewMode, true) })} + {t('tables.recentDays.showing', { + shown: displayed.length, + total: sorted.length, + unit: periodLabel(viewMode, true), + })}
{sorted.length > 30 && ( @@ -106,34 +140,57 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP {summary && (
-
{t('tables.recentDays.totalCost')}
-
+
+ {t('tables.recentDays.totalCost')} +
+
+ +
-
{t('tables.recentDays.totalTokens')}
-
+
+ {t('tables.recentDays.totalTokens')} +
+
+ +
-
{t('tables.recentDays.requests')}
-
+
+ {t('tables.recentDays.requests')} +
+
+ +
-
{t('tables.recentDays.cacheReadShare')}
+
+ {t('tables.recentDays.cacheReadShare')} +
{formatPercent(summary.cacheShare, 1)}
-
{t('tables.recentDays.peak')}
-
{summary.top ? formatDate(summary.top.date) : '–'}
-
{summary.top ? `${summary.top.totalCost.toFixed(2)} USD` : '–'}
+
+ {t('tables.recentDays.peak')} +
+
+ {summary.top ? formatDate(summary.top.date) : '–'} +
+
+ {summary.top ? `${summary.top.totalCost.toFixed(2)} USD` : '–'} +
)}
- {displayed.map(day => { + {displayed.map((day) => { const costPerM = day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 const uniqueModels = day.modelBreakdowns - .map(mb => ({ name: normalizeModelName(mb.modelName), provider: getModelProvider(mb.modelName) })) - .filter((entry, i, a) => a.findIndex(item => item.name === entry.name) === i) + .map((mb) => ({ + name: normalizeModelName(mb.modelName), + provider: getModelProvider(mb.modelName), + })) + .filter((entry, i, a) => a.findIndex((item) => item.name === entry.name) === i) return (
{t('common.input')}
-
+
+ +
{t('common.output')}
-
+
+ +
$/1M
-
+
+ +
@@ -181,7 +253,12 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP }} > {name} - + {provider} @@ -194,7 +271,10 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{viewMode === 'daily' && benchmarkMap.get(day.date)?.avgCost7 !== undefined && (
- {t('tables.recentDays.avg7d')} {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} · {t('tables.recentDays.reqAvg')} {benchmarkMap.get(day.date)!.avgRequests7?.toFixed(0) ?? '–'} + {t('tables.recentDays.avg7d')}{' '} + {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} ·{' '} + {t('tables.recentDays.reqAvg')}{' '} + {benchmarkMap.get(day.date)!.avgRequests7?.toFixed(0) ?? '–'}
)} @@ -206,30 +286,85 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{t('tables.providerEfficiency.provider')} + {t('tables.providerEfficiency.provider')} +
- + {row.name} {formatPercent(row.share, 1)}{formatNumber(row.requests)}{formatTokens(row.tokens)}{formatPercent(row.cacheShare, 1)} + + + {formatPercent(row.share, 1)} + + {formatNumber(row.requests)} + + {formatTokens(row.tokens)} + + + + + + {formatPercent(row.cacheShare, 1)} +
- + - + + + - - - - - - - - + + + - - {displayed.map(day => { - const costPerM = day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 + {displayed.map((day) => { + const costPerM = + day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 const intensity = maxCost > 0 ? day.totalCost / maxCost : 0 return ( onClickDay?.(day.date)} > - + ) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index c4ad3b9..fa164cb 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -16,12 +16,11 @@ const badgeVariants = cva( defaultVariants: { variant: 'default', }, - } + }, ) export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e41c3ae..90a1ea0 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -26,12 +26,11 @@ const buttonVariants = cva( variant: 'default', size: 'default', }, - } + }, ) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } @@ -39,13 +38,9 @@ const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( - + ) - } + }, ) Button.displayName = 'Button' diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 6b3d103..1a278a3 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -4,50 +4,53 @@ import { cn } from '@/lib/cn' type CardProps = React.ComponentPropsWithoutRef -const Card = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -) +const Card = React.forwardRef(({ className, ...props }, ref) => ( + +)) Card.displayName = 'Card' const CardHeader = React.forwardRef>( ({ className, ...props }, ref) => (
- ) + ), ) CardHeader.displayName = 'CardHeader' const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -

- ) +

+ ), ) CardTitle.displayName = 'CardTitle' const CardContent = React.forwardRef>( ({ className, ...props }, ref) => (
- ) + ), ) CardContent.displayName = 'CardContent' -const CardDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -

- ) -) +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) CardDescription.displayName = 'CardDescription' export { Card, CardHeader, CardTitle, CardContent, CardDescription } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 1b135d4..ac05f3b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef< ref={ref} className={cn( 'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', - className + className, )} {...props} /> @@ -32,7 +32,7 @@ const DialogContent = React.forwardRef< ref={ref} className={cn( 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl', - className + className, )} {...props} > @@ -73,4 +73,12 @@ const DialogDescription = React.forwardRef< )) DialogDescription.displayName = 'DialogDescription' -export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription } +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/expandable-card.tsx b/src/components/ui/expandable-card.tsx index 612b730..89ff069 100644 --- a/src/components/ui/expandable-card.tsx +++ b/src/components/ui/expandable-card.tsx @@ -12,7 +12,13 @@ interface ExpandableCardProps { stats?: { label: string; value: string }[] } -export function ExpandableCard({ children, title, className, expandedClassName, stats }: ExpandableCardProps) { +export function ExpandableCard({ + children, + title, + className, + expandedClassName, + stats, +}: ExpandableCardProps) { const { t } = useTranslation() const [expanded, setExpanded] = useState(false) @@ -30,10 +36,12 @@ export function ExpandableCard({ children, title, className, expandedClassName,

- + {title ?? t('common.expand')} Expanded card view with additional metrics and full content. @@ -41,9 +49,11 @@ export function ExpandableCard({ children, title, className, expandedClassName,
{stats && stats.length > 0 && (
- {stats.map(s => ( + {stats.map((s) => (
-
{s.label}
+
+ {s.label} +
{s.value}
))} diff --git a/src/components/ui/formatted-value.tsx b/src/components/ui/formatted-value.tsx index 1e95e27..98df64c 100644 --- a/src/components/ui/formatted-value.tsx +++ b/src/components/ui/formatted-value.tsx @@ -1,5 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { formatCurrency, formatCurrencyExact, formatTokens, formatTokensExact, formatNumber, formatPercent } from '@/lib/formatters' +import { + formatCurrency, + formatCurrencyExact, + formatTokens, + formatTokensExact, + formatNumber, + formatPercent, +} from '@/lib/formatters' import { cn } from '@/lib/cn' type ValueType = 'currency' | 'tokens' | 'number' | 'percent' @@ -8,7 +15,7 @@ interface FormattedValueProps { value: number type: ValueType className?: string - decimals?: number // for percent type + decimals?: number // for percent type label?: string insight?: string } @@ -29,7 +36,14 @@ const EXACT_FORMATTERS: Record string> = { percent: (v) => formatPercent(v, 4), } -export function FormattedValue({ value, type, className, decimals, label, insight }: FormattedValueProps) { +export function FormattedValue({ + value, + type, + className, + decimals, + label, + insight, +}: FormattedValueProps) { const abbreviated = FORMATTERS[type](value, decimals) const exact = EXACT_FORMATTERS[type](value) @@ -43,15 +57,26 @@ export function FormattedValue({ value, type, className, decimals, label, insigh return ( - + {abbreviated}
- {label &&
{label}
} + {label && ( +
+ {label} +
+ )}
{exact}
- {insight &&
{insight}
} + {insight && ( +
{insight}
+ )}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 9f431c9..7e5d8b0 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef< ref={ref} className={cn( 'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', - className + className, )} {...props} > @@ -36,13 +36,17 @@ const SelectContent = React.forwardRef< className={cn( 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95', position === 'popper' && 'translate-y-1', - className + className, )} position={position} {...props} > {children} @@ -59,7 +63,7 @@ const SelectItem = React.forwardRef< ref={ref} className={cn( 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', - className + className, )} {...props} > diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index 498ee9c..39791e1 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -1,12 +1,7 @@ import { cn } from '@/lib/cn' function Skeleton({ className, ...props }: React.HTMLAttributes) { - return ( -
- ) + return
} function MetricCardSkeleton() { @@ -24,7 +19,9 @@ function MetricCardSkeleton() { function ChartCardSkeleton({ className }: { className?: string }) { return ( -
+
@@ -56,19 +53,25 @@ function DashboardSkeleton() {
- {[1, 2, 3].map(i => )} + {[1, 2, 3].map((i) => ( + + ))}
{/* Primary metrics */}
- {[1, 2, 3, 4, 5, 6].map(i => )} + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))}
{/* Secondary metrics */}
- {[1, 2, 3, 4].map(i => )} + {[1, 2, 3, 4].map((i) => ( + + ))}
{/* Chart area */} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 8dc4a82..418d6ac 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -20,21 +20,21 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { const addToast = useCallback((message: string, type: Toast['type'] = 'info') => { const id = Math.random().toString(36).slice(2) - setToasts(prev => [...prev, { id, message, type }]) + setToasts((prev) => [...prev, { id, message, type }]) setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) + setToasts((prev) => prev.filter((t) => t.id !== id)) }, 4000) }, []) const removeToast = useCallback((id: string) => { - setToasts(prev => prev.filter(t => t.id !== id)) + setToasts((prev) => prev.filter((t) => t.id !== id)) }, []) return ( {children}
- {toasts.map(toast => ( + {toasts.map((toast) => (
diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index 11487c2..8c502fd 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -55,12 +55,30 @@ export function useAppSettings(availableProviders: string[]) { ) const setTheme = useCallback((theme: AppTheme) => mutation.mutateAsync({ theme }), [mutation]) - const setLanguage = useCallback((language: AppLanguage) => mutation.mutateAsync({ language }), [mutation]) - const setProviderLimits = useCallback((limits: ProviderLimits) => mutation.mutateAsync({ providerLimits: limits }), [mutation]) - const setDefaultFilters = useCallback((defaultFilters: DashboardDefaultFilters) => mutation.mutateAsync({ defaultFilters }), [mutation]) - const setSectionVisibility = useCallback((sectionVisibility: DashboardSectionVisibility) => mutation.mutateAsync({ sectionVisibility }), [mutation]) - const setSectionOrder = useCallback((sectionOrder: DashboardSectionOrder) => mutation.mutateAsync({ sectionOrder }), [mutation]) - const saveSettings = useCallback((patch: UpdateSettingsRequest) => mutation.mutateAsync(patch), [mutation]) + const setLanguage = useCallback( + (language: AppLanguage) => mutation.mutateAsync({ language }), + [mutation], + ) + const setProviderLimits = useCallback( + (limits: ProviderLimits) => mutation.mutateAsync({ providerLimits: limits }), + [mutation], + ) + const setDefaultFilters = useCallback( + (defaultFilters: DashboardDefaultFilters) => mutation.mutateAsync({ defaultFilters }), + [mutation], + ) + const setSectionVisibility = useCallback( + (sectionVisibility: DashboardSectionVisibility) => mutation.mutateAsync({ sectionVisibility }), + [mutation], + ) + const setSectionOrder = useCallback( + (sectionOrder: DashboardSectionOrder) => mutation.mutateAsync({ sectionOrder }), + [mutation], + ) + const saveSettings = useCallback( + (patch: UpdateSettingsRequest) => mutation.mutateAsync(patch), + [mutation], + ) return { settings, diff --git a/src/hooks/use-computed-metrics.ts b/src/hooks/use-computed-metrics.ts index e8a2744..e8cab01 100644 --- a/src/hooks/use-computed-metrics.ts +++ b/src/hooks/use-computed-metrics.ts @@ -1,7 +1,13 @@ import { useMemo } from 'react' import type { DailyUsage } from '@/types' import { computeMetrics, computeModelCosts, computeProviderMetrics } from '@/lib/calculations' -import { toCostChartData, toModelCostChartData, toTokenChartData, toRequestChartData, toWeekdayData } from '@/lib/data-transforms' +import { + toCostChartData, + toModelCostChartData, + toTokenChartData, + toRequestChartData, + toWeekdayData, +} from '@/lib/data-transforms' import { getUniqueModels } from '@/lib/model-utils' export function useComputedMetrics(data: DailyUsage[]) { @@ -13,7 +19,7 @@ export function useComputedMetrics(data: DailyUsage[]) { const tokenChartData = useMemo(() => toTokenChartData(data), [data]) const requestChartData = useMemo(() => toRequestChartData(data), [data]) const weekdayData = useMemo(() => toWeekdayData(data), [data]) - const allModels = useMemo(() => getUniqueModels(data.map(d => d.modelsUsed)), [data]) + const allModels = useMemo(() => getUniqueModels(data.map((d) => d.modelsUsed)), [data]) const modelPieData = useMemo(() => { return Array.from(modelCosts.entries()) @@ -21,13 +27,16 @@ export function useComputedMetrics(data: DailyUsage[]) { .sort((a, b) => b.value - a.value) }, [modelCosts]) - const tokenPieData = useMemo(() => [ - { name: 'Input', value: metrics.totalInput }, - { name: 'Output', value: metrics.totalOutput }, - { name: 'Cache Write', value: metrics.totalCacheCreate }, - { name: 'Cache Read', value: metrics.totalCacheRead }, - { name: 'Thinking', value: metrics.totalThinking }, - ], [metrics]) + const tokenPieData = useMemo( + () => [ + { name: 'Input', value: metrics.totalInput }, + { name: 'Output', value: metrics.totalOutput }, + { name: 'Cache Write', value: metrics.totalCacheCreate }, + { name: 'Cache Read', value: metrics.totalCacheRead }, + { name: 'Thinking', value: metrics.totalThinking }, + ], + [metrics], + ) return { metrics, diff --git a/src/hooks/use-dashboard-filters.ts b/src/hooks/use-dashboard-filters.ts index 9d9773d..85f144d 100644 --- a/src/hooks/use-dashboard-filters.ts +++ b/src/hooks/use-dashboard-filters.ts @@ -1,7 +1,16 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react' import type { DailyUsage, DashboardDefaultFilters, DashboardDatePreset, ViewMode } from '@/types' import { DEFAULT_DASHBOARD_FILTERS } from '@/lib/dashboard-preferences' -import { filterByDateRange, filterByModels, filterByMonth, sortByDate, getAvailableMonths, getDateRange, aggregateToDailyFormat, filterByProviders } from '@/lib/data-transforms' +import { + filterByDateRange, + filterByModels, + filterByMonth, + sortByDate, + getAvailableMonths, + getDateRange, + aggregateToDailyFormat, + filterByProviders, +} from '@/lib/data-transforms' import { toLocalDateStr } from '@/lib/formatters' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' @@ -36,18 +45,21 @@ function resolvePresetRange(preset: DashboardDatePreset) { } function sanitizeDefaultFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters) { - const providers = new Set(getUniqueProviders(data.map(entry => entry.modelsUsed))) - const models = new Set(getUniqueModels(data.map(entry => entry.modelsUsed))) + const providers = new Set(getUniqueProviders(data.map((entry) => entry.modelsUsed))) + const models = new Set(getUniqueModels(data.map((entry) => entry.modelsUsed))) return { viewMode: defaultFilters.viewMode, datePreset: defaultFilters.datePreset, - providers: defaultFilters.providers.filter(provider => providers.has(provider)), - models: defaultFilters.models.filter(model => models.has(model)), + providers: defaultFilters.providers.filter((provider) => providers.has(provider)), + models: defaultFilters.models.filter((model) => models.has(model)), } } -export function useDashboardFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters = DEFAULT_DASHBOARD_FILTERS) { +export function useDashboardFilters( + data: DailyUsage[], + defaultFilters: DashboardDefaultFilters = DEFAULT_DASHBOARD_FILTERS, +) { const resolvedDefaults = useMemo( () => sanitizeDefaultFilters(data, defaultFilters), [data, defaultFilters], @@ -56,32 +68,34 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar () => resolvePresetRange(resolvedDefaults.datePreset), [resolvedDefaults.datePreset], ) - const defaultFiltersKey = useMemo( - () => JSON.stringify(resolvedDefaults), - [resolvedDefaults], - ) + const defaultFiltersKey = useMemo(() => JSON.stringify(resolvedDefaults), [resolvedDefaults]) const [viewModeState, setViewModeState] = useState(resolvedDefaults.viewMode) const [selectedMonthState, setSelectedMonthState] = useState(null) - const [selectedProvidersState, setSelectedProvidersState] = useState(resolvedDefaults.providers) + const [selectedProvidersState, setSelectedProvidersState] = useState( + resolvedDefaults.providers, + ) const [selectedModelsState, setSelectedModelsState] = useState(resolvedDefaults.models) const [startDateState, setStartDateState] = useState(defaultRange.startDate) const [endDateState, setEndDateState] = useState(defaultRange.endDate) const userModifiedRef = useRef(false) const appliedDefaultsKeyRef = useRef(defaultFiltersKey) - const applyDefaultFilters = useCallback((nextDefaultFilters: DashboardDefaultFilters = defaultFilters) => { - const sanitizedDefaults = sanitizeDefaultFilters(data, nextDefaultFilters) - const nextRange = resolvePresetRange(sanitizedDefaults.datePreset) - userModifiedRef.current = false - appliedDefaultsKeyRef.current = JSON.stringify(sanitizedDefaults) - setViewModeState(sanitizedDefaults.viewMode) - setSelectedMonthState(null) - setSelectedProvidersState(sanitizedDefaults.providers) - setSelectedModelsState(sanitizedDefaults.models) - setStartDateState(nextRange.startDate) - setEndDateState(nextRange.endDate) - }, [data, defaultFilters]) + const applyDefaultFilters = useCallback( + (nextDefaultFilters: DashboardDefaultFilters = defaultFilters) => { + const sanitizedDefaults = sanitizeDefaultFilters(data, nextDefaultFilters) + const nextRange = resolvePresetRange(sanitizedDefaults.datePreset) + userModifiedRef.current = false + appliedDefaultsKeyRef.current = JSON.stringify(sanitizedDefaults) + setViewModeState(sanitizedDefaults.viewMode) + setSelectedMonthState(null) + setSelectedProvidersState(sanitizedDefaults.providers) + setSelectedModelsState(sanitizedDefaults.models) + setStartDateState(nextRange.startDate) + setEndDateState(nextRange.endDate) + }, + [data, defaultFilters], + ) useEffect(() => { if (appliedDefaultsKeyRef.current === defaultFiltersKey || userModifiedRef.current) { @@ -119,8 +133,8 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar const toggleProvider = useCallback((provider: string) => { userModifiedRef.current = true - setSelectedProvidersState(prev => - prev.includes(provider) ? prev.filter(p => p !== provider) : [...prev, provider] + setSelectedProvidersState((prev) => + prev.includes(provider) ? prev.filter((p) => p !== provider) : [...prev, provider], ) setSelectedModelsState([]) }, []) @@ -133,8 +147,8 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar const toggleModel = useCallback((model: string) => { userModifiedRef.current = true - setSelectedModelsState(prev => - prev.includes(model) ? prev.filter(m => m !== model) : [...prev, model] + setSelectedModelsState((prev) => + prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model], ) }, []) @@ -181,17 +195,31 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar }, [filteredDailyData, viewModeState]) const availableMonths = useMemo(() => getAvailableMonths(data), [data]) - const availableProviders = useMemo(() => getUniqueProviders(preProviderFilteredData.map(d => d.modelsUsed)), [preProviderFilteredData]) - const availableModels = useMemo(() => getUniqueModels(preModelFilteredData.map(d => d.modelsUsed)), [preModelFilteredData]) + const availableProviders = useMemo( + () => getUniqueProviders(preProviderFilteredData.map((d) => d.modelsUsed)), + [preProviderFilteredData], + ) + const availableModels = useMemo( + () => getUniqueModels(preModelFilteredData.map((d) => d.modelsUsed)), + [preModelFilteredData], + ) const dateRange = useMemo(() => getDateRange(filteredDailyData), [filteredDailyData]) return { - viewMode: viewModeState, setViewMode, - selectedMonth: selectedMonthState, setSelectedMonth, - selectedProviders: selectedProvidersState, toggleProvider, clearProviders, - selectedModels: selectedModelsState, toggleModel, clearModels, - startDate: startDateState, setStartDate, - endDate: endDateState, setEndDate, + viewMode: viewModeState, + setViewMode, + selectedMonth: selectedMonthState, + setSelectedMonth, + selectedProviders: selectedProvidersState, + toggleProvider, + clearProviders, + selectedModels: selectedModelsState, + toggleModel, + clearModels, + startDate: startDateState, + setStartDate, + endDate: endDateState, + setEndDate, resetAll, applyDefaultFilters, applyPreset, diff --git a/src/hooks/use-provider-limits.ts b/src/hooks/use-provider-limits.ts index 03d01a2..90fec20 100644 --- a/src/hooks/use-provider-limits.ts +++ b/src/hooks/use-provider-limits.ts @@ -6,7 +6,7 @@ export function useProviderLimits(availableProviders: string[]) { const [limits, setLimits] = useState({}) useEffect(() => { - setLimits(prev => syncProviderLimits(availableProviders, prev)) + setLimits((prev) => syncProviderLimits(availableProviders, prev)) }, [availableProviders]) return { diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 923c89f..8804b10 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -14,7 +14,7 @@ export function useTheme() { } }, [isDark]) - const toggle = useCallback(() => setIsDark(prev => !prev), []) + const toggle = useCallback(() => setIsDark((prev) => !prev), []) return { isDark, toggle } } diff --git a/src/index.css b/src/index.css index b821be7..aea50a9 100644 --- a/src/index.css +++ b/src/index.css @@ -1,8 +1,9 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @theme { - --font-sans: "Avenir Next", "Segoe UI Variable", "Segoe UI", "Helvetica Neue", "Arial Nova", sans-serif; - --font-mono: "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + --font-sans: + 'Avenir Next', 'Segoe UI Variable', 'Segoe UI', 'Helvetica Neue', 'Arial Nova', sans-serif; + --font-mono: 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace; --radius: 0.5rem; --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -34,13 +35,21 @@ } @keyframes accordion-down { - from { height: 0; } - to { height: var(--radix-accordion-content-height); } + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } } @keyframes accordion-up { - from { height: var(--radix-accordion-content-height); } - to { height: 0; } + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } } :root { @@ -156,8 +165,13 @@ body { } @keyframes subtle-glow { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } } .pdf-rendering * { @@ -169,7 +183,7 @@ body { background: hsl(var(--card)) !important; } -.pdf-rendering [class*="backdrop-blur"] { +.pdf-rendering [class*='backdrop-blur'] { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: hsl(var(--card)) !important; diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 6899179..f7a4595 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -41,9 +41,7 @@ export function normalizeStoredProviderLimits(value: unknown): ProviderLimits { } export function normalizeDataLoadSource(value: unknown): DataLoadSource { - return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' - ? value - : null + return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null } export function normalizeStoredTimestamp(value: unknown): string | null { @@ -56,7 +54,7 @@ export function normalizeStoredTimestamp(value: unknown): string | null { } export function normalizeAppSettings(value: unknown): AppSettings { - const source = value && typeof value === 'object' ? value as Partial : {} + const source = value && typeof value === 'object' ? (value as Partial) : {} return { language: normalizeAppLanguage(source.language), diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 8d226f0..15a291b 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -1,8 +1,22 @@ -export interface CheckEvent { tool: string; status: string; method?: string; version?: string } -export interface ProgressEvent { message: string } -export interface StderrEvent { line: string } -export interface SuccessEvent { days: number; totalCost: number } -export interface ErrorEvent { message: string } +export interface CheckEvent { + tool: string + status: string + method?: string + version?: string +} +export interface ProgressEvent { + message: string +} +export interface StderrEvent { + line: string +} +export interface SuccessEvent { + days: number + totalCost: number +} +export interface ErrorEvent { + message: string +} type AutoImportTranslationVars = Record type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string @@ -22,7 +36,9 @@ function translateAutoImportMessage(message: string, t: AutoImportTranslator) { } if (message.startsWith('Lade Nutzungsdaten via ')) { - return t('autoImportModal.loadingUsageData', { command: message.replace('Lade Nutzungsdaten via ', '').replace(/\.\.\.$/, '') }) + return t('autoImportModal.loadingUsageData', { + command: message.replace('Lade Nutzungsdaten via ', '').replace(/\.\.\.$/, ''), + }) } const processingMatch = message.match(/^Verarbeite Nutzungsdaten\.\.\. \((\d+)s\)$/) @@ -49,14 +65,17 @@ function translateAutoImportMessage(message: string, t: AutoImportTranslator) { return message } -export function startAutoImport(callbacks: { - onCheck: (data: CheckEvent) => void - onProgress: (data: ProgressEvent) => void - onStderr: (data: StderrEvent) => void - onSuccess: (data: SuccessEvent) => void - onError: (data: ErrorEvent) => void - onDone: () => void -}, t: AutoImportTranslator = (key) => key): { close: () => void } { +export function startAutoImport( + callbacks: { + onCheck: (data: CheckEvent) => void + onProgress: (data: ProgressEvent) => void + onStderr: (data: StderrEvent) => void + onSuccess: (data: SuccessEvent) => void + onError: (data: ErrorEvent) => void + onDone: () => void + }, + t: AutoImportTranslator = (key) => key, +): { close: () => void } { const es = new EventSource('/api/auto-import/stream') es.addEventListener('check', (event) => { diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index bb6e9ae..c598f83 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -1,16 +1,46 @@ -import type { AggregateMetrics, CacheHitRateByModelChartDataPoint, DailyUsage, DashboardMetrics } from '@/types' +import type { + AggregateMetrics, + CacheHitRateByModelChartDataPoint, + DailyUsage, + DashboardMetrics, +} from '@/types' import { getModelProvider, normalizeModelName } from './model-utils' export function computeMetrics(data: DailyUsage[]): DashboardMetrics { if (data.length === 0) { return { - totalCost: 0, totalTokens: 0, activeDays: 0, topModel: null, - topRequestModel: null, topTokenModel: null, - topModelShare: 0, topThreeModelsShare: 0, topProvider: null, providerCount: 0, hasRequestData: false, - cacheHitRate: 0, costPerMillion: 0, avgTokensPerRequest: 0, avgCostPerRequest: 0, avgModelsPerEntry: 0, avgDailyCost: 0, avgRequestsPerDay: 0, - topDay: null, cheapestDay: null, busiestWeek: null, weekendCostShare: null, totalInput: 0, totalOutput: 0, - totalCacheRead: 0, totalCacheCreate: 0, totalThinking: 0, totalRequests: 0, weekOverWeekChange: null, - requestVolatility: 0, modelConcentrationIndex: 0, providerConcentrationIndex: 0, + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 0, + hasRequestData: false, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 0, + avgRequestsPerDay: 0, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, } } @@ -48,7 +78,8 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { totalCacheCreate += d.cacheCreationTokens totalThinking += d.thinkingTokens totalRequests += d.requestCount - if (d.requestCount > 0 || d.modelBreakdowns.some(mb => mb.requestCount > 0)) hasRequestData = true + if (d.requestCount > 0 || d.modelBreakdowns.some((mb) => mb.requestCount > 0)) + hasRequestData = true activeDays += d._aggregatedDays ?? 1 totalModelsUsed += d.modelsUsed.length @@ -63,7 +94,15 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { for (const mb of d.modelBreakdowns) { const name = normalizeModelName(mb.modelName) modelCosts.set(name, (modelCosts.get(name) ?? 0) + mb.cost) - modelTokens.set(name, (modelTokens.get(name) ?? 0) + mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens) + modelTokens.set( + name, + (modelTokens.get(name) ?? 0) + + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens, + ) modelRequests.set(name, (modelRequests.get(name) ?? 0) + mb.requestCount) const provider = getModelProvider(mb.modelName) providerCosts.set(provider, (providerCosts.get(provider) ?? 0) + mb.cost) @@ -85,16 +124,23 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { } let topRequestModel: { name: string; requests: number } | null = null for (const [name, requests] of modelRequests) { - if (!topRequestModel || requests > topRequestModel.requests) topRequestModel = { name, requests } + if (!topRequestModel || requests > topRequestModel.requests) + topRequestModel = { name, requests } } let topTokenModel: { name: string; tokens: number } | null = null for (const [name, tokens] of modelTokens) { if (!topTokenModel || tokens > topTokenModel.tokens) topTokenModel = { name, tokens } } const topModelShare = topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0 - const topThreeModelsShare = totalCost > 0 - ? [...modelCosts.values()].sort((a, b) => b - a).slice(0, 3).reduce((sum, value) => sum + value, 0) / totalCost * 100 - : 0 + const topThreeModelsShare = + totalCost > 0 + ? ([...modelCosts.values()] + .sort((a, b) => b - a) + .slice(0, 3) + .reduce((sum, value) => sum + value, 0) / + totalCost) * + 100 + : 0 let topProvider: { name: string; cost: number; share: number } | null = null for (const [name, cost] of providerCosts) { @@ -105,37 +151,67 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { const busiestWeek = computeBusiestWeek(data) const weekendCostShare = weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null - const requestValues = data.map(entry => entry.requestCount) + const requestValues = data.map((entry) => entry.requestCount) const requestVolatility = stdDev(requestValues) - const modelConcentrationIndex = totalCost > 0 - ? [...modelCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 - const providerConcentrationIndex = totalCost > 0 - ? [...providerCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 + const modelConcentrationIndex = + totalCost > 0 + ? [...modelCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + const providerConcentrationIndex = + totalCost > 0 + ? [...providerCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 // Week-over-week change const weekOverWeekChange = computeWeekOverWeekChange(data) return { - totalCost, totalTokens, activeDays, topModel, topRequestModel, topTokenModel, topModelShare, topThreeModelsShare, topProvider, providerCount: providerCosts.size, hasRequestData, cacheHitRate, - costPerMillion, avgTokensPerRequest, avgCostPerRequest, avgModelsPerEntry, avgDailyCost, avgRequestsPerDay, topDay, cheapestDay, busiestWeek, weekendCostShare, - totalInput, totalOutput, totalCacheRead, totalCacheCreate, - totalThinking, totalRequests, + totalCost, + totalTokens, + activeDays, + topModel, + topRequestModel, + topTokenModel, + topModelShare, + topThreeModelsShare, + topProvider, + providerCount: providerCosts.size, + hasRequestData, + cacheHitRate, + costPerMillion, + avgTokensPerRequest, + avgCostPerRequest, + avgModelsPerEntry, + avgDailyCost, + avgRequestsPerDay, + topDay, + cheapestDay, + busiestWeek, + weekendCostShare, + totalInput, + totalOutput, + totalCacheRead, + totalCacheCreate, + totalThinking, + totalRequests, weekOverWeekChange, - requestVolatility, modelConcentrationIndex, providerConcentrationIndex, + requestVolatility, + modelConcentrationIndex, + providerConcentrationIndex, } } -function computeBusiestWeek(data: DailyUsage[]): { start: string; end: string; cost: number } | null { +function computeBusiestWeek( + data: DailyUsage[], +): { start: string; end: string; cost: number } | null { const sorted = data - .filter(entry => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) .sort((a, b) => a.date.localeCompare(b.date)) if (sorted.length < 3) return null @@ -174,7 +250,7 @@ function computeBusiestWeek(data: DailyUsage[]): { start: string; end: string; c } export function computeWeekOverWeekChange(data: DailyUsage[]): number | null { - if (data.some(entry => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null + if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null if (data.length < 14) return null const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) const last7 = sorted.slice(-7) @@ -211,21 +287,58 @@ export function computeMovingAverage(values: number[], window = 7): (number | un return result } -export function computeModelCosts(data: DailyUsage[]): Map { - const map = new Map - }>() +export function computeModelCosts(data: DailyUsage[]): Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + } +> { + const map = new Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + _dates: Set + } + >() for (const d of data) { const entryDays = d._aggregatedDays ?? 1 for (const mb of d.modelBreakdowns) { const name = normalizeModelName(mb.modelName) - const existing = map.get(name) ?? { cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0, requests: 0, days: 0, _dates: new Set() } + const existing = map.get(name) ?? { + cost: 0, + tokens: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + thinking: 0, + requests: 0, + days: 0, + _dates: new Set(), + } existing.cost += mb.cost - existing.tokens += mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens + existing.tokens += + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens existing.input += mb.inputTokens existing.output += mb.outputTokens existing.cacheRead += mb.cacheReadTokens @@ -270,7 +383,12 @@ export function computeProviderMetrics(data: DailyUsage[]): Map [ - provider, - { - cost: value.cost, - tokens: value.tokens, - input: value.input, - output: value.output, - cacheRead: value.cacheRead, - cacheCreate: value.cacheCreate, - thinking: value.thinking, - requests: value.requests, - days: value.days, - }, - ])) + return new Map( + Array.from(map.entries()).map(([provider, value]) => [ + provider, + { + cost: value.cost, + tokens: value.tokens, + input: value.input, + output: value.output, + cacheRead: value.cacheRead, + cacheCreate: value.cacheCreate, + thinking: value.thinking, + requests: value.requests, + days: value.days, + }, + ]), + ) } -function computeCacheHitRate(cacheRead: number, cacheCreate: number, input: number, output: number, thinking: number): number { +function computeCacheHitRate( + cacheRead: number, + cacheCreate: number, + input: number, + output: number, + thinking: number, +): number { const base = cacheRead + cacheCreate + input + output + thinking return base > 0 ? (cacheRead / base) * 100 : 0 } -export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByModelChartDataPoint[] { +export function computeCacheHitRateByModel( + data: DailyUsage[], +): CacheHitRateByModelChartDataPoint[] { if (data.length === 0) return [] const sorted = [...data] - .filter(entry => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) .sort((a, b) => a.date.localeCompare(b.date)) if (sorted.length === 0) return [] const trailingWindow = sorted.slice(-Math.min(7, sorted.length)) - const totals = new Map() - const trailing = new Map() + const totals = new Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >() + const trailing = new Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >() const updateMetricMap = ( - target: Map, + target: Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >, modelName: string, cacheRead: number, cacheCreate: number, @@ -323,7 +460,13 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo thinking: number, ) => { const key = normalizeModelName(modelName) - const current = target.get(key) ?? { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 } + const current = target.get(key) ?? { + cacheRead: 0, + cacheCreate: 0, + input: 0, + output: 0, + thinking: 0, + } current.cacheRead += cacheRead current.cacheCreate += cacheCreate current.input += input @@ -360,39 +503,94 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo } } - const sumMetricMap = (source: Map) => ( - Array.from(source.values()).reduce((acc, metric) => ({ - cacheRead: acc.cacheRead + metric.cacheRead, - cacheCreate: acc.cacheCreate + metric.cacheCreate, - input: acc.input + metric.input, - output: acc.output + metric.output, - thinking: acc.thinking + metric.thinking, - }), { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 }) - ) + const sumMetricMap = ( + source: Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >, + ) => + Array.from(source.values()).reduce( + (acc, metric) => ({ + cacheRead: acc.cacheRead + metric.cacheRead, + cacheCreate: acc.cacheCreate + metric.cacheCreate, + input: acc.input + metric.input, + output: acc.output + metric.output, + thinking: acc.thinking + metric.thinking, + }), + { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 }, + ) const totalAll = sumMetricMap(totals) const trailingAll = sumMetricMap(trailing) - const rows: CacheHitRateByModelChartDataPoint[] = [{ - model: 'Total', - totalRate: computeCacheHitRate(totalAll.cacheRead, totalAll.cacheCreate, totalAll.input, totalAll.output, totalAll.thinking), - trailing7Rate: computeCacheHitRate(trailingAll.cacheRead, trailingAll.cacheCreate, trailingAll.input, trailingAll.output, trailingAll.thinking), - totalBaseTokens: totalAll.cacheRead + totalAll.cacheCreate + totalAll.input + totalAll.output + totalAll.thinking, - trailing7BaseTokens: trailingAll.cacheRead + trailingAll.cacheCreate + trailingAll.input + trailingAll.output + trailingAll.thinking, - }] + const rows: CacheHitRateByModelChartDataPoint[] = [ + { + model: 'Total', + totalRate: computeCacheHitRate( + totalAll.cacheRead, + totalAll.cacheCreate, + totalAll.input, + totalAll.output, + totalAll.thinking, + ), + trailing7Rate: computeCacheHitRate( + trailingAll.cacheRead, + trailingAll.cacheCreate, + trailingAll.input, + trailingAll.output, + trailingAll.thinking, + ), + totalBaseTokens: + totalAll.cacheRead + + totalAll.cacheCreate + + totalAll.input + + totalAll.output + + totalAll.thinking, + trailing7BaseTokens: + trailingAll.cacheRead + + trailingAll.cacheCreate + + trailingAll.input + + trailingAll.output + + trailingAll.thinking, + }, + ] const modelRows = Array.from(totals.entries()) .map(([model, metric]) => { - const trailingMetric = trailing.get(model) ?? { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 } + const trailingMetric = trailing.get(model) ?? { + cacheRead: 0, + cacheCreate: 0, + input: 0, + output: 0, + thinking: 0, + } return { model, - totalRate: computeCacheHitRate(metric.cacheRead, metric.cacheCreate, metric.input, metric.output, metric.thinking), - trailing7Rate: computeCacheHitRate(trailingMetric.cacheRead, trailingMetric.cacheCreate, trailingMetric.input, trailingMetric.output, trailingMetric.thinking), - totalBaseTokens: metric.cacheRead + metric.cacheCreate + metric.input + metric.output + metric.thinking, - trailing7BaseTokens: trailingMetric.cacheRead + trailingMetric.cacheCreate + trailingMetric.input + trailingMetric.output + trailingMetric.thinking, + totalRate: computeCacheHitRate( + metric.cacheRead, + metric.cacheCreate, + metric.input, + metric.output, + metric.thinking, + ), + trailing7Rate: computeCacheHitRate( + trailingMetric.cacheRead, + trailingMetric.cacheCreate, + trailingMetric.input, + trailingMetric.output, + trailingMetric.thinking, + ), + totalBaseTokens: + metric.cacheRead + metric.cacheCreate + metric.input + metric.output + metric.thinking, + trailing7BaseTokens: + trailingMetric.cacheRead + + trailingMetric.cacheCreate + + trailingMetric.input + + trailingMetric.output + + trailingMetric.thinking, } }) - .filter(entry => entry.totalBaseTokens > 0) + .filter((entry) => entry.totalBaseTokens > 0) .sort((a, b) => b.totalBaseTokens - a.totalBaseTokens) return [...rows, ...modelRows] @@ -400,17 +598,20 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo export function computeAnomalies(data: DailyUsage[], threshold = 2): DailyUsage[] { if (data.length < 3) return [] - const costs = data.map(d => d.totalCost) + const costs = data.map((d) => d.totalCost) const mean = costs.reduce((s, v) => s + v, 0) / costs.length const stdDev = Math.sqrt(costs.reduce((s, v) => s + (v - mean) ** 2, 0) / costs.length) if (stdDev === 0) return [] - return data.filter(d => Math.abs(d.totalCost - mean) > threshold * stdDev) + return data.filter((d) => Math.abs(d.totalCost - mean) > threshold * stdDev) } export function linearRegression(values: number[]): { slope: number; intercept: number } { const n = values.length if (n < 2) return { slope: 0, intercept: values[0] ?? 0 } - let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0 + let sumX = 0, + sumY = 0, + sumXY = 0, + sumXX = 0 for (let i = 0; i < n; i++) { const value = values[i] if (value === undefined) continue @@ -454,7 +655,7 @@ function winsorizedAverage(values: number[], limit = 0.15): number { if (values.length < 4) return average(values) const low = quantile(values, limit) const high = quantile(values, 1 - limit) - return average(values.map(value => Math.min(high, Math.max(low, value)))) + return average(values.map((value) => Math.min(high, Math.max(low, value)))) } function clamp(value: number, min: number, max: number): number { @@ -470,12 +671,12 @@ export function computeCurrentMonthForecast(data: DailyUsage[]) { const lastDate = new Date(lastEntry.date + 'T00:00:00') const currentMonth = lastEntry.date.slice(0, 7) - const monthData = sorted.filter(d => d.date.startsWith(currentMonth)) + const monthData = sorted.filter((d) => d.date.startsWith(currentMonth)) if (monthData.length < 2) return null const monthTotal = monthData.reduce((sum, day) => sum + day.totalCost, 0) - const monthCostMap = new Map(monthData.map(day => [day.date, day.totalCost])) + const monthCostMap = new Map(monthData.map((day) => [day.date, day.totalCost])) const daysInMonth = new Date(lastDate.getFullYear(), lastDate.getMonth() + 1, 0).getDate() const elapsedDays = lastDate.getDate() const remainingDays = Math.max(0, daysInMonth - elapsedDays) @@ -489,24 +690,30 @@ export function computeCurrentMonthForecast(data: DailyUsage[]) { } }) - const elapsedCosts = elapsedCalendarSeries.map(point => point.cost) + const elapsedCosts = elapsedCalendarSeries.map((point) => point.cost) const monthToDateAvg = monthTotal / elapsedDays const recentWindow = elapsedCosts.slice(-Math.min(7, elapsedCosts.length)) - const previousWindow = elapsedCosts.slice(-Math.min(14, elapsedCosts.length), -Math.min(7, elapsedCosts.length)) + const previousWindow = elapsedCosts.slice( + -Math.min(14, elapsedCosts.length), + -Math.min(7, elapsedCosts.length), + ) const recentAvg = winsorizedAverage(recentWindow) const previousAvg = previousWindow.length > 0 ? winsorizedAverage(previousWindow) : 0 - const trendAdjustment = previousAvg > 0 - ? clamp((recentAvg - previousAvg) / previousAvg, -0.35, 0.35) * 0.25 - : 0 - const projectedDailyBurn = Math.max(0, (monthToDateAvg * 0.6 + recentAvg * 0.4) * (1 + trendAdjustment)) + const trendAdjustment = + previousAvg > 0 ? clamp((recentAvg - previousAvg) / previousAvg, -0.35, 0.35) * 0.25 : 0 + const projectedDailyBurn = Math.max( + 0, + (monthToDateAvg * 0.6 + recentAvg * 0.4) * (1 + trendAdjustment), + ) const volatility = stdDev(recentWindow.length >= 4 ? recentWindow : elapsedCosts) const lowerDaily = Math.max(0, projectedDailyBurn - volatility) const upperDaily = projectedDailyBurn + volatility const forecastTotal = monthTotal + projectedDailyBurn * remainingDays - const dailyAvgTrend = previousAvg > 0 - ? { avg: recentAvg, change: ((recentAvg - previousAvg) / previousAvg) * 100 } - : { avg: recentAvg, change: 0 } + const dailyAvgTrend = + previousAvg > 0 + ? { avg: recentAvg, change: ((recentAvg - previousAvg) / previousAvg) * 100 } + : { avg: recentAvg, change: 0 } let confidence = 'low' if (elapsedDays >= 14 && volatility <= projectedDailyBurn * 0.75) confidence = 'high' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a0a9c0..75f0193 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -9,8 +9,8 @@ export const MODEL_COLORS: Record = { 'GPT-5.4': 'hsl(12, 78%, 56%)', 'GPT-5': 'hsl(12, 78%, 56%)', 'Gemini 3 Flash Preview': 'hsl(48, 92%, 50%)', - 'Gemini': 'hsl(48, 92%, 50%)', - 'OpenCode': 'hsl(186, 58%, 48%)', + Gemini: 'hsl(48, 92%, 50%)', + OpenCode: 'hsl(186, 58%, 48%)', } export const MODEL_COLOR_DEFAULT = 'hsl(220, 8%, 56%)' @@ -23,7 +23,10 @@ export const VIEW_MODE_LABELS = { yearly: 'Jahresansicht', } as const -export const MODEL_PRICES: Record = { +export const MODEL_PRICES: Record< + string, + { input: number; output: number; cacheRead: number; cacheWrite: number } +> = { 'Opus 4.6': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, 'Opus 4.5': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, 'Sonnet 4.6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, @@ -32,6 +35,6 @@ export const MODEL_PRICES: Record { + const header = + 'date,totalCost,totalTokens,inputTokens,outputTokens,cacheCreationTokens,cacheReadTokens,thinkingTokens,requestCount,models' + const rows = data.map((d) => { const models = d.modelBreakdowns - .map(mb => normalizeModelName(mb.modelName)) + .map((mb) => normalizeModelName(mb.modelName)) .filter((v, i, a) => a.indexOf(v) === i) .join('; ') return `${d.date},${d.totalCost.toFixed(2)},${d.totalTokens},${d.inputTokens},${d.outputTokens},${d.cacheCreationTokens},${d.cacheReadTokens},${d.thinkingTokens},${d.requestCount},"${models}"` diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts index e7d40ef..3267f93 100644 --- a/src/lib/dashboard-preferences.ts +++ b/src/lib/dashboard-preferences.ts @@ -21,12 +21,28 @@ export const DASHBOARD_SECTION_DEFINITIONS: DashboardSectionDefinition[] = [ { id: 'today', domId: 'today', labelKey: 'helpPanel.sectionLabels.today' }, { id: 'currentMonth', domId: 'current-month', labelKey: 'helpPanel.sectionLabels.currentMonth' }, { id: 'activity', domId: 'activity', labelKey: 'helpPanel.sectionLabels.activity' }, - { id: 'forecastCache', domId: 'forecast-cache', labelKey: 'helpPanel.sectionLabels.forecastCache' }, + { + id: 'forecastCache', + domId: 'forecast-cache', + labelKey: 'helpPanel.sectionLabels.forecastCache', + }, { id: 'limits', domId: 'limits', labelKey: 'helpPanel.sectionLabels.limits' }, { id: 'costAnalysis', domId: 'charts', labelKey: 'helpPanel.sectionLabels.costAnalysis' }, - { id: 'tokenAnalysis', domId: 'token-analysis', labelKey: 'helpPanel.sectionLabels.tokenAnalysis' }, - { id: 'requestAnalysis', domId: 'request-analysis', labelKey: 'helpPanel.sectionLabels.requestAnalysis' }, - { id: 'advancedAnalysis', domId: 'advanced-analysis', labelKey: 'helpPanel.sectionLabels.advancedAnalysis' }, + { + id: 'tokenAnalysis', + domId: 'token-analysis', + labelKey: 'helpPanel.sectionLabels.tokenAnalysis', + }, + { + id: 'requestAnalysis', + domId: 'request-analysis', + labelKey: 'helpPanel.sectionLabels.requestAnalysis', + }, + { + id: 'advancedAnalysis', + domId: 'advanced-analysis', + labelKey: 'helpPanel.sectionLabels.advancedAnalysis', + }, { id: 'comparisons', domId: 'comparisons', labelKey: 'helpPanel.sectionLabels.comparisons' }, { id: 'tables', domId: 'tables', labelKey: 'helpPanel.sectionLabels.tables' }, ] @@ -42,10 +58,13 @@ export const DEFAULT_DASHBOARD_FILTERS: DashboardDefaultFilters = { } export function getDefaultDashboardSectionVisibility(): DashboardSectionVisibility { - return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ - ...visibility, - [section.id]: true, - }), {} as DashboardSectionVisibility) + return DASHBOARD_SECTION_DEFINITIONS.reduce( + (visibility, section) => ({ + ...visibility, + [section.id]: true, + }), + {} as DashboardSectionVisibility, + ) } export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { @@ -55,26 +74,29 @@ export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { function normalizeStringList(value: unknown): string[] { if (!Array.isArray(value)) return [] - return [...new Set(value - .filter((entry): entry is string => typeof entry === 'string') - .map(entry => entry.trim()) - .filter(Boolean))] + return [ + ...new Set( + value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ] } export function normalizeDashboardDatePreset(value: unknown): DashboardDatePreset { return DASHBOARD_DATE_PRESETS.includes(value as DashboardDatePreset) - ? value as DashboardDatePreset + ? (value as DashboardDatePreset) : 'all' } export function normalizeDashboardViewMode(value: unknown): ViewMode { - return DASHBOARD_VIEW_MODES.includes(value as ViewMode) - ? value as ViewMode - : 'daily' + return DASHBOARD_VIEW_MODES.includes(value as ViewMode) ? (value as ViewMode) : 'daily' } export function normalizeDashboardDefaultFilters(value: unknown): DashboardDefaultFilters { - const source = value && typeof value === 'object' ? value as Partial : {} + const source = + value && typeof value === 'object' ? (value as Partial) : {} return { viewMode: normalizeDashboardViewMode(source.viewMode), @@ -85,13 +107,20 @@ export function normalizeDashboardDefaultFilters(value: unknown): DashboardDefau } export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility { - const source = value && typeof value === 'object' ? value as Partial : {} + const source = + value && typeof value === 'object' ? (value as Partial) : {} const defaults = getDefaultDashboardSectionVisibility() - return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ - ...visibility, - [section.id]: typeof source[section.id] === 'boolean' ? Boolean(source[section.id]) : defaults[section.id], - }), {} as DashboardSectionVisibility) + return DASHBOARD_SECTION_DEFINITIONS.reduce( + (visibility, section) => ({ + ...visibility, + [section.id]: + typeof source[section.id] === 'boolean' + ? Boolean(source[section.id]) + : defaults[section.id], + }), + {} as DashboardSectionVisibility, + ) } export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder { @@ -101,9 +130,10 @@ export function normalizeDashboardSectionOrder(value: unknown): DashboardSection return defaults } - const incoming = value.filter((sectionId): sectionId is DashboardSectionId => ( - typeof sectionId === 'string' && defaults.includes(sectionId as DashboardSectionId) - )) + const incoming = value.filter( + (sectionId): sectionId is DashboardSectionId => + typeof sectionId === 'string' && defaults.includes(sectionId as DashboardSectionId), + ) const uniqueIncoming = [...new Set(incoming)] const missing = defaults.filter((sectionId) => !uniqueIncoming.includes(sectionId)) diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 2356844..1b9d64a 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -1,9 +1,19 @@ -import type { DailyUsage, ChartDataPoint, TokenChartDataPoint, RequestChartDataPoint, WeekdayData, ViewMode } from '@/types' +import type { + DailyUsage, + ChartDataPoint, + TokenChartDataPoint, + RequestChartDataPoint, + WeekdayData, + ViewMode, +} from '@/types' import { computeMovingAverage } from './calculations' import { getModelProvider, normalizeModelName } from './model-utils' import { getCurrentLocale } from './i18n' -function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: DailyUsage['modelBreakdowns']): DailyUsage { +function recalculateDayFromBreakdowns( + day: DailyUsage, + filteredBreakdowns: DailyUsage['modelBreakdowns'], +): DailyUsage { let totalCost = 0 let inputTokens = 0 let outputTokens = 0 @@ -25,7 +35,8 @@ function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: Daily return { ...day, totalCost, - totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, inputTokens, outputTokens, cacheCreationTokens, @@ -33,12 +44,12 @@ function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: Daily thinkingTokens, requestCount, modelBreakdowns: filteredBreakdowns, - modelsUsed: filteredBreakdowns.map(mb => mb.modelName), + modelsUsed: filteredBreakdowns.map((mb) => mb.modelName), } } export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[] { - return data.filter(d => { + return data.filter((d) => { if (start && d.date < start) return false if (end && d.date > end) return false return true @@ -50,9 +61,9 @@ export function filterByModels(data: DailyUsage[], selectedModels: string[]): Da const selected = new Set(selectedModels) return data - .map(d => { - const filteredBreakdowns = d.modelBreakdowns.filter(mb => - selected.has(normalizeModelName(mb.modelName)) + .map((d) => { + const filteredBreakdowns = d.modelBreakdowns.filter((mb) => + selected.has(normalizeModelName(mb.modelName)), ) if (filteredBreakdowns.length === 0) return null @@ -66,9 +77,9 @@ export function filterByProviders(data: DailyUsage[], selectedProviders: string[ const selected = new Set(selectedProviders) return data - .map(d => { - const filteredBreakdowns = d.modelBreakdowns.filter(mb => - selected.has(getModelProvider(mb.modelName)) + .map((d) => { + const filteredBreakdowns = d.modelBreakdowns.filter((mb) => + selected.has(getModelProvider(mb.modelName)), ) if (filteredBreakdowns.length === 0) return null @@ -79,7 +90,7 @@ export function filterByProviders(data: DailyUsage[], selectedProviders: string[ export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[] { if (!month) return data - return data.filter(d => d.date.startsWith(month)) + return data.filter((d) => d.date.startsWith(month)) } export function sortByDate(data: DailyUsage[]): DailyUsage[] { @@ -113,7 +124,7 @@ export function getDateRange(data: DailyUsage[]): { start: string; end: string } export function toCostChartData(data: DailyUsage[]): ChartDataPoint[] { const sorted = sortByDate(data) - const costs = sorted.map(d => d.totalCost) + const costs = sorted.map((d) => d.totalCost) const ma7 = computeMovingAverage(costs) let cumulative = 0 return sorted.map((d, i) => { @@ -131,7 +142,9 @@ export function toCostChartData(data: DailyUsage[]): ChartDataPoint[] { }) } -export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Record)[] { +export function toModelCostChartData( + data: DailyUsage[], +): (ChartDataPoint & Record)[] { const sorted = sortByDate(data) const allModels = new Set() for (const d of sorted) { @@ -180,12 +193,12 @@ export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Reco export function toTokenChartData(data: DailyUsage[]): TokenChartDataPoint[] { const sorted = sortByDate(data) - const totals = sorted.map(d => d.totalTokens) - const inputs = sorted.map(d => d.inputTokens) - const outputs = sorted.map(d => d.outputTokens) - const cacheWrites = sorted.map(d => d.cacheCreationTokens) - const cacheReads = sorted.map(d => d.cacheReadTokens) - const thinking = sorted.map(d => d.thinkingTokens) + const totals = sorted.map((d) => d.totalTokens) + const inputs = sorted.map((d) => d.inputTokens) + const outputs = sorted.map((d) => d.outputTokens) + const cacheWrites = sorted.map((d) => d.cacheCreationTokens) + const cacheReads = sorted.map((d) => d.cacheReadTokens) + const thinking = sorted.map((d) => d.thinkingTokens) const ma7 = computeMovingAverage(totals) const inputMA7 = computeMovingAverage(inputs) const outputMA7 = computeMovingAverage(outputs) @@ -217,7 +230,7 @@ export function toTokenChartData(data: DailyUsage[]): TokenChartDataPoint[] { export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] { const sorted = sortByDate(data) - const totals = sorted.map(d => d.requestCount) + const totals = sorted.map((d) => d.requestCount) const totalMA7 = computeMovingAverage(totals) const allModels = new Set() @@ -280,7 +293,7 @@ export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) .format(new Date(Date.UTC(2024, 0, 1 + index))) .replace('.', '') - .slice(0, 2) + .slice(0, 2), ) for (const d of data) { // Skip non-daily entries (monthly "2026-03" or yearly "2026") @@ -302,9 +315,8 @@ export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { export function aggregateToDailyFormat(data: DailyUsage[], mode: ViewMode): DailyUsage[] { if (mode === 'daily') return data - const groupKey = mode === 'monthly' - ? (date: string) => date.slice(0, 7) - : (date: string) => date.slice(0, 4) + const groupKey = + mode === 'monthly' ? (date: string) => date.slice(0, 7) : (date: string) => date.slice(0, 4) const map = new Map() @@ -336,17 +348,47 @@ export function aggregateToDailyFormat(data: DailyUsage[], mode: ViewMode): Dail return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)) } -export function aggregateByMonth(data: DailyUsage[]): { period: string; totalCost: number; totalTokens: number; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; thinkingTokens: number; requestCount: number; days: number; modelBreakdowns: DailyUsage['modelBreakdowns'] }[] { - const map = new Map() +export function aggregateByMonth(data: DailyUsage[]): { + period: string + totalCost: number + totalTokens: number + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + requestCount: number + days: number + modelBreakdowns: DailyUsage['modelBreakdowns'] +}[] { + const map = new Map< + string, + { + totalCost: number + totalTokens: number + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + requestCount: number + days: number + modelBreakdowns: DailyUsage['modelBreakdowns'] + } + >() for (const d of data) { const month = d.date.slice(0, 7) const existing = map.get(month) ?? { - totalCost: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0, requestCount: 0, days: 0, modelBreakdowns: [], + totalCost: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + requestCount: 0, + days: 0, + modelBreakdowns: [], } existing.totalCost += d.totalCost existing.totalTokens += d.totalTokens diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 4ecc99a..6d619e0 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -71,7 +71,8 @@ export function formatDate(dateStr: string, mode: 'short' | 'long' | 'weekday' = if (/^\d{4}-\d{2}$/.test(dateStr)) { const [y = '0', m = '1'] = dateStr.split('-') const d = new Date(parseInt(y, 10), parseInt(m, 10) - 1) - if (mode === 'short') return d.toLocaleDateString(getCurrentLocale(), { month: 'short', year: '2-digit' }) + if (mode === 'short') + return d.toLocaleDateString(getCurrentLocale(), { month: 'short', year: '2-digit' }) return d.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }) } diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 17921f6..2b89b08 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -7,64 +7,114 @@ const HELP_CONTENT = { { keys: 'ESC', description: 'Dialog / Zoom schliessen' }, ], metric: { - totalCost: 'Zeigt die Gesamtkosten aller API-Aufrufe im gewählten Zeitraum. Die Berechnung basiert auf den hinterlegten Token-Preisen pro Modell.', - totalTokens: 'Zeigt die Summe aller verarbeiteten Tokens aus Input, Output und Cache. Ein Token entspricht grob vier Zeichen Text.', - activeDays: 'Zeigt, an wie vielen Tagen im gewählten Zeitraum mindestens ein API-Aufruf vorhanden war.', + totalCost: + 'Zeigt die Gesamtkosten aller API-Aufrufe im gewählten Zeitraum. Die Berechnung basiert auf den hinterlegten Token-Preisen pro Modell.', + totalTokens: + 'Zeigt die Summe aller verarbeiteten Tokens aus Input, Output und Cache. Ein Token entspricht grob vier Zeichen Text.', + activeDays: + 'Zeigt, an wie vielen Tagen im gewählten Zeitraum mindestens ein API-Aufruf vorhanden war.', topModel: 'Zeigt das Modell mit den höchsten Gesamtkosten im aktuellen Ausschnitt.', - cacheHitRate: 'Zeigt den Anteil der Tokens, die aus dem Cache gelesen wurden. Höhere Werte sprechen meist für bessere Kosteneffizienz.', - costPerMillion: 'Zeigt die durchschnittlichen Kosten pro 1 Million verarbeiteter Tokens. Niedrigere Werte sprechen für effizientere Nutzung.', - mostExpensiveDay: 'Zeigt den Zeitraumspunkt mit den höchsten API-Kosten im aktuellen Ausschnitt.', - cheapestDay: 'Zeigt den Zeitraumspunkt mit den niedrigsten API-Kosten im aktuellen Ausschnitt.', + cacheHitRate: + 'Zeigt den Anteil der Tokens, die aus dem Cache gelesen wurden. Höhere Werte sprechen meist für bessere Kosteneffizienz.', + costPerMillion: + 'Zeigt die durchschnittlichen Kosten pro 1 Million verarbeiteter Tokens. Niedrigere Werte sprechen für effizientere Nutzung.', + mostExpensiveDay: + 'Zeigt den Zeitraumspunkt mit den höchsten API-Kosten im aktuellen Ausschnitt.', + cheapestDay: + 'Zeigt den Zeitraumspunkt mit den niedrigsten API-Kosten im aktuellen Ausschnitt.', avgCostPerDay: 'Zeigt die durchschnittlichen Kosten pro aktivem Zeitraumspunkt.', - outputTokens: 'Zeigt die Menge der generierten Output-Tokens. Diese sind meist teurer als reine Input-Tokens.', + outputTokens: + 'Zeigt die Menge der generierten Output-Tokens. Diese sind meist teurer als reine Input-Tokens.', }, chart: { - costOverTime: 'Zeigt die API-Kosten im Zeitverlauf zusammen mit einem gleitenden 7-Tage-Durchschnitt. Klick auf einen Punkt öffnet den Drilldown.', - costByModel: 'Zeigt die Kostenverteilung nach Modell als Donut. So wird sichtbar, welche Modelle den grössten Kostenanteil tragen.', - costByModelOverTime: 'Zeigt, wie sich die Kosten je Modell über die Zeit entwickeln. Gut geeignet, um Treiber und Verschiebungen im Modellmix zu erkennen.', - cumulativeCost: 'Zeigt die kumulierten Gesamtkosten über den gewählten Zeitraum. Falls möglich, wird zusätzlich die Monatsend-Projektion eingeblendet.', - costByWeekday: 'Zeigt die durchschnittlichen Kosten pro Wochentag. So werden wiederkehrende Lastmuster über die Woche sichtbar.', - tokensOverTime: 'Zeigt den Token-Verbrauch über die Zeit, getrennt nach Input, Output, Cache Write, Cache Read und Thinking.', - requestsOverTime: 'Zeigt Requests im Zeitverlauf mit Gesamtlinie, Modelllinien und Trendlinie. Klick auf einen Punkt öffnet den Drilldown.', - requestCacheHitRate: 'Zeigt die Cache-Hit-Rate pro Modell zusammen mit dem gefilterten Gesamtwert und dem gleitenden 7-Tage-Durchschnitt auf Basis des gewählten Tagesbereichs.', - tokenTypes: 'Zeigt die Verteilung der Token-Typen als Donut. So wird sichtbar, welcher Anteil auf Input, Output, Cache oder Thinking entfällt.', - tokenEfficiency: 'Zeigt die Kosten pro 1 Million Tokens im Zeitverlauf. So lässt sich erkennen, ob Modellmix und Cache-Nutzung effizienter oder teurer werden.', - modelMix: 'Zeigt den prozentualen Kostenanteil der Modelle je Zeitraumspunkt. So werden Modellwechsel und Konzentration sichtbar.', - distributionAnalysis: 'Zeigt Histogramme für Kosten, Requests und Tokens pro Request. So wird die Streuung sichtbar, nicht nur der Durchschnitt.', - correlationAnalysis: 'Zeigt Punktdiagramme für mögliche Zusammenhänge, etwa Requests zu Kosten oder Cache-Rate zu Kosten pro Request. Die Korrelation ist ein Signal, aber kein Beweis für Kausalität.', - heatmap: 'Zeigt eine Kalender-Heatmap der täglichen Kosten. Dunklere Felder stehen für höhere Werte.', - requestHeatmap: 'Zeigt eine Kalender-Heatmap der Requests pro Tag. So werden Lastmuster unabhängig von Kosten sichtbar.', - tokenHeatmap: 'Zeigt eine Kalender-Heatmap des Tokenvolumens pro Tag. So lassen sich volumenstarke und kostenstarke Tage besser unterscheiden.', - forecast: 'Zeigt die Kostenprognose für den laufenden Monat auf Basis geglätteter Kalendertageskosten. Ergänzt wird sie durch Trend und Unsicherheitsband.', - cacheROI: 'Zeigt den Effekt der Cache-Nutzung, indem hypothetische Kosten ohne Cache mit den tatsächlichen Kosten verglichen werden.', - providerLimitProgress: 'Zeigt pro Anbieter, wie stark das konfigurierte Monatslimit bereits verbraucht ist. Überschreitungen werden separat markiert.', - providerSubscriptionMix: 'Vergleicht pro Anbieter die fixe Subscription mit den variablen API-Kosten und blendet optional das gesetzte Monatslimit ein.', - providerLimitTimeline: 'Zeigt im Monatsverlauf die Summe der aktuellen Provider-Kosten gegen die Summe aller konfigurierten Limits. So werden Engpässe früh sichtbar.', - periodComparison: 'Zeigt den Vergleich zweier Zeiträume, etwa Woche gegen Vorwoche oder Monat gegen Vormonat, anhand zentraler Kennzahlen.', - anomalyDetection: 'Zeigt auffällige Zeitraumspunkte mit ungewöhnlich hohen oder niedrigen Kosten. Grundlage ist die Abweichung vom Mittelwert in Standardabweichungen.', + costOverTime: + 'Zeigt die API-Kosten im Zeitverlauf zusammen mit einem gleitenden 7-Tage-Durchschnitt. Klick auf einen Punkt öffnet den Drilldown.', + costByModel: + 'Zeigt die Kostenverteilung nach Modell als Donut. So wird sichtbar, welche Modelle den grössten Kostenanteil tragen.', + costByModelOverTime: + 'Zeigt, wie sich die Kosten je Modell über die Zeit entwickeln. Gut geeignet, um Treiber und Verschiebungen im Modellmix zu erkennen.', + cumulativeCost: + 'Zeigt die kumulierten Gesamtkosten über den gewählten Zeitraum. Falls möglich, wird zusätzlich die Monatsend-Projektion eingeblendet.', + costByWeekday: + 'Zeigt die durchschnittlichen Kosten pro Wochentag. So werden wiederkehrende Lastmuster über die Woche sichtbar.', + tokensOverTime: + 'Zeigt den Token-Verbrauch über die Zeit, getrennt nach Input, Output, Cache Write, Cache Read und Thinking.', + requestsOverTime: + 'Zeigt Requests im Zeitverlauf mit Gesamtlinie, Modelllinien und Trendlinie. Klick auf einen Punkt öffnet den Drilldown.', + requestCacheHitRate: + 'Zeigt die Cache-Hit-Rate pro Modell zusammen mit dem gefilterten Gesamtwert und dem gleitenden 7-Tage-Durchschnitt auf Basis des gewählten Tagesbereichs.', + tokenTypes: + 'Zeigt die Verteilung der Token-Typen als Donut. So wird sichtbar, welcher Anteil auf Input, Output, Cache oder Thinking entfällt.', + tokenEfficiency: + 'Zeigt die Kosten pro 1 Million Tokens im Zeitverlauf. So lässt sich erkennen, ob Modellmix und Cache-Nutzung effizienter oder teurer werden.', + modelMix: + 'Zeigt den prozentualen Kostenanteil der Modelle je Zeitraumspunkt. So werden Modellwechsel und Konzentration sichtbar.', + distributionAnalysis: + 'Zeigt Histogramme für Kosten, Requests und Tokens pro Request. So wird die Streuung sichtbar, nicht nur der Durchschnitt.', + correlationAnalysis: + 'Zeigt Punktdiagramme für mögliche Zusammenhänge, etwa Requests zu Kosten oder Cache-Rate zu Kosten pro Request. Die Korrelation ist ein Signal, aber kein Beweis für Kausalität.', + heatmap: + 'Zeigt eine Kalender-Heatmap der täglichen Kosten. Dunklere Felder stehen für höhere Werte.', + requestHeatmap: + 'Zeigt eine Kalender-Heatmap der Requests pro Tag. So werden Lastmuster unabhängig von Kosten sichtbar.', + tokenHeatmap: + 'Zeigt eine Kalender-Heatmap des Tokenvolumens pro Tag. So lassen sich volumenstarke und kostenstarke Tage besser unterscheiden.', + forecast: + 'Zeigt die Kostenprognose für den laufenden Monat auf Basis geglätteter Kalendertageskosten. Ergänzt wird sie durch Trend und Unsicherheitsband.', + cacheROI: + 'Zeigt den Effekt der Cache-Nutzung, indem hypothetische Kosten ohne Cache mit den tatsächlichen Kosten verglichen werden.', + providerLimitProgress: + 'Zeigt pro Anbieter, wie stark das konfigurierte Monatslimit bereits verbraucht ist. Überschreitungen werden separat markiert.', + providerSubscriptionMix: + 'Vergleicht pro Anbieter die fixe Subscription mit den variablen API-Kosten und blendet optional das gesetzte Monatslimit ein.', + providerLimitTimeline: + 'Zeigt im Monatsverlauf die Summe der aktuellen Provider-Kosten gegen die Summe aller konfigurierten Limits. So werden Engpässe früh sichtbar.', + periodComparison: + 'Zeigt den Vergleich zweier Zeiträume, etwa Woche gegen Vorwoche oder Monat gegen Vormonat, anhand zentraler Kennzahlen.', + anomalyDetection: + 'Zeigt auffällige Zeitraumspunkte mit ungewöhnlich hohen oder niedrigen Kosten. Grundlage ist die Abweichung vom Mittelwert in Standardabweichungen.', }, section: { - insights: 'Zeigt verdichtete Aussagen zu Konzentration, Request-Ökonomie, Nutzungsmuster und Peak-Fenstern. Diese Sektion ist als schneller Einstieg vor dem Detailblick gedacht.', - metrics: 'Zeigt die wichtigsten Kennzahlen auf einen Blick. Hover über abgekürzte Werte zeigt den exakten Zahlenwert.', - today: 'Zeigt die KPIs des aktuellen Tages im Datensatz. So wird sichtbar, wie stark der Tageswert vom Zeitraumdurchschnitt abweicht.', - currentMonth: 'Zeigt die KPIs des laufenden Monats. So werden Fortschritt, Abdeckung und der Vergleich mit dem Vormonat sichtbar.', - activity: 'Zeigt Kalenderansichten für Kosten, Requests und Tokens. So werden Lastspitzen, Lücken und saisonale Muster sichtbar.', - forecastCache: 'Zeigt Monatsprognose, Cache-Ersparnis und operative Request-Qualität in einem Block. So entsteht ein gemeinsamer Blick auf Ausblick und Effizienz.', - limits: 'Zeigt pro Anbieter konfigurierte Subscriptions und Monatslimits. Die Sektion macht sichtbar, wie weit Kostenbudgets im aktuellen Ausschnitt ausgereizt sind.', - costAnalysis: 'Zeigt Kostenverlauf und Kostenverteilung nach Modell. So wird sichtbar, wo Geld ausgegeben wurde und welche Modelle die Haupttreiber sind.', - tokenAnalysis: 'Zeigt Tokenvolumen, Token-Typen, Wochentagsmuster und Effizienz. So lässt sich besser einordnen, ob Kosten eher aus Menge oder Preisniveau entstehen.', - requestAnalysis: 'Zeigt Requests gesamt, nach Modell und im Verlauf. Im Zoom kommen zusätzliche Trends und Verteilungen hinzu.', - advancedAnalysis: 'Zeigt Verteilungen, Korrelationen und Konzentrationsrisiken. So wird sichtbar, wie stabil, konzentriert oder ungewöhnlich die Nutzung ist.', - comparisons: 'Zeigt Veränderungen zwischen Perioden und markiert Ausreisser. So lassen sich Verschiebungen schneller einordnen.', - tables: 'Zeigt detaillierte Tabellen mit Sortierung und Drilldown. So lassen sich einzelne Modelle, Provider und Tage gezielt prüfen.', + insights: + 'Zeigt verdichtete Aussagen zu Konzentration, Request-Ökonomie, Nutzungsmuster und Peak-Fenstern. Diese Sektion ist als schneller Einstieg vor dem Detailblick gedacht.', + metrics: + 'Zeigt die wichtigsten Kennzahlen auf einen Blick. Hover über abgekürzte Werte zeigt den exakten Zahlenwert.', + today: + 'Zeigt die KPIs des aktuellen Tages im Datensatz. So wird sichtbar, wie stark der Tageswert vom Zeitraumdurchschnitt abweicht.', + currentMonth: + 'Zeigt die KPIs des laufenden Monats. So werden Fortschritt, Abdeckung und der Vergleich mit dem Vormonat sichtbar.', + activity: + 'Zeigt Kalenderansichten für Kosten, Requests und Tokens. So werden Lastspitzen, Lücken und saisonale Muster sichtbar.', + forecastCache: + 'Zeigt Monatsprognose, Cache-Ersparnis und operative Request-Qualität in einem Block. So entsteht ein gemeinsamer Blick auf Ausblick und Effizienz.', + limits: + 'Zeigt pro Anbieter konfigurierte Subscriptions und Monatslimits. Die Sektion macht sichtbar, wie weit Kostenbudgets im aktuellen Ausschnitt ausgereizt sind.', + costAnalysis: + 'Zeigt Kostenverlauf und Kostenverteilung nach Modell. So wird sichtbar, wo Geld ausgegeben wurde und welche Modelle die Haupttreiber sind.', + tokenAnalysis: + 'Zeigt Tokenvolumen, Token-Typen, Wochentagsmuster und Effizienz. So lässt sich besser einordnen, ob Kosten eher aus Menge oder Preisniveau entstehen.', + requestAnalysis: + 'Zeigt Requests gesamt, nach Modell und im Verlauf. Im Zoom kommen zusätzliche Trends und Verteilungen hinzu.', + advancedAnalysis: + 'Zeigt Verteilungen, Korrelationen und Konzentrationsrisiken. So wird sichtbar, wie stabil, konzentriert oder ungewöhnlich die Nutzung ist.', + comparisons: + 'Zeigt Veränderungen zwischen Perioden und markiert Ausreisser. So lassen sich Verschiebungen schneller einordnen.', + tables: + 'Zeigt detaillierte Tabellen mit Sortierung und Drilldown. So lassen sich einzelne Modelle, Provider und Tage gezielt prüfen.', }, feature: { - requestQuality: 'Zeigt verdichtete Request-Signale wie Tokens pro Request, Kosten pro Request sowie Cache- und Thinking-Anteil. So lässt sich die operative Anfragequalität schneller einordnen.', - providerLimits: 'Hier werden fixe Subscription-Kosten und variable Monatslimits pro Anbieter gepflegt. Die Eingaben bleiben lokal im Browser gespeichert und gelten nur für Anbieter, die im geladenen Report vorkommen.', - concentrationRisk: 'Zeigt die Abhängigkeit von einzelnen Modellen und Providern. Hohe Werte bedeuten, dass wenige Akteure einen grossen Teil der Kosten tragen.', - providerEfficiency: 'Zeigt den Vergleich der Anbieter nach Kosten, Requests, Tokens und Effizienzkennzahlen wie $/Req oder $/1M Tokens.', - modelEfficiency: 'Zeigt den Vergleich der Modelle nach Kosten, Volumen und Effizienz. So lassen sich teure oder ineffiziente Kandidaten schnell erkennen.', - recentDays: 'Zeigt die Detailtabelle pro Tag, Monat oder Jahr mit Drilldown und Benchmarks gegen Vortag und 7-Tage-Mittel.', + requestQuality: + 'Zeigt verdichtete Request-Signale wie Tokens pro Request, Kosten pro Request sowie Cache- und Thinking-Anteil. So lässt sich die operative Anfragequalität schneller einordnen.', + providerLimits: + 'Hier werden fixe Subscription-Kosten und variable Monatslimits pro Anbieter gepflegt. Die Eingaben bleiben lokal im Browser gespeichert und gelten nur für Anbieter, die im geladenen Report vorkommen.', + concentrationRisk: + 'Zeigt die Abhängigkeit von einzelnen Modellen und Providern. Hohe Werte bedeuten, dass wenige Akteure einen grossen Teil der Kosten tragen.', + providerEfficiency: + 'Zeigt den Vergleich der Anbieter nach Kosten, Requests, Tokens und Effizienzkennzahlen wie $/Req oder $/1M Tokens.', + modelEfficiency: + 'Zeigt den Vergleich der Modelle nach Kosten, Volumen und Effizienz. So lassen sich teure oder ineffiziente Kandidaten schnell erkennen.', + recentDays: + 'Zeigt die Detailtabelle pro Tag, Monat oder Jahr mit Drilldown und Benchmarks gegen Vortag und 7-Tage-Mittel.', }, }, en: { @@ -73,64 +123,110 @@ const HELP_CONTENT = { { keys: 'ESC', description: 'Close dialog / zoom view' }, ], metric: { - totalCost: 'Shows total API cost across the selected range. The calculation is based on configured per-model token prices.', - totalTokens: 'Shows the sum of all processed tokens across input, output, and cache. One token is roughly four text characters.', + totalCost: + 'Shows total API cost across the selected range. The calculation is based on configured per-model token prices.', + totalTokens: + 'Shows the sum of all processed tokens across input, output, and cache. One token is roughly four text characters.', activeDays: 'Shows how many days in the selected range contain at least one API call.', topModel: 'Shows the model with the highest total cost in the current slice.', - cacheHitRate: 'Shows the share of tokens served from cache. Higher values usually indicate better cost efficiency.', - costPerMillion: 'Shows average cost per 1 million processed tokens. Lower values indicate more efficient usage.', + cacheHitRate: + 'Shows the share of tokens served from cache. Higher values usually indicate better cost efficiency.', + costPerMillion: + 'Shows average cost per 1 million processed tokens. Lower values indicate more efficient usage.', mostExpensiveDay: 'Shows the period point with the highest API cost in the current slice.', cheapestDay: 'Shows the period point with the lowest API cost in the current slice.', avgCostPerDay: 'Shows the average cost per active period point.', - outputTokens: 'Shows the volume of generated output tokens. These are usually more expensive than pure input tokens.', + outputTokens: + 'Shows the volume of generated output tokens. These are usually more expensive than pure input tokens.', }, chart: { - costOverTime: 'Shows API cost over time together with a rolling 7-day average. Clicking a point opens the drilldown.', - costByModel: 'Shows cost distribution by model as a donut chart. This makes the main cost drivers visible.', - costByModelOverTime: 'Shows how cost per model evolves over time. Useful for spotting shifts in the model mix.', - cumulativeCost: 'Shows cumulative total cost over the selected range. When possible, a month-end projection is added.', - costByWeekday: 'Shows average cost by weekday, making recurring weekly load patterns visible.', - tokensOverTime: 'Shows token usage over time split by input, output, cache write, cache read, and thinking.', - requestsOverTime: 'Shows requests over time with total line, per-model lines, and a trend line. Clicking a point opens the drilldown.', - requestCacheHitRate: 'Shows cache hit rate per model together with the filtered overall total and the trailing 7-day average based on the selected daily range.', - tokenTypes: 'Shows the distribution of token types as a donut chart so input, output, cache, and thinking shares become visible.', - tokenEfficiency: 'Shows cost per 1 million tokens over time. This helps spot whether model mix and cache usage are becoming more or less efficient.', - modelMix: 'Shows the percentage cost share of models at each period point. This makes model shifts and concentration visible.', - distributionAnalysis: 'Shows histograms for costs, requests, and tokens per request. This highlights spread, not just averages.', - correlationAnalysis: 'Shows scatter plots for potential relationships such as requests to cost or cache rate to cost per request. Correlation is a signal, not proof of causality.', + costOverTime: + 'Shows API cost over time together with a rolling 7-day average. Clicking a point opens the drilldown.', + costByModel: + 'Shows cost distribution by model as a donut chart. This makes the main cost drivers visible.', + costByModelOverTime: + 'Shows how cost per model evolves over time. Useful for spotting shifts in the model mix.', + cumulativeCost: + 'Shows cumulative total cost over the selected range. When possible, a month-end projection is added.', + costByWeekday: + 'Shows average cost by weekday, making recurring weekly load patterns visible.', + tokensOverTime: + 'Shows token usage over time split by input, output, cache write, cache read, and thinking.', + requestsOverTime: + 'Shows requests over time with total line, per-model lines, and a trend line. Clicking a point opens the drilldown.', + requestCacheHitRate: + 'Shows cache hit rate per model together with the filtered overall total and the trailing 7-day average based on the selected daily range.', + tokenTypes: + 'Shows the distribution of token types as a donut chart so input, output, cache, and thinking shares become visible.', + tokenEfficiency: + 'Shows cost per 1 million tokens over time. This helps spot whether model mix and cache usage are becoming more or less efficient.', + modelMix: + 'Shows the percentage cost share of models at each period point. This makes model shifts and concentration visible.', + distributionAnalysis: + 'Shows histograms for costs, requests, and tokens per request. This highlights spread, not just averages.', + correlationAnalysis: + 'Shows scatter plots for potential relationships such as requests to cost or cache rate to cost per request. Correlation is a signal, not proof of causality.', heatmap: 'Shows a calendar heatmap of daily cost. Darker cells indicate higher values.', - requestHeatmap: 'Shows a calendar heatmap of requests per day. This reveals load patterns independently of cost.', - tokenHeatmap: 'Shows a calendar heatmap of token volume per day. This helps distinguish high-volume days from high-cost days.', - forecast: 'Shows cost forecast for the current month based on smoothed calendar-day costs, complemented by trend and uncertainty band.', - cacheROI: 'Shows the impact of cache usage by comparing hypothetical no-cache costs with actual costs.', - providerLimitProgress: 'Shows how much of each configured monthly provider limit has already been used. Overruns are marked separately.', - providerSubscriptionMix: 'Compares fixed subscription cost with variable API cost per provider and can optionally include the configured monthly limit.', - providerLimitTimeline: 'Shows monthly provider cost against total configured limits over time so bottlenecks become visible early.', - periodComparison: 'Shows the comparison of two periods, such as week-over-week or month-over-month, using central metrics.', - anomalyDetection: 'Shows unusual period points with unusually high or low cost based on deviation from the mean in standard deviations.', + requestHeatmap: + 'Shows a calendar heatmap of requests per day. This reveals load patterns independently of cost.', + tokenHeatmap: + 'Shows a calendar heatmap of token volume per day. This helps distinguish high-volume days from high-cost days.', + forecast: + 'Shows cost forecast for the current month based on smoothed calendar-day costs, complemented by trend and uncertainty band.', + cacheROI: + 'Shows the impact of cache usage by comparing hypothetical no-cache costs with actual costs.', + providerLimitProgress: + 'Shows how much of each configured monthly provider limit has already been used. Overruns are marked separately.', + providerSubscriptionMix: + 'Compares fixed subscription cost with variable API cost per provider and can optionally include the configured monthly limit.', + providerLimitTimeline: + 'Shows monthly provider cost against total configured limits over time so bottlenecks become visible early.', + periodComparison: + 'Shows the comparison of two periods, such as week-over-week or month-over-month, using central metrics.', + anomalyDetection: + 'Shows unusual period points with unusually high or low cost based on deviation from the mean in standard deviations.', }, section: { - insights: 'Shows condensed statements about concentration, request economics, usage patterns, and peak windows. This section is meant as a fast entry point before deeper analysis.', - metrics: 'Shows the most important KPIs at a glance. Hover abbreviated values to see exact numbers.', - today: 'Shows the KPIs of the current day in the dataset. This makes it easy to compare the day against the period average.', - currentMonth: 'Shows the KPIs of the current month, including progress, coverage, and comparison to the previous month.', - activity: 'Shows calendar views for cost, requests, and tokens, making spikes, gaps, and seasonal patterns visible.', - forecastCache: 'Shows month forecast, cache savings, and operational request quality in one block for a combined outlook on efficiency.', - limits: 'Shows configured subscriptions and monthly limits per provider. This section makes it visible how far budgets are stretched in the current slice.', - costAnalysis: 'Shows cost trend and cost distribution by model so it is clear where spend happened and which models dominate.', - tokenAnalysis: 'Shows token volume, token types, weekday patterns, and efficiency so you can judge whether cost comes from volume or pricing level.', - requestAnalysis: 'Shows requests overall, by model, and over time. The expanded views add extra trends and distributions.', - advancedAnalysis: 'Shows distributions, correlations, and concentration risk so usage stability, concentration, and unusual behavior become visible.', - comparisons: 'Shows changes between periods and marks outliers so shifts can be understood faster.', - tables: 'Shows detailed tables with sorting and drilldown so individual models, providers, and days can be inspected directly.', + insights: + 'Shows condensed statements about concentration, request economics, usage patterns, and peak windows. This section is meant as a fast entry point before deeper analysis.', + metrics: + 'Shows the most important KPIs at a glance. Hover abbreviated values to see exact numbers.', + today: + 'Shows the KPIs of the current day in the dataset. This makes it easy to compare the day against the period average.', + currentMonth: + 'Shows the KPIs of the current month, including progress, coverage, and comparison to the previous month.', + activity: + 'Shows calendar views for cost, requests, and tokens, making spikes, gaps, and seasonal patterns visible.', + forecastCache: + 'Shows month forecast, cache savings, and operational request quality in one block for a combined outlook on efficiency.', + limits: + 'Shows configured subscriptions and monthly limits per provider. This section makes it visible how far budgets are stretched in the current slice.', + costAnalysis: + 'Shows cost trend and cost distribution by model so it is clear where spend happened and which models dominate.', + tokenAnalysis: + 'Shows token volume, token types, weekday patterns, and efficiency so you can judge whether cost comes from volume or pricing level.', + requestAnalysis: + 'Shows requests overall, by model, and over time. The expanded views add extra trends and distributions.', + advancedAnalysis: + 'Shows distributions, correlations, and concentration risk so usage stability, concentration, and unusual behavior become visible.', + comparisons: + 'Shows changes between periods and marks outliers so shifts can be understood faster.', + tables: + 'Shows detailed tables with sorting and drilldown so individual models, providers, and days can be inspected directly.', }, feature: { - requestQuality: 'Shows condensed request signals such as tokens per request, cost per request, and cache and thinking shares. This helps assess operational request quality faster.', - providerLimits: 'This is where fixed subscription cost and variable monthly limits are maintained per provider. Values are stored in the local app settings and only apply to providers present in the loaded report.', - concentrationRisk: 'Shows dependency on individual models and providers. Higher values mean that a small number of actors carries a large share of cost.', - providerEfficiency: 'Shows the provider comparison by cost, requests, tokens, and efficiency metrics such as $/req or $/1M tokens.', - modelEfficiency: 'Shows the model comparison by cost, volume, and efficiency so expensive or inefficient candidates are easy to spot.', - recentDays: 'Shows the detailed table per day, month, or year with drilldown and benchmarks against the previous day and 7-day average.', + requestQuality: + 'Shows condensed request signals such as tokens per request, cost per request, and cache and thinking shares. This helps assess operational request quality faster.', + providerLimits: + 'This is where fixed subscription cost and variable monthly limits are maintained per provider. Values are stored in the local app settings and only apply to providers present in the loaded report.', + concentrationRisk: + 'Shows dependency on individual models and providers. Higher values mean that a small number of actors carries a large share of cost.', + providerEfficiency: + 'Shows the provider comparison by cost, requests, tokens, and efficiency metrics such as $/req or $/1M tokens.', + modelEfficiency: + 'Shows the model comparison by cost, volume, and efficiency so expensive or inefficient candidates are easy to spot.', + recentDays: + 'Shows the detailed table per day, month, or year with drilldown and benchmarks against the previous day and 7-day average.', }, }, } as const diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 7edffe4..97eab35 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -19,21 +19,19 @@ export async function initI18n(language: AppLanguage = 'de') { const nextLanguage = normalizeLanguage(language) if (!i18n.isInitialized) { - await i18n - .use(initReactI18next) - .init({ - resources: { - de: { common: de }, - en: { common: en }, - }, - lng: nextLanguage, - fallbackLng: 'de', - defaultNS: 'common', - ns: ['common'], - interpolation: { - escapeValue: false, - }, - }) + await i18n.use(initReactI18next).init({ + resources: { + de: { common: de }, + en: { common: en }, + }, + lng: nextLanguage, + fallbackLng: 'de', + defaultNS: 'common', + ns: ['common'], + interpolation: { + escapeValue: false, + }, + }) } else if (i18n.resolvedLanguage !== nextLanguage) { await i18n.changeLanguage(nextLanguage) } diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 1970152..6c31160 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -50,7 +50,9 @@ export function normalizeModelName(raw: string): string { .replace(/-{2,}/g, '-') .replace(/^-|-$/g, '') - const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i) + const familyMatch = stripped.match( + /(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i, + ) if (familyMatch) { const family = familyMatch[1] if (!family) return stripped @@ -60,20 +62,30 @@ export function normalizeModelName(raw: string): string { return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() } - return stripped - .split('-') - .filter(Boolean) - .map(titleCaseSegment) - .join(' ') || raw + return stripped.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || raw } export function getModelProvider(raw: string): string { const lower = raw.toLowerCase() - if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI' - if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic' + if ( + lower.includes('gpt') || + lower.includes('openai') || + lower.includes('/o1') || + lower.includes('/o3') || + /\bo\d\b/.test(lower) + ) + return 'OpenAI' + if ( + lower.includes('claude') || + lower.includes('opus') || + lower.includes('sonnet') || + lower.includes('haiku') + ) + return 'Anthropic' if (lower.includes('gemini')) return 'Google' if (lower.includes('grok') || lower.includes('xai')) return 'xAI' - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta' + if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) + return 'Meta' if (lower.includes('command') || lower.includes('cohere')) return 'Cohere' if (lower.includes('mistral')) return 'Mistral' if (lower.includes('deepseek')) return 'DeepSeek' @@ -109,30 +121,78 @@ export function getProviderBadgeClasses(provider: string): string { } } -export function getProviderBadgeStyle(provider: string): { color: string; backgroundColor: string; borderColor: string } { +export function getProviderBadgeStyle(provider: string): { + color: string + backgroundColor: string + borderColor: string +} { switch (provider) { case 'OpenAI': - return { color: 'rgb(52, 211, 153)', backgroundColor: 'rgba(16, 185, 129, 0.10)', borderColor: 'rgba(16, 185, 129, 0.20)' } + return { + color: 'rgb(52, 211, 153)', + backgroundColor: 'rgba(16, 185, 129, 0.10)', + borderColor: 'rgba(16, 185, 129, 0.20)', + } case 'Anthropic': - return { color: 'rgb(251, 146, 60)', backgroundColor: 'rgba(249, 115, 22, 0.10)', borderColor: 'rgba(249, 115, 22, 0.20)' } + return { + color: 'rgb(251, 146, 60)', + backgroundColor: 'rgba(249, 115, 22, 0.10)', + borderColor: 'rgba(249, 115, 22, 0.20)', + } case 'Google': - return { color: 'rgb(56, 189, 248)', backgroundColor: 'rgba(14, 165, 233, 0.10)', borderColor: 'rgba(14, 165, 233, 0.20)' } + return { + color: 'rgb(56, 189, 248)', + backgroundColor: 'rgba(14, 165, 233, 0.10)', + borderColor: 'rgba(14, 165, 233, 0.20)', + } case 'xAI': - return { color: 'rgb(232, 121, 249)', backgroundColor: 'rgba(217, 70, 239, 0.10)', borderColor: 'rgba(217, 70, 239, 0.20)' } + return { + color: 'rgb(232, 121, 249)', + backgroundColor: 'rgba(217, 70, 239, 0.10)', + borderColor: 'rgba(217, 70, 239, 0.20)', + } case 'Meta': - return { color: 'rgb(96, 165, 250)', backgroundColor: 'rgba(59, 130, 246, 0.10)', borderColor: 'rgba(59, 130, 246, 0.20)' } + return { + color: 'rgb(96, 165, 250)', + backgroundColor: 'rgba(59, 130, 246, 0.10)', + borderColor: 'rgba(59, 130, 246, 0.20)', + } case 'Cohere': - return { color: 'rgb(163, 230, 53)', backgroundColor: 'rgba(132, 204, 22, 0.10)', borderColor: 'rgba(132, 204, 22, 0.20)' } + return { + color: 'rgb(163, 230, 53)', + backgroundColor: 'rgba(132, 204, 22, 0.10)', + borderColor: 'rgba(132, 204, 22, 0.20)', + } case 'Mistral': - return { color: 'rgb(252, 211, 77)', backgroundColor: 'rgba(245, 158, 11, 0.10)', borderColor: 'rgba(245, 158, 11, 0.20)' } + return { + color: 'rgb(252, 211, 77)', + backgroundColor: 'rgba(245, 158, 11, 0.10)', + borderColor: 'rgba(245, 158, 11, 0.20)', + } case 'DeepSeek': - return { color: 'rgb(45, 212, 191)', backgroundColor: 'rgba(20, 184, 166, 0.10)', borderColor: 'rgba(20, 184, 166, 0.20)' } + return { + color: 'rgb(45, 212, 191)', + backgroundColor: 'rgba(20, 184, 166, 0.10)', + borderColor: 'rgba(20, 184, 166, 0.20)', + } case 'Alibaba': - return { color: 'rgb(250, 204, 21)', backgroundColor: 'rgba(234, 179, 8, 0.10)', borderColor: 'rgba(234, 179, 8, 0.20)' } + return { + color: 'rgb(250, 204, 21)', + backgroundColor: 'rgba(234, 179, 8, 0.10)', + borderColor: 'rgba(234, 179, 8, 0.20)', + } case 'OpenCode': - return { color: 'rgb(34, 211, 238)', backgroundColor: 'rgba(6, 182, 212, 0.10)', borderColor: 'rgba(6, 182, 212, 0.20)' } + return { + color: 'rgb(34, 211, 238)', + backgroundColor: 'rgba(6, 182, 212, 0.10)', + borderColor: 'rgba(6, 182, 212, 0.20)', + } default: - return { color: 'rgb(148, 163, 184)', backgroundColor: 'rgba(100, 116, 139, 0.10)', borderColor: 'rgba(100, 116, 139, 0.20)' } + return { + color: 'rgb(148, 163, 184)', + backgroundColor: 'rgba(100, 116, 139, 0.10)', + borderColor: 'rgba(100, 116, 139, 0.20)', + } } } diff --git a/src/lib/provider-limits.ts b/src/lib/provider-limits.ts index 67a15ae..f17ad56 100644 --- a/src/lib/provider-limits.ts +++ b/src/lib/provider-limits.ts @@ -24,7 +24,7 @@ export function normalizeProviderLimitConfig(value: unknown): ProviderLimitConfi } export function syncProviderLimits(providers: string[], source: unknown): ProviderLimits { - const input = source && typeof source === 'object' ? source as Record : {} + const input = source && typeof source === 'object' ? (source as Record) : {} const next: ProviderLimits = {} for (const provider of providers) { @@ -36,8 +36,8 @@ export function syncProviderLimits(providers: string[], source: unknown): Provid export function getLatestMonth(data: DailyUsage[]): string | null { const months = data - .map(entry => entry.date.slice(0, 7)) - .filter(month => /^\d{4}-\d{2}$/.test(month)) + .map((entry) => entry.date.slice(0, 7)) + .filter((month) => /^\d{4}-\d{2}$/.test(month)) .sort() return months.length > 0 ? (months[months.length - 1] ?? null) : null diff --git a/src/types/index.ts b/src/types/index.ts index a6cc3ff..d7ad7ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -130,7 +130,19 @@ export interface AggregatedPeriod { thinkingTokens: number requestCount: number days: number - modelBreakdowns: Map + modelBreakdowns: Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + } + > } export interface ChartDataPoint { diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 3f5af81..3cfd054 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -5,12 +5,14 @@ import { expect, test, type Page } from '@playwright/test' const sampleUsagePath = path.join(process.cwd(), 'examples', 'sample-usage.json') const sampleUsage = JSON.parse(fs.readFileSync(sampleUsagePath, 'utf-8')) -const uploadToastPattern = /^(Datei sample-usage\.json erfolgreich geladen|File sample-usage\.json loaded successfully)$/ +const uploadToastPattern = + /^(Datei sample-usage\.json erfolgreich geladen|File sample-usage\.json loaded successfully)$/ const autoImportButtonPattern = /^(Auto-Import|Auto import)$/ const uploadFileButtonPattern = /^(Datei hochladen|Upload file)$/ const exportSettingsButtonPattern = /^(Einstellungen exportieren|Export settings)$/ const exportDataButtonPattern = /^(Daten exportieren|Export data)$/ -const dataImportToastPattern = /^(Backup importiert: 1 neue Tage ergänzt, 1 Konflikttage lokal beibehalten|Backup imported: added 1 new days, kept 1 conflicting days local)$/ +const dataImportToastPattern = + /^(Backup importiert: 1 neue Tage ergänzt, 1 Konflikttage lokal beibehalten|Backup imported: added 1 new days, kept 1 conflicting days local)$/ const saveSettingsButtonPattern = /^(Speichern|Save)$/ const monthlySettingsPattern = /^(Monatlich|Monthly)$/ const monthlyViewPattern = /^(Monatsansicht|Monthly view)$/ @@ -24,16 +26,18 @@ async function uploadSampleUsage(page: Page) { await expect(page.getByText(uploadToastPattern)).toBeVisible() } -test('uploads sample usage data and renders the dashboard without browser errors', async ({ page }) => { +test('uploads sample usage data and renders the dashboard without browser errors', async ({ + page, +}) => { const pageErrors: string[] = [] - page.on('console', message => { + page.on('console', (message) => { if (message.type() === 'error') { pageErrors.push(message.text()) } }) - page.on('pageerror', error => { + page.on('pageerror', (error) => { pageErrors.push(error.message) }) @@ -56,14 +60,26 @@ test('uploads sample usage data and renders the dashboard without browser errors expect(pageErrors, pageErrors.join('\n')).toEqual([]) }) -test('manages settings and backup imports through the settings dialog using isolated test storage', async ({ page }, testInfo) => { +test('manages settings and backup imports through the settings dialog using isolated test storage', async ({ + page, +}, testInfo) => { await page.request.delete('/api/usage') await page.request.delete('/api/settings') await page.addInitScript(() => { const globalWindow = window as typeof window & { - __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> __TTDASH_TEST_HOOKS__?: { - onJsonDownload?: (record: { filename: string, mimeType: string, size: number, text: string }) => void + onJsonDownload?: (record: { + filename: string + mimeType: string + size: number + text: string + }) => void openSettings?: () => void } } @@ -95,29 +111,45 @@ test('manages settings and backup imports through the settings dialog using isol await dialog.getByTestId('move-section-up-tokenAnalysis').click() await dialog.getByTestId('toggle-section-visibility-tokenAnalysis').click() await dialog.getByTestId('reset-all-settings-drafts').click() - await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute('aria-pressed', 'true') - await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute('aria-pressed', 'true') - await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText(/Sichtbar|Visible/) - await expect.poll(async () => dialog.locator('[data-section-id]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id')))).toEqual([ - 'insights', - 'metrics', - 'today', - 'currentMonth', - 'activity', - 'forecastCache', - 'limits', - 'costAnalysis', - 'tokenAnalysis', - 'requestAnalysis', - 'advancedAnalysis', - 'comparisons', - 'tables', - ]) + await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText( + /Sichtbar|Visible/, + ) + await expect + .poll(async () => + dialog + .locator('[data-section-id]') + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id'))), + ) + .toEqual([ + 'insights', + 'metrics', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', + ]) await dialog.getByRole('button', { name: saveSettingsButtonPattern }).click() await expect(dialog).toBeHidden() await expect(page.locator('#token-analysis')).toBeVisible() - await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(dailyViewPattern) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText( + dailyViewPattern, + ) await page.evaluate(() => { const globalWindow = window as typeof window & { @@ -129,23 +161,35 @@ test('manages settings and backup imports through the settings dialog using isol }) await expect(dialog).toBeVisible() await dialog.getByTestId('reset-default-filters').click() - await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute('aria-pressed', 'true') - await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute( + 'aria-pressed', + 'true', + ) await dialog.getByRole('button', { name: monthlySettingsPattern }).click() await dialog.getByRole('button', { name: last30DaysPattern }).click() await dialog.getByTestId('reset-section-visibility').click() - await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText(/Sichtbar|Visible/) + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText( + /Sichtbar|Visible/, + ) await dialog.getByTestId('move-section-up-tokenAnalysis').click() await dialog.getByTestId('toggle-section-visibility-tokenAnalysis').click() await dialog.getByRole('button', { name: saveSettingsButtonPattern }).click() await expect(dialog).toBeHidden() await expect(page.locator('#token-analysis')).toHaveCount(0) - await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText( + monthlyViewPattern, + ) await page.reload() await expect(page.locator('#token-analysis')).toHaveCount(0) - await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText( + monthlyViewPattern, + ) await page.evaluate(() => { const globalWindow = window as typeof window & { @@ -158,43 +202,71 @@ test('manages settings and backup imports through the settings dialog using isol await expect(dialog).toBeVisible() await page.getByRole('button', { name: exportSettingsButtonPattern }).click() - await expect.poll(async () => { - const records = await page.evaluate(() => { - const globalWindow = window as typeof window & { - __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> - } - return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + await expect + .poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length }) - return records.length - }).toBe(1) + .toBe(1) const exportedSettingsRecord = await page.evaluate(() => { const globalWindow = window as typeof window & { - __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> } const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] return records[0] }) - expect(exportedSettingsRecord.filename).toMatch(/^ttdash-settings-backup-\d{4}-\d{2}-\d{2}\.json$/) + expect(exportedSettingsRecord.filename).toMatch( + /^ttdash-settings-backup-\d{4}-\d{2}-\d{2}\.json$/, + ) const exportedSettings = JSON.parse(exportedSettingsRecord.text) expect(exportedSettings.kind).toBe('ttdash-settings-backup') expect(exportedSettings.settings.defaultFilters.viewMode).toBe('monthly') expect(exportedSettings.settings.defaultFilters.datePreset).toBe('30d') expect(exportedSettings.settings.sectionVisibility.tokenAnalysis).toBe(false) - expect(exportedSettings.settings.sectionOrder.indexOf('tokenAnalysis')).toBeLessThan(exportedSettings.settings.sectionOrder.indexOf('costAnalysis')) + expect(exportedSettings.settings.sectionOrder.indexOf('tokenAnalysis')).toBeLessThan( + exportedSettings.settings.sectionOrder.indexOf('costAnalysis'), + ) await page.getByRole('button', { name: exportDataButtonPattern }).click() - await expect.poll(async () => { - const records = await page.evaluate(() => { - const globalWindow = window as typeof window & { - __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> - } - return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + await expect + .poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length }) - return records.length - }).toBe(2) + .toBe(2) const exportedDataRecord = await page.evaluate(() => { const globalWindow = window as typeof window & { - __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> } const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] return records[1] @@ -205,23 +277,30 @@ test('manages settings and backup imports through the settings dialog using isol expect(exportedData.data.daily).toHaveLength(5) const importDataPath = testInfo.outputPath('usage-backup-import.json') - await fsPromises.writeFile(importDataPath, JSON.stringify({ - kind: 'ttdash-usage-backup', - version: 1, - data: { - daily: [ - sampleUsage.daily[0], - { - ...sampleUsage.daily[1], - totalCost: 999, - }, - { - ...sampleUsage.daily[0], - date: '2026-03-31', + await fsPromises.writeFile( + importDataPath, + JSON.stringify( + { + kind: 'ttdash-usage-backup', + version: 1, + data: { + daily: [ + sampleUsage.daily[0], + { + ...sampleUsage.daily[1], + totalCost: 999, + }, + { + ...sampleUsage.daily[0], + date: '2026-03-31', + }, + ], }, - ], - }, - }, null, 2)) + }, + null, + 2, + ), + ) await page.locator('[data-testid="data-import-input"]').setInputFiles(importDataPath) await expect(page.getByText(dataImportToastPattern)).toBeVisible() @@ -231,37 +310,46 @@ test('manages settings and backup imports through the settings dialog using isol const mergedUsage = await mergedUsageResponse.json() expect(mergedUsage.daily).toHaveLength(6) expect(mergedUsage.daily[0].date).toBe('2026-03-31') - expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBe(3.94) + expect( + mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost, + ).toBe(3.94) const importSettingsPath = testInfo.outputPath('settings-backup-import.json') - await fsPromises.writeFile(importSettingsPath, JSON.stringify({ - kind: 'ttdash-settings-backup', - version: 1, - settings: { - language: 'en', - theme: 'light', - providerLimits: { - OpenAI: { - hasSubscription: true, - subscriptionPrice: 20, - monthlyLimit: 400, + await fsPromises.writeFile( + importSettingsPath, + JSON.stringify( + { + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'en', + theme: 'light', + providerLimits: { + OpenAI: { + hasSubscription: true, + subscriptionPrice: 20, + monthlyLimit: 400, + }, + }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['tables', 'advancedAnalysis', 'metrics', 'insights'], + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', }, }, - defaultFilters: { - viewMode: 'monthly', - datePreset: '30d', - providers: ['OpenAI'], - models: ['GPT-5.4'], - }, - sectionVisibility: { - tokenAnalysis: false, - comparisons: false, - }, - sectionOrder: ['tables', 'advancedAnalysis', 'metrics', 'insights'], - lastLoadedAt: '2026-04-01T12:30:00.000Z', - lastLoadSource: 'file', - }, - }, null, 2)) + null, + 2, + ), + ) await page.locator('[data-testid="settings-import-input"]').setInputFiles(importSettingsPath) await expect(page.getByRole('button', { name: 'Export settings' })).toBeVisible() @@ -280,10 +368,18 @@ test('manages settings and backup imports through the settings dialog using isol }) expect(importedSettings.sectionVisibility.tokenAnalysis).toBe(false) expect(importedSettings.sectionVisibility.comparisons).toBe(false) - expect(importedSettings.sectionOrder.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + expect(importedSettings.sectionOrder.slice(0, 4)).toEqual([ + 'tables', + 'advancedAnalysis', + 'metrics', + 'insights', + ]) }) -test('loads persisted settings on a fresh browser start and applies them immediately', async ({ browser, page }) => { +test('loads persisted settings on a fresh browser start and applies them immediately', async ({ + browser, + page, +}) => { await page.request.delete('/api/usage') await page.request.delete('/api/settings') @@ -334,36 +430,59 @@ test('loads persisted settings on a fresh browser start and applies them immedia await freshPage.goto('/') await expect(freshPage.locator('#token-analysis')).toHaveCount(0) await expect(freshPage.locator('#comparisons')).toHaveCount(0) - await expect.poll(async () => freshPage.evaluate(() => document.documentElement.classList.contains('dark'))).toBe(false) + await expect + .poll(async () => + freshPage.evaluate(() => document.documentElement.classList.contains('dark')), + ) + .toBe(false) await expect(freshPage.getByRole('button', { name: 'Settings' })).toBeVisible() await expect(freshPage.locator('#filters').getByText('Filter status')).toBeVisible() await expect(freshPage.locator('#filters').getByText('1 providers active')).toBeVisible() await expect(freshPage.locator('#filters').getByText('1 models active')).toBeVisible() - await expect(freshPage.locator('#filters').getByRole('combobox').first()).toContainText('Monthly view') + await expect(freshPage.locator('#filters').getByRole('combobox').first()).toContainText( + 'Monthly view', + ) await expect(freshPage.getByRole('button', { name: 'Delete' })).toBeVisible() - await expect.poll(async () => freshPage.evaluate(() => { - const tables = document.getElementById('tables') - const advancedAnalysis = document.getElementById('advanced-analysis') - const metrics = document.getElementById('metrics') - const insights = document.getElementById('insights') - - if (!tables || !advancedAnalysis || !metrics || !insights) { - return false - } - - const tablesBeforeAdvanced = Boolean(tables.compareDocumentPosition(advancedAnalysis) & Node.DOCUMENT_POSITION_FOLLOWING) - const advancedBeforeMetrics = Boolean(advancedAnalysis.compareDocumentPosition(metrics) & Node.DOCUMENT_POSITION_FOLLOWING) - const metricsBeforeInsights = Boolean(metrics.compareDocumentPosition(insights) & Node.DOCUMENT_POSITION_FOLLOWING) - - return tablesBeforeAdvanced && advancedBeforeMetrics && metricsBeforeInsights - })).toBe(true) + await expect + .poll(async () => + freshPage.evaluate(() => { + const tables = document.getElementById('tables') + const advancedAnalysis = document.getElementById('advanced-analysis') + const metrics = document.getElementById('metrics') + const insights = document.getElementById('insights') + + if (!tables || !advancedAnalysis || !metrics || !insights) { + return false + } + + const tablesBeforeAdvanced = Boolean( + tables.compareDocumentPosition(advancedAnalysis) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + const advancedBeforeMetrics = Boolean( + advancedAnalysis.compareDocumentPosition(metrics) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + const metricsBeforeInsights = Boolean( + metrics.compareDocumentPosition(insights) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + + return tablesBeforeAdvanced && advancedBeforeMetrics && metricsBeforeInsights + }), + ) + .toBe(true) await freshPage.keyboard.press('Control+k') await expect(freshPage.getByTestId('command-section-advancedAnalysis')).toBeVisible() - const orderedSectionCommandIds = await freshPage.locator('[data-testid^="command-section-"]').evaluateAll((nodes) => ( - nodes.map((node) => node.getAttribute('data-testid')?.replace('command-section-', '')) - )) - expect(orderedSectionCommandIds.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + const orderedSectionCommandIds = await freshPage + .locator('[data-testid^="command-section-"]') + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute('data-testid')?.replace('command-section-', '')), + ) + expect(orderedSectionCommandIds.slice(0, 4)).toEqual([ + 'tables', + 'advancedAnalysis', + 'metrics', + 'insights', + ]) await freshPage.keyboard.press('Escape') await freshPage.evaluate(() => { @@ -379,13 +498,28 @@ test('loads persisted settings on a fresh browser start and applies them immedia await expect(dialog).toBeVisible() await expect(dialog.getByRole('button', { name: 'Export settings' })).toBeVisible() await expect(dialog.getByRole('button', { name: 'OpenAI', exact: true })).toBeVisible() - await expect(dialog.getByRole('button', { name: 'Monthly' })).toHaveAttribute('aria-pressed', 'true') - await expect(dialog.getByRole('button', { name: 'Last 30 days' })).toHaveAttribute('aria-pressed', 'true') - await expect(dialog.locator('[data-section-id="advancedAnalysis"]')).toContainText('Distributions & Risk') + await expect(dialog.getByRole('button', { name: 'Monthly' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(dialog.getByRole('button', { name: 'Last 30 days' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(dialog.locator('[data-section-id="advancedAnalysis"]')).toContainText( + 'Distributions & Risk', + ) await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText('Hidden') - const orderedSectionIds = await dialog.locator('[data-section-id]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id'))) - expect(orderedSectionIds.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + const orderedSectionIds = await dialog + .locator('[data-section-id]') + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id'))) + expect(orderedSectionIds.slice(0, 4)).toEqual([ + 'tables', + 'advancedAnalysis', + 'metrics', + 'insights', + ]) const openAiCard = dialog.locator('[data-provider-id="OpenAI"]') await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('20') await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('400') @@ -397,12 +531,14 @@ test('loads persisted settings on a fresh browser start and applies them immedia } }) -test('uses the current UI language when generating a PDF report after switching locale', async ({ page }) => { +test('uses the current UI language when generating a PDF report after switching locale', async ({ + page, +}) => { await page.request.delete('/api/usage') let reportRequest: Record | null = null - await page.route('**/api/report/pdf', async route => { + await page.route('**/api/report/pdf', async (route) => { reportRequest = JSON.parse(route.request().postData() ?? '{}') as Record await route.fulfill({ status: 200, diff --git a/tests/frontend/use-dashboard-filters.test.tsx b/tests/frontend/use-dashboard-filters.test.tsx index 918a164..f5ad17d 100644 --- a/tests/frontend/use-dashboard-filters.test.tsx +++ b/tests/frontend/use-dashboard-filters.test.tsx @@ -25,7 +25,7 @@ describe('useDashboardFilters', () => { result.current.toggleProvider('OpenAI') }) - expect(result.current.filteredDailyData.map(entry => entry.date)).toEqual([ + expect(result.current.filteredDailyData.map((entry) => entry.date)).toEqual([ '2026-03-30', '2026-03-31', '2026-04-06', diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index d0e68ef..2076a54 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -5,7 +5,10 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import sampleUsage from '../../examples/sample-usage.json' -import { DEFAULT_DASHBOARD_FILTERS, getDefaultDashboardSectionOrder } from '@/lib/dashboard-preferences' +import { + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, +} from '@/lib/dashboard-preferences' let child: ChildProcessWithoutNullStreams | null = null let baseUrl = '' @@ -57,7 +60,7 @@ async function waitForServer(url: string) { } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup:\n${output}`) @@ -74,7 +77,7 @@ async function waitForUrlAvailable(url: string) { } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup: ${url}`) @@ -90,7 +93,7 @@ async function waitForServerUnavailable(url: string) { return } - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server shutdown: ${url}`) @@ -115,7 +118,7 @@ async function waitForProcessServer( } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup:\n${getOutput()}`) @@ -127,7 +130,7 @@ async function stopProcess(currentChild: ChildProcessWithoutNullStreams) { } currentChild.kill('SIGTERM') - await new Promise(resolve => currentChild.once('close', resolve)) + await new Promise((resolve) => currentChild.once('close', resolve)) } function createCliEnv(root: string) { @@ -154,7 +157,7 @@ async function startStandaloneServer({ args?: string[] envOverrides?: NodeJS.ProcessEnv }) { - const port = Number(envOverrides.PORT) || await getFreePort() + const port = Number(envOverrides.PORT) || (await getFreePort()) const url = `http://127.0.0.1:${port}` let serverOutput = '' @@ -168,11 +171,11 @@ async function startStandaloneServer({ stdio: ['ignore', 'pipe', 'pipe'], }) - currentChild.stdout.on('data', chunk => { + currentChild.stdout.on('data', (chunk) => { serverOutput += chunk.toString() }) - currentChild.stderr.on('data', chunk => { + currentChild.stderr.on('data', (chunk) => { serverOutput += chunk.toString() }) @@ -200,7 +203,11 @@ function getCliConfigDir(root: string) { function readBackgroundRegistry(root: string) { const registryPath = path.join(getCliConfigDir(root), 'background-instances.json') - return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ url: string, port: number, pid: number }> + return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ + url: string + port: number + pid: number + }> } function writeBackgroundRegistry(root: string, entries: unknown) { @@ -209,8 +216,8 @@ function writeBackgroundRegistry(root: string, entries: unknown) { writeFileSync(registryPath, JSON.stringify(entries, null, 2)) } -async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv, input?: string }) { - return await new Promise<{ code: number | null, output: string }>((resolve, reject) => { +async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv; input?: string }) { + return await new Promise<{ code: number | null; output: string }>((resolve, reject) => { const cli = spawn(process.execPath, ['server.js', ...args], { cwd: process.cwd(), env, @@ -219,16 +226,16 @@ async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv, let cliOutput = '' - cli.stdout.on('data', chunk => { + cli.stdout.on('data', (chunk) => { cliOutput += chunk.toString() }) - cli.stderr.on('data', chunk => { + cli.stderr.on('data', (chunk) => { cliOutput += chunk.toString() }) cli.on('error', reject) - cli.on('close', code => { + cli.on('close', (code) => { resolve({ code, output: cliOutput }) }) @@ -272,11 +279,11 @@ beforeAll(async () => { stdio: ['ignore', 'pipe', 'pipe'], }) - child.stdout.on('data', chunk => { + child.stdout.on('data', (chunk) => { output += chunk.toString() }) - child.stderr.on('data', chunk => { + child.stderr.on('data', (chunk) => { output += chunk.toString() }) @@ -560,11 +567,9 @@ describe('local server API', () => { { ...sampleUsage.daily[1], totalCost: 999, - modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => ( - index === 0 - ? { ...entry, cost: 997 } - : entry - )), + modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => + index === 0 ? { ...entry, cost: 997 } : entry, + ), }, newImportedDay, ], @@ -586,7 +591,9 @@ describe('local server API', () => { const mergedUsage = await mergedUsageResponse.json() expect(mergedUsage.daily).toHaveLength(6) expect(mergedUsage.daily[0].date).toBe('2026-03-31') - expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBeCloseTo(3.94, 6) + expect( + mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost, + ).toBeCloseTo(3.94, 6) const mergedSettingsResponse = await fetch(`${baseUrl}/api/settings`) expect(mergedSettingsResponse.status).toBe(200) @@ -802,9 +809,12 @@ describe('local server API', () => { expect(firstStart.output).toContain(firstUrl) await waitForUrlAvailable(firstUrl) - const secondStart = await runCli(['--background', '--no-open', '--port', String(secondPort)], { - env: backgroundEnv, - }) + const secondStart = await runCli( + ['--background', '--no-open', '--port', String(secondPort)], + { + env: backgroundEnv, + }, + ) expect(secondStart.code).toBe(0) expect(secondStart.output).toContain('TTDash is running in the background.') @@ -867,7 +877,7 @@ describe('local server API', () => { const registry = readBackgroundRegistry(backgroundRoot) expect(registry).toHaveLength(2) - expect(registry.map(instance => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) + expect(registry.map((instance) => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) } finally { await stopAllBackgroundServers(backgroundEnv) rmSync(backgroundRoot, { recursive: true, force: true }) @@ -883,15 +893,17 @@ describe('local server API', () => { expect(runtimeResponse.status).toBe(200) const runtime = await runtimeResponse.json() - writeBackgroundRegistry(backgroundRoot, [{ - id: 'stale-entry', - pid: child?.pid, - port: runtime.port, - url: baseUrl, - host: '127.0.0.1', - startedAt: new Date().toISOString(), - logFile: null, - }]) + writeBackgroundRegistry(backgroundRoot, [ + { + id: 'stale-entry', + pid: child?.pid, + port: runtime.port, + url: baseUrl, + host: '127.0.0.1', + startedAt: new Date().toISOString(), + logFile: null, + }, + ]) const stopResult = await runCli(['stop'], { env: backgroundEnv, @@ -912,23 +924,30 @@ describe('local server API', () => { const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-runtime-dir-test-')) const explicitConfigDir = path.join(runtimeRoot, 'explicit-config') - const expectedPlatformPaths = process.platform === 'darwin' - ? { - dataFile: path.join(runtimeRoot, 'Library', 'Application Support', 'TTDash', 'data.json'), - settingsFile: path.join(explicitConfigDir, 'settings.json'), - cacheDir: path.join(runtimeRoot, 'Library', 'Caches', 'TTDash'), - } - : process.platform === 'win32' + const expectedPlatformPaths = + process.platform === 'darwin' ? { - dataFile: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'data.json'), - settingsFile: path.join(explicitConfigDir, 'settings.json'), - cacheDir: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'Cache'), - } - : { - dataFile: path.join(runtimeRoot, 'data', 'ttdash', 'data.json'), + dataFile: path.join( + runtimeRoot, + 'Library', + 'Application Support', + 'TTDash', + 'data.json', + ), settingsFile: path.join(explicitConfigDir, 'settings.json'), - cacheDir: path.join(runtimeRoot, 'cache', 'ttdash'), + cacheDir: path.join(runtimeRoot, 'Library', 'Caches', 'TTDash'), } + : process.platform === 'win32' + ? { + dataFile: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'data.json'), + settingsFile: path.join(explicitConfigDir, 'settings.json'), + cacheDir: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'Cache'), + } + : { + dataFile: path.join(runtimeRoot, 'data', 'ttdash', 'data.json'), + settingsFile: path.join(explicitConfigDir, 'settings.json'), + cacheDir: path.join(runtimeRoot, 'cache', 'ttdash'), + } let standaloneServer: Awaited> | null = null @@ -954,8 +973,12 @@ describe('local server API', () => { }) expect(settingsResponse.status).toBe(200) - expect(standaloneServer.getOutput()).toContain(`Data File: ${expectedPlatformPaths.dataFile}`) - expect(standaloneServer.getOutput()).toContain(`Settings File: ${expectedPlatformPaths.settingsFile}`) + expect(standaloneServer.getOutput()).toContain( + `Data File: ${expectedPlatformPaths.dataFile}`, + ) + expect(standaloneServer.getOutput()).toContain( + `Settings File: ${expectedPlatformPaths.settingsFile}`, + ) expect(existsSync(expectedPlatformPaths.dataFile)).toBe(true) expect(existsSync(expectedPlatformPaths.settingsFile)).toBe(true) expect(existsSync(path.join(expectedPlatformPaths.cacheDir, 'npx-cache'))).toBe(true) @@ -986,7 +1009,7 @@ describe('local server API', () => { expect(result.output).toContain('No free port found (65535-65535)') expect(result.output).not.toContain('trying 65536') } finally { - await new Promise(resolve => occupiedPortServer.close(() => resolve(undefined))) + await new Promise((resolve) => occupiedPortServer.close(() => resolve(undefined))) rmSync(cliRoot, { recursive: true, force: true }) } }, 20_000) diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index c94d61e..d555c2f 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -7,11 +7,7 @@ describe('dashboard analytics', () => { it('recalculates totals when provider filtering removes model breakdowns', () => { const filtered = filterByProviders(dashboardFixture, ['OpenAI']) - expect(filtered.map(entry => entry.date)).toEqual([ - '2026-03-30', - '2026-03-31', - '2026-04-06', - ]) + expect(filtered.map((entry) => entry.date)).toEqual(['2026-03-30', '2026-03-31', '2026-04-06']) expect(filtered[0]).toMatchObject({ totalCost: 6, totalTokens: 210, @@ -135,11 +131,6 @@ describe('dashboard analytics', () => { }) it('computes moving averages with leading gaps instead of partial windows', () => { - expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([ - undefined, - undefined, - 2, - 3, - ]) + expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([undefined, undefined, 2, 3]) }) }) diff --git a/tests/unit/report-charts.test.ts b/tests/unit/report-charts.test.ts index 333f19d..83c785d 100644 --- a/tests/unit/report-charts.test.ts +++ b/tests/unit/report-charts.test.ts @@ -4,37 +4,47 @@ describe('report charts', () => { it('uses the provided formatter for stacked chart y-axis labels', async () => { const { stackedBarChart } = await import('../../server/report/charts.js') - const svg = stackedBarChart([ - { label: 'Mar', input: 1200, output: 300, cacheWrite: 0, cacheRead: 0, thinking: 0 }, - { label: 'Apr', input: 2400, output: 600, cacheWrite: 100, cacheRead: 20, thinking: 0 }, - ], { - title: 'Token mix', - formatter: value => `fmt:${Math.round(value)}`, - segments: [ - { key: 'input', label: 'Input', color: '#000' }, - { key: 'output', label: 'Output', color: '#111' }, - { key: 'cacheWrite', label: 'Cache Write', color: '#222' }, - { key: 'cacheRead', label: 'Cache Read', color: '#333' }, - { key: 'thinking', label: 'Thinking', color: '#444' }, + const svg = stackedBarChart( + [ + { label: 'Mar', input: 1200, output: 300, cacheWrite: 0, cacheRead: 0, thinking: 0 }, + { label: 'Apr', input: 2400, output: 600, cacheWrite: 100, cacheRead: 20, thinking: 0 }, ], - }) + { + title: 'Token mix', + formatter: (value) => `fmt:${Math.round(value)}`, + segments: [ + { key: 'input', label: 'Input', color: '#000' }, + { key: 'output', label: 'Output', color: '#111' }, + { key: 'cacheWrite', label: 'Cache Write', color: '#222' }, + { key: 'cacheRead', label: 'Cache Read', color: '#333' }, + { key: 'thinking', label: 'Thinking', color: '#444' }, + ], + }, + ) expect(svg).toContain('fmt:0') expect(svg).toContain('fmt:780') - expect(svg).not.toContain("de-CH") + expect(svg).not.toContain('de-CH') }) it('truncates overly long horizontal bar labels to keep the chart readable', async () => { const { horizontalBarChart } = await import('../../server/report/charts.js') - const svg = horizontalBarChart([ - { name: 'This is a very long model name that should not overflow the chart area', value: 42, color: '#123456' }, - ], { - title: 'Top models', - getValue: entry => entry.value, - getLabel: entry => entry.name, - getColor: entry => entry.color, - }) + const svg = horizontalBarChart( + [ + { + name: 'This is a very long model name that should not overflow the chart area', + value: 42, + color: '#123456', + }, + ], + { + title: 'Top models', + getValue: (entry) => entry.value, + getLabel: (entry) => entry.name, + getColor: (entry) => entry.color, + }, + ) expect(svg).toContain('This is a very long model nam…') }) diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index e38c152..14d3119 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -24,8 +24,12 @@ describe('report utils', () => { selectedModels: ['gpt-5.4', 'claude-sonnet-4-5', 'gemini-2.5-pro', 'opencode'], }) - expect(report.meta.filterSummary.selectedProvidersLabel).toBe('OpenAI, Anthropic, Google +1 more') - expect(report.meta.filterSummary.selectedModelsLabel).toBe('GPT-5.4, Sonnet 4.5, Gemini +1 more') + expect(report.meta.filterSummary.selectedProvidersLabel).toBe( + 'OpenAI, Anthropic, Google +1 more', + ) + expect(report.meta.filterSummary.selectedModelsLabel).toBe( + 'GPT-5.4, Sonnet 4.5, Gemini +1 more', + ) expect(report.summaryCards[5].label).toBe('Peak period') expect(report.summaryCards[5].value).not.toMatch(/^\d{4}-\d{2}-\d{2}$/) }) @@ -53,8 +57,14 @@ describe('report utils', () => { }) expect(report.insights.items.length).toBeGreaterThan(0) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Data coverage')).toBe(true) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Provider concentration')).toBe(true) + expect( + report.insights.items.some((item: { title: string }) => item.title === 'Data coverage'), + ).toBe(true) + expect( + report.insights.items.some( + (item: { title: string }) => item.title === 'Provider concentration', + ), + ).toBe(true) }) it('formats compact chart axes for the current language', async () => { @@ -70,10 +80,10 @@ describe('report utils', () => { it('keeps cache insights visible without request counters when token cache data exists', async () => { const { buildReportData } = await import('../../server/report/utils.js') - const dataWithoutRequests = dashboardFixture.map(day => ({ + const dataWithoutRequests = dashboardFixture.map((day) => ({ ...day, requestCount: 0, - modelBreakdowns: day.modelBreakdowns.map(entry => ({ + modelBreakdowns: day.modelBreakdowns.map((entry) => ({ ...entry, requestCount: 0, })), @@ -86,7 +96,9 @@ describe('report utils', () => { expect(report.metrics.hasRequestData).toBe(false) expect(report.metrics.cacheHitRate).toBeGreaterThan(0) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Cache contribution')).toBe(true) + expect( + report.insights.items.some((item: { title: string }) => item.title === 'Cache contribution'), + ).toBe(true) }) it('keeps percent strings in german report output and localizes the report header', async () => { @@ -98,7 +110,9 @@ describe('report utils', () => { }) expect(report.summaryCards[0].note).toContain('%') - expect(report.insights.items.some((item: { body: string }) => item.body.includes('%'))).toBe(true) + expect(report.insights.items.some((item: { body: string }) => item.body.includes('%'))).toBe( + true, + ) expect(report.text.headerEyebrow).toBe('TTDash PDF-Bericht') }) @@ -111,7 +125,9 @@ describe('report utils', () => { }) expect(report.labels.topModel).toContain(report.summaryCards[4].note) - expect(report.labels.topProvider).toContain(report.summaryCards[0].note.replace(`${report.metrics.topProvider?.name} `, '')) + expect(report.labels.topProvider).toContain( + report.summaryCards[0].note.replace(`${report.metrics.topProvider?.name} `, ''), + ) }) it('uses period averages for aggregated summary cards', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 29f3e65..4f628b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,41 +2,41 @@ import { defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config' export default defineConfig(async () => { - const resolvedViteConfig = typeof viteConfig === 'function' - ? await viteConfig({ - command: 'serve', - mode: 'test', - isSsrBuild: false, - isPreview: false, - }) - : viteConfig + const resolvedViteConfig = + typeof viteConfig === 'function' + ? await viteConfig({ + command: 'serve', + mode: 'test', + isSsrBuild: false, + isPreview: false, + }) + : viteConfig - return mergeConfig(resolvedViteConfig, defineConfig({ - test: { - environment: 'node', - setupFiles: ['./vitest.setup.ts'], - include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], - reporters: ['default', 'junit'], - outputFile: { - junit: './test-results/vitest.junit.xml', + return mergeConfig( + resolvedViteConfig, + defineConfig({ + test: { + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + reporters: ['default', 'junit'], + outputFile: { + junit: './test-results/vitest.junit.xml', + }, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: './coverage', + include: ['src/hooks/**/*.ts', 'src/lib/**/*.ts', 'usage-normalizer.js'], + exclude: [ + 'src/lib/i18n.ts', + 'src/lib/constants.ts', + 'src/lib/help-content.ts', + 'src/lib/cn.ts', + 'tests/**', + ], + }, }, - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov'], - reportsDirectory: './coverage', - include: [ - 'src/hooks/**/*.ts', - 'src/lib/**/*.ts', - 'usage-normalizer.js', - ], - exclude: [ - 'src/lib/i18n.ts', - 'src/lib/constants.ts', - 'src/lib/help-content.ts', - 'src/lib/cn.ts', - 'tests/**', - ], - }, - }, - })) + }), + ) }) From 0e7963c3d21f82499e393e0ba60603f2b0fbb772 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 22:49:03 +0200 Subject: [PATCH 15/34] v6.1.6: Integrate lint and format gates --- .github/workflows/ci.yml | 11 ++++- .github/workflows/release.yml | 11 ++++- AGENTS.md | 6 +++ package.json | 10 +++-- scripts/verify-package.js | 2 +- usage-normalizer.js | 80 +++++++++++++++++++---------------- 6 files changed, 77 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9016800..348db2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,20 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Check formatting + run: npm run format:check + + - name: Run ESLint + run: npm run lint + + - name: Run TypeScript checks + run: ./node_modules/.bin/tsc --noEmit + - name: Run unit and integration tests with coverage run: npm run test:unit:coverage - name: Build production bundle - run: npm run build + run: npm run build:app - name: Verify packed npm artifact run: npm run verify:package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3bca1e..d89f759 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,11 +115,20 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Check formatting + run: npm run format:check + + - name: Run ESLint + run: npm run lint + + - name: Run TypeScript checks + run: ./node_modules/.bin/tsc --noEmit + - name: Run unit and integration tests with coverage run: npm run test:unit:coverage - name: Build production bundle - run: npm run build + run: npm run build:app - name: Verify packed npm artifact run: npm run verify:package diff --git a/AGENTS.md b/AGENTS.md index c56d481..d92334f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,11 @@ # Repository Guidelines ## Project Structure & Module Organization + `src/` contains the Vite frontend. Use `components/` for UI, grouped by `ui/`, `layout/`, `cards/`, `charts/`, `tables/`, and `features/`. Put shared logic in `lib/`, reusable stateful logic in `hooks/`, and TypeScript shapes in `types/`. Static assets live in `public/`. The production bundle is generated into `dist/`. `server.js` serves `dist/`, exposes `/api`, and handles local data import. ## Build, Test, and Development Commands + Install dependencies with `npm install`. - `npm run dev`: starts the Vite dev server on port `5173`. @@ -15,13 +17,17 @@ Install dependencies with `npm install`. During development, keep `npm run dev` and `node server.js` running in separate terminals so `/api` requests resolve correctly. ## Coding Style & Naming Conventions + Frontend code is TypeScript + React. Follow the existing style: 2-space indentation, single quotes, trailing commas where the formatter leaves them, and no semicolons in `src/` files. Component, hook, and type filenames use PascalCase or descriptive kebab-free names such as `Dashboard.tsx`, `use-usage-data.ts`, and `formatters.ts`. Keep utilities small and colocate feature-specific UI under `src/components/features/`. In `server.js`, preserve the current CommonJS style and semicolon usage instead of rewriting it to match the frontend. ## Testing Guidelines + Automated tests are part of the repo now. Before opening a PR, run `npm run build`, `npm run test:unit`, `npm run verify:package`, and `npm run test:e2e`. If local port `3015` is already in use, run Playwright with `PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e`. Continue to manually verify the main flows affected by the change: dashboard load, auto-import, JSON upload, filtering, and export actions. If you add tests, prefer focused `*.test.ts` or `*.test.tsx` coverage for data transforms, hooks, or complex UI behavior. ## Commit & Pull Request Guidelines + Recent history favors short, imperative subjects, often with a version prefix, for example `v5.3.1: Fix timezone bug` or `Fix install.sh -e output`. Keep commits narrowly scoped. PRs should explain the user-visible change, note any manual verification performed, link related issues, and include screenshots or GIFs for UI changes. ## Configuration Tips + Use `PORT=8080 node server.js` to override the default server port. Do not commit generated `dist/` output or local usage data unless the change explicitly requires it. diff --git a/package.json b/package.json index dd692ab..d905e08 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ }, "scripts": { "dev": "vite", - "build": "vite build", + "build:app": "vite build", + "build": "npm run format:check && npm run lint && npm run build:app", "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint .", @@ -28,14 +29,15 @@ "test:unit": "vitest run", "test:unit:watch": "vitest", "test:unit:coverage": "vitest run --coverage", - "test:e2e": "npm run build && playwright test", + "test:e2e": "npm run build:app && playwright test", "test:e2e:ci": "playwright test", "test:all": "npm run test:unit && npm run test:e2e", "pack:dry-run": "npm pack --dry-run", + "verify": "npm run format:check && npm run lint && ./node_modules/.bin/tsc --noEmit && npm run test:unit && npm run build:app && npm run verify:package", "verify:package": "node scripts/verify-package.js", "verify:registry-install": "node scripts/verify-registry-install.js", - "verify:release": "npm run test:unit:coverage && npm run build && npm run verify:package", - "prepare": "npm run build" + "verify:release": "npm run format:check && npm run lint && ./node_modules/.bin/tsc --noEmit && npm run test:unit:coverage && npm run build:app && npm run verify:package", + "prepare": "npm run build:app" }, "files": [ "server.js", diff --git a/scripts/verify-package.js b/scripts/verify-package.js index 1ea8bf0..f59c011 100644 --- a/scripts/verify-package.js +++ b/scripts/verify-package.js @@ -209,7 +209,7 @@ async function main() { if (!fs.existsSync(path.join(ROOT, 'dist', 'index.html'))) { log('Production bundle missing, running build first.'); - run(command, ['run', 'build'], { env: npmEnv }); + run(command, ['run', 'build:app'], { env: npmEnv }); } const packJson = run( diff --git a/usage-normalizer.js b/usage-normalizer.js index 26cbdf1..1cdb259 100644 --- a/usage-normalizer.js +++ b/usage-normalizer.js @@ -1,9 +1,9 @@ function toNumber(value) { - return Number.isFinite(value) ? value : Number(value) || 0 + return Number.isFinite(value) ? value : Number(value) || 0; } function toStringValue(value) { - return typeof value === 'string' ? value : '' + return typeof value === 'string' ? value : ''; } function normalizeLegacyModelBreakdown(entry) { @@ -20,13 +20,13 @@ function normalizeLegacyModelBreakdown(entry) { } function withDailyTotals(day) { - const totalTokens = toNumber(day.totalTokens) || ( + const totalTokens = + toNumber(day.totalTokens) || toNumber(day.inputTokens) + - toNumber(day.outputTokens) + - toNumber(day.cacheCreationTokens) + - toNumber(day.cacheReadTokens) + - toNumber(day.thinkingTokens) - ); + toNumber(day.outputTokens) + + toNumber(day.cacheCreationTokens) + + toNumber(day.cacheReadTokens) + + toNumber(day.thinkingTokens); return { date: toStringValue(day.date), @@ -38,8 +38,12 @@ function withDailyTotals(day) { totalTokens, totalCost: toNumber(day.totalCost), requestCount: toNumber(day.requestCount), - modelsUsed: Array.isArray(day.modelsUsed) ? day.modelsUsed.filter((value) => typeof value === 'string') : [], - modelBreakdowns: Array.isArray(day.modelBreakdowns) ? day.modelBreakdowns.map(normalizeLegacyModelBreakdown) : [], + modelsUsed: Array.isArray(day.modelsUsed) + ? day.modelsUsed.filter((value) => typeof value === 'string') + : [], + modelBreakdowns: Array.isArray(day.modelBreakdowns) + ? day.modelBreakdowns.map(normalizeLegacyModelBreakdown) + : [], }; } @@ -66,9 +70,10 @@ function normalizeLegacyDay(entry) { } function normalizeToktrackDay(entry) { - const models = entry?.models && typeof entry.models === 'object' && !Array.isArray(entry.models) - ? entry.models - : {}; + const models = + entry?.models && typeof entry.models === 'object' && !Array.isArray(entry.models) + ? entry.models + : {}; const modelBreakdowns = Object.entries(models).map(([modelName, modelData]) => ({ modelName, @@ -98,25 +103,28 @@ function normalizeToktrackDay(entry) { } function computeTotals(daily) { - return daily.reduce((totals, day) => ({ - inputTokens: totals.inputTokens + day.inputTokens, - outputTokens: totals.outputTokens + day.outputTokens, - cacheCreationTokens: totals.cacheCreationTokens + day.cacheCreationTokens, - cacheReadTokens: totals.cacheReadTokens + day.cacheReadTokens, - thinkingTokens: totals.thinkingTokens + day.thinkingTokens, - totalCost: totals.totalCost + day.totalCost, - totalTokens: totals.totalTokens + day.totalTokens, - requestCount: totals.requestCount + day.requestCount, - }), { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }); + return daily.reduce( + (totals, day) => ({ + inputTokens: totals.inputTokens + day.inputTokens, + outputTokens: totals.outputTokens + day.outputTokens, + cacheCreationTokens: totals.cacheCreationTokens + day.cacheCreationTokens, + cacheReadTokens: totals.cacheReadTokens + day.cacheReadTokens, + thinkingTokens: totals.thinkingTokens + day.thinkingTokens, + totalCost: totals.totalCost + day.totalCost, + totalTokens: totals.totalTokens + day.totalTokens, + requestCount: totals.requestCount + day.requestCount, + }), + { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + ); } function normalizeIncomingData(payload) { @@ -125,7 +133,9 @@ function normalizeIncomingData(payload) { if (Array.isArray(payload)) { daily = payload.map(normalizeToktrackDay); } else if (payload && typeof payload === 'object' && Array.isArray(payload.daily)) { - const looksLikeToktrack = payload.daily.some((item) => item && typeof item === 'object' && 'total_input_tokens' in item); + const looksLikeToktrack = payload.daily.some( + (item) => item && typeof item === 'object' && 'total_input_tokens' in item, + ); daily = looksLikeToktrack ? payload.daily.map(normalizeToktrackDay) : payload.daily.map(normalizeLegacyDay); @@ -133,9 +143,7 @@ function normalizeIncomingData(payload) { throw new Error('Die JSON-Datei muss ein gültiges tägliches Nutzungsformat enthalten.'); } - const filtered = daily - .filter((item) => item.date) - .sort((a, b) => a.date.localeCompare(b.date)); + const filtered = daily.filter((item) => item.date).sort((a, b) => a.date.localeCompare(b.date)); if (filtered.length === 0) { throw new Error('Keine Nutzungsdaten gefunden.'); From 4f2433a41ee159d96e0b95fb0c59388c1834a036 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 22:52:56 +0200 Subject: [PATCH 16/34] v6.1.6: Update tooling documentation --- AGENTS.md | 6 ++++-- CONTRIBUTING.md | 16 +++++++++++++--- README.md | 18 ++++++++++++++---- RELEASING.md | 22 +++++++++++----------- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d92334f..467def2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ Install dependencies with `npm install`. - `npm run dev`: starts the Vite dev server on port `5173`. - `node server.js`: runs the local API/static server on port `3000`. -- `npm run build`: creates the production bundle in `dist/`. +- `npm run build`: runs `prettier --check`, `eslint`, and then creates the production bundle in `dist/`. +- `npm run build:app`: creates the production bundle in `dist/` without the lint/format gate. +- `npm run verify`: runs the main local quality gate (`format:check`, `lint`, `tsc --noEmit`, unit tests, `build:app`, and `verify:package`). - `npm run preview`: serves the built frontend for a production-style check. - `npm start`: runs the packaged server entrypoint. @@ -22,7 +24,7 @@ Frontend code is TypeScript + React. Follow the existing style: 2-space indentat ## Testing Guidelines -Automated tests are part of the repo now. Before opening a PR, run `npm run build`, `npm run test:unit`, `npm run verify:package`, and `npm run test:e2e`. If local port `3015` is already in use, run Playwright with `PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e`. Continue to manually verify the main flows affected by the change: dashboard load, auto-import, JSON upload, filtering, and export actions. If you add tests, prefer focused `*.test.ts` or `*.test.tsx` coverage for data transforms, hooks, or complex UI behavior. +Automated tests are part of the repo now. Before opening a PR, run `npm run verify` and `npm run test:e2e`. If you want the same gate the release workflow uses, also run `npm run test:unit:coverage`. If local port `3015` is already in use, run Playwright with `PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e`. Continue to manually verify the main flows affected by the change: dashboard load, auto-import, JSON upload, filtering, and export actions. If you add tests, prefer focused `*.test.ts` or `*.test.tsx` coverage for data transforms, hooks, or complex UI behavior. ## Commit & Pull Request Guidelines diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a1e38f..7525e01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,12 +44,22 @@ Make sure the change is small, focused, and aligned with the existing product di Run the main local checks: ```bash -npm run build -npm run test:unit -npm run verify:package +npm run verify npm run test:e2e ``` +`npm run verify` covers formatting, ESLint, `tsc --noEmit`, unit tests, the production bundle, and packaged-artifact verification. If you want the same coverage gate used in release preparation, also run: + +```bash +npm run test:unit:coverage +``` + +If you only need the production bundle without the lint/format gate, use: + +```bash +npm run build:app +``` + If local port `3015` is already occupied, run Playwright on another isolated port: ```bash diff --git a/README.md b/README.md index 8070465..21d8a68 100644 --- a/README.md +++ b/README.md @@ -263,15 +263,25 @@ Build the production bundle: npm run build ``` +`npm run build` is the gated build and runs `format:check` and `lint` before bundling. If you only want the Vite production bundle, use: + +```bash +npm run build:app +``` + Run automated checks: ```bash -npm run test:unit -npm run test:unit:coverage -npm run verify:package +npm run verify npm run test:e2e ``` +If you want the release-style coverage run as well, execute: + +```bash +npm run test:unit:coverage +``` + The Playwright suite uses its own isolated local app directory. If port `3015` is already occupied locally, run it on another isolated port: ```bash @@ -287,7 +297,7 @@ PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e ## Status -GitHub Actions runs unit/integration coverage, packaged-artifact verification, and Playwright smoke tests for pull requests and pushes to `main`. +GitHub Actions now runs formatting checks, ESLint, `tsc --noEmit`, unit/integration coverage, the production bundle, packaged-artifact verification, and Playwright smoke tests for pull requests and pushes to `main`. ## License diff --git a/RELEASING.md b/RELEASING.md index e96316d..8e31fa8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,9 +37,8 @@ If branch protection or rulesets block the `ttdash-release` app from writing to Optional local confidence check before starting the workflow: ```bash +npm run verify npm run test:unit:coverage -npm run build -npm run verify:package PLAYWRIGHT_TEST_PORT=3016 npm_config_cache=/tmp/ttdash-npm-cache npm run test:e2e ``` @@ -51,19 +50,20 @@ On a manual `workflow_dispatch` run against `main`, the workflow: or resumes a partially completed release when the requested version is already on `main` 2. verifies the latest `CI` run for the current `main` commit succeeded 3. bumps `package.json` and `package-lock.json` to the requested version -4. runs unit/integration tests with coverage -5. builds the production bundle -6. verifies the packed npm artifact -7. runs the Playwright smoke suite -8. creates and pushes the release commit and annotated tag -9. publishes `@roastcodes/ttdash` to npm through Trusted Publishing -10. waits for npm registry propagation -11. verifies: +4. runs `prettier --check`, ESLint, and `tsc --noEmit` +5. runs unit/integration tests with coverage +6. builds the production bundle +7. verifies the packed npm artifact +8. runs the Playwright smoke suite +9. creates and pushes the release commit and annotated tag +10. publishes `@roastcodes/ttdash` to npm through Trusted Publishing +11. waits for npm registry propagation +12. verifies: - `npx --yes @roastcodes/ttdash@ --help` - `bunx @roastcodes/ttdash@ --help` -12. creates the GitHub release +13. creates the GitHub release Note: the workflow reruns the release-critical test suite itself after the version bump. This is necessary because the workflow-created push back to `main` should not be relied on to trigger the normal `CI` workflow again. If a release fails after the version bump was already pushed, rerunning the workflow with the same version resumes that release instead of forcing another version bump. From 08d9b784a14993427083f4cd608c92688754db96 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:05:14 +0200 Subject: [PATCH 17/34] v6.1.6: Simplify drill-down modal open state --- src/components/Dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 3c2d5eb..23113cf 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1102,7 +1102,7 @@ export function Dashboard() { setDrillDownDate(null)} /> )} From 652f49b3d0f0aeb2623a19e09d03a2ebd203eebc Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:12:29 +0200 Subject: [PATCH 18/34] v6.1.6: Refactor server runner and port scanning --- scripts/start-test-server.js | 2 +- server.js | 145 ++++++++++++++++++++++++----------- 2 files changed, 102 insertions(+), 45 deletions(-) diff --git a/scripts/start-test-server.js b/scripts/start-test-server.js index c273dd1..4093fbb 100644 --- a/scripts/start-test-server.js +++ b/scripts/start-test-server.js @@ -21,4 +21,4 @@ process.env.XDG_CACHE_HOME = path.join(runtimeRoot, 'cache'); process.env.XDG_CONFIG_HOME = path.join(runtimeRoot, 'config'); process.env.XDG_DATA_HOME = path.join(runtimeRoot, 'data'); -require(path.join(root, 'server.js')); +require(path.join(root, 'server.js')).bootstrapCli(); diff --git a/server.js b/server.js index 0f35ff4..8757179 100755 --- a/server.js +++ b/server.js @@ -1410,6 +1410,22 @@ function shouldUseShell(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command); } +function getExecutableName(baseName, isWindows = IS_WINDOWS) { + if (!isWindows) { + return baseName; + } + + switch (baseName) { + case 'bun': + case 'bunx': + return 'bun.exe'; + case 'npx': + return 'npx.cmd'; + default: + return baseName; + } +} + function spawnCommand(command, args, options = {}) { return spawn(command, args, { ...options, @@ -1438,9 +1454,9 @@ async function resolveToktrackRunner() { }; } - if (await commandExists(IS_WINDOWS ? 'bun.exe' : 'bun')) { + if (await commandExists(getExecutableName('bun'))) { return { - command: IS_WINDOWS ? 'bun.exe' : 'bunx', + command: getExecutableName('bunx'), prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'], env: process.env, method: 'bunx', @@ -1449,9 +1465,9 @@ async function resolveToktrackRunner() { }; } - if (await commandExists(IS_WINDOWS ? 'npx.cmd' : 'npx')) { + if (await commandExists(getExecutableName('npx'))) { return { - command: IS_WINDOWS ? 'npx.cmd' : 'npx', + command: getExecutableName('npx'), prefixArgs: ['--yes', 'toktrack'], env: { ...process.env, @@ -1845,36 +1861,58 @@ const server = http.createServer(async (req, res) => { serveFile(res, filePath); }); -function tryListen(port) { - return new Promise((resolve, reject) => { - if (port > MAX_PORT) { - reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); - return; - } +function createNoFreePortError(rangeStartPort, maxPort) { + return new Error(`No free port found (${rangeStartPort}-${maxPort})`); +} - const onError = (err) => { - server.off('listening', onListening); - if (err.code === 'EADDRINUSE') { - if (port >= MAX_PORT) { - reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); - return; +async function listenOnAvailablePort( + serverInstance, + port, + maxPort, + bindHost, + log = console.log, + rangeStartPort = port, +) { + if (port > maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + + for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { + try { + await new Promise((resolve, reject) => { + const onError = (err) => { + serverInstance.off('listening', onListening); + reject(err); + }; + + const onListening = () => { + serverInstance.off('error', onError); + resolve(); + }; + + serverInstance.once('error', onError); + serverInstance.once('listening', onListening); + serverInstance.listen(currentPort, bindHost); + }); + + return currentPort; + } catch (err) { + if (err && err.code === 'EADDRINUSE') { + if (currentPort >= maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); } - console.log(`Port ${port} is in use, trying ${port + 1}...`); - resolve(tryListen(port + 1)); - } else { - reject(err); + log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); + continue; } - }; + throw err; + } + } - const onListening = () => { - server.off('error', onError); - resolve(port); - }; + throw createNoFreePortError(rangeStartPort, maxPort); +} - server.once('error', onError); - server.once('listening', onListening); - server.listen(port, BIND_HOST); - }); +function tryListen(port) { + return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT); } async function start() { @@ -1915,18 +1953,40 @@ async function runCli() { await start(); } -runCli().catch((error) => { - Promise.resolve() - .then(async () => { - if (IS_BACKGROUND_CHILD) { - await unregisterBackgroundInstance(process.pid); - } - }) - .finally(() => { - console.error(error); - process.exit(1); - }); -}); +function registerShutdownHandlers() { + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +function bootstrapCli() { + runCli().catch((error) => { + Promise.resolve() + .then(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + }) + .finally(() => { + console.error(error); + process.exit(1); + }); + }); + + registerShutdownHandlers(); +} + +module.exports = { + bootstrapCli, + runCli, + __test__: { + getExecutableName, + listenOnAvailablePort, + }, +}; + +if (require.main === module) { + bootstrapCli(); +} // Graceful shutdown on Ctrl+C / kill function shutdown(signal) { @@ -1947,6 +2007,3 @@ function shutdown(signal) { process.exit(0); }, 3000); } - -process.on('SIGINT', () => shutdown('SIGINT')); -process.on('SIGTERM', () => shutdown('SIGTERM')); From f333f7405a6cb2d139475ea8756fb7881ba372aa Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:13:25 +0200 Subject: [PATCH 19/34] v6.1.6: Add server helper tests --- tests/unit/server-helpers.test.ts | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/unit/server-helpers.test.ts diff --git a/tests/unit/server-helpers.test.ts b/tests/unit/server-helpers.test.ts new file mode 100644 index 0000000..0de76a7 --- /dev/null +++ b/tests/unit/server-helpers.test.ts @@ -0,0 +1,111 @@ +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + __test__: { getExecutableName, listenOnAvailablePort }, +} = require('../../server.js') as { + __test__: { + getExecutableName: (baseName: string, isWindows?: boolean) => string + listenOnAvailablePort: ( + serverInstance: { + once: (event: string, handler: (...args: unknown[]) => void) => unknown + off: (event: string, handler: (...args: unknown[]) => void) => unknown + listen: (port: number, bindHost: string) => void + }, + port: number, + maxPort: number, + bindHost: string, + log?: (message: string) => void, + rangeStartPort?: number, + ) => Promise + } +} + +function createFakeServer( + onListen: (port: number, bindHost: string, emitter: EventEmitter) => void, +) { + const emitter = new EventEmitter() + + return { + once(event: string, handler: (...args: unknown[]) => void) { + emitter.once(event, handler) + return this + }, + off(event: string, handler: (...args: unknown[]) => void) { + emitter.off(event, handler) + return this + }, + listen(port: number, bindHost: string) { + onListen(port, bindHost, emitter) + }, + } +} + +describe('server helper utilities', () => { + it('maps executable names correctly across platforms', () => { + expect(getExecutableName('bun', true)).toBe('bun.exe') + expect(getExecutableName('bunx', true)).toBe('bun.exe') + expect(getExecutableName('npx', true)).toBe('npx.cmd') + expect(getExecutableName('toktrack', true)).toBe('toktrack') + expect(getExecutableName('bun', false)).toBe('bun') + expect(getExecutableName('bunx', false)).toBe('bunx') + expect(getExecutableName('npx', false)).toBe('npx') + }) + + it('retries iteratively on EADDRINUSE and logs each skipped port', async () => { + const attempts: number[] = [] + const logs: string[] = [] + const fakeServer = createFakeServer((port, _bindHost, emitter) => { + attempts.push(port) + queueMicrotask(() => { + if (port < 3002) { + emitter.emit('error', Object.assign(new Error('busy'), { code: 'EADDRINUSE' })) + return + } + emitter.emit('listening') + }) + }) + + const resolvedPort = await listenOnAvailablePort( + fakeServer, + 3000, + 3002, + '127.0.0.1', + (message) => logs.push(message), + ) + + expect(resolvedPort).toBe(3002) + expect(attempts).toEqual([3000, 3001, 3002]) + expect(logs).toEqual([ + 'Port 3000 is in use, trying 3001...', + 'Port 3001 is in use, trying 3002...', + ]) + }) + + it('throws the configured range error when no free port exists', async () => { + const fakeServer = createFakeServer((_port, _bindHost, emitter) => { + queueMicrotask(() => { + emitter.emit('error', Object.assign(new Error('busy'), { code: 'EADDRINUSE' })) + }) + }) + + await expect( + listenOnAvailablePort(fakeServer, 4100, 4101, '127.0.0.1', () => undefined, 4100), + ).rejects.toThrow('No free port found (4100-4101)') + }) + + it('rethrows non-EADDRINUSE errors unchanged', async () => { + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + const fakeServer = createFakeServer((_port, _bindHost, emitter) => { + queueMicrotask(() => { + emitter.emit('error', permissionError) + }) + }) + + await expect( + listenOnAvailablePort(fakeServer, 5200, 5201, '127.0.0.1', () => undefined, 5200), + ).rejects.toBe(permissionError) + }) +}) From a529d32e996daad63790bc63802b0d0700afba56 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:33:21 +0200 Subject: [PATCH 20/34] v6.1.6: Unify model normalization across app and report --- server/model-normalization.json | 28 ++++ server/report/utils.js | 198 +++++++++++++++++------ src/lib/model-utils.ts | 209 ++++++++++++++++++------- tests/unit/model-normalization.test.ts | 56 +++++++ tests/unit/report-utils.test.ts | 16 +- 5 files changed, 404 insertions(+), 103 deletions(-) create mode 100644 server/model-normalization.json create mode 100644 tests/unit/model-normalization.test.ts diff --git a/server/model-normalization.json b/server/model-normalization.json new file mode 100644 index 0000000..965a7e8 --- /dev/null +++ b/server/model-normalization.json @@ -0,0 +1,28 @@ +{ + "displayAliases": [ + { "pattern": "(^|-)gpt-5-4($|-)", "name": "GPT-5.4" }, + { "pattern": "(^|-)gpt-5(?:$|-(?!\\d))", "name": "GPT-5" }, + { "pattern": "(^|-)opus-4-6($|-)", "name": "Opus 4.6" }, + { "pattern": "(^|-)opus-4-5($|-)", "name": "Opus 4.5" }, + { "pattern": "(^|-)sonnet-4-6($|-)", "name": "Sonnet 4.6" }, + { "pattern": "(^|-)sonnet-4-5($|-)", "name": "Sonnet 4.5" }, + { "pattern": "(^|-)haiku-4-5($|-)", "name": "Haiku 4.5" }, + { "pattern": "(^|-)gemini-3-flash-preview($|-)", "name": "Gemini 3 Flash Preview" }, + { "pattern": "(^|-)opencode($|-)", "name": "OpenCode" } + ], + "providerMatchers": [ + { "pattern": "(^|-)opencode($|-)", "provider": "OpenCode" }, + { + "pattern": "openai-codex|(^|-)codex($|-)|(^|-)gpt($|-)|(^|[^a-z0-9])o\\d(?:$|[^a-z0-9])|openai", + "provider": "OpenAI" + }, + { "pattern": "claude|anthropic|opus|sonnet|haiku", "provider": "Anthropic" }, + { "pattern": "gemini|google|vertex", "provider": "Google" }, + { "pattern": "grok|xai", "provider": "xAI" }, + { "pattern": "llama|meta-llama|meta/", "provider": "Meta" }, + { "pattern": "command|cohere", "provider": "Cohere" }, + { "pattern": "mistral", "provider": "Mistral" }, + { "pattern": "deepseek", "provider": "DeepSeek" }, + { "pattern": "qwen|alibaba", "provider": "Alibaba" } + ] +} diff --git a/server/report/utils.js b/server/report/utils.js index e1b1458..4aaa303 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -1,5 +1,15 @@ const { version: APP_VERSION } = require('../../package.json'); const { getLanguage, getLocale, translate } = require('./i18n'); +const modelNormalizationSpec = require('../model-normalization.json'); + +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})); +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})); const MODEL_COLORS = { 'Opus 4.6': 'rgb(175, 92, 224)', @@ -23,72 +33,158 @@ function titleCaseSegment(segment) { return segment.charAt(0).toUpperCase() + segment.slice(1); } -function normalizeModelName(raw) { - const lower = String(raw || '') - .toLowerCase() - .trim(); - if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4'; - if (lower.includes('gpt-5')) return 'GPT-5'; - if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6'; - if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5'; - if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6'; - if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5'; - if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5'; - if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview'; - if (lower.includes('gemini')) return 'Gemini'; - if (lower.includes('opencode')) return 'OpenCode'; - if (lower.includes('haiku')) return 'Haiku'; - - const stripped = String(raw || '') +function capitalize(segment) { + if (!segment) return ''; + return segment.charAt(0).toUpperCase() + segment.slice(1); +} + +function formatVersion(version) { + return version.replace(/-/g, '.'); +} + +function canonicalizeModelName(raw) { + const normalized = String(raw || '') .trim() - .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '') - .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '') + .toLowerCase() .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') .replace(/[_/]+/g, '-') .replace(/\s+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, ''); - const familyMatch = stripped.match( - /(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i, + const suffixStart = normalized.lastIndexOf('-'); + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1); + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart); + } + } + + return normalized; +} + +function parseClaudeName(rest) { + const parts = rest.split('-', 2); + if (parts.length < 2) { + return `Claude ${capitalize(rest)}`; + } + + return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim(); +} + +function parseGptName(rest) { + const parts = rest.split('-'); + const variant = parts[0] || ''; + const minor = parts[1] || ''; + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}`; + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' '); + return `GPT-${version}${suffix ? ` ${suffix}` : ''}`; + } + return `GPT-${version}`; + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' '); + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`; + } + + return `GPT-${rest}`; +} + +function parseGeminiName(rest) { + const parts = rest.split('-'); + if (parts.length < 2) { + return `Gemini ${rest}`; + } + + const versionParts = []; + const tierParts = []; + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part); + } else { + tierParts.push(capitalize(part)); + } + } + + const version = versionParts.join('.'); + const tier = tierParts.join(' '); + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`; +} + +function parseCodexName(rest) { + const normalized = rest.replace(/-latest$/i, ''); + if (!normalized) { + return 'Codex'; + } + return `Codex ${normalized.split('-').map(capitalize).join(' ')}`; +} + +function parseOSeries(name) { + const separatorIndex = name.indexOf('-'); + if (separatorIndex === -1) { + return name; + } + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`; +} + +function normalizeModelName(raw) { + const canonical = canonicalizeModelName(raw); + + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) return alias.name; + } + + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)); + } + + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)); + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)); + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)); + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical); + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, ); if (familyMatch) { const family = familyMatch[1]; - const suffix = familyMatch[2] ? familyMatch[2].replace(/-/g, '.') : ''; + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] || ''); + } + if (/^(o\d)$/i.test(family)) return parseOSeries(canonical); + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : ''; if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`; - if (/^(o\d)$/i.test(family)) return family.toUpperCase(); return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim(); } - return stripped.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); } function getModelProvider(raw) { - const lower = String(raw || '').toLowerCase(); - if ( - lower.includes('gpt') || - lower.includes('openai') || - lower.includes('/o1') || - lower.includes('/o3') || - /\bo\d\b/.test(lower) - ) - return 'OpenAI'; - if ( - lower.includes('claude') || - lower.includes('opus') || - lower.includes('sonnet') || - lower.includes('haiku') - ) - return 'Anthropic'; - if (lower.includes('gemini')) return 'Google'; - if (lower.includes('grok') || lower.includes('xai')) return 'xAI'; - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) - return 'Meta'; - if (lower.includes('command') || lower.includes('cohere')) return 'Cohere'; - if (lower.includes('mistral')) return 'Mistral'; - if (lower.includes('deepseek')) return 'DeepSeek'; - if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba'; - if (lower.includes('opencode')) return 'OpenCode'; + const canonical = canonicalizeModelName(raw); + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) return matcher.provider; + } return 'Other'; } @@ -968,4 +1064,8 @@ module.exports = { formatDateAxis, getModelColor, truncateLabel, + __test__: { + getModelProvider, + normalizeModelName, + }, }; diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 6c31160..40c3aef 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -1,6 +1,15 @@ import { MODEL_COLORS, MODEL_COLOR_DEFAULT } from './constants' +import modelNormalizationSpec from '../../server/model-normalization.json' const DYNAMIC_COLOR_CACHE = new Map() +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})) +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})) function titleCaseSegment(segment: string): string { if (!segment) return segment @@ -9,6 +18,107 @@ function titleCaseSegment(segment: string): string { return segment.charAt(0).toUpperCase() + segment.slice(1) } +function capitalize(segment: string): string { + if (!segment) return '' + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function formatVersion(version: string): string { + return version.replace(/-/g, '.') +} + +function canonicalizeModelName(raw: string): string { + const normalized = String(raw || '') + .trim() + .toLowerCase() + .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') + .replace(/[_/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, '') + + const suffixStart = normalized.lastIndexOf('-') + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1) + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart) + } + } + + return normalized +} + +function parseClaudeName(rest: string): string { + const parts = rest.split('-', 2) + if (parts.length < 2) { + return `Claude ${capitalize(rest)}` + } + return `${capitalize(parts[0] ?? '')} ${formatVersion(parts[1] ?? '')}`.trim() +} + +function parseGptName(rest: string): string { + const parts = rest.split('-') + const variant = parts[0] ?? '' + const minor = parts[1] ?? '' + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}` + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' ') + return `GPT-${version}${suffix ? ` ${suffix}` : ''}` + } + return `GPT-${version}` + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' ') + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}` + } + + return `GPT-${rest}` +} + +function parseGeminiName(rest: string): string { + const parts = rest.split('-') + if (parts.length < 2) { + return `Gemini ${rest}` + } + + const versionParts: string[] = [] + const tierParts: string[] = [] + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part) + } else { + tierParts.push(capitalize(part)) + } + } + + const version = versionParts.join('.') + const tier = tierParts.join(' ') + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}` +} + +function parseCodexName(rest: string): string { + const normalized = rest.replace(/-latest$/i, '') + if (!normalized) { + return 'Codex' + } + return `Codex ${normalized.split('-').map(capitalize).join(' ')}` +} + +function parseOSeries(name: string): string { + const separatorIndex = name.indexOf('-') + if (separatorIndex === -1) { + return name + } + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}` +} + function dynamicColor(name: string): string { const cached = DYNAMIC_COLOR_CACHE.get(name) if (cached) return cached @@ -27,70 +137,63 @@ function dynamicColor(name: string): string { } export function normalizeModelName(raw: string): string { - const lower = raw.toLowerCase().trim() - if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4' - if (lower.includes('gpt-5')) return 'GPT-5' - if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6' - if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5' - if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6' - if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5' - if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5' - if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview' - if (lower.includes('gemini')) return 'Gemini' - if (lower.includes('opencode')) return 'OpenCode' - if (lower.includes('haiku')) return 'Haiku' - - const stripped = raw - .trim() - .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '') - .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '') - .replace(/^model[:/ -]*/i, '') - .replace(/[_/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, '') + const canonical = canonicalizeModelName(raw) + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) { + return alias.name + } + } + + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)) + } - const familyMatch = stripped.match( - /(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i, + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)) + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)) + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)) + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical) + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, ) if (familyMatch) { const family = familyMatch[1] - if (!family) return stripped - const suffix = familyMatch[2]?.replace(/-/g, '.') ?? '' + if (!family) return canonical + + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] ?? '') + } + + if (/^(o\d)$/i.test(family)) { + return parseOSeries(canonical) + } + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '' if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}` - if (/^(o\d)$/i.test(family)) return family.toUpperCase() return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() } - return stripped.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || raw + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || raw } export function getModelProvider(raw: string): string { - const lower = raw.toLowerCase() - if ( - lower.includes('gpt') || - lower.includes('openai') || - lower.includes('/o1') || - lower.includes('/o3') || - /\bo\d\b/.test(lower) - ) - return 'OpenAI' - if ( - lower.includes('claude') || - lower.includes('opus') || - lower.includes('sonnet') || - lower.includes('haiku') - ) - return 'Anthropic' - if (lower.includes('gemini')) return 'Google' - if (lower.includes('grok') || lower.includes('xai')) return 'xAI' - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) - return 'Meta' - if (lower.includes('command') || lower.includes('cohere')) return 'Cohere' - if (lower.includes('mistral')) return 'Mistral' - if (lower.includes('deepseek')) return 'DeepSeek' - if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba' - if (lower.includes('opencode')) return 'OpenCode' + const canonical = canonicalizeModelName(raw) + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) { + return matcher.provider + } + } return 'Other' } diff --git a/tests/unit/model-normalization.test.ts b/tests/unit/model-normalization.test.ts new file mode 100644 index 0000000..89ea958 --- /dev/null +++ b/tests/unit/model-normalization.test.ts @@ -0,0 +1,56 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { + getModelProvider as getUiModelProvider, + normalizeModelName as normalizeUiModelName, +} from '@/lib/model-utils' + +const require = createRequire(import.meta.url) +const { + __test__: { + getModelProvider: getReportModelProvider, + normalizeModelName: normalizeReportModelName, + }, +} = require('../../server/report/utils.js') as { + __test__: { + getModelProvider: (raw: string) => string + normalizeModelName: (raw: string) => string + } +} + +const MODEL_CASES = [ + { raw: 'claude-opus-4.5', name: 'Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-opus-4-5-20251101', name: 'Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-sonnet-4-20250514', name: 'Sonnet 4', provider: 'Anthropic' }, + { raw: 'claude-haiku-4-5', name: 'Haiku 4.5', provider: 'Anthropic' }, + { raw: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' }, + { raw: 'gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' }, + { raw: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' }, + { raw: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI' }, + { raw: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'Google' }, + { raw: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google' }, + { + raw: 'gemini-3-flash-preview', + name: 'Gemini 3 Flash Preview', + provider: 'Google', + }, + { raw: 'codex-mini-latest', name: 'Codex Mini', provider: 'OpenAI' }, + { raw: 'o4-mini', name: 'o4 Mini', provider: 'OpenAI' }, + { raw: 'o1', name: 'o1', provider: 'OpenAI' }, + { raw: 'opencode', name: 'OpenCode', provider: 'OpenCode' }, +] as const + +describe('model normalization parity', () => { + it.each(MODEL_CASES)('normalizes $raw consistently in UI and report', ({ raw, name }) => { + expect(normalizeUiModelName(raw)).toBe(name) + expect(normalizeReportModelName(raw)).toBe(name) + }) + + it.each(MODEL_CASES)( + 'maps provider for $raw consistently in UI and report', + ({ raw, provider }) => { + expect(getUiModelProvider(raw)).toBe(provider) + expect(getReportModelProvider(raw)).toBe(provider) + }, + ) +}) diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index 14d3119..288b534 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -28,7 +28,7 @@ describe('report utils', () => { 'OpenAI, Anthropic, Google +1 more', ) expect(report.meta.filterSummary.selectedModelsLabel).toBe( - 'GPT-5.4, Sonnet 4.5, Gemini +1 more', + 'GPT-5.4, Sonnet 4.5, Gemini 2.5 Pro +1 more', ) expect(report.summaryCards[5].label).toBe('Peak period') expect(report.summaryCards[5].value).not.toMatch(/^\d{4}-\d{2}-\d{2}$/) @@ -149,4 +149,18 @@ describe('report utils', () => { expect(yearlyReport.summaryCards[3].value).toBe('$30.00') expect(monthlyReport.summaryCards[3].value).not.toBe('$7.50') }) + + it('normalizes current toktrack model families in report filter summaries', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const report = buildReportData(dashboardFixture, { + viewMode: 'daily', + language: 'en', + selectedModels: ['gpt-5.3-codex', 'gemini-2.5-flash', 'codex-mini-latest', 'o4-mini'], + }) + + expect(report.meta.filterSummary.selectedModelsLabel).toBe( + 'GPT-5.3 Codex, Gemini 2.5 Flash, Codex Mini +1 more', + ) + }) }) From 29b0266a76129e09bce94ca4eb0317bf8f96b80b Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:40:17 +0200 Subject: [PATCH 21/34] v6.1.6: Simplify provider limit badge formatting --- .../features/limits/ProviderLimitsSection.tsx | 18 +++++-- .../frontend/provider-limits-section.test.tsx | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 tests/frontend/provider-limits-section.test.tsx diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index aabd48f..b400e8c 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -68,6 +68,18 @@ function subscriptionLabel(row: ProviderLimitRow) { return i18n.t('limits.statuses.belowSubscription') } +function formatLimitBadge(row: ProviderLimitRow, subscriptionProgress: number) { + if (row.monthlyLimit > 0) { + return `${row.utilization?.toFixed(0)}% Limit` + } + + if (row.hasSubscription) { + return `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` + } + + return 'Offen' +} + function toTooltipNumber(value: TooltipValueType | undefined) { const numericValue = Array.isArray(value) ? Number(value[0] ?? 0) : Number(value ?? 0) return Number.isFinite(numericValue) ? numericValue : 0 @@ -318,11 +330,7 @@ export function ProviderLimitsSection({ className="rounded-full border px-2.5 py-1 text-[11px] font-medium" style={providerStyle} > - {row.monthlyLimit > 0 - ? `${row.utilization?.toFixed(0)}% Limit` - : row.hasSubscription - ? `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` - : 'Offen'} + {formatLimitBadge(row, subscriptionProgress)}
diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx new file mode 100644 index 0000000..5fddca0 --- /dev/null +++ b/tests/frontend/provider-limits-section.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ProviderLimitsSection } from '@/components/features/limits/ProviderLimitsSection' +import { initI18n } from '@/lib/i18n' +import { TooltipProvider } from '@/components/ui/tooltip' + +describe('ProviderLimitsSection', () => { + beforeEach(async () => { + class MockIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('de') + }) + + it('renders the limit badge for limit, subscription, and open states', () => { + render( + + + , + ) + + expect(screen.getByText('0% Limit')).toBeInTheDocument() + expect(screen.getByText('0% Sub')).toBeInTheDocument() + expect(screen.getByText('Offen')).toBeInTheDocument() + }) +}) From 2a78146b807ec6152fb763da3bbd0ad6fe1e9bc3 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sun, 12 Apr 2026 23:47:13 +0200 Subject: [PATCH 22/34] v6.1.6: Document Vitest config resolution --- vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 4f628b4..4d86682 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config' export default defineConfig(async () => { + // Resolve the imported Vite config explicitly before mergeConfig because + // vite.config may export either a config object or an async config factory. + // Vitest needs stable test-mode inputs when reusing that config here. const resolvedViteConfig = typeof viteConfig === 'function' ? await viteConfig({ From 555b127a0461f91e481ad277bcb15f7d99966f94 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 00:22:24 +0200 Subject: [PATCH 23/34] v6.1.6: Harden GitHub Actions workflows --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 348db2c..bd77260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 24 cache: npm @@ -57,7 +57,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-reports if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d89f759..f009ffc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ concurrency: jobs: release: + environment: release runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -27,15 +28,16 @@ jobs: steps: - name: Create release app token id: app-token - uses: actions/create-github-app-token@v2.1.4 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + permission-contents: write repositories: ttdash - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 ref: main @@ -78,7 +80,7 @@ jobs: fi - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 24 cache: npm @@ -140,7 +142,7 @@ jobs: run: npm run test:e2e:ci - name: Set up Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest From 1db830c9db5ffe107930dcb4ed7d2b7410d65d1e Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 00:23:50 +0200 Subject: [PATCH 24/34] v6.1.6: Update changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fee79e..42b24c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [6.1.6] - 2026-04-13 + +### Added + +- **Striktere Code-Quality-Gates** — ESLint, typed TypeScript-ESLint-Regeln und Prettier sind jetzt vollständig im Repo eingerichtet und als verbindliche Prüfungen in den lokalen Verify-Pfad sowie die GitHub-Workflows integriert +- **Gezielte Infrastruktur-Tests** — neue Unit-Tests decken die Server-Helfer für Runner-Auflösung und Portsuche sowie die gemeinsame Modellnormalisierung und die Limits-Badge-Logik explizit ab + +### Improved + +- **TypeScript-Hardening** — die Compiler-Konfiguration ist jetzt deutlich strenger und nutzt zusätzliche Best-Practice-Flags wie `noImplicitReturns`, `noImplicitOverride`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax` und weitere Konsistenz-Gates +- **Konsistente Modellnormalisierung** — UI und PDF-/Report-Pfad verwenden jetzt dieselbe datengetriebene Normalisierung und Provider-Zuordnung für aktuelle `toktrack`-Modellfamilien wie Claude, GPT, Gemini, Codex, OpenAI-`o` und OpenCode +- **Maintainer- und Release-Tooling** — README, Contribution-, Release- und Agent-Dokumentation wurden an die neuen Lint-, Format- und Verify-Workflows angepasst; GitHub Actions nutzt jetzt zusätzlich SHA-gepinnte Actions, minimale App-Token-Rechte und ein dediziertes Release-Environment +- **Konfigurationsklarheit** — die Vitest-Konfiguration dokumentiert jetzt explizit, warum die asynchrone Vite-Config vor dem Mergen manuell aufgelöst wird + +### Fixed + +- **Unbenutzter Code und Compiler-Warnpfade** — ungenutzte Imports, Helpers und Parameter wurden entfernt oder bereinigt, sodass die neuen Compiler- und Lint-Gates auf dem gesamten Repo sauber greifen +- **Server-Runner und Portsuche** — die Windows-/Cross-Platform-Runner-Auflösung ist weniger dupliziert, und die Portsuche für den lokalen Server läuft jetzt iterativ statt rekursiv +- **Kleine UI-Wartbarkeitsprobleme** — redundante Drill-Down-Modal-Logik und schwer lesbare Badge-Bedingungen in der Limits-Sektion wurden vereinfacht, ohne das Verhalten zu ändern + ## [6.1.5] - 2026-04-12 ### Added From de4cb311db1120f7bda2b51344abe707738caaae Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 00:49:26 +0200 Subject: [PATCH 25/34] v6.1.6: Fix settings and export robustness --- server/model-normalization.json | 18 ++--- src/components/charts/ChartCard.tsx | 40 ++++++---- .../features/settings/SettingsModal.tsx | 25 +++++-- src/lib/auto-import.ts | 14 +++- tests/unit/code-rabbit-phase1.test.ts | 74 +++++++++++++++++++ tests/unit/model-normalization.test.ts | 6 ++ 6 files changed, 143 insertions(+), 34 deletions(-) create mode 100644 tests/unit/code-rabbit-phase1.test.ts diff --git a/server/model-normalization.json b/server/model-normalization.json index 965a7e8..b316fa2 100644 --- a/server/model-normalization.json +++ b/server/model-normalization.json @@ -1,14 +1,14 @@ { "displayAliases": [ - { "pattern": "(^|-)gpt-5-4($|-)", "name": "GPT-5.4" }, - { "pattern": "(^|-)gpt-5(?:$|-(?!\\d))", "name": "GPT-5" }, - { "pattern": "(^|-)opus-4-6($|-)", "name": "Opus 4.6" }, - { "pattern": "(^|-)opus-4-5($|-)", "name": "Opus 4.5" }, - { "pattern": "(^|-)sonnet-4-6($|-)", "name": "Sonnet 4.6" }, - { "pattern": "(^|-)sonnet-4-5($|-)", "name": "Sonnet 4.5" }, - { "pattern": "(^|-)haiku-4-5($|-)", "name": "Haiku 4.5" }, - { "pattern": "(^|-)gemini-3-flash-preview($|-)", "name": "Gemini 3 Flash Preview" }, - { "pattern": "(^|-)opencode($|-)", "name": "OpenCode" } + { "pattern": "(^|-)gpt-5-4$", "name": "GPT-5.4" }, + { "pattern": "(^|-)gpt-5$", "name": "GPT-5" }, + { "pattern": "(^|-)opus-4-6$", "name": "Opus 4.6" }, + { "pattern": "(^|-)opus-4-5$", "name": "Opus 4.5" }, + { "pattern": "(^|-)sonnet-4-6$", "name": "Sonnet 4.6" }, + { "pattern": "(^|-)sonnet-4-5$", "name": "Sonnet 4.5" }, + { "pattern": "(^|-)haiku-4-5$", "name": "Haiku 4.5" }, + { "pattern": "(^|-)gemini-3-flash-preview$", "name": "Gemini 3 Flash Preview" }, + { "pattern": "(^|-)opencode$", "name": "OpenCode" } ], "providerMatchers": [ { "pattern": "(^|-)opencode($|-)", "provider": "OpenCode" }, diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index ffb02c8..f821426 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -30,7 +30,9 @@ interface ChartCardProps { expandedExtra?: ReactNode } -function stringifyCsvCell(value: unknown): string { +export function stringifyCsvCell(value: unknown): string { + let stringValue = '' + if (value == null) return '' if ( typeof value === 'string' || @@ -38,14 +40,29 @@ function stringifyCsvCell(value: unknown): string { typeof value === 'boolean' || typeof value === 'bigint' ) { - return String(value) + stringValue = String(value) + } else { + try { + stringValue = JSON.stringify(value) ?? '' + } catch { + stringValue = '' + } } - try { - return JSON.stringify(value) ?? '' - } catch { - return '' - } + return `"${stringValue.replace(/"/g, '""')}"` +} + +export function buildChartCsv(chartData: Record[]): string { + if (chartData.length === 0) return '' + + const firstRow = chartData[0] + if (!firstRow) return '' + + const keys = Object.keys(firstRow) + return [ + keys.map((key) => stringifyCsvCell(key)).join(','), + ...chartData.map((row) => keys.map((key) => stringifyCsvCell(row[key])).join(',')), + ].join('\n') } const ChartAnimationContext = createContext(false) @@ -149,13 +166,8 @@ export function ChartCard({ const handleExport = useCallback(() => { if (!chartData || chartData.length === 0) return - const firstRow = chartData[0] - if (!firstRow) return - const keys = Object.keys(firstRow) - const csv = [ - keys.join(','), - ...chartData.map((row) => keys.map((k) => stringifyCsvCell(row[k])).join(',')), - ].join('\n') + const csv = buildChartCsv(chartData) + if (!csv) return const blob = new Blob([csv], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index 7713b8f..6629994 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -88,6 +88,19 @@ function normalizeSelection(values: string[]) { ) } +export function buildProviderLimitsState( + providers: string[], + draft: ProviderLimits, +): ProviderLimits { + const nextProviderLimits: ProviderLimits = {} + + for (const provider of providers) { + nextProviderLimits[provider] = draft[provider] ?? { ...DEFAULT_PROVIDER_LIMIT_CONFIG } + } + + return nextProviderLimits +} + function moveSection( order: DashboardSectionOrder, sectionId: DashboardSectionOrder[number], @@ -107,7 +120,7 @@ function moveSection( return next } -function reorderSections( +export function reorderSections( order: DashboardSectionOrder, sourceId: DashboardSectionOrder[number], targetId: DashboardSectionOrder[number], @@ -124,7 +137,8 @@ function reorderSections( const next = [...order] const [moved] = next.splice(sourceIndex, 1) if (!moved) return order - next.splice(targetIndex, 0, moved) + const insertionIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex + next.splice(insertionIndex, 0, moved) return next } @@ -197,13 +211,8 @@ export function SettingsModal({ } const handleSave = async () => { - const nextProviderLimits = { ...limits } - for (const provider of limitProviders) { - nextProviderLimits[provider] = limitDraft[provider] ?? { ...DEFAULT_PROVIDER_LIMIT_CONFIG } - } - await onSaveSettings({ - providerLimits: nextProviderLimits, + providerLimits: buildProviderLimitsState(limitProviders, limitDraft), defaultFilters: { ...defaultFilterDraft, providers: normalizeSelection(defaultFilterDraft.providers), diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 15a291b..5ff00e1 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -21,13 +21,21 @@ export interface ErrorEvent { type AutoImportTranslationVars = Record type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string -function parseEventData(event: Event): T | null { +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseEventData(event: Event): T | null { if (!(event instanceof MessageEvent) || typeof event.data !== 'string') { return null } - const data: unknown = JSON.parse(event.data) - return data as T + try { + const data: unknown = JSON.parse(event.data) + return isPlainObject(data) ? (data as T) : null + } catch { + return null + } } function translateAutoImportMessage(message: string, t: AutoImportTranslator) { diff --git a/tests/unit/code-rabbit-phase1.test.ts b/tests/unit/code-rabbit-phase1.test.ts new file mode 100644 index 0000000..58a891a --- /dev/null +++ b/tests/unit/code-rabbit-phase1.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { buildChartCsv, stringifyCsvCell } from '@/components/charts/ChartCard' +import { + buildProviderLimitsState, + reorderSections, +} from '@/components/features/settings/SettingsModal' +import { parseEventData } from '@/lib/auto-import' + +describe('phase 1 helper fixes', () => { + it('reorders sections to the target slot when dragging downward', () => { + expect(reorderSections(['metrics', 'activity', 'tables'], 'metrics', 'tables')).toEqual([ + 'activity', + 'metrics', + 'tables', + ]) + }) + + it('replaces provider limit state instead of preserving stale providers', () => { + expect( + buildProviderLimitsState(['OpenAI', 'Anthropic'], { + OpenAI: { + monthlyLimit: 120, + hasSubscription: false, + subscriptionPrice: 0, + }, + Anthropic: { + monthlyLimit: 0, + hasSubscription: true, + subscriptionPrice: 50, + }, + Legacy: { + monthlyLimit: 999, + hasSubscription: true, + subscriptionPrice: 999, + }, + }), + ).toEqual({ + OpenAI: { + monthlyLimit: 120, + hasSubscription: false, + subscriptionPrice: 0, + }, + Anthropic: { + monthlyLimit: 0, + hasSubscription: true, + subscriptionPrice: 50, + }, + }) + }) + + it('returns null for malformed or non-object auto-import events', () => { + expect(parseEventData(new MessageEvent('message', { data: '{"message":"ok"}' }))).toEqual({ + message: 'ok', + }) + expect(parseEventData(new MessageEvent('message', { data: '{"message"' }))).toBeNull() + expect(parseEventData(new MessageEvent('message', { data: '[]' }))).toBeNull() + expect(parseEventData(new MessageEvent('message', { data: '"oops"' }))).toBeNull() + }) + + it('quotes CSV cells and preserves commas, quotes, and newlines', () => { + expect(stringifyCsvCell('value,with,"quotes"\nand newline')).toBe( + '"value,with,""quotes""\nand newline"', + ) + expect( + buildChartCsv([ + { + label: 'hello,world', + note: 'line 1\nline 2', + quote: '"quoted"', + }, + ]), + ).toBe('"label","note","quote"\n"hello,world","line 1\nline 2","""quoted"""') + }) +}) diff --git a/tests/unit/model-normalization.test.ts b/tests/unit/model-normalization.test.ts index 89ea958..92ebe36 100644 --- a/tests/unit/model-normalization.test.ts +++ b/tests/unit/model-normalization.test.ts @@ -26,6 +26,7 @@ const MODEL_CASES = [ { raw: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' }, { raw: 'gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' }, { raw: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' }, + { raw: 'gpt-5-4-codex', name: 'GPT-5.4 Codex', provider: 'OpenAI' }, { raw: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI' }, { raw: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'Google' }, { raw: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google' }, @@ -34,6 +35,11 @@ const MODEL_CASES = [ name: 'Gemini 3 Flash Preview', provider: 'Google', }, + { + raw: 'gemini-3-flash-preview-experimental', + name: 'Gemini 3 Flash Preview Experimental', + provider: 'Google', + }, { raw: 'codex-mini-latest', name: 'Codex Mini', provider: 'OpenAI' }, { raw: 'o4-mini', name: 'o4 Mini', provider: 'OpenAI' }, { raw: 'o1', name: 'o1', provider: 'OpenAI' }, From 5b74d8259193cd4018f239d1f8845991b0c730a8 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 00:53:41 +0200 Subject: [PATCH 26/34] v6.1.6: Improve sortable table accessibility --- src/components/tables/ModelEfficiency.tsx | 20 ++- src/components/tables/ProviderEfficiency.tsx | 20 ++- src/components/tables/RecentDays.tsx | 63 +++++--- tests/frontend/sortable-tables.test.tsx | 152 +++++++++++++++++++ 4 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 tests/frontend/sortable-tables.test.tsx diff --git a/src/components/tables/ModelEfficiency.tsx b/src/components/tables/ModelEfficiency.tsx index 9bb57dc..1ec93e4 100644 --- a/src/components/tables/ModelEfficiency.tsx +++ b/src/components/tables/ModelEfficiency.tsx @@ -135,18 +135,28 @@ export function ModelEfficiency({ } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + const SortHeader = ({ label, field }: { label: string; field: SortKey }) => (

) diff --git a/src/components/tables/ProviderEfficiency.tsx b/src/components/tables/ProviderEfficiency.tsx index de5391e..106eb1c 100644 --- a/src/components/tables/ProviderEfficiency.tsx +++ b/src/components/tables/ProviderEfficiency.tsx @@ -88,18 +88,28 @@ export function ProviderEfficiency({ } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + const SortHeader = ({ label, field }: { label: string; field: SortKey }) => ( ) diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index 9b02008..60e397d 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -108,6 +108,9 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + return ( @@ -287,42 +290,61 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
handleSort('date')}> - {t('tables.recentDays.date')} + handleSort('date')} + > + + {t('tables.recentDays.date')}{' '} + + + handleSort('cost')} + > + + {t('tables.recentDays.cost')}{' '} + + handleSort('cost')}> - {t('tables.recentDays.cost')} + handleSort('tokens')} + > + + {t('tables.recentDays.tokens')}{' '} + + + + {t('common.input')} + + {t('common.output')} + + {t('common.cacheWrite')} handleSort('tokens')}> - {t('tables.recentDays.tokens')} + + {t('common.cacheRead')} {t('common.input')}{t('common.output')}{t('common.cacheWrite')}{t('common.cacheRead')}{t('common.thinking')}{t('common.requestsShort')} handleSort('costPerM')}> - $/1M + + {t('common.thinking')} + + {t('common.requestsShort')} + handleSort('costPerM')} + > + + $/1M{' '} + + + + {t('tables.recentDays.models')} {t('tables.recentDays.models')}
{formatDate(day.date, 'long')} + {formatDate(day.date, 'long')} + -
0 ? (day.totalCost / maxCost) * 100 : 0}%` }} /> - +
0 ? (day.totalCost / maxCost) * 100 : 0}%` }} + /> + + +
@@ -270,8 +412,13 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{day.modelBreakdowns - .map(mb => ({ name: normalizeModelName(mb.modelName), provider: getModelProvider(mb.modelName) })) - .filter((entry, i, a) => a.findIndex(item => item.name === entry.name) === i) + .map((mb) => ({ + name: normalizeModelName(mb.modelName), + provider: getModelProvider(mb.modelName), + })) + .filter( + (entry, i, a) => a.findIndex((item) => item.name === entry.name) === i, + ) .map(({ name, provider }) => ( {name} - + {provider} ))}
- {viewMode === 'daily' && benchmarkMap.get(day.date)?.avgCost7 !== undefined && ( -
- {t('tables.recentDays.previousDay')} {benchmarkMap.get(day.date)?.prevCostDelta !== undefined ? `${benchmarkMap.get(day.date)!.prevCostDelta! >= 0 ? '↑' : '↓'}${Math.abs(benchmarkMap.get(day.date)!.prevCostDelta!).toFixed(0)}%` : '–'} · {t('tables.recentDays.avg7d')} {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} -
- )} + {viewMode === 'daily' && + benchmarkMap.get(day.date)?.avgCost7 !== undefined && ( +
+ {t('tables.recentDays.previousDay')}{' '} + {benchmarkMap.get(day.date)?.prevCostDelta !== undefined + ? `${benchmarkMap.get(day.date)!.prevCostDelta! >= 0 ? '↑' : '↓'}${Math.abs(benchmarkMap.get(day.date)!.prevCostDelta!).toFixed(0)}%` + : '–'}{' '} + · {t('tables.recentDays.avg7d')}{' '} + {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} +
+ )}
handleSort(field)} > - + handleSort(field)} > - +
handleSort('date')} > - + handleSort('cost')} > - + handleSort('tokens')} > - + {t('common.input')} @@ -343,18 +365,23 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP {t('common.requestsShort')} handleSort('costPerM')} > - + {t('tables.recentDays.models')} diff --git a/tests/frontend/sortable-tables.test.tsx b/tests/frontend/sortable-tables.test.tsx new file mode 100644 index 0000000..147f990 --- /dev/null +++ b/tests/frontend/sortable-tables.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ModelEfficiency } from '@/components/tables/ModelEfficiency' +import { ProviderEfficiency } from '@/components/tables/ProviderEfficiency' +import { RecentDays } from '@/components/tables/RecentDays' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +function renderWithProviders(ui: React.ReactNode) { + return render({ui}) +} + +describe('sortable tables', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('exposes accessible sort state for provider efficiency headers', () => { + renderWithProviders( + , + ) + + const costButton = screen.getByRole('button', { name: /^cost$/i }) + const requestsButton = screen.getByRole('button', { name: /^req$/i }) + const costHeader = costButton.closest('th') + const requestsHeader = requestsButton.closest('th') + + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + expect(costButton).toBeInTheDocument() + expect(requestsHeader).toHaveAttribute('aria-sort', 'none') + + fireEvent.click(requestsButton) + expect(screen.getByRole('button', { name: /^req$/i }).closest('th')).toHaveAttribute( + 'aria-sort', + 'descending', + ) + }) + + it('renders model efficiency sort controls as buttons inside column headers', () => { + renderWithProviders( + , + ) + + const costButton = screen.getByRole('button', { name: /^cost$/i }) + const tokensButton = screen.getByRole('button', { name: /^tokens$/i }) + const costHeader = costButton.closest('th') + + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + expect(costButton).toBeInTheDocument() + + fireEvent.click(tokensButton) + expect(screen.getByRole('button', { name: /^tokens$/i }).closest('th')).toHaveAttribute( + 'aria-sort', + 'descending', + ) + }) + + it('updates aria-sort when recent days headers are toggled', () => { + renderWithProviders( + , + ) + + const dateHeader = screen.getByRole('columnheader', { name: /date/i }) + const costHeader = screen.getByRole('columnheader', { name: /^cost$/i }) + + expect(dateHeader).toHaveAttribute('aria-sort', 'descending') + expect(costHeader).toHaveAttribute('aria-sort', 'none') + + fireEvent.click(within(costHeader).getByRole('button', { name: /^cost$/i })) + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + + fireEvent.click(within(costHeader).getByRole('button', { name: /^cost$/i })) + expect(costHeader).toHaveAttribute('aria-sort', 'ascending') + }) +}) From e466bf55bc746ab27aabcbc97e10baba0766208b Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 01:00:28 +0200 Subject: [PATCH 27/34] v6.1.6: Localize dashboard labels --- src/components/cards/MonthMetrics.tsx | 4 +- src/components/cards/SecondaryMetrics.tsx | 8 +++- src/components/charts/ChartCard.tsx | 10 ++--- .../features/limits/ProviderLimitsSection.tsx | 12 +++--- src/components/layout/FilterBar.tsx | 4 +- src/components/tables/RecentDays.tsx | 2 +- src/locales/de/common.json | 14 +++++++ src/locales/en/common.json | 14 +++++++ tests/frontend/chart-card.test.tsx | 40 +++++++++++++++++++ tests/frontend/filter-bar.test.tsx | 34 +++++++++++++++- .../frontend/provider-limits-section.test.tsx | 2 +- 11 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 tests/frontend/chart-card.test.tsx diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index d68ce7b..7fd542c 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -183,7 +183,9 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { value={agg.requestCount} type="number" label={t('metricCards.month.requestsInMonth')} - insight={`${formatCurrency(agg.totalCost / agg.requestCount)} / Request`} + insight={t('metricCards.month.costPerRequest', { + value: formatCurrency(agg.totalCost / agg.requestCount), + })} /> ) : ( t('common.notAvailable') diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index b08f36b..ab5982a 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -62,7 +62,13 @@ export function SecondaryMetrics({ : null const medianSubtitle = median !== null && metrics.avgDailyCost > 0 - ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` + ? t('metricCards.secondary.vsAverageWithVolatility', { + direction: median < metrics.avgDailyCost ? '↓' : '↑', + value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed( + 0, + ), + volatility: Math.round(metrics.requestVolatility), + }) : null return ( diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index f821426..8a114f8 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -239,25 +239,25 @@ export function ChartCard({
- Min + {t('dashboard.stats.min')}
{fmt(stats.min)}
- Max + {t('dashboard.stats.max')}
{fmt(stats.max)}
- Avg + {t('dashboard.stats.avg')}
{fmt(stats.avg)}
- Gesamt + {t('dashboard.stats.total')}
{fmt(stats.total)} @@ -265,7 +265,7 @@ export function ChartCard({
- Datenpunkte + {t('dashboard.stats.dataPoints')}
{stats.count}
diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index b400e8c..4ecc5ac 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -70,14 +70,16 @@ function subscriptionLabel(row: ProviderLimitRow) { function formatLimitBadge(row: ProviderLimitRow, subscriptionProgress: number) { if (row.monthlyLimit > 0) { - return `${row.utilization?.toFixed(0)}% Limit` + return i18n.t('limits.badge.limit', { value: row.utilization?.toFixed(0) ?? '0' }) } if (row.hasSubscription) { - return `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` + return i18n.t('limits.badge.subscription', { + value: Math.min(subscriptionProgress, 999).toFixed(0), + }) } - return 'Offen' + return i18n.t('limits.badge.open') } function toTooltipNumber(value: TooltipValueType | undefined) { @@ -923,7 +925,7 @@ export function ProviderLimitsSection({ new Date(prev.getFullYear(), prev.getMonth() - 1, 1)) } className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background/70 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" - aria-label="Previous month" + aria-label={t('common.previousMonth')} > @@ -253,7 +253,7 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { setDisplayMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)) } className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background/70 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" - aria-label="Next month" + aria-label={t('common.nextMonth')} > diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index 60e397d..32b9a2d 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -180,7 +180,7 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP {summary.top ? formatDate(summary.top.date) : '–'}
- {summary.top ? `${summary.top.totalCost.toFixed(2)} USD` : '–'} + {summary.top ? formatCurrency(summary.top.totalCost) : '–'}
diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 113aa2b..03201b5 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -72,6 +72,8 @@ "hidden": "Versteckt", "open": "Öffnen", "close": "Schliessen", + "previousMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", "startDate": "Startdatum", "endDate": "Enddatum", "selectedProviders": "Ausgewählte Provider", @@ -165,6 +167,9 @@ "cacheRoi": "Cache ROI" }, "stats": { + "min": "Min", + "max": "Max", + "avg": "Ø", "cacheHitRate": "Cache-Hit-Rate", "totalTokens": "Gesamt-Tokens", "cacheRead": "Cache Read", @@ -206,6 +211,7 @@ "spread": "Spanne: {{value}}", "medianPerUnit": "Median/{{unit}}", "vsAverage": "{{direction}}{{value}}% vs. Ø", + "vsAverageWithVolatility": "{{direction}}{{value}}% vs. Ø · σ Req {{volatility}}", "medianInfo": "Der Median zeigt den typischen Wert und ist weniger anfällig für Ausreisser als der Durchschnitt.", "requestLeader": "{{model}} · {{requests}} Req", "dominantProviderSubtitle": "{{share}} Anteil · {{cost}}{{requestLeader}}" @@ -250,6 +256,7 @@ "ioRatio": "I/O Ratio: {{value}}:1", "topModel": "Top: {{value}}", "cacheMix": "In: {{input}} / Out: {{output}}", + "costPerRequest": "{{value}} / Req", "requestsSubtitle": "Ø {{value}}/Tag · {{cost}}/Req", "requestCountersMissing": "Keine Request-Zähler", "thinkingSubtitle": "{{value}} Anteil" @@ -919,6 +926,11 @@ "subscriptionPaysOff": "Subscription zahlt sich aus", "belowSubscription": "Noch unter Subscription" }, + "badge": { + "limit": "{{value}}% Limit", + "subscription": "{{value}}% Abo", + "open": "Offen" + }, "tracks": { "budgetTitle": "Budget-Status je Anbieter", "budgetSubtitle": "Jeder Track zeigt pro Anbieter direkt den Abstand bis zum Limit oder den bereits eingetretenen Überzug", @@ -930,7 +942,9 @@ "portfolioSubtitle": "Monatlicher Verlauf von Usage, konfigurierten Limits und Subscription-Wirkung", "usage": "Usage", "limit": "Limit", + "limits": "Limits", "subscription": "Subscription", + "subscriptions": "Subscriptions", "breakEven": "Break-even", "currentlyUsed": "Aktuell verbraucht", "remainingToLimit": "Bis Limit offen", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index d22d09b..b734cd7 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -72,6 +72,8 @@ "hidden": "Hidden", "open": "Open", "close": "Close", + "previousMonth": "Previous month", + "nextMonth": "Next month", "startDate": "Start date", "endDate": "End date", "selectedProviders": "Selected providers", @@ -165,6 +167,9 @@ "cacheRoi": "Cache ROI" }, "stats": { + "min": "Min", + "max": "Max", + "avg": "Avg", "cacheHitRate": "Cache hit rate", "totalTokens": "Total tokens", "cacheRead": "Cache read", @@ -206,6 +211,7 @@ "spread": "Spread: {{value}}", "medianPerUnit": "Median/{{unit}}", "vsAverage": "{{direction}}{{value}}% vs avg", + "vsAverageWithVolatility": "{{direction}}{{value}}% vs avg · σ Req {{volatility}}", "medianInfo": "The median shows the typical value and is less sensitive to outliers than the average.", "requestLeader": "{{model}} · {{requests}} req", "dominantProviderSubtitle": "{{share}} share · {{cost}}{{requestLeader}}" @@ -250,6 +256,7 @@ "ioRatio": "I/O ratio: {{value}}:1", "topModel": "Top: {{value}}", "cacheMix": "In: {{input}} / Out: {{output}}", + "costPerRequest": "{{value}} / req", "requestsSubtitle": "Avg {{value}}/day · {{cost}}/req", "requestCountersMissing": "No request counters", "thinkingSubtitle": "{{value}} share" @@ -919,6 +926,11 @@ "subscriptionPaysOff": "Subscription pays off", "belowSubscription": "Still below subscription" }, + "badge": { + "limit": "{{value}}% Limit", + "subscription": "{{value}}% Sub", + "open": "Open" + }, "tracks": { "budgetTitle": "Budget status by provider", "budgetSubtitle": "Each track shows the remaining distance to the limit or the already incurred overrun for each provider", @@ -930,7 +942,9 @@ "portfolioSubtitle": "Monthly trend of usage, configured limits, and subscription impact", "usage": "Usage", "limit": "Limit", + "limits": "Limits", "subscription": "Subscription", + "subscriptions": "Subscriptions", "breakEven": "Break-even", "currentlyUsed": "Currently used", "remainingToLimit": "Remaining to limit", diff --git a/tests/frontend/chart-card.test.tsx b/tests/frontend/chart-card.test.tsx new file mode 100644 index 0000000..297f3ed --- /dev/null +++ b/tests/frontend/chart-card.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChartCard } from '@/components/charts/ChartCard' +import { initI18n } from '@/lib/i18n' + +describe('ChartCard', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('uses localized stat labels in the expanded view', () => { + render( + +
Content
+
, + ) + + fireEvent.click(screen.getByRole('button', { name: /demo chart expand/i })) + + expect(screen.getByText('Total')).toBeInTheDocument() + expect(screen.getByText('Data points')).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/filter-bar.test.tsx b/tests/frontend/filter-bar.test.tsx index df48ab1..4c2f43a 100644 --- a/tests/frontend/filter-bar.test.tsx +++ b/tests/frontend/filter-bar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FilterBar } from '@/components/layout/FilterBar' import { initI18n } from '@/lib/i18n' @@ -124,4 +124,36 @@ describe('FilterBar', () => { expect(screen.getByRole('button', { name: 'All' }).className).not.toContain('bg-primary') }) + + it('localizes the calendar month navigation aria labels', () => { + const noop = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Start date' })) + expect(screen.getByRole('button', { name: 'Previous month' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next month' })).toBeInTheDocument() + }) }) diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx index 5fddca0..282f7cd 100644 --- a/tests/frontend/provider-limits-section.test.tsx +++ b/tests/frontend/provider-limits-section.test.tsx @@ -47,7 +47,7 @@ describe('ProviderLimitsSection', () => { ) expect(screen.getByText('0% Limit')).toBeInTheDocument() - expect(screen.getByText('0% Sub')).toBeInTheDocument() + expect(screen.getByText('0% Abo')).toBeInTheDocument() expect(screen.getByText('Offen')).toBeInTheDocument() }) }) From 8494d50a4bfe976dde5146c1d8df1221944f71f4 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 01:05:50 +0200 Subject: [PATCH 28/34] v6.1.6: Harden metric edge cases --- src/components/cards/TodayMetrics.tsx | 7 +- src/components/charts/CorrelationAnalysis.tsx | 2 +- src/components/charts/CostByModelOverTime.tsx | 10 +- src/components/charts/CostByWeekday.tsx | 5 +- src/components/charts/CostOverTime.tsx | 5 +- src/components/charts/CumulativeCost.tsx | 5 +- src/components/charts/ModelMix.tsx | 5 +- src/components/charts/TokenEfficiency.tsx | 5 +- .../features/drill-down/DrillDownModal.tsx | 26 ++-- .../features/forecast/CostForecast.tsx | 5 +- .../features/limits/ProviderLimitsSection.tsx | 10 +- src/lib/data-transforms.ts | 2 +- src/lib/formatters.ts | 20 ++- tests/frontend/phase4-correctness.test.tsx | 120 ++++++++++++++++++ .../frontend/provider-limits-section.test.tsx | 31 ++++- tests/unit/analytics.test.ts | 2 +- tests/unit/code-rabbit-phase4.test.ts | 67 ++++++++++ 17 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 tests/frontend/phase4-correctness.test.tsx create mode 100644 tests/unit/code-rabbit-phase4.test.ts diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index b9dbe4d..7a831be 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -24,6 +24,7 @@ interface TodayMetricsProps { export function TodayMetrics({ today, metrics }: TodayMetricsProps) { const { t } = useTranslation() + const modelsCount = today.modelsUsed?.length ?? 0 const cacheHitRate = today.cacheReadTokens + today.cacheCreationTokens > 0 ? (today.cacheReadTokens / @@ -61,9 +62,9 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : null const requestsSubtitle = - today.requestCount > 0 && today.modelsUsed.length > 0 + today.requestCount > 0 && modelsCount > 0 ? t('metricCards.today.requestsSubtitle', { - value: (today.requestCount / today.modelsUsed.length).toFixed(1), + value: (today.requestCount / modelsCount).toFixed(1), cost: formatCurrency(today.totalCost / today.requestCount), }) : t('metricCards.today.requestCountersMissing') @@ -109,7 +110,7 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { /> } {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} /> diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index fcd3a90..b51a847 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -86,7 +86,7 @@ function ScatterTooltip({
{t('charts.correlation.tokensLabel')} - {point.tokens ? formatTokens(point.tokens) : '–'} + {point.tokens !== undefined ? formatTokens(point.tokens) : '–'}
diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index 7b5054d..373500a 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -55,7 +55,10 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} @@ -122,7 +125,10 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/charts/CostByWeekday.tsx b/src/components/charts/CostByWeekday.tsx index 13cfc14..c79d7ae 100644 --- a/src/components/charts/CostByWeekday.tsx +++ b/src/components/charts/CostByWeekday.tsx @@ -87,7 +87,10 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/charts/CostOverTime.tsx b/src/components/charts/CostOverTime.tsx index 2706885..af16658 100644 --- a/src/components/charts/CostOverTime.tsx +++ b/src/components/charts/CostOverTime.tsx @@ -90,7 +90,10 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/charts/CumulativeCost.tsx b/src/components/charts/CumulativeCost.tsx index 38bf48f..938f172 100644 --- a/src/components/charts/CumulativeCost.tsx +++ b/src/components/charts/CumulativeCost.tsx @@ -85,7 +85,10 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/charts/ModelMix.tsx b/src/components/charts/ModelMix.tsx index ffe5142..61af901 100644 --- a/src/components/charts/ModelMix.tsx +++ b/src/components/charts/ModelMix.tsx @@ -111,7 +111,10 @@ export function ModelMix({ data }: ModelMixProps) { tickLine={false} /> formatPercent(Math.round(coerceNumber(value)), 0)} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatPercent(Math.round(numericValue), 0) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/charts/TokenEfficiency.tsx b/src/components/charts/TokenEfficiency.tsx index 21f3ee5..7de1b6f 100644 --- a/src/components/charts/TokenEfficiency.tsx +++ b/src/components/charts/TokenEfficiency.tsx @@ -76,7 +76,10 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/features/drill-down/DrillDownModal.tsx b/src/components/features/drill-down/DrillDownModal.tsx index 4a3dc70..4d5d5b4 100644 --- a/src/components/features/drill-down/DrillDownModal.tsx +++ b/src/components/features/drill-down/DrillDownModal.tsx @@ -76,6 +76,8 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo if (!day) return null + const hasTokens = day.totalTokens > 0 + const cacheRate = day.cacheReadTokens + day.cacheCreationTokens + @@ -95,6 +97,7 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo const pieData = modelData.map((m) => ({ name: m.name, value: m.cost })) const avgTokensPerRequest = day.requestCount > 0 ? day.totalTokens / day.requestCount : 0 const avgCostPerRequest = day.requestCount > 0 ? day.totalCost / day.requestCount : 0 + const costPerMillion = hasTokens ? day.totalCost / (day.totalTokens / 1_000_000) : null const costRanking = [...contextData] .sort((a, b) => b.totalCost - a.totalCost) @@ -122,6 +125,8 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo }, null as (typeof modelData)[number] | null, ) + const formatTokenShare = (value: number) => + hasTokens ? formatPercent((value / day.totalTokens) * 100) : '–' return ( !o && onClose()}> @@ -146,10 +151,11 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
$/1M
- + {costPerMillion !== null ? ( + + ) : ( + '–' + )}
@@ -225,7 +231,7 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Token-Verteilung
- {day.totalTokens > 0 && + {hasTokens && ( [ { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, @@ -256,35 +262,35 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo className="w-2 h-2 rounded-full" style={{ backgroundColor: 'hsl(160, 50%, 42%)' }} /> - Cache Read {formatPercent((day.cacheReadTokens / day.totalTokens) * 100)} + Cache Read {formatTokenShare(day.cacheReadTokens)} - Cache Write {formatPercent((day.cacheCreationTokens / day.totalTokens) * 100)} + Cache Write {formatTokenShare(day.cacheCreationTokens)} - Input {formatPercent((day.inputTokens / day.totalTokens) * 100)} + Input {formatTokenShare(day.inputTokens)} - Output {formatPercent((day.outputTokens / day.totalTokens) * 100)} + Output {formatTokenShare(day.outputTokens)} - Thinking {formatPercent((day.thinkingTokens / day.totalTokens) * 100)} + Thinking {formatTokenShare(day.thinkingTokens)}
diff --git a/src/components/features/forecast/CostForecast.tsx b/src/components/features/forecast/CostForecast.tsx index 272eed4..8bff733 100644 --- a/src/components/features/forecast/CostForecast.tsx +++ b/src/components/features/forecast/CostForecast.tsx @@ -225,7 +225,10 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { tickLine={false} /> formatCurrency(coerceNumber(value))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 4ecc5ac..16e4197 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -300,8 +300,9 @@ export function ProviderLimitsSection({ row.monthlyLimit > 0 ? Math.min((row.cost / row.monthlyLimit) * 100, 100) : 0 const subscriptionProgress = row.hasSubscription && row.subscriptionPrice > 0 - ? Math.min((row.cost / row.subscriptionPrice) * 100, 100) + ? (row.cost / row.subscriptionPrice) * 100 : 0 + const subscriptionProgressWidth = Math.min(subscriptionProgress, 100) return ( formatCurrency(Math.abs(coerceNumber(value)))} + tickFormatter={(value) => { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(Math.abs(numericValue)) + }} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 1b9d64a..185eec3 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -44,7 +44,7 @@ function recalculateDayFromBreakdowns( thinkingTokens, requestCount, modelBreakdowns: filteredBreakdowns, - modelsUsed: filteredBreakdowns.map((mb) => mb.modelName), + modelsUsed: [...new Set(filteredBreakdowns.map((mb) => normalizeModelName(mb.modelName)))], } } diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 6d619e0..6916845 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -15,17 +15,17 @@ export function localMonth(): string { return localToday().slice(0, 7) } -export function coerceNumber(value: unknown): number { +export function coerceNumber(value: unknown): number | null { if (typeof value === 'number') { - return Number.isFinite(value) ? value : 0 + return Number.isFinite(value) ? value : null } if (typeof value === 'string') { const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : 0 + return Number.isFinite(parsed) ? parsed : null } - return 0 + return null } export function formatCurrency(value: number): string { @@ -122,8 +122,16 @@ export function periodUnit(viewMode: 'daily' | 'monthly' | 'yearly'): string { } export function formatMonthYear(dateStr: string): string { - const [year = '0', month = '1'] = dateStr.split('-') - const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1) + if (!/^\d{4}-\d{2}$/.test(dateStr)) return '' + + const [year = '', month = ''] = dateStr.split('-') + const parsedYear = Number.parseInt(year, 10) + const parsedMonth = Number.parseInt(month, 10) + + if (!Number.isInteger(parsedYear) || !Number.isInteger(parsedMonth)) return '' + if (parsedMonth < 1 || parsedMonth > 12) return '' + + const date = new Date(parsedYear, parsedMonth - 1) return date.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }) } diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx new file mode 100644 index 0000000..6aa7d5b --- /dev/null +++ b/tests/frontend/phase4-correctness.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TodayMetrics } from '@/components/cards/TodayMetrics' +import { DrillDownModal } from '@/components/features/drill-down/DrillDownModal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage, DashboardMetrics } from '@/types' + +const emptyMetrics: DashboardMetrics = { + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 0, + hasRequestData: false, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 0, + avgRequestsPerDay: 0, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, +} + +describe('phase 4 UI correctness', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + + await initI18n('en') + }) + + it('falls back safely when today.modelsUsed is missing', () => { + const today = { + date: '2026-04-06', + inputTokens: 50, + outputTokens: 25, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 75, + totalCost: 3, + requestCount: 2, + modelBreakdowns: [], + } as unknown as DailyUsage + + render( + + + , + ) + + expect(screen.getByText('No request counters')).toBeInTheDocument() + expect(screen.getAllByText('0').length).toBeGreaterThan(0) + }) + + it('avoids Infinity and NaN in the drill-down modal when a day has zero tokens', () => { + const day: DailyUsage = { + date: '2026-04-06', + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 0, + totalCost: 4, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 4, + requestCount: 1, + }, + ], + } + + render( + + {}} /> + , + ) + + expect(document.body.textContent).not.toContain('Infinity') + expect(document.body.textContent).not.toContain('NaN') + expect(screen.getAllByText('–').length).toBeGreaterThan(0) + }) +}) diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx index 282f7cd..a69366d 100644 --- a/tests/frontend/provider-limits-section.test.tsx +++ b/tests/frontend/provider-limits-section.test.tsx @@ -22,7 +22,32 @@ describe('ProviderLimitsSection', () => { render( { monthlyLimit: 0, }, }} - selectedMonth={null} + selectedMonth="2026-04" /> , ) expect(screen.getByText('0% Limit')).toBeInTheDocument() - expect(screen.getByText('0% Abo')).toBeInTheDocument() + expect(screen.getByText('240% Abo')).toBeInTheDocument() expect(screen.getByText('Offen')).toBeInTheDocument() }) }) diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index d555c2f..0fc1470 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -12,7 +12,7 @@ describe('dashboard analytics', () => { totalCost: 6, totalTokens: 210, requestCount: 3, - modelsUsed: ['gpt-5.4'], + modelsUsed: ['GPT-5.4'], }) }) diff --git a/tests/unit/code-rabbit-phase4.test.ts b/tests/unit/code-rabbit-phase4.test.ts new file mode 100644 index 0000000..4cc050d --- /dev/null +++ b/tests/unit/code-rabbit-phase4.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { filterByModels } from '@/lib/data-transforms' +import { coerceNumber, formatMonthYear } from '@/lib/formatters' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +describe('phase 4 correctness helpers', () => { + beforeAll(async () => { + await initI18n('en') + }) + + it('returns null instead of coercing malformed numeric values to zero', () => { + expect(coerceNumber(undefined)).toBeNull() + expect(coerceNumber(Number.NaN)).toBeNull() + expect(coerceNumber(Number.POSITIVE_INFINITY)).toBeNull() + expect(coerceNumber('not-a-number')).toBeNull() + expect(coerceNumber('42.5')).toBe(42.5) + expect(coerceNumber(7)).toBe(7) + }) + + it('rejects malformed month identifiers instead of defaulting them to January', () => { + expect(formatMonthYear('2026')).toBe('') + expect(formatMonthYear('2026-13')).toBe('') + expect(formatMonthYear('2026-04')).toBe('April 2026') + }) + + it('deduplicates normalized modelsUsed entries after model filtering', () => { + const data: DailyUsage[] = [ + { + date: '2026-04-01', + inputTokens: 30, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 40, + totalCost: 3, + requestCount: 2, + modelsUsed: ['gpt-5-4', 'gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5-4', + inputTokens: 20, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 2, + requestCount: 1, + }, + { + modelName: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 1, + requestCount: 1, + }, + ], + }, + ] + + expect(filterByModels(data, ['GPT-5.4'])[0]?.modelsUsed).toEqual(['GPT-5.4']) + }) +}) From 6f2af8be90e9658b530140fa87684ef6df31664f Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 01:10:07 +0200 Subject: [PATCH 29/34] v6.1.6: Polish chart and UI helpers --- src/components/charts/CorrelationAnalysis.tsx | 32 +++++++++++-------- src/components/charts/CostByModelOverTime.tsx | 8 ++--- src/components/charts/ModelMix.tsx | 4 +-- .../features/anomaly/AnomalyDetection.tsx | 2 +- .../features/forecast/CostForecast.tsx | 6 +--- src/components/tables/ModelEfficiency.tsx | 2 +- src/components/ui/card.tsx | 2 +- src/lib/help-content.ts | 15 ++++++--- tests/unit/help-content.test.ts | 20 ++++++++++++ 9 files changed, 58 insertions(+), 33 deletions(-) create mode 100644 tests/unit/help-content.test.ts diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index b51a847..997d7c5 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -38,6 +38,22 @@ interface ScatterPoint { cacheRate?: number } +function getCorrelationInterpretation( + t: ReturnType['t'], + correlationValue: number, + mode: 'requestCost' | 'cacheEfficiency', +) { + if (mode === 'requestCost') { + if (correlationValue >= 0.6) return t('charts.correlation.strongRequestCost') + if (correlationValue >= 0.3) return t('charts.correlation.mediumRequestCost') + return t('charts.correlation.weakRequestCost') + } + + if (correlationValue <= -0.3) return t('charts.correlation.negativeCache') + if (correlationValue < 0.2) return t('charts.correlation.neutralCache') + return t('charts.correlation.positiveCache') +} + function correlation(valuesA: number[], valuesB: number[]) { if (valuesA.length !== valuesB.length || valuesA.length < 2) return 0 const avgA = valuesA.reduce((sum, value) => sum + value, 0) / valuesA.length @@ -282,13 +298,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { color={CHART_COLORS.cost} xAxisName={t('charts.correlation.requestsAxis')} yAxisName={t('charts.correlation.cost')} - footer={ - requestCostCorrelation >= 0.6 - ? t('charts.correlation.strongRequestCost') - : requestCostCorrelation >= 0.3 - ? t('charts.correlation.mediumRequestCost') - : t('charts.correlation.weakRequestCost') - } + footer={getCorrelationInterpretation(t, requestCostCorrelation, 'requestCost')} delay={0.02} /> @@ -302,13 +312,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { xAxisName={t('charts.correlation.cacheRate')} xTickFormatter={(value) => formatPercent(value, 0)} yAxisName={t('charts.correlation.costPerRequestAxis')} - footer={ - cacheEfficiencyCorrelation <= -0.3 - ? t('charts.correlation.negativeCache') - : cacheEfficiencyCorrelation < 0.2 - ? t('charts.correlation.neutralCache') - : t('charts.correlation.positiveCache') - } + footer={getCorrelationInterpretation(t, cacheEfficiencyCorrelation, 'cacheEfficiency')} delay={0.08} /> diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index 373500a..f843cc2 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -69,7 +69,7 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> - {models.map((model) => ( + {models.map((model, index) => ( ))} @@ -139,7 +139,7 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> - {models.map((model) => ( + {models.map((model, index) => ( diff --git a/src/components/charts/ModelMix.tsx b/src/components/charts/ModelMix.tsx index 61af901..d08a483 100644 --- a/src/components/charts/ModelMix.tsx +++ b/src/components/charts/ModelMix.tsx @@ -126,7 +126,7 @@ export function ModelMix({ data }: ModelMixProps) { content={} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - {models.map((model) => { + {models.map((model, index) => { const color = getModelColor(model) const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` return ( @@ -141,7 +141,7 @@ export function ModelMix({ data }: ModelMixProps) { fill={`url(#${id})`} name={model} isAnimationActive={animate} - animationBegin={CHART_ANIMATION.stagger * (models.indexOf(model) % 5)} + animationBegin={CHART_ANIMATION.stagger * (index % 5)} animationDuration={CHART_ANIMATION.duration} animationEasing={CHART_ANIMATION.easing} /> diff --git a/src/components/features/anomaly/AnomalyDetection.tsx b/src/components/features/anomaly/AnomalyDetection.tsx index c47b191..4d4a8dc 100644 --- a/src/components/features/anomaly/AnomalyDetection.tsx +++ b/src/components/features/anomaly/AnomalyDetection.tsx @@ -62,7 +62,7 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma })}

- {anomalies + {[...anomalies] .sort((a, b) => b.totalCost - a.totalCost) .map((day) => { const zScoreNum = stdDev > 0 ? (day.totalCost - mean) / stdDev : 0 diff --git a/src/components/features/forecast/CostForecast.tsx b/src/components/features/forecast/CostForecast.tsx index 8bff733..fd14b8b 100644 --- a/src/components/features/forecast/CostForecast.tsx +++ b/src/components/features/forecast/CostForecast.tsx @@ -183,11 +183,7 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { } - value={ - <> - ~ - - } + value={} subtitle={`${t('forecast.soFar', { value: formatCurrency(currentMonthTotal) })} · ${t('forecast.remainingDays', { count: remainingDays })}${dailyAvgTrend ? ` · ${t('forecast.projectedPerDay', { value: formatCurrency(projectedDailyBurn) })}` : ''}`} icon={} trend={ diff --git a/src/components/tables/ModelEfficiency.tsx b/src/components/tables/ModelEfficiency.tsx index 1ec93e4..9b563bc 100644 --- a/src/components/tables/ModelEfficiency.tsx +++ b/src/components/tables/ModelEfficiency.tsx @@ -319,7 +319,7 @@ export function ModelEfficiency({ {sorted.map((model) => (
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 1a278a3..d67c9ce 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -7,6 +7,7 @@ type CardProps = React.ComponentPropsWithoutRef const Card = React.forwardRef(({ className, ...props }, ref) => ( (({ className, ...props 'relative rounded-xl border border-border/50 bg-card/80 backdrop-blur-xl text-card-foreground shadow-[var(--shadow-card)] transition-all duration-300 hover:shadow-[var(--shadow-card-hover)] hover:border-border/80', className, )} - {...props} /> )) Card.displayName = 'Card' diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 2b89b08..838407a 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -247,11 +247,16 @@ function dynamicMap>(selector: () => T): get: (_, key) => Reflect.get(selector(), key), has: (_, key) => key in selector(), ownKeys: () => Reflect.ownKeys(selector()), - getOwnPropertyDescriptor: (_, key) => ({ - value: Reflect.get(selector(), key), - enumerable: true, - configurable: true, - }), + getOwnPropertyDescriptor: (_, key) => { + const map = selector() + if (!Object.prototype.hasOwnProperty.call(map, key)) return undefined + + return { + value: Reflect.get(map, key), + enumerable: true, + configurable: true, + } + }, }) } diff --git a/tests/unit/help-content.test.ts b/tests/unit/help-content.test.ts new file mode 100644 index 0000000..6ee8b1d --- /dev/null +++ b/tests/unit/help-content.test.ts @@ -0,0 +1,20 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { CHART_HELP } from '@/lib/help-content' +import { initI18n } from '@/lib/i18n' + +describe('help-content proxy semantics', () => { + beforeAll(async () => { + await initI18n('en') + }) + + it('only reports own property descriptors for existing help entries', () => { + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'costOverTime')).toBe(true) + expect(Object.getOwnPropertyDescriptor(CHART_HELP, 'costOverTime')).toMatchObject({ + enumerable: true, + configurable: true, + }) + + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'missingHelpKey')).toBe(false) + expect(Object.getOwnPropertyDescriptor(CHART_HELP, 'missingHelpKey')).toBeUndefined() + }) +}) From 842370084b3d6f1cd242526bf9a1e9b531d39acc Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 01:13:06 +0200 Subject: [PATCH 30/34] v6.1.6: Finalize release tooling --- .github/workflows/release.yml | 2 +- RELEASING.md | 5 ++--- package.json | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f009ffc..cfd36c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,7 +144,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.4 - name: Create release commit and tag run: | diff --git a/RELEASING.md b/RELEASING.md index 8e31fa8..b98e904 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,9 +59,8 @@ On a manual `workflow_dispatch` run against `main`, the workflow: 10. publishes `@roastcodes/ttdash` to npm through Trusted Publishing 11. waits for npm registry propagation 12. verifies: - -- `npx --yes @roastcodes/ttdash@ --help` -- `bunx @roastcodes/ttdash@ --help` + - `npx --yes @roastcodes/ttdash@ --help` + - `bunx @roastcodes/ttdash@ --help` 13. creates the GitHub release diff --git a/package.json b/package.json index d905e08..ba0b594 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,10 @@ "test:e2e:ci": "playwright test", "test:all": "npm run test:unit && npm run test:e2e", "pack:dry-run": "npm pack --dry-run", - "verify": "npm run format:check && npm run lint && ./node_modules/.bin/tsc --noEmit && npm run test:unit && npm run build:app && npm run verify:package", + "verify": "npm run format:check && npm run lint && tsc --noEmit && npm run test:unit && npm run build:app && npm run verify:package", "verify:package": "node scripts/verify-package.js", "verify:registry-install": "node scripts/verify-registry-install.js", - "verify:release": "npm run format:check && npm run lint && ./node_modules/.bin/tsc --noEmit && npm run test:unit:coverage && npm run build:app && npm run verify:package", + "verify:release": "npm run format:check && npm run lint && tsc --noEmit && npm run test:unit:coverage && npm run build:app && npm run verify:package", "prepare": "npm run build:app" }, "files": [ From 55f3925f5f8f00e4d6efd83a64463cd9d25ff997 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 08:17:31 +0200 Subject: [PATCH 31/34] v6.1.6: Harden transform edge cases --- src/components/charts/CostByModelOverTime.tsx | 8 +-- src/lib/data-transforms.ts | 16 ++++-- .../frontend/cost-by-model-over-time.test.tsx | 50 +++++++++++++++++++ tests/unit/code-rabbit-phase4.test.ts | 45 ++++++++++++++++- 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 tests/frontend/cost-by-model-over-time.test.tsx diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index f843cc2..3cf45fc 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -28,10 +28,10 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) models .map((model) => ({ model, - total: data.reduce( - (sum, point) => sum + (typeof point[model] === 'number' ? point[model] : 0), - 0, - ), + total: data.reduce((sum, point) => { + const value = coerceNumber(point[model]) + return sum + (value ?? 0) + }, 0), })) .sort((a, b) => b.total - a.total)[0] ?? null diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 185eec3..1b6a490 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -107,12 +107,18 @@ export function getAvailableMonths(data: DailyUsage[]): string[] { export function getDateRange(data: DailyUsage[]): { start: string; end: string } | null { if (data.length === 0) return null - const firstEntry = data[0] + let firstEntry: DailyUsage | null = null + for (const entry of data) { + if (entry) { + firstEntry = entry + break + } + } if (!firstEntry) return null let start = firstEntry.date let end = firstEntry.date - for (let i = 1; i < data.length; i++) { + for (let i = 0; i < data.length; i++) { const entry = data[i] if (!entry) continue const date = entry.date @@ -289,8 +295,12 @@ export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { const weekdayCosts: Record = { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] } + const weekdayFormatter = new Intl.DateTimeFormat(getCurrentLocale(), { + weekday: 'short', + timeZone: 'UTC', + }) const weekdayLabels = Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + weekdayFormatter .format(new Date(Date.UTC(2024, 0, 1 + index))) .replace('.', '') .slice(0, 2), diff --git a/tests/frontend/cost-by-model-over-time.test.tsx b/tests/frontend/cost-by-model-over-time.test.tsx new file mode 100644 index 0000000..13c3e24 --- /dev/null +++ b/tests/frontend/cost-by-model-over-time.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CostByModelOverTime } from '@/components/charts/CostByModelOverTime' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +describe('CostByModelOverTime', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('ignores non-finite series values when computing the top model summary', () => { + render( + + + , + ) + + expect(screen.getByText(/gpt-5\.4/i)).toBeInTheDocument() + expect(screen.getByText(/\$9\.00/)).toBeInTheDocument() + expect(screen.queryByText(/nan/i)).not.toBeInTheDocument() + expect(screen.queryByText(/infinity/i)).not.toBeInTheDocument() + }) +}) diff --git a/tests/unit/code-rabbit-phase4.test.ts b/tests/unit/code-rabbit-phase4.test.ts index 4cc050d..da7acb0 100644 --- a/tests/unit/code-rabbit-phase4.test.ts +++ b/tests/unit/code-rabbit-phase4.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest' -import { filterByModels } from '@/lib/data-transforms' +import { filterByModels, getDateRange, toWeekdayData } from '@/lib/data-transforms' import { coerceNumber, formatMonthYear } from '@/lib/formatters' import { initI18n } from '@/lib/i18n' import type { DailyUsage } from '@/types' @@ -64,4 +64,47 @@ describe('phase 4 correctness helpers', () => { expect(filterByModels(data, ['GPT-5.4'])[0]?.modelsUsed).toEqual(['GPT-5.4']) }) + + it('finds the date range from the first valid entry instead of assuming index zero is usable', () => { + const validEntry: DailyUsage = { + date: '2026-04-03', + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 2, + totalCost: 1, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + } + const laterEntry: DailyUsage = { ...validEntry, date: '2026-04-06' } + + expect(getDateRange([undefined, validEntry, laterEntry] as unknown as DailyUsage[])).toEqual({ + start: '2026-04-03', + end: '2026-04-06', + }) + }) + + it('keeps weekday labels aligned with Monday-first buckets', () => { + const weekdayData = toWeekdayData([ + { + date: '2026-04-06', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 15, + totalCost: 9, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + ]) + + expect(weekdayData[0]?.day).toBe('Mo') + expect(weekdayData[0]?.cost).toBe(9) + }) }) From 787d771739b04192fbc953495cf00af3b8f11b6f Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 08:20:36 +0200 Subject: [PATCH 32/34] v6.1.6: Canonicalize drill-down token totals --- .../features/drill-down/DrillDownModal.tsx | 36 +++++++----------- tests/frontend/phase4-correctness.test.tsx | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/components/features/drill-down/DrillDownModal.tsx b/src/components/features/drill-down/DrillDownModal.tsx index 4d5d5b4..40e3b81 100644 --- a/src/components/features/drill-down/DrillDownModal.tsx +++ b/src/components/features/drill-down/DrillDownModal.tsx @@ -76,28 +76,20 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo if (!day) return null - const hasTokens = day.totalTokens > 0 - - const cacheRate = + const tokensTotal = day.cacheReadTokens + - day.cacheCreationTokens + - day.inputTokens + - day.outputTokens + - day.thinkingTokens > - 0 - ? (day.cacheReadTokens / - (day.cacheReadTokens + - day.cacheCreationTokens + - day.inputTokens + - day.outputTokens + - day.thinkingTokens)) * - 100 - : 0 + day.cacheCreationTokens + + day.inputTokens + + day.outputTokens + + day.thinkingTokens + const hasTokens = tokensTotal > 0 + + const cacheRate = hasTokens ? (day.cacheReadTokens / tokensTotal) * 100 : 0 const pieData = modelData.map((m) => ({ name: m.name, value: m.cost })) - const avgTokensPerRequest = day.requestCount > 0 ? day.totalTokens / day.requestCount : 0 + const avgTokensPerRequest = day.requestCount > 0 ? tokensTotal / day.requestCount : 0 const avgCostPerRequest = day.requestCount > 0 ? day.totalCost / day.requestCount : 0 - const costPerMillion = hasTokens ? day.totalCost / (day.totalTokens / 1_000_000) : null + const costPerMillion = hasTokens ? day.totalCost / (tokensTotal / 1_000_000) : null const costRanking = [...contextData] .sort((a, b) => b.totalCost - a.totalCost) @@ -126,7 +118,7 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo null as (typeof modelData)[number] | null, ) const formatTokenShare = (value: number) => - hasTokens ? formatPercent((value / day.totalTokens) * 100) : '–' + hasTokens ? formatPercent((value / tokensTotal) * 100) : '–' return ( !o && onClose()}> @@ -145,7 +137,7 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Tokens
- +
@@ -249,10 +241,10 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo key={seg.label} className="h-full transition-all duration-500" style={{ - width: `${(seg.value / day.totalTokens) * 100}%`, + width: `${(seg.value / tokensTotal) * 100}%`, backgroundColor: seg.color, }} - title={`${seg.label}: ${formatTokens(seg.value)} (${((seg.value / day.totalTokens) * 100).toFixed(1)}%)`} + title={`${seg.label}: ${formatTokens(seg.value)} (${((seg.value / tokensTotal) * 100).toFixed(1)}%)`} /> ))}
diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx index 6aa7d5b..ed2a8a0 100644 --- a/tests/frontend/phase4-correctness.test.tsx +++ b/tests/frontend/phase4-correctness.test.tsx @@ -117,4 +117,41 @@ describe('phase 4 UI correctness', () => { expect(document.body.textContent).not.toContain('NaN') expect(screen.getAllByText('–').length).toBeGreaterThan(0) }) + + it('uses the canonical token sum instead of a stale day.totalTokens value', () => { + const day: DailyUsage = { + date: '2026-04-07', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + totalTokens: 1, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + cost: 5, + requestCount: 2, + }, + ], + } + + render( + + {}} /> + , + ) + + expect(screen.getAllByText('100').length).toBeGreaterThan(0) + expect(screen.getByText(/\$50\.0k/)).toBeInTheDocument() + expect(screen.getByText('Cache Read 10.0%')).toBeInTheDocument() + }) }) From 06bce9665f85af5148baed15377404f9a81b22dd Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 08:26:39 +0200 Subject: [PATCH 33/34] v6.1.6: Harden help content proxy --- src/lib/help-content.ts | 11 +++++++++-- tests/unit/help-content.test.ts | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 838407a..ee96125 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -244,8 +244,15 @@ export type FeatureHelp = HelpMap function dynamicMap>(selector: () => T): T { return new Proxy({} as T, { - get: (_, key) => Reflect.get(selector(), key), - has: (_, key) => key in selector(), + get: (_, key) => { + const map = selector() + if (!Object.prototype.hasOwnProperty.call(map, key)) return undefined + return Reflect.get(map, key) + }, + has: (_, key) => { + const map = selector() + return Object.prototype.hasOwnProperty.call(map, key) + }, ownKeys: () => Reflect.ownKeys(selector()), getOwnPropertyDescriptor: (_, key) => { const map = selector() diff --git a/tests/unit/help-content.test.ts b/tests/unit/help-content.test.ts index 6ee8b1d..fb9e2c4 100644 --- a/tests/unit/help-content.test.ts +++ b/tests/unit/help-content.test.ts @@ -17,4 +17,10 @@ describe('help-content proxy semantics', () => { expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'missingHelpKey')).toBe(false) expect(Object.getOwnPropertyDescriptor(CHART_HELP, 'missingHelpKey')).toBeUndefined() }) + + it('does not expose prototype properties as help keys', () => { + expect('toString' in CHART_HELP).toBe(false) + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'toString')).toBe(false) + expect(CHART_HELP.toString).toBeUndefined() + }) }) From 9a6bf96a09a6559507444525b874aa94701f8b3a Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 08:31:42 +0200 Subject: [PATCH 34/34] v6.1.6: Optimize cost peak summary --- src/components/charts/CostOverTime.tsx | 8 +++++- tests/frontend/cost-over-time.test.tsx | 38 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/frontend/cost-over-time.test.tsx diff --git a/src/components/charts/CostOverTime.tsx b/src/components/charts/CostOverTime.tsx index af16658..1947d8f 100644 --- a/src/components/charts/CostOverTime.tsx +++ b/src/components/charts/CostOverTime.tsx @@ -29,7 +29,13 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { const summary = useMemo(() => { if (data.length === 0) return null const latest = data[data.length - 1] - const peak = [...data].sort((a, b) => b.cost - a.cost)[0] + let peak = data[0] + for (let index = 1; index < data.length; index += 1) { + const candidate = data[index] + if (candidate && peak && candidate.cost > peak.cost) { + peak = candidate + } + } if (!latest || !peak) return null return { latest: latest.cost, diff --git a/tests/frontend/cost-over-time.test.tsx b/tests/frontend/cost-over-time.test.tsx new file mode 100644 index 0000000..0c0bf39 --- /dev/null +++ b/tests/frontend/cost-over-time.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CostOverTime } from '@/components/charts/CostOverTime' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +describe('CostOverTime', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('summarizes the latest point and peak day without reordering the source data', () => { + const data = [ + { date: '2026-04-01', cost: 4 }, + { date: '2026-04-02', cost: 12 }, + { date: '2026-04-03', cost: 6 }, + ] + + render( + + + , + ) + + expect(screen.getByText(/latest \$6\.00 · peak \$12\.0 on 04\/02/i)).toBeInTheDocument() + expect(data.map((point) => point.date)).toEqual(['2026-04-01', '2026-04-02', '2026-04-03']) + }) +})