diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd93c6..bd17f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [6.2.0] - 2026-04-14 + +### Added + +- **Zentrales modellbasiertes Farbsystem** — bekannte Modellfamilien nutzen jetzt eine kuratierte, theme-aware Palette mit stabilen Familienfarben, kontrollierten Fallbacks für unbekannte Modelle und gezielten Tests für UI- und Report-Konsistenz + +### Improved + +- **Modellfarb-Integration im Dashboard und Report** — Filter, Tabellen und PDF-/Report-Ausgabe greifen jetzt auf dieselbe Farbquelle zu, Versionen innerhalb einer Modellfamilie lassen sich besser unterscheiden, und Light-/Dark-Kontexte werden sauberer berücksichtigt +- **PDF-Report-Qualität und Semantik** — Kostenachsen bleiben auch bei kleinen Werten wahrheitsgetreu, Charts erhalten beschreibende Alternativtexte und sichtbare Kurzsummaries, der Report trägt jetzt einen echten Dokumenttitel in den PDF-Metadaten, und der Seitenfluss vermeidet unnötige Leerflächen +- **PDF-Report-Absicherung für die Weiterentwicklung** — neue Unit- und Integrationstests prüfen Chart-Formatierung, Chart-Beschreibungen und zentrale PDF-Strukturmerkmale statt nur den reinen Binary-Exportpfad + ## [6.1.9] - 2026-04-14 ### Added diff --git a/server/report/chart-labels.js b/server/report/chart-labels.js new file mode 100644 index 0000000..486fac0 --- /dev/null +++ b/server/report/chart-labels.js @@ -0,0 +1,12 @@ +const TOP_MODEL_CHART_LABEL_MAX_LENGTH = 34; + +function truncateTopModelChartLabel(value) { + const stringValue = String(value || ''); + if (stringValue.length <= TOP_MODEL_CHART_LABEL_MAX_LENGTH) return stringValue; + return `${stringValue.slice(0, Math.max(1, TOP_MODEL_CHART_LABEL_MAX_LENGTH - 1)).trimEnd()}…`; +} + +module.exports = { + TOP_MODEL_CHART_LABEL_MAX_LENGTH, + truncateTopModelChartLabel, +}; diff --git a/server/report/charts.js b/server/report/charts.js index d85ebd1..c5e8a0c 100644 --- a/server/report/charts.js +++ b/server/report/charts.js @@ -1,3 +1,5 @@ +const { truncateTopModelChartLabel } = require('./chart-labels'); + function escapeXml(value) { return String(value) .replace(/&/g, '&') @@ -19,12 +21,6 @@ ${body} const DEFAULT_FONT_FAMILY = 'Liberation Sans, DejaVu Sans, Arial, sans-serif'; -function truncateSvgLabel(value, maxLength = 28) { - const stringValue = String(value || ''); - if (stringValue.length <= maxLength) return stringValue; - return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; -} - function lineChart( data, { @@ -136,7 +132,7 @@ function horizontalBarChart( top: 46, right: 100, bottom: 24, - left: clamp(180 + longestLabelLength * 3.4, 220, 320), + left: clamp(180 + longestLabelLength * 3.4, 220, 360), }; const plotWidth = width - margin.left - margin.right; const barGap = 18; @@ -158,7 +154,7 @@ function horizontalBarChart( const value = getValue(entry); const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); return ` - ${escapeXml(truncateSvgLabel(getLabel(entry), 30))} + ${escapeXml(truncateTopModelChartLabel(getLabel(entry)))} ${escapeXml(formatter(value))} diff --git a/server/report/index.js b/server/report/index.js index 477025a..442b7b3 100644 --- a/server/report/index.js +++ b/server/report/index.js @@ -3,7 +3,7 @@ const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const { buildReportData, formatCompactAxis, formatDateAxis } = require('./utils'); -const { translate } = require('./i18n'); +const { getLocale, translate } = require('./i18n'); const { horizontalBarChart, lineChart, stackedBarChart } = require('./charts'); function ensureTypstInstalled() { @@ -39,10 +39,41 @@ function compileTypst(workingDir, typPath, pdfPath) { }); } +function formatCostAxisValue(value, language = 'de') { + const numericValue = Number(value) || 0; + const absoluteValue = Math.abs(numericValue); + const locale = getLocale(language); + + if (absoluteValue >= 100) { + return `$${Math.round(numericValue).toLocaleString(locale)}`; + } + + if (absoluteValue >= 10) { + return `$${numericValue.toLocaleString(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + })}`; + } + + if (absoluteValue >= 1) { + return `$${numericValue.toLocaleString(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}`; + } + + return `$${numericValue.toLocaleString(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + function buildTemplate() { return ` #let report = json("report.json") +#set document(title: report.meta.reportTitle) + #set page( paper: "a4", margin: (x: 14mm, y: 16mm), @@ -56,10 +87,10 @@ function buildTemplate() { #let muted = rgb("#5c6b7e") #let panel = rgb("#ffffff") #let line = rgb("#d9e2ec") -#let accent = rgb("#1d6fd8") +#let accent = rgb("#175fc0") #let accent-soft = rgb("#eaf2ff") #let good = rgb("#16825d") -#let warn = rgb("#c67700") +#let warn = rgb("#9a5a00") #let metric-card(label, value, note: none, tone: accent) = rect( inset: 10pt, @@ -89,6 +120,22 @@ function buildTemplate() { ], ) +#let chart-panel(file, alt, summary, note: none) = rect( + inset: 10pt, + radius: 14pt, + fill: panel, + stroke: (paint: line, thickness: 0.8pt), + [ + #image(file, width: 100%, alt: alt) + #v(6pt) + #text(size: 8.7pt, fill: muted)[#summary] + #if note != none [ + #v(4pt) + #text(size: 8.5pt, fill: muted)[#note] + ] + ], +) + #show heading.where(level: 1): it => block(above: 0pt, below: 10pt)[ #text(size: 24pt, fill: ink, weight: "bold")[#it.body] ] @@ -150,21 +197,26 @@ function buildTemplate() { #grid( columns: (1fr, 1fr), gutter: 10pt, - rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[ - #image("cost-trend.svg", width: 100%) - ], - rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[ - #image("top-models.svg", width: 100%) - ], + chart-panel( + "cost-trend.svg", + report.chartDescriptions.costTrend.alt, + report.chartDescriptions.costTrend.summary, + ), + chart-panel( + "top-models.svg", + report.chartDescriptions.topModels.alt, + report.chartDescriptions.topModels.summary, + note: report.chartDescriptions.topModels.fullNamesNote, + ), ) #v(10pt) -#rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[ - #image("token-trend.svg", width: 100%) -] - -#pagebreak() +#chart-panel( + "token-trend.svg", + report.chartDescriptions.tokenTrend.alt, + report.chartDescriptions.tokenTrend.summary, +) #v(12pt) @@ -279,14 +331,14 @@ function createChartAssets(reportData) { title: reportData.text.charts.costTrend, valueKey: 'cost', secondaryKey: reportData.meta.filterSummary.viewModeKey === 'daily' ? 'ma7' : null, - formatter: (value) => `$${Math.round(value)}`, + formatter: (value) => formatCostAxisValue(value, reportData.meta.language), }), 'top-models.svg': horizontalBarChart(topModels, { title: reportData.text.charts.topModels, getValue: (entry) => entry.cost, getLabel: (entry) => entry.name, getColor: (entry) => entry.color, - formatter: (value) => `$${value.toFixed(value >= 100 ? 0 : 2)}`, + formatter: (value) => formatCostAxisValue(value, reportData.meta.language), }), 'token-trend.svg': stackedBarChart(tokenTrend, { title: reportData.text.charts.tokenTrend, @@ -363,4 +415,9 @@ async function generatePdfReport(allDailyData, options = {}) { module.exports = { generatePdfReport, + __test__: { + buildTemplate, + createChartAssets, + formatCostAxisValue, + }, }; diff --git a/server/report/utils.js b/server/report/utils.js index 14c545c..8d5b36f 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -1,5 +1,6 @@ const { version: APP_VERSION } = require('../../package.json'); const { getLanguage, getLocale, translate } = require('./i18n'); +const { truncateTopModelChartLabel } = require('./chart-labels'); const { aggregateToDailyFormat, computeMetrics, @@ -12,24 +13,12 @@ const { normalizeModelName, sortByDate, } = require('../../shared/dashboard-domain'); - -const MODEL_COLORS = { - 'Opus 4.6': 'rgb(175, 92, 224)', - 'Opus 4.5': 'rgb(200, 66, 111)', - 'Sonnet 4.6': 'rgb(71, 134, 221)', - 'Sonnet 4.5': 'rgb(66, 161, 130)', - 'Haiku 4.5': 'rgb(231, 146, 34)', - '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)', -}; +const { getModelColorRgb } = require('../../shared/model-colors.js'); const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; function getModelColor(name) { - return MODEL_COLORS[name] || 'rgb(113, 128, 150)'; + return getModelColorRgb(name, { theme: 'light' }); } function toCostChartData(data) { @@ -232,6 +221,16 @@ function formatPercent(value, language = 'de') { })}%`; } +function findPeakEntry(data, getValue) { + let best = null; + for (const entry of data) { + if (!best || getValue(entry) > getValue(best)) { + best = entry; + } + } + return best; +} + function formatCompactNumber(value, language = 'de') { if (!Number.isFinite(value)) return '0'; @@ -292,12 +291,6 @@ function summarizeSelection( return `${visible.join(', ')}${suffix}`; } -function truncateLabel(value, maxLength = 28) { - const stringValue = String(value || ''); - if (stringValue.length <= maxLength) return stringValue; - return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; -} - function buildInsights(metrics, { filteredDaily, filtered, language }) { const insights = []; @@ -409,6 +402,9 @@ function buildReportData(allDailyData, options = {}) { 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 latestPeriod = filtered[filtered.length - 1] || null; + const peakCostPeriod = findPeakEntry(filtered, (entry) => entry.totalCost); + const peakTokenPeriod = findPeakEntry(filtered, (entry) => entry.totalTokens); const recentRows = sortByDate(filtered) .slice(-12) .reverse() @@ -466,6 +462,34 @@ function buildReportData(allDailyData, options = {}) { }, ]; + const topChartModels = modelRows.slice(0, 8); + const truncatedTopModelNames = topChartModels + .filter((entry) => truncateTopModelChartLabel(entry.name) !== entry.name) + .map((entry) => entry.name); + const topModelSummary = metrics.topModel + ? translate(language, 'report.charts.topModelsSummary', { + model: metrics.topModel.name, + cost: formatCurrency(metrics.topModel.cost, language), + share: formatPercent(metrics.topModelShare, language), + }) + : translate(language, 'report.charts.noDataSummary'); + const costTrendSummary = + latestPeriod && peakCostPeriod + ? translate(language, 'report.charts.costTrendSummary', { + latest: formatCurrency(latestPeriod.totalCost, language), + peak: formatCurrency(peakCostPeriod.totalCost, language), + date: formatDate(peakCostPeriod.date, 'long', language), + }) + : translate(language, 'report.charts.noDataSummary'); + const tokenTrendSummary = + peakTokenPeriod && metrics.totalTokens > 0 + ? translate(language, 'report.charts.tokenTrendSummary', { + total: formatCompact(metrics.totalTokens, language), + peak: formatCompact(peakTokenPeriod.totalTokens, language), + date: formatDate(peakTokenPeriod.date, 'long', language), + }) + : translate(language, 'report.charts.noDataSummary'); + const interpretationSummary = translate(language, 'report.interpretation.summary', { days: formatInteger(filteredDaily.length, language), periods: formatInteger(filtered.length, language), @@ -530,6 +554,26 @@ function buildReportData(allDailyData, options = {}) { tokensLabel: formatCompact(entry.tokens, language), })), recentPeriods: recentRows, + chartDescriptions: { + costTrend: { + alt: translate(language, 'report.charts.costTrendAlt'), + summary: costTrendSummary, + }, + topModels: { + alt: translate(language, 'report.charts.topModelsAlt'), + summary: topModelSummary, + fullNamesNote: + truncatedTopModelNames.length > 0 + ? translate(language, 'report.charts.topModelsFullNames', { + names: truncatedTopModelNames.join(', '), + }) + : null, + }, + tokenTrend: { + alt: translate(language, 'report.charts.tokenTrendAlt'), + summary: tokenTrendSummary, + }, + }, labels: { dateRangeText: dateRange ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` @@ -618,7 +662,6 @@ module.exports = { formatDate, formatDateAxis, getModelColor, - truncateLabel, __test__: { getModelProvider, normalizeModelName, diff --git a/shared/model-colors.d.ts b/shared/model-colors.d.ts new file mode 100644 index 0000000..f4fceff --- /dev/null +++ b/shared/model-colors.d.ts @@ -0,0 +1,17 @@ +export type ModelColorTheme = 'dark' | 'light' + +export interface ModelColorSpec { + h: number + s: number + l: number +} + +export interface ModelColorOptions { + theme?: ModelColorTheme + alpha?: number +} + +export function normalizeTheme(theme?: string): ModelColorTheme +export function getModelColorSpec(name: string, options?: ModelColorOptions): ModelColorSpec +export function getModelColor(name: string, options?: ModelColorOptions): string +export function getModelColorRgb(name: string, options?: ModelColorOptions): string diff --git a/shared/model-colors.js b/shared/model-colors.js new file mode 100644 index 0000000..83da52a --- /dev/null +++ b/shared/model-colors.js @@ -0,0 +1,241 @@ +const MODEL_COLOR_RULES = [ + { + pattern: /^OpenCode$/i, + light: { h: 192, s: 76, l: 40 }, + dark: { h: 192, s: 82, l: 58 }, + }, + { + pattern: /^Codex(?: .+)?$/i, + light: { h: 194, s: 72, l: 38 }, + dark: { h: 194, s: 78, l: 55 }, + }, + { + pattern: /^GPT-5\.4 Codex$/i, + light: { h: 194, s: 76, l: 42 }, + dark: { h: 194, s: 82, l: 60 }, + }, + { + pattern: /^GPT-5\.3 Codex$/i, + light: { h: 194, s: 70, l: 36 }, + dark: { h: 194, s: 74, l: 52 }, + }, + { + pattern: /^GPT-5\.4$/i, + light: { h: 148, s: 68, l: 40 }, + dark: { h: 148, s: 72, l: 57 }, + }, + { + pattern: /^GPT-5$/i, + light: { h: 148, s: 60, l: 33 }, + dark: { h: 148, s: 64, l: 47 }, + }, + { + pattern: /^GPT-(?:4o|4\.1)(?: .+)?$/i, + light: { h: 160, s: 58, l: 34 }, + dark: { h: 160, s: 62, l: 49 }, + }, + { + pattern: /^o4 Mini$/i, + light: { h: 166, s: 64, l: 33 }, + dark: { h: 166, s: 68, l: 48 }, + }, + { + pattern: /^o1$/i, + light: { h: 166, s: 56, l: 30 }, + dark: { h: 166, s: 60, l: 43 }, + }, + { + pattern: /^Gemini 3 Flash Preview(?: .+)?$/i, + light: { h: 48, s: 100, l: 42 }, + dark: { h: 52, s: 98, l: 61 }, + }, + { + pattern: /^Gemini 2\.5 Flash$/i, + light: { h: 44, s: 92, l: 39 }, + dark: { h: 46, s: 94, l: 56 }, + }, + { + pattern: /^Gemini 2\.5 Pro$/i, + light: { h: 38, s: 86, l: 34 }, + dark: { h: 40, s: 88, l: 49 }, + }, + { + pattern: /^Gemini(?: .+)?$/i, + light: { h: 42, s: 88, l: 36 }, + dark: { h: 44, s: 90, l: 52 }, + }, + { + pattern: /^(?:Claude\s+)?Opus 4\.6$/i, + light: { h: 274, s: 68, l: 44 }, + dark: { h: 274, s: 74, l: 66 }, + }, + { + pattern: /^(?:Claude\s+)?Opus 4\.5$/i, + light: { h: 274, s: 58, l: 36 }, + dark: { h: 274, s: 62, l: 56 }, + }, + { + pattern: /^(?:Claude\s+)?Opus(?: .+)?$/i, + light: { h: 274, s: 62, l: 40 }, + dark: { h: 274, s: 68, l: 60 }, + }, + { + pattern: /^(?:Claude\s+)?Sonnet 4\.6$/i, + light: { h: 214, s: 72, l: 44 }, + dark: { h: 214, s: 80, l: 63 }, + }, + { + pattern: /^(?:Claude\s+)?Sonnet 4\.5$/i, + light: { h: 214, s: 60, l: 36 }, + dark: { h: 214, s: 66, l: 52 }, + }, + { + pattern: /^(?:Claude\s+)?Sonnet 4$/i, + light: { h: 214, s: 56, l: 34 }, + dark: { h: 214, s: 62, l: 48 }, + }, + { + pattern: /^(?:Claude\s+)?Sonnet(?: .+)?$/i, + light: { h: 214, s: 64, l: 40 }, + dark: { h: 214, s: 70, l: 56 }, + }, + { + pattern: /^(?:Claude\s+)?Haiku 4\.5$/i, + light: { h: 28, s: 90, l: 43 }, + dark: { h: 28, s: 92, l: 61 }, + }, + { + pattern: /^(?:Claude\s+)?Haiku(?: .+)?$/i, + light: { h: 28, s: 84, l: 38 }, + dark: { h: 28, s: 88, l: 56 }, + }, +] + +const FALLBACK_HUES = [148, 168, 190, 208, 226, 248, 272, 332, 18, 30, 44] + +function normalizeTheme(theme) { + return theme === 'light' ? 'light' : 'dark' +} + +function normalizeAlpha(alpha) { + if (typeof alpha !== 'number' || !Number.isFinite(alpha)) return null + if (alpha <= 0) return 0 + if (alpha >= 1) return 1 + return Math.round(alpha * 1000) / 1000 +} + +function hashName(name) { + let hash = 0 + const value = String(name || '') + .trim() + .toLowerCase() + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 + } + return hash +} + +function mod(value, divisor) { + return ((value % divisor) + divisor) % divisor +} + +function findKnownColor(name) { + return MODEL_COLOR_RULES.find((rule) => rule.pattern.test(String(name || '').trim())) ?? null +} + +function fallbackColor(name, theme) { + const hash = hashName(name) + const baseHue = FALLBACK_HUES[mod(hash, FALLBACK_HUES.length)] + const hueShift = ((Math.abs(hash >> 4) % 7) - 3) * 4 + const hue = mod(baseHue + hueShift, 360) + + if (theme === 'light') { + return { + h: hue, + s: 62 + (Math.abs(hash >> 8) % 10), + l: 34 + (Math.abs(hash >> 12) % 8), + } + } + + return { + h: hue, + s: 68 + (Math.abs(hash >> 8) % 10), + l: 52 + (Math.abs(hash >> 12) % 8), + } +} + +function getModelColorSpec(name, options = {}) { + const theme = normalizeTheme(options.theme) + const known = findKnownColor(name) + return known ? { ...known[theme] } : fallbackColor(name, theme) +} + +function toHslString(spec) { + return `hsl(${spec.h}, ${spec.s}%, ${spec.l}%)` +} + +function toHslaString(spec, alpha) { + return `hsla(${spec.h}, ${spec.s}%, ${spec.l}%, ${alpha})` +} + +function hslToRgb(spec) { + const hue = mod(spec.h, 360) + const saturation = Math.max(0, Math.min(100, spec.s)) / 100 + const lightness = Math.max(0, Math.min(100, spec.l)) / 100 + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation + const huePrime = hue / 60 + const x = chroma * (1 - Math.abs((huePrime % 2) - 1)) + + let red = 0 + let green = 0 + let blue = 0 + + if (huePrime >= 0 && huePrime < 1) { + red = chroma + green = x + } else if (huePrime < 2) { + red = x + green = chroma + } else if (huePrime < 3) { + green = chroma + blue = x + } else if (huePrime < 4) { + green = x + blue = chroma + } else if (huePrime < 5) { + red = x + blue = chroma + } else { + red = chroma + blue = x + } + + const match = lightness - chroma / 2 + return { + r: Math.round((red + match) * 255), + g: Math.round((green + match) * 255), + b: Math.round((blue + match) * 255), + } +} + +function getModelColor(name, options = {}) { + const spec = getModelColorSpec(name, options) + const alpha = normalizeAlpha(options.alpha) + return alpha === null ? toHslString(spec) : toHslaString(spec, alpha) +} + +function getModelColorRgb(name, options = {}) { + const spec = getModelColorSpec(name, options) + const { r, g, b } = hslToRgb(spec) + const alpha = normalizeAlpha(options.alpha) + if (alpha === null) return `rgb(${r}, ${g}, ${b})` + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +module.exports = { + MODEL_COLOR_RULES, + getModelColor, + getModelColorRgb, + getModelColorSpec, + normalizeTheme, +} diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index 6262a7a..3f804bd 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -9,7 +9,12 @@ import { SelectItem, } from '@/components/ui/select' import { cn } from '@/lib/cn' -import { getModelColor, getProviderBadgeClasses, getProviderBadgeStyle } from '@/lib/model-utils' +import { + getModelColor, + getModelColorAlpha, + getProviderBadgeClasses, + getProviderBadgeStyle, +} from '@/lib/model-utils' import { formatDate, formatMonthYear, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react' @@ -541,7 +546,9 @@ export function FilterBar({ style={{ borderColor: color, backgroundColor: - isSelected || selectedModels.length === 0 ? `${color}20` : 'transparent', + isSelected || selectedModels.length === 0 + ? getModelColorAlpha(model, 0.16) + : 'transparent', color: color, }} > diff --git a/src/components/tables/ModelEfficiency.tsx b/src/components/tables/ModelEfficiency.tsx index d9e1e6f..1143345 100644 --- a/src/components/tables/ModelEfficiency.tsx +++ b/src/components/tables/ModelEfficiency.tsx @@ -11,7 +11,12 @@ import { periodLabel, formatNumber, } from '@/lib/formatters' -import { getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' +import { + getModelColor, + getModelColorAlpha, + getModelProvider, + getProviderBadgeClasses, +} from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' import type { ViewMode } from '@/types' @@ -359,7 +364,7 @@ export function ModelEfficiency({ className="absolute inset-y-1 left-0 rounded-sm transition-all duration-500" style={{ width: `${model.share}%`, - backgroundColor: `${getModelColor(model.name)}20`, + backgroundColor: getModelColorAlpha(model.name, 0.16), }} /> {formatPercent(model.share)} diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index 49a17f1..bbbc2b6 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -9,6 +9,7 @@ import { formatCurrency, formatDate, formatPercent, formatNumber } from '@/lib/f import { normalizeModelName, getModelColor, + getModelColorAlpha, getModelProvider, getProviderBadgeClasses, } from '@/lib/model-utils' @@ -250,7 +251,7 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP key={name} className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium leading-tight" style={{ - backgroundColor: `${getModelColor(name)}20`, + backgroundColor: getModelColorAlpha(name, 0.16), color: getModelColor(name), }} > @@ -450,7 +451,7 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP key={name} className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium leading-tight" style={{ - backgroundColor: `${getModelColor(name)}20`, + backgroundColor: getModelColorAlpha(name, 0.16), color: getModelColor(name), }} > diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 33ccf97..00858ef 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,19 +3,6 @@ export const GITHUB_REPO_URL = 'https://github.com/roastcodes/ttdash' export const GITHUB_ISSUES_URL = 'https://github.com/roastcodes/ttdash/issues' export const NPM_PACKAGE_URL = `https://www.npmjs.com/package/@roastcodes/ttdash/v/${VERSION}` -export const MODEL_COLORS: Record = { - 'Opus 4.6': 'hsl(262, 60%, 55%)', - 'Opus 4.5': 'hsl(340, 55%, 52%)', - 'Sonnet 4.6': 'hsl(215, 70%, 55%)', - 'Sonnet 4.5': 'hsl(160, 50%, 42%)', - 'Haiku 4.5': 'hsl(35, 80%, 52%)', - '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%)', -} - export const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] export const VIEW_MODE_LABELS = { diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 4f049f8..357ccb6 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -1,26 +1,16 @@ -import { MODEL_COLORS } from './constants' import { getModelProvider as getSharedModelProvider, normalizeModelName as normalizeSharedModelName, } from '../../shared/dashboard-domain.js' +import { + getModelColor as getSharedModelColor, + type ModelColorTheme, +} from '../../shared/model-colors.js' -const DYNAMIC_COLOR_CACHE = new Map() - -function dynamicColor(name: string): string { - const cached = DYNAMIC_COLOR_CACHE.get(name) - if (cached) return cached - - let hash = 0 - for (let i = 0; i < name.length; i++) { - hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0 - } - - const hue = Math.abs(hash) % 360 - const saturation = 62 + (Math.abs(hash) % 12) - const lightness = 54 + (Math.abs(hash >> 3) % 8) - const color = `hsl(${hue}, ${saturation}%, ${lightness}%)` - DYNAMIC_COLOR_CACHE.set(name, color) - return color +function resolveModelTheme(theme?: ModelColorTheme): ModelColorTheme { + if (theme === 'light' || theme === 'dark') return theme + if (typeof document === 'undefined') return 'dark' + return document.documentElement.classList.contains('dark') ? 'dark' : 'light' } export function normalizeModelName(raw: string): string { @@ -133,8 +123,15 @@ export function getProviderBadgeStyle(provider: string): { } } -export function getModelColor(name: string): string { - return MODEL_COLORS[name] ?? dynamicColor(name) +export function getModelColor(name: string, theme?: ModelColorTheme): string { + return getSharedModelColor(name, { theme: resolveModelTheme(theme) }) +} + +export function getModelColorAlpha(name: string, alpha: number, theme?: ModelColorTheme): string { + return getSharedModelColor(name, { + theme: resolveModelTheme(theme), + alpha, + }) } export function getUniqueModels(modelsUsed: string[][]): string[] { diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 02e2f4e..b12c004 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -1101,7 +1101,15 @@ "charts": { "costTrend": "Kostenverlauf", "topModels": "Top-Modelle nach Kosten", - "tokenTrend": "Token-Mix pro Zeitraum" + "tokenTrend": "Token-Mix pro Zeitraum", + "costTrendAlt": "Liniendiagramm der Report-Kosten pro Zeitraum.", + "costTrendSummary": "Letzter Wert {{latest}}. Peak {{peak}} am {{date}}.", + "topModelsAlt": "Horizontales Balkendiagramm der teuersten Modelle im Report.", + "topModelsSummary": "{{model}} führt mit {{cost}} und {{share}} der Report-Kosten.", + "topModelsFullNames": "Vollständige Diagramm-Labels: {{names}}.", + "tokenTrendAlt": "Gestapeltes Balkendiagramm des Token-Mix pro Report-Zeitraum.", + "tokenTrendSummary": "Gesamt {{total}}. Höchstes Token-Volumen {{peak}} am {{date}}.", + "noDataSummary": "Nicht genug Daten für eine belastbare Diagramm-Zusammenfassung." }, "interpretation": { "summary": "Dieser Report umfasst {{days}} Rohdaten-Tage und {{periods}} aggregierte Zeiträume. Spitzenzeitraum: {{peak}}. Top-Modell: {{topModel}}. Führender Anbieter: {{topProvider}}.", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 3e2c827..679299b 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -1101,7 +1101,15 @@ "charts": { "costTrend": "Cost trend", "topModels": "Top models by cost", - "tokenTrend": "Token mix by period" + "tokenTrend": "Token mix by period", + "costTrendAlt": "Line chart showing report cost by period.", + "costTrendSummary": "Latest {{latest}}. Peak {{peak}} on {{date}}.", + "topModelsAlt": "Horizontal bar chart comparing the highest-cost models in the report.", + "topModelsSummary": "{{model}} leads with {{cost}} and {{share}} of report cost.", + "topModelsFullNames": "Full chart labels: {{names}}.", + "tokenTrendAlt": "Stacked bar chart showing the token mix for each report period.", + "tokenTrendSummary": "Total {{total}}. Peak token volume {{peak}} on {{date}}.", + "noDataSummary": "Not enough data for a stable chart summary." }, "interpretation": { "summary": "This report covers {{days}} raw days and {{periods}} aggregated periods. Peak period: {{peak}}. Top model: {{topModel}}. Leading provider: {{topProvider}}.", diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 044298d..e165c6f 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1113,6 +1113,12 @@ describe('local server API', () => { const pdf = Buffer.from(await response.arrayBuffer()) expect(pdf.subarray(0, 5).toString('ascii')).toBe('%PDF-') expect(pdf.length).toBeGreaterThan(1000) + + const pdfText = pdf.toString('latin1') + expect(pdfText).toContain('/StructTreeRoot') + expect(pdfText).toContain('/Figure') + expect(pdfText).toContain('/Title') + expect(pdfText).toContain('/Alt') }) it('rejects malformed report payloads before report generation starts', async () => { diff --git a/tests/unit/model-colors.test.ts b/tests/unit/model-colors.test.ts new file mode 100644 index 0000000..319acbd --- /dev/null +++ b/tests/unit/model-colors.test.ts @@ -0,0 +1,127 @@ +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it } from 'vitest' +import { getModelColor, getModelColorAlpha } from '@/lib/model-utils' + +const require = createRequire(import.meta.url) +const { + getModelColor: getSharedModelColor, + getModelColorRgb, + getModelColorSpec, +} = require('../../shared/model-colors.js') as { + getModelColor: (name: string, options?: { theme?: 'light' | 'dark'; alpha?: number }) => string + getModelColorRgb: (name: string, options?: { theme?: 'light' | 'dark'; alpha?: number }) => string + getModelColorSpec: ( + name: string, + options?: { theme?: 'light' | 'dark'; alpha?: number }, + ) => { h: number; s: number; l: number } +} + +const { getModelColor: getReportModelColor } = require('../../server/report/utils.js') as { + getModelColor: (name: string) => string +} + +describe('model colors', () => { + function setDocumentTheme(isDark: boolean) { + ;( + globalThis as { + document?: { documentElement: { classList: { contains: (name: string) => boolean } } } + } + ).document = { + documentElement: { + classList: { + contains: (name: string) => name === 'dark' && isDark, + }, + }, + } + } + + afterEach(() => { + delete (globalThis as { document?: unknown }).document + }) + + it('assigns curated theme-aware colors to current model families', () => { + expect(getModelColor('GPT-5.4', 'dark')).toBe('hsl(148, 72%, 57%)') + expect(getModelColor('GPT-5.4', 'light')).toBe('hsl(148, 68%, 40%)') + expect(getModelColor('Claude Sonnet 4.5', 'dark')).toBe('hsl(214, 66%, 52%)') + expect(getModelColor('Claude Sonnet 4.5', 'light')).toBe('hsl(214, 60%, 36%)') + expect(getModelColor('Gemini 2.5 Pro', 'dark')).toBe('hsl(40, 88%, 49%)') + expect(getModelColor('Gemini 2.5 Pro', 'light')).toBe('hsl(38, 86%, 34%)') + }) + + it('keeps model families recognizable while separating versions', () => { + expect(getModelColor('GPT-5.4', 'dark')).not.toBe(getModelColor('GPT-5', 'dark')) + expect(getModelColor('GPT-5.4', 'light')).not.toBe(getModelColor('GPT-5', 'light')) + expect(getModelColor('Claude Opus 4.6', 'dark')).not.toBe( + getModelColor('Claude Opus 4.5', 'dark'), + ) + expect(getModelColor('Gemini 3 Flash Preview', 'light')).not.toBe( + getModelColor('Gemini 2.5 Pro', 'light'), + ) + }) + + it('maps prefixed and unprefixed Anthropic display names to the same curated color', () => { + expect(getModelColor('Claude Sonnet 4.5', 'dark')).toBe(getModelColor('Sonnet 4.5', 'dark')) + expect(getModelColor('Claude Opus 4.6', 'light')).toBe(getModelColor('Opus 4.6', 'light')) + }) + + it('returns deterministic fallback colors for unknown models and tunes them per theme', () => { + const dark = getModelColor('Mystery Frontier Alpha', 'dark') + const light = getModelColor('Mystery Frontier Alpha', 'light') + + expect(dark).toBe(getModelColor('Mystery Frontier Alpha', 'dark')) + expect(light).toBe(getModelColor('Mystery Frontier Alpha', 'light')) + expect(dark).not.toBe(light) + }) + + it('creates valid alpha variants for chip and bar backgrounds', () => { + expect(getModelColorAlpha('GPT-5.4', 0.16, 'dark')).toBe('hsla(148, 72%, 57%, 0.16)') + expect(getModelColorAlpha('Claude Sonnet 4.5', 0.16, 'light')).toBe('hsla(214, 60%, 36%, 0.16)') + }) + + it('uses the light palette consistently in report output', () => { + expect(getReportModelColor('GPT-5.4')).toBe(getModelColorRgb('GPT-5.4', { theme: 'light' })) + expect(getReportModelColor('Claude Sonnet 4.5')).toBe( + getModelColorRgb('Claude Sonnet 4.5', { theme: 'light' }), + ) + }) + + it('shares the same underlying palette helpers between app and shared module', () => { + expect(getModelColor('OpenCode', 'dark')).toBe( + getSharedModelColor('OpenCode', { theme: 'dark' }), + ) + expect(getModelColor('OpenCode', 'light')).toBe( + getSharedModelColor('OpenCode', { theme: 'light' }), + ) + }) + + it('does not expose mutable shared color specs for curated models', () => { + const spec = getModelColorSpec('GPT-5.4', { theme: 'dark' }) + spec.h = 0 + spec.s = 0 + spec.l = 0 + + expect(getModelColorSpec('GPT-5.4', { theme: 'dark' })).toEqual({ + h: 148, + s: 72, + l: 57, + }) + }) + + it('uses the dark palette when no theme is passed and the document is dark', () => { + setDocumentTheme(true) + + expect(getModelColor('GPT-5.4')).toBe(getModelColor('GPT-5.4', 'dark')) + expect(getModelColor('Mystery Frontier Alpha')).toBe( + getModelColor('Mystery Frontier Alpha', 'dark'), + ) + }) + + it('uses the light palette when no theme is passed and the document is not dark', () => { + setDocumentTheme(false) + + expect(getModelColor('GPT-5.4')).toBe(getModelColor('GPT-5.4', 'light')) + expect(getModelColorAlpha('Mystery Frontier Alpha', 0.16)).toBe( + getModelColorAlpha('Mystery Frontier Alpha', 0.16, 'light'), + ) + }) +}) diff --git a/tests/unit/report-charts.test.ts b/tests/unit/report-charts.test.ts index 83c785d..a4ea180 100644 --- a/tests/unit/report-charts.test.ts +++ b/tests/unit/report-charts.test.ts @@ -1,6 +1,70 @@ import { describe, expect, it } from 'vitest' describe('report charts', () => { + it('shares one truncation rule for top-model labels and boundary cases', async () => { + const { truncateTopModelChartLabel, TOP_MODEL_CHART_LABEL_MAX_LENGTH } = + await import('../../server/report/chart-labels.js') + + expect(truncateTopModelChartLabel('x'.repeat(TOP_MODEL_CHART_LABEL_MAX_LENGTH))).toBe( + 'x'.repeat(TOP_MODEL_CHART_LABEL_MAX_LENGTH), + ) + expect(truncateTopModelChartLabel('x'.repeat(TOP_MODEL_CHART_LABEL_MAX_LENGTH + 1))).toBe( + `${'x'.repeat(TOP_MODEL_CHART_LABEL_MAX_LENGTH - 1)}…`, + ) + }) + + it('keeps sub-dollar cost axis labels precise in PDF chart assets', async () => { + const { __test__ } = await import('../../server/report/index.js') + + expect(__test__.formatCostAxisValue(0.06, 'en')).toBe('$0.06') + expect(__test__.formatCostAxisValue(0.24, 'en')).toBe('$0.24') + expect(__test__.formatCostAxisValue(12.5, 'en')).toBe('$12.5') + expect(__test__.formatCostAxisValue(120, 'en')).toBe('$120') + expect(__test__.formatCostAxisValue(1234.5, 'de')).toBe("$1'235") + }) + + it('uses the same locale-aware cost formatter for top-model PDF charts', async () => { + const { __test__ } = await import('../../server/report/index.js') + + const chartAssets = __test__.createChartAssets({ + meta: { + language: 'de', + filterSummary: { + viewModeKey: 'daily', + }, + }, + charts: { + costTrend: [{ date: '2026-04-14', cost: 1234.5, ma7: 1234.5 }], + tokenTrend: [ + { + date: '2026-04-14', + input: 1200, + output: 300, + cacheWrite: 0, + cacheRead: 0, + thinking: 0, + }, + ], + }, + topModels: [ + { + name: 'GPT-5.4', + cost: 1234.5, + color: '#123456', + }, + ], + text: { + charts: { + costTrend: 'Kostenverlauf', + topModels: 'Top-Modelle nach Kosten', + tokenTrend: 'Token-Mix pro Zeitraum', + }, + }, + }) + + expect(chartAssets['top-models.svg']).toContain("$1'235") + }) + it('uses the provided formatter for stacked chart y-axis labels', async () => { const { stackedBarChart } = await import('../../server/report/charts.js') @@ -46,6 +110,6 @@ describe('report charts', () => { }, ) - expect(svg).toContain('This is a very long model nam…') + expect(svg).toContain('This is a very long model name th…') }) }) diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index f72b81b..b8eeaa2 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -175,4 +175,46 @@ describe('report utils', () => { expect(report.meta.filterSummary.selectedModelsLabel).toBe('Claude Sonnet 4.5') }) + + it('builds localized chart descriptions and exposes full labels for truncated model names', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const longModelName = + 'This is a very long model name that should stay understandable in the PDF' + const dataWithLongModel = dashboardFixture.map((day, index) => ({ + ...day, + modelBreakdowns: day.modelBreakdowns.map((entry, entryIndex) => + index === 0 && entryIndex === 0 + ? { + ...entry, + modelName: longModelName, + cost: entry.cost + 50, + } + : entry, + ), + })) + + const report = buildReportData(dataWithLongModel, { + viewMode: 'daily', + language: 'en', + }) + const normalizedLongModelName = report.topModels[0].name + + expect(report.chartDescriptions.costTrend.alt).toBe('Line chart showing report cost by period.') + expect(report.chartDescriptions.costTrend.summary).toContain('Peak') + expect(report.chartDescriptions.topModels.summary).toContain(normalizedLongModelName) + expect(report.chartDescriptions.topModels.fullNamesNote).toContain(normalizedLongModelName) + expect(report.chartDescriptions.tokenTrend.summary).toContain('Peak token volume') + }) + + it('omits the full-names note when top-model labels fit without truncation', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const report = buildReportData(dashboardFixture, { + viewMode: 'daily', + language: 'en', + }) + + expect(report.chartDescriptions.topModels.fullNamesNote).toBeNull() + }) })