@roastcodes/ttdash 6.1.4 → 6.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,15 @@
1
1
  const { version: APP_VERSION } = require('../../package.json');
2
2
  const { getLanguage, getLocale, translate } = require('./i18n');
3
+ const modelNormalizationSpec = require('../model-normalization.json');
4
+
5
+ const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({
6
+ ...alias,
7
+ matcher: new RegExp(alias.pattern, 'i'),
8
+ }));
9
+ const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({
10
+ ...matcher,
11
+ matcher: new RegExp(matcher.pattern, 'i'),
12
+ }));
3
13
 
4
14
  const MODEL_COLORS = {
5
15
  'Opus 4.6': 'rgb(175, 92, 224)',
@@ -10,8 +20,8 @@ const MODEL_COLORS = {
10
20
  'GPT-5.4': 'rgb(230, 98, 56)',
11
21
  'GPT-5': 'rgb(230, 98, 56)',
12
22
  'Gemini 3 Flash Preview': 'rgb(237, 188, 8)',
13
- 'Gemini': 'rgb(237, 188, 8)',
14
- 'OpenCode': 'rgb(51, 181, 193)',
23
+ Gemini: 'rgb(237, 188, 8)',
24
+ OpenCode: 'rgb(51, 181, 193)',
15
25
  };
16
26
 
17
27
  const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -23,58 +33,158 @@ function titleCaseSegment(segment) {
23
33
  return segment.charAt(0).toUpperCase() + segment.slice(1);
24
34
  }
25
35
 
26
- function normalizeModelName(raw) {
27
- const lower = String(raw || '').toLowerCase().trim();
28
- if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4';
29
- if (lower.includes('gpt-5')) return 'GPT-5';
30
- if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6';
31
- if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5';
32
- if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6';
33
- if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5';
34
- if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5';
35
- if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview';
36
- if (lower.includes('gemini')) return 'Gemini';
37
- if (lower.includes('opencode')) return 'OpenCode';
38
- if (lower.includes('haiku')) return 'Haiku';
39
-
40
- const stripped = String(raw || '')
36
+ function capitalize(segment) {
37
+ if (!segment) return '';
38
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
39
+ }
40
+
41
+ function formatVersion(version) {
42
+ return version.replace(/-/g, '.');
43
+ }
44
+
45
+ function canonicalizeModelName(raw) {
46
+ const normalized = String(raw || '')
41
47
  .trim()
42
- .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '')
43
- .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '')
48
+ .toLowerCase()
44
49
  .replace(/^model[:/ -]*/i, '')
50
+ .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '')
51
+ .replace(/\./g, '-')
45
52
  .replace(/[_/]+/g, '-')
46
53
  .replace(/\s+/g, '-')
47
54
  .replace(/-{2,}/g, '-')
48
55
  .replace(/^-|-$/g, '');
49
56
 
