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