From 6fbb848c83944965ffd5e0fb5b0091ba305e895e Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 14:15:22 +0200 Subject: [PATCH 1/4] Refine model color system --- server/report/utils.js | 16 +- shared/model-colors.d.ts | 17 ++ shared/model-colors.js | 241 ++++++++++++++++++++++ src/components/layout/FilterBar.tsx | 11 +- src/components/tables/ModelEfficiency.tsx | 9 +- src/components/tables/RecentDays.tsx | 5 +- src/lib/constants.ts | 13 -- src/lib/model-utils.ts | 37 ++-- tests/unit/model-colors.test.ts | 74 +++++++ 9 files changed, 370 insertions(+), 53 deletions(-) create mode 100644 shared/model-colors.d.ts create mode 100644 shared/model-colors.js create mode 100644 tests/unit/model-colors.test.ts diff --git a/server/report/utils.js b/server/report/utils.js index 14c545c..c366bbd 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -12,24 +12,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) { 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..60127fd --- /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/tests/unit/model-colors.test.ts b/tests/unit/model-colors.test.ts new file mode 100644 index 0000000..a8f9ef4 --- /dev/null +++ b/tests/unit/model-colors.test.ts @@ -0,0 +1,74 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { getModelColor, getModelColorAlpha } from '@/lib/model-utils' + +const require = createRequire(import.meta.url) +const { getModelColor: getSharedModelColor, getModelColorRgb } = + 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 + } + +const { getModelColor: getReportModelColor } = require('../../server/report/utils.js') as { + getModelColor: (name: string) => string +} + +describe('model colors', () => { + 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' }), + ) + }) +}) From 30da88d78d5e19ddd68a15479779b52ea7b56301 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 15:31:37 +0200 Subject: [PATCH 2/4] Improve PDF report quality --- server/report/charts.js | 4 +- server/report/index.js | 87 ++++++++++++++++++++++++++------ server/report/utils.js | 61 ++++++++++++++++++++++ src/locales/de/common.json | 10 +++- src/locales/en/common.json | 10 +++- tests/integration/server.test.ts | 6 +++ tests/unit/report-charts.test.ts | 11 +++- tests/unit/report-utils.test.ts | 31 ++++++++++++ 8 files changed, 200 insertions(+), 20 deletions(-) diff --git a/server/report/charts.js b/server/report/charts.js index d85ebd1..0aaaac1 100644 --- a/server/report/charts.js +++ b/server/report/charts.js @@ -136,7 +136,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 +158,7 @@ function horizontalBarChart( const value = getValue(entry); const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); return ` - ${escapeXml(truncateSvgLabel(getLabel(entry), 30))} + ${escapeXml(truncateSvgLabel(getLabel(entry), 34))} ${escapeXml(formatter(value))} diff --git a/server/report/index.js b/server/report/index.js index 477025a..dac3302 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,7 +331,7 @@ 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, @@ -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 c366bbd..b2efc7e 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -220,6 +220,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'; @@ -397,6 +407,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() @@ -454,6 +467,34 @@ function buildReportData(allDailyData, options = {}) { }, ]; + const topChartModels = modelRows.slice(0, 8); + const truncatedTopModelNames = topChartModels + .filter((entry) => entry.name.length > 30) + .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), @@ -518,6 +559,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)}` 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/report-charts.test.ts b/tests/unit/report-charts.test.ts index 83c785d..1a44f6e 100644 --- a/tests/unit/report-charts.test.ts +++ b/tests/unit/report-charts.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest' describe('report charts', () => { + 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') + }) + it('uses the provided formatter for stacked chart y-axis labels', async () => { const { stackedBarChart } = await import('../../server/report/charts.js') @@ -46,6 +55,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..8700e4c 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -175,4 +175,35 @@ 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') + }) }) From 4a70abda19206c82844130aff3e898ceca2744aa Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 15:32:57 +0200 Subject: [PATCH 3/4] Update changelog for 6.2.0 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 132279fff2ff03f2b6a817dcfa4902999d25b216 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 19:53:13 +0200 Subject: [PATCH 4/4] Address CodeRabbit review feedback --- server/report/chart-labels.js | 12 ++++++ server/report/charts.js | 10 ++--- server/report/index.js | 2 +- server/report/utils.js | 10 +---- shared/model-colors.js | 2 +- tests/unit/model-colors.test.ts | 71 ++++++++++++++++++++++++++++---- tests/unit/report-charts.test.ts | 55 +++++++++++++++++++++++++ tests/unit/report-utils.test.ts | 11 +++++ 8 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 server/report/chart-labels.js 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 0aaaac1..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, { @@ -158,7 +154,7 @@ function horizontalBarChart( const value = getValue(entry); const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); return ` - ${escapeXml(truncateSvgLabel(getLabel(entry), 34))} + ${escapeXml(truncateTopModelChartLabel(getLabel(entry)))} ${escapeXml(formatter(value))} diff --git a/server/report/index.js b/server/report/index.js index dac3302..442b7b3 100644 --- a/server/report/index.js +++ b/server/report/index.js @@ -338,7 +338,7 @@ function createChartAssets(reportData) { 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, diff --git a/server/report/utils.js b/server/report/utils.js index b2efc7e..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, @@ -290,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 = []; @@ -469,7 +464,7 @@ function buildReportData(allDailyData, options = {}) { const topChartModels = modelRows.slice(0, 8); const truncatedTopModelNames = topChartModels - .filter((entry) => entry.name.length > 30) + .filter((entry) => truncateTopModelChartLabel(entry.name) !== entry.name) .map((entry) => entry.name); const topModelSummary = metrics.topModel ? translate(language, 'report.charts.topModelsSummary', { @@ -667,7 +662,6 @@ module.exports = { formatDate, formatDateAxis, getModelColor, - truncateLabel, __test__: { getModelProvider, normalizeModelName, diff --git a/shared/model-colors.js b/shared/model-colors.js index 60127fd..83da52a 100644 --- a/shared/model-colors.js +++ b/shared/model-colors.js @@ -167,7 +167,7 @@ function fallbackColor(name, theme) { function getModelColorSpec(name, options = {}) { const theme = normalizeTheme(options.theme) const known = findKnownColor(name) - return known ? known[theme] : fallbackColor(name, theme) + return known ? { ...known[theme] } : fallbackColor(name, theme) } function toHslString(spec) { diff --git a/tests/unit/model-colors.test.ts b/tests/unit/model-colors.test.ts index a8f9ef4..319acbd 100644 --- a/tests/unit/model-colors.test.ts +++ b/tests/unit/model-colors.test.ts @@ -1,22 +1,44 @@ import { createRequire } from 'node:module' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { getModelColor, getModelColorAlpha } from '@/lib/model-utils' const require = createRequire(import.meta.url) -const { getModelColor: getSharedModelColor, getModelColorRgb } = - 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 - } +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%)') @@ -71,4 +93,35 @@ describe('model colors', () => { 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 1a44f6e..a4ea180 100644 --- a/tests/unit/report-charts.test.ts +++ b/tests/unit/report-charts.test.ts @@ -1,6 +1,18 @@ 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') @@ -8,6 +20,49 @@ describe('report charts', () => { 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 () => { diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index 8700e4c..b8eeaa2 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -206,4 +206,15 @@ describe('report utils', () => { 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() + }) })