50
- const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i);
57
+ const suffixStart = normalized.lastIndexOf('-');
58
+ if (suffixStart > 0) {
59
+ const suffix = normalized.slice(suffixStart + 1);
60
+ if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) {
61
+ return normalized.slice(0, suffixStart);
62
+ }
63
+ }
64
+
65
+ return normalized;
66
+ }
67
+
68
+ function parseClaudeName(rest) {
69
+ const parts = rest.split('-', 2);
70
+ if (parts.length < 2) {
71
+ return `Claude ${capitalize(rest)}`;
72
+ }
73
+
74
+ return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim();
75
+ }
76
+
77
+ function parseGptName(rest) {
78
+ const parts = rest.split('-');
79
+ const variant = parts[0] || '';
80
+ const minor = parts[1] || '';
81
+
82
+ if (minor && minor.length <= 2 && /^\d+$/.test(minor)) {
83
+ const version = `${variant}.${minor}`;
84
+ if (parts.length > 2) {
85
+ const suffix = parts.slice(2).map(capitalize).join(' ');
86
+ return `GPT-${version}${suffix ? ` ${suffix}` : ''}`;
87
+ }
88
+ return `GPT-${version}`;
89
+ }
90
+
91
+ if (parts.length > 1) {
92
+ const suffix = parts.slice(1).map(capitalize).join(' ');
93
+ return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`;
94
+ }
95
+
96
+ return `GPT-${rest}`;
97
+ }
98
+
99
+ function parseGeminiName(rest) {
100
+ const parts = rest.split('-');
101
+ if (parts.length < 2) {
102
+ return `Gemini ${rest}`;
103
+ }
104
+
105
+ const versionParts = [];
106
+ const tierParts = [];
107
+
108
+ for (const part of parts) {
109
+ if (/^\d+$/.test(part) && tierParts.length === 0) {
110
+ versionParts.push(part);
111
+ } else {
112
+ tierParts.push(capitalize(part));
113
+ }
114
+ }
115
+
116
+ const version = versionParts.join('.');
117
+ const tier = tierParts.join(' ');
118
+
119
+ return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`;
120
+ }
121
+
122
+ function parseCodexName(rest) {
123
+ const normalized = rest.replace(/-latest$/i, '');
124
+ if (!normalized) {
125
+ return 'Codex';
126
+ }
127
+ return `Codex ${normalized.split('-').map(capitalize).join(' ')}`;
128
+ }
129
+
130
+ function parseOSeries(name) {
131
+ const separatorIndex = name.indexOf('-');
132
+ if (separatorIndex === -1) {
133
+ return name;
134
+ }
135
+ return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`;
136
+ }
137
+
138
+ function normalizeModelName(raw) {
139
+ const canonical = canonicalizeModelName(raw);
140
+
141
+ for (const alias of DISPLAY_ALIASES) {
142
+ if (alias.matcher.test(canonical)) return alias.name;
143
+ }
144
+
145
+ if (canonical.startsWith('claude-')) {
146
+ return parseClaudeName(canonical.slice('claude-'.length));
147
+ }
148
+
149
+ if (canonical.startsWith('gpt-')) {
150
+ return parseGptName(canonical.slice('gpt-'.length));
151
+ }
152
+
153
+ if (canonical.startsWith('gemini-')) {
154
+ return parseGeminiName(canonical.slice('gemini-'.length));
155
+ }
156
+
157
+ if (canonical.startsWith('codex-')) {
158
+ return parseCodexName(canonical.slice('codex-'.length));
159
+ }
160
+
161
+ if (/^o\d/i.test(canonical)) {
162
+ return parseOSeries(canonical);
163
+ }
164
+
165
+ const familyMatch = canonical.match(
166
+ /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i,
167
+ );
51
168
  if (familyMatch) {
52
169
  const family = familyMatch[1];
53
- const suffix = familyMatch[2] ? familyMatch[2].replace(/-/g, '.') : '';
170
+ if (/^codex$/i.test(family)) {
171
+ return parseCodexName(familyMatch[2] || '');
172
+ }
173
+ if (/^(o\d)$/i.test(family)) return parseOSeries(canonical);
174
+
175
+ const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '';
54
176
  if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`;
55
- if (/^(o\d)$/i.test(family)) return family.toUpperCase();
56
177
  return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim();
57
178
  }
58
179
 
59
- return stripped
60
- .split('-')
61
- .filter(Boolean)
62
- .map(titleCaseSegment)
63
- .join(' ') || String(raw || '');
180
+ return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '');
64
181
  }
65
182
 
66
183
  function getModelProvider(raw) {
67
- const lower = String(raw || '').toLowerCase();
68
- if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI';
69
- if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic';
70
- if (lower.includes('gemini')) return 'Google';
71
- if (lower.includes('grok') || lower.includes('xai')) return 'xAI';
72
- if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta';
73
- if (lower.includes('command') || lower.includes('cohere')) return 'Cohere';
74
- if (lower.includes('mistral')) return 'Mistral';
75
- if (lower.includes('deepseek')) return 'DeepSeek';
76
- if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba';
77
- if (lower.includes('opencode')) return 'OpenCode';
184
+ const canonical = canonicalizeModelName(raw);
185
+ for (const matcher of PROVIDER_MATCHERS) {
186
+ if (matcher.matcher.test(canonical)) return matcher.provider;
187
+ }
78
188
  return 'Other';
79
189
  }
80
190
 
@@ -127,7 +237,8 @@ function recalculateDayFromBreakdowns(day, modelBreakdowns) {
127
237
  thinkingTokens,
128
238
  totalCost,
129
239
  requestCount,
130
- totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens,
240
+ totalTokens:
241
+ inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens,
131
242
  modelsUsed: modelBreakdowns.map((item) => item.modelName),
132
243
  modelBreakdowns,
133
244
  };
@@ -138,8 +249,12 @@ function filterByProviders(data, selectedProviders) {
138
249
  const selected = new Set(selectedProviders);
139
250
  return data
140
251
  .map((day) => {
141
- const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(getModelProvider(entry.modelName)));
142
- return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null;
252
+ const filteredBreakdowns = day.modelBreakdowns.filter((entry) =>
253
+ selected.has(getModelProvider(entry.modelName)),
254
+ );
255
+ return filteredBreakdowns.length > 0
256
+ ? recalculateDayFromBreakdowns(day, filteredBreakdowns)
257
+ : null;
143
258
  })
144
259
  .filter(Boolean);
145
260
  }
@@ -149,17 +264,19 @@ function filterByModels(data, selectedModels) {
149
264
  const selected = new Set(selectedModels);
150
265
  return data
151
266
  .map((day) => {
152
- const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(normalizeModelName(entry.modelName)));
153
- return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null;
267
+ const filteredBreakdowns = day.modelBreakdowns.filter((entry) =>
268
+ selected.has(normalizeModelName(entry.modelName)),
269
+ );
270
+ return filteredBreakdowns.length > 0
271
+ ? recalculateDayFromBreakdowns(day, filteredBreakdowns)
272
+ : null;
154
273
  })
155
274
  .filter(Boolean);
156
275
  }
157
276
 
158
277
  function aggregateToDailyFormat(data, viewMode) {
159
278
  if (viewMode === 'daily') return data;
160
- const groupKey = viewMode === 'monthly'
161
- ? (date) => date.slice(0, 7)
162
- : (date) => date.slice(0, 4);
279
+ const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4);
163
280
  const groups = new Map();
164
281
 
165
282
  for (const day of data) {
@@ -239,7 +356,8 @@ function toWeekdayData(data) {
239
356
  }
240
357
  return WEEKDAYS.map((label, index) => {
241
358
  const values = weekdayCosts[index];
242
- const average = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
359
+ const average =
360
+ values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
243
361
  return { day: label, cost: average };
244
362
  });
245
363
  }
@@ -338,7 +456,8 @@ function computeMetrics(data) {
338
456
  totalCacheCreate += day.cacheCreationTokens;
339
457
  totalThinking += day.thinkingTokens;
340
458
  activeDays += day._aggregatedDays || 1;
341
- if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) hasRequestData = true;
459
+ if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0))
460
+ hasRequestData = true;
342
461
  if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost };
343
462
  if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost };
344
463
 
@@ -416,7 +535,12 @@ function computeModelRows(data) {
416
535
  _dates: new Set(),
417
536
  };
418
537
  current.cost += breakdown.cost;
419
- current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens;
538
+ current.tokens +=
539
+ breakdown.inputTokens +
540
+ breakdown.outputTokens +
541
+ breakdown.cacheCreationTokens +
542
+ breakdown.cacheReadTokens +
543
+ breakdown.thinkingTokens;
420
544
  current.requests += breakdown.requestCount;
421
545
  if (!current._dates.has(day.date)) {
422
546
  current._dates.add(day.date);
@@ -454,7 +578,12 @@ function computeProviderRows(data) {
454
578
  _dates: new Set(),
455
579
  };
456
580
  current.cost += breakdown.cost;
457
- current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens;
581
+ current.tokens +=
582
+ breakdown.inputTokens +
583
+ breakdown.outputTokens +
584
+ breakdown.cacheCreationTokens +
585
+ breakdown.cacheReadTokens +
586
+ breakdown.thinkingTokens;
458
587
  current.requests += breakdown.requestCount;
459
588
  if (!current._dates.has(day.date)) {
460
589
  current._dates.add(day.date);
@@ -464,7 +593,13 @@ function computeProviderRows(data) {
464
593
  }
465
594
  }
466
595
  return Array.from(rows.values())
467
- .map(({ _dates, ...entry }) => entry)
596
+ .map((entry) => ({
597
+ name: entry.name,
598
+ cost: entry.cost,
599
+ tokens: entry.tokens,
600
+ requests: entry.requests,
601
+ days: entry.days,
602
+ }))
468
603
  .sort((a, b) => b.cost - a.cost);
469
604
  }
470
605
 
@@ -491,7 +626,12 @@ function formatDate(dateStr, mode = 'short', language = 'de') {
491
626
  }
492
627
  const date = new Date(`${dateStr}T00:00:00`);
493
628
  if (mode === 'long') {
494
- return date.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
629
+ return date.toLocaleDateString(locale, {
630
+ weekday: 'short',
631
+ day: '2-digit',
632
+ month: '2-digit',
633
+ year: 'numeric',
634
+ });
495
635
  }
496
636
  return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
497
637
  }
@@ -504,7 +644,10 @@ function formatDateAxis(dateStr, language = 'de') {
504
644
  const date = new Date(Number(year), Number(month) - 1);
505
645
  return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
506
646
  }
507
- return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
647
+ return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, {
648
+ day: '2-digit',
649
+ month: '2-digit',
650
+ });
508
651
  }
509
652
 
510
653
  function formatFilterValue(value, language = 'de') {
@@ -527,16 +670,127 @@ function formatInteger(value, language = 'de') {
527
670
  return Math.round(value || 0).toLocaleString(getLocale(language));
528
671
  }
529
672
 
530
- function formatCompact(value, language = 'de') {
673
+ function formatPercent(value, language = 'de') {
674
+ return `${(value || 0).toLocaleString(getLocale(language), {
675
+ minimumFractionDigits: 1,
676
+ maximumFractionDigits: 1,
677
+ })}%`;
678
+ }
679
+
680
+ function formatCompactNumber(value, language = 'de') {
531
681
  if (!Number.isFinite(value)) return '0';
532
- if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
533
- if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
534
- if (value >= 1e3) return `${(value / 1e3).toFixed(1)}k`;
682
+
683
+ const abs = Math.abs(value);
684
+ const locale = getLocale(language);
685
+
686
+ if (abs >= 1e9) {
687
+ const suffix = language === 'en' ? 'B' : ' Mrd.';
688
+ return `${(value / 1e9).toLocaleString(locale, {
689
+ minimumFractionDigits: 1,
690
+ maximumFractionDigits: 1,
691
+ })}${suffix}`;
692
+ }
693
+
694
+ if (abs >= 1e6) {
695
+ const suffix = language === 'en' ? 'M' : ' Mio.';
696
+ return `${(value / 1e6).toLocaleString(locale, {
697
+ minimumFractionDigits: 1,
698
+ maximumFractionDigits: 1,
699
+ })}${suffix}`;
700
+ }
701
+
702
+ if (abs >= 1e3) {
703
+ const suffix = language === 'en' ? 'k' : ' Tsd.';
704
+ return `${(value / 1e3).toLocaleString(locale, {
705
+ minimumFractionDigits: 1,
706
+ maximumFractionDigits: 1,
707
+ })}${suffix}`;
708
+ }
709
+
535
710
  return formatInteger(value, language);
536
711
  }
537
712
 
538
- function formatPercent(value) {
539
- return `${(value || 0).toFixed(1)}%`;
713
+ function formatCompact(value, language = 'de') {
714
+ return formatCompactNumber(value, language);
715
+ }
716
+
717
+ function formatCompactAxis(value, language = 'de') {
718
+ return formatCompactNumber(value, language);
719
+ }
720
+
721
+ function summarizeSelection(
722
+ values,
723
+ language,
724
+ { emptyKey, maxVisible = 3, normalize = (value) => value } = {},
725
+ ) {
726
+ const normalized = (values || []).map(normalize).filter(Boolean);
727
+
728
+ if (normalized.length === 0) {
729
+ return translate(language, emptyKey);
730
+ }
731
+
732
+ const visible = normalized.slice(0, maxVisible);
733
+ const hidden = normalized.length - visible.length;
734
+ const suffix =
735
+ hidden > 0 ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` : '';
736
+
737
+ return `${visible.join(', ')}${suffix}`;
738
+ }
739
+
740
+ function truncateLabel(value, maxLength = 28) {
741
+ const stringValue = String(value || '');
742
+ if (stringValue.length <= maxLength) return stringValue;
743
+ return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
744
+ }
745
+
746
+ function buildInsights(metrics, { filteredDaily, filtered, language }) {
747
+ const insights = [];
748
+
749
+ if (filteredDaily.length > 0 && filteredDaily.length < 7) {
750
+ insights.push({
751
+ tone: 'warn',
752
+ title: translate(language, 'report.insights.coverageTitle'),
753
+ body: translate(language, 'report.insights.coverageBody', {
754
+ days: formatInteger(filteredDaily.length, language),
755
+ periods: formatInteger(filtered.length, language),
756
+ }),
757
+ });
758
+ }
759
+
760
+ if (metrics.topProvider) {
761
+ insights.push({
762
+ tone: metrics.topProvider.share >= 60 ? 'warn' : 'accent',
763
+ title: translate(language, 'report.insights.providerTitle'),
764
+ body: translate(language, 'report.insights.providerBody', {
765
+ provider: metrics.topProvider.name,
766
+ share: formatPercent(metrics.topProvider.share, language),
767
+ }),
768
+ });
769
+ }
770
+
771
+ if (metrics.cacheHitRate > 0) {
772
+ insights.push({
773
+ tone: metrics.cacheHitRate >= 20 ? 'good' : 'accent',
774
+ title: translate(language, 'report.insights.cacheTitle'),
775
+ body: translate(language, 'report.insights.cacheBody', {
776
+ share: formatPercent(metrics.cacheHitRate, language),
777
+ }),
778
+ });
779
+ }
780
+
781
+ if (metrics.busiestWeek) {
782
+ insights.push({
783
+ tone: 'accent',
784
+ title: translate(language, 'report.insights.peakWindowTitle'),
785
+ body: translate(language, 'report.insights.peakWindowBody', {
786
+ start: formatDate(metrics.busiestWeek.start, 'long', language),
787
+ end: formatDate(metrics.busiestWeek.end, 'long', language),
788
+ cost: formatCurrency(metrics.busiestWeek.cost, language),
789
+ }),
790
+ });
791
+ }
792
+
793
+ return insights.slice(0, 4);
540
794
  }
541
795
 
542
796
  function periodUnit(viewMode, language = 'de') {
@@ -547,7 +801,10 @@ function periodUnit(viewMode, language = 'de') {
547
801
 
548
802
  function applyReportFilters(allDailyData, filters) {
549
803
  const sorted = sortByDate(allDailyData);
550
- const preProvider = filterByMonth(filterByDateRange(sorted, filters.startDate, filters.endDate), filters.selectedMonth);
804
+ const preProvider = filterByMonth(
805
+ filterByDateRange(sorted, filters.startDate, filters.endDate),
806
+ filters.selectedMonth,
807
+ );
551
808
  const preModel = filterByProviders(preProvider, filters.selectedProviders || []);
552
809
  const filteredDaily = filterByModels(preModel, filters.selectedModels || []);
553
810
  const filtered = aggregateToDailyFormat(filteredDaily, filters.viewMode || 'daily');
@@ -573,26 +830,99 @@ function buildReportData(allDailyData, options = {}) {
573
830
  const metrics = computeMetrics(filtered);
574
831
  const modelRows = computeModelRows(filtered).slice(0, 12);
575
832
  const providerRows = computeProviderRows(filtered).slice(0, 8);
576
- const recentRows = sortByDate(filtered).slice(-12).reverse().map((entry) => ({
577
- period: entry.date,
578
- label: formatDate(entry.date, 'long', language),
579
- cost: entry.totalCost,
580
- costLabel: formatCurrency(entry.totalCost, language),
581
- tokens: entry.totalTokens,
582
- tokensLabel: formatCompact(entry.totalTokens, language),
583
- requests: entry.requestCount,
584
- requestsLabel: formatInteger(entry.requestCount, language),
585
- }));
833
+ const periodLabel = periodUnit(filters.viewMode, language);
834
+ const notAvailable = translate(language, 'report.common.notAvailable');
835
+ const selectedProvidersLabel = summarizeSelection(filters.selectedProviders, language, {
836
+ emptyKey: 'report.filters.all',
837
+ });
838
+ const selectedModelsLabel = summarizeSelection(filters.selectedModels, language, {
839
+ emptyKey: 'report.filters.all',
840
+ normalize: normalizeModelName,
841
+ });
842
+ const monthLabel =
843
+ formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all');
844
+ const startDateLabel =
845
+ formatFilterValue(filters.startDate || null, language) ||
846
+ translate(language, 'report.filters.noFilter');
847
+ const endDateLabel =
848
+ formatFilterValue(filters.endDate || null, language) ||
849
+ translate(language, 'report.filters.noFilter');
850
+ const peakPeriodLabel = metrics.topDay
851
+ ? formatDate(metrics.topDay.date, 'long', language)
852
+ : notAvailable;
853
+ const topModelValue = metrics.topModel ? metrics.topModel.name : notAvailable;
854
+ const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable;
855
+ const insights = buildInsights(metrics, { filteredDaily, filtered, language });
856
+ const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0;
857
+ const recentRows = sortByDate(filtered)
858
+ .slice(-12)
859
+ .reverse()
860
+ .map((entry) => ({
861
+ period: entry.date,
862
+ label: formatDate(entry.date, 'long', language),
863
+ cost: entry.totalCost,
864
+ costLabel: formatCurrency(entry.totalCost, language),
865
+ tokens: entry.totalTokens,
866
+ tokensLabel: formatCompact(entry.totalTokens, language),
867
+ requests: entry.requestCount,
868
+ requestsLabel: formatInteger(entry.requestCount, language),
869
+ }));
586
870
 
587
871
  const summaryCards = [
588
- { label: translate(language, 'common.costs'), value: formatCurrency(metrics.totalCost, language), note: metrics.topProvider ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share)}` : 'n/a', tone: 'accent' },
589
- { label: translate(language, 'common.tokens'), value: formatCompact(metrics.totalTokens, language), note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, tone: 'accent' },
590
- { label: translate(language, 'common.requests'), value: formatInteger(metrics.totalRequests, language), note: metrics.hasRequestData ? `${formatPercent(metrics.cacheHitRate)} Cache` : 'n/a', tone: 'good' },
591
- { label: `Ø ${translate(language, 'common.cost')} / ${periodUnit(filters.viewMode, language)}`, value: formatCurrency(metrics.avgDailyCost, language), note: `${reportDataLabel(filters.viewMode, language)}`, tone: 'accent' },
592
- { label: translate(language, 'common.model'), value: metrics.topModel ? metrics.topModel.name : 'n/a', note: metrics.topModel ? formatPercent(metrics.topModelShare) : 'n/a', tone: 'warn' },
593
- { label: translate(language, 'common.dateRange'), value: metrics.topDay ? metrics.topDay.date : 'n/a', note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : 'n/a', tone: 'warn' },
872
+ {
873
+ label: translate(language, 'common.costs'),
874
+ value: formatCurrency(metrics.totalCost, language),
875
+ note: metrics.topProvider
876
+ ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}`
877
+ : notAvailable,
878
+ tone: 'accent',
879
+ },
880
+ {
881
+ label: translate(language, 'common.tokens'),
882
+ value: formatCompact(metrics.totalTokens, language),
883
+ note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`,
884
+ tone: 'accent',
885
+ },
886
+ {
887
+ label: translate(language, 'common.requests'),
888
+ value: formatInteger(metrics.totalRequests, language),
889
+ note: metrics.hasRequestData
890
+ ? `${formatPercent(metrics.cacheHitRate, language)} Cache`
891
+ : notAvailable,
892
+ tone: 'good',
893
+ },
894
+ {
895
+ label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`,
896
+ value: formatCurrency(avgPeriodCost, language),
897
+ note: `${reportDataLabel(filters.viewMode, language)}`,
898
+ tone: 'accent',
899
+ },
900
+ {
901
+ label: translate(language, 'common.model'),
902
+ value: topModelValue,
903
+ note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable,
904
+ tone: 'warn',
905
+ },
906
+ {
907
+ label: translate(language, 'report.summary.peakPeriod'),
908
+ value: peakPeriodLabel,
909
+ note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable,
910
+ tone: 'warn',
911
+ },
594
912
  ];
595
913
 
914
+ const interpretationSummary = translate(language, 'report.interpretation.summary', {
915
+ days: formatInteger(filteredDaily.length, language),
916
+ periods: formatInteger(filtered.length, language),
917
+ peak: peakPeriodLabel,
918
+ topModel: topModelValue,
919
+ topProvider: topProviderValue,
920
+ });
921
+
922
+ const interpretationFooter = translate(language, 'report.interpretation.footer', {
923
+ version: APP_VERSION,
924
+ });
925
+
596
926
  return {
597
927
  meta: {
598
928
  language,
@@ -607,20 +937,23 @@ function buildReportData(allDailyData, options = {}) {
607
937
  }),
608
938
  reportTitle: options.reportTitle || translate(language, 'report.title'),
609
939
  filterSummary: {
940
+ viewModeKey: filters.viewMode,
610
941
  viewMode: translate(language, `viewModes.${filters.viewMode}`),
611
942
  selectedMonth: filters.selectedMonth,
612
- selectedMonthLabel: formatFilterValue(filters.selectedMonth, language),
943
+ selectedMonthLabel: monthLabel,
613
944
  selectedProviders: filters.selectedProviders,
945
+ selectedProvidersLabel,
614
946
  selectedModels: filters.selectedModels,
947
+ selectedModelsLabel,
615
948
  startDate: filters.startDate || null,
616
- startDateLabel: formatFilterValue(filters.startDate || null, language),
949
+ startDateLabel,
617
950
  endDate: filters.endDate || null,
618
- endDateLabel: formatFilterValue(filters.endDate || null, language),
951
+ endDateLabel,
619
952
  },
620
953
  dateRange,
621
954
  periods: filtered.length,
622
955
  days: filteredDaily.length,
623
- periodUnit: periodUnit(filters.viewMode, language),
956
+ periodUnit: periodLabel,
624
957
  },
625
958
  metrics,
626
959
  summaryCards,
@@ -643,13 +976,69 @@ function buildReportData(allDailyData, options = {}) {
643
976
  })),
644
977
  recentPeriods: recentRows,
645
978
  labels: {
646
- dateRangeText: dateRange ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` : translate(language, 'common.noData'),
647
- topModel: metrics.topModel ? `${metrics.topModel.name} (${metrics.topModelShare.toFixed(1)}%)` : 'n/a',
648
- topProvider: metrics.topProvider ? `${metrics.topProvider.name} (${metrics.topProvider.share.toFixed(1)}%)` : 'n/a',
649
- topDay: metrics.topDay ? `${formatDate(metrics.topDay.date, 'long', language)} (${metrics.topDay.cost.toFixed(2)} USD)` : 'n/a',
979
+ dateRangeText: dateRange
980
+ ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}`
981
+ : translate(language, 'common.noData'),
982
+ topModel: metrics.topModel
983
+ ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})`
984
+ : notAvailable,
985
+ topProvider: metrics.topProvider
986
+ ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})`
987
+ : notAvailable,
988
+ topDay: metrics.topDay
989
+ ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})`
990
+ : notAvailable,
991
+ },
992
+ interpretation: {
993
+ summary: interpretationSummary,
994
+ footer: interpretationFooter,
995
+ },
996
+ insights: {
997
+ items: insights,
998
+ },
999
+ text: {
1000
+ headerEyebrow: translate(language, 'report.header.eyebrow'),
1001
+ sections: {
1002
+ overview: translate(language, 'report.sections.overview'),
1003
+ insights: translate(language, 'report.sections.insights'),
1004
+ filters: translate(language, 'report.sections.filters'),
1005
+ modelsProviders: translate(language, 'report.sections.modelsProviders'),
1006
+ recentPeriods: translate(language, 'report.sections.recentPeriods'),
1007
+ interpretation: translate(language, 'report.sections.interpretation'),
1008
+ },
1009
+ fields: {
1010
+ dateRange: translate(language, 'report.fields.dateRange'),
1011
+ view: translate(language, 'report.fields.view'),
1012
+ generated: translate(language, 'report.fields.generated'),
1013
+ month: translate(language, 'report.fields.month'),
1014
+ selectedProviders: translate(language, 'report.fields.selectedProviders'),
1015
+ selectedModels: translate(language, 'report.fields.selectedModels'),
1016
+ startDate: translate(language, 'report.fields.startDate'),
1017
+ endDate: translate(language, 'report.fields.endDate'),
1018
+ },
1019
+ tables: {
1020
+ topModels: translate(language, 'report.tables.topModels'),
1021
+ providers: translate(language, 'report.tables.providers'),
1022
+ columns: {
1023
+ model: translate(language, 'report.tables.columns.model'),
1024
+ provider: translate(language, 'report.tables.columns.provider'),
1025
+ cost: translate(language, 'report.tables.columns.cost'),
1026
+ tokens: translate(language, 'report.tables.columns.tokens'),
1027
+ requests: translate(language, 'report.tables.columns.requests'),
1028
+ period: translate(language, 'report.tables.columns.period'),
1029
+ },
1030
+ },
1031
+ charts: {
1032
+ costTrend: translate(language, 'report.charts.costTrend'),
1033
+ topModels: translate(language, 'report.charts.topModels'),
1034
+ tokenTrend: translate(language, 'report.charts.tokenTrend'),
1035
+ },
650
1036
  },
651
1037
  formatting: {
652
- axisDates: filtered.map((entry) => ({ date: entry.date, label: formatDateAxis(entry.date, language) })),
1038
+ axisDates: filtered.map((entry) => ({
1039
+ date: entry.date,
1040
+ label: formatDateAxis(entry.date, language),
1041
+ })),
653
1042
  },
654
1043
  };
655
1044
  }
@@ -668,7 +1057,15 @@ function reportDataLabel(viewMode, language = 'de') {
668
1057
  module.exports = {
669
1058
  applyReportFilters,
670
1059
  buildReportData,
1060
+ formatCompact,
1061
+ formatCompactAxis,
1062
+ formatCurrency,
671
1063
  formatDate,
672
1064
  formatDateAxis,
673
1065
  getModelColor,
1066
+ truncateLabel,
1067
+ __test__: {
1068
+ getModelProvider,
1069
+ normalizeModelName,
1070
+ },
674
1071
  };