@roastcodes/ttdash 6.1.4 → 6.1.5

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.
package/dist/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
9
9
  <link rel="shortcut icon" href="/favicon.png" />
10
10
  <link rel="apple-touch-icon" href="/favicon.png" />
11
- <script type="module" crossorigin src="/assets/index-9cPAel40.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-_318nw_j.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-COnpUsM8.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/charts-vendor-CiBqdKXh.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-0R1rd57Z.js">
@@ -17,8 +17,8 @@
17
17
  <link rel="modulepreload" crossorigin href="/assets/icons-vendor-DFoaijFJ.js">
18
18
  <link rel="modulepreload" crossorigin href="/assets/dialog-Cn1m7WhC.js">
19
19
  <link rel="modulepreload" crossorigin href="/assets/button-D7Ib8H7t.js">
20
- <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-YeXs5zcp.js">
21
- <link rel="stylesheet" crossorigin href="/assets/index-DWoj-vpZ.css">
20
+ <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-Be-rHcDB.js">
21
+ <link rel="stylesheet" crossorigin href="/assets/index-TppJ6Iqj.css">
22
22
  </head>
23
23
  <body>
24
24
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roastcodes/ttdash",
3
- "version": "6.1.4",
3
+ "version": "6.1.5",
4
4
  "description": "Local-first dashboard and CLI for toktrack usage data",
5
5
  "main": "server.js",
6
6
  "repository": {
@@ -17,7 +17,15 @@ ${body}
17
17
  </svg>`;
18
18
  }
19
19
 
20
- function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fill = 'rgba(31, 111, 235, 0.14)', formatter = (value) => String(value) }) {
20
+ const DEFAULT_FONT_FAMILY = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
21
+
22
+ function truncateSvgLabel(value, maxLength = 28) {
23
+ const stringValue = String(value || '');
24
+ if (stringValue.length <= maxLength) return stringValue;
25
+ return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
26
+ }
27
+
28
+ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fill = 'rgba(31, 111, 235, 0.14)', formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) {
21
29
  const width = 980;
22
30
  const height = 360;
23
31
  const margin = { top: 42, right: 28, bottom: 54, left: 74 };
@@ -53,10 +61,10 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi
53
61
 
54
62
  return svgDoc(width, height, `
55
63
  <rect width="${width}" height="${height}" rx="24" fill="#ffffff"/>
56
- <text x="${margin.left}" y="26" font-size="18" font-family="Arial, sans-serif" font-weight="700" fill="#122033">${escapeXml(title)}</text>
64
+ <text x="${margin.left}" y="26" font-size="18" font-family="${fontFamily}" font-weight="700" fill="#122033">${escapeXml(title)}</text>
57
65
  ${yTicks.map((tick) => `
58
66
  <line x1="${margin.left}" y1="${tick.y}" x2="${margin.left + plotWidth}" y2="${tick.y}" stroke="#e6edf5" stroke-width="1"/>
59
- <text x="${margin.left - 12}" y="${tick.y + 4}" text-anchor="end" font-size="11" font-family="Arial, sans-serif" fill="#5c6b7e">${escapeXml(formatter(tick.value))}</text>
67
+ <text x="${margin.left - 12}" y="${tick.y + 4}" text-anchor="end" font-size="11" font-family="${fontFamily}" fill="#5c6b7e">${escapeXml(formatter(tick.value))}</text>
60
68
  `).join('')}
61
69
  <line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotHeight}" stroke="#98a6b7" stroke-width="1.2"/>
62
70
  <line x1="${margin.left}" y1="${margin.top + plotHeight}" x2="${margin.left + plotWidth}" y2="${margin.top + plotHeight}" stroke="#98a6b7" stroke-width="1.2"/>
@@ -69,15 +77,21 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi
69
77
  <circle cx="${x(index)}" cy="${y(value)}" r="${data.length > 40 ? 0 : 3.8}" fill="${stroke}"/>
70
78
  `).join('')}
71
79
  ${data.map((entry, index) => index % labelStep === 0 || index === data.length - 1 ? `
72
- <text x="${x(index)}" y="${height - 18}" text-anchor="middle" font-size="11" font-family="Arial, sans-serif" fill="#5c6b7e">${escapeXml(entry.label)}</text>
80
+ <text x="${x(index)}" y="${height - 18}" text-anchor="middle" font-size="11" font-family="${fontFamily}" fill="#5c6b7e">${escapeXml(entry.label)}</text>
73
81
  ` : '').join('')}
74
82
  `);
75
83
  }
76
84
 
77
- function horizontalBarChart(data, { title, formatter = (value) => String(value), getValue, getLabel, getColor }) {
85
+ function horizontalBarChart(data, { title, formatter = (value) => String(value), getValue, getLabel, getColor, fontFamily = DEFAULT_FONT_FAMILY }) {
78
86
  const width = 980;
79
87
  const height = 360;
80
- const margin = { top: 46, right: 100, bottom: 24, left: 220 };
88
+ const longestLabelLength = data.reduce((max, entry) => Math.max(max, String(getLabel(entry) || '').length), 0);
89
+ const margin = {
90
+ top: 46,
91
+ right: 100,
92
+ bottom: 24,
93
+ left: clamp(180 + longestLabelLength * 3.4, 220, 320),
94
+ };
81
95
  const plotWidth = width - margin.left - margin.right;
82
96
  const barGap = 18;
83
97
  const barHeight = Math.min(28, (height - margin.top - margin.bottom - barGap * (data.length - 1)) / Math.max(data.length, 1));
@@ -85,22 +99,22 @@ function horizontalBarChart(data, { title, formatter = (value) => String(value),
85
99
 
86
100
  return svgDoc(width, height, `
87
101
  <rect width="${width}" height="${height}" rx="24" fill="#ffffff"/>
88
- <text x="${margin.left}" y="28" font-size="18" font-family="Arial, sans-serif" font-weight="700" fill="#122033">${escapeXml(title)}</text>
102
+ <text x="${margin.left}" y="28" font-size="18" font-family="${fontFamily}" font-weight="700" fill="#122033">${escapeXml(title)}</text>
89
103
  ${data.map((entry, index) => {
90
104
  const y = margin.top + index * (barHeight + barGap);
91
105
  const value = getValue(entry);
92
106
  const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth);
93
107
  return `
94
- <text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="Arial, sans-serif" fill="#122033">${escapeXml(getLabel(entry))}</text>
108
+ <text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(truncateSvgLabel(getLabel(entry), 30))}</text>
95
109
  <rect x="${margin.left}" y="${y}" width="${plotWidth}" height="${barHeight}" rx="12" fill="#eef3f8"/>
96
110
  <rect x="${margin.left}" y="${y}" width="${barWidth}" height="${barHeight}" rx="12" fill="${getColor(entry)}"/>
97
- <text x="${margin.left + plotWidth + 12}" y="${y + barHeight / 2 + 4}" font-size="12" font-family="Arial, sans-serif" fill="#475569">${escapeXml(formatter(value))}</text>
111
+ <text x="${margin.left + plotWidth + 12}" y="${y + barHeight / 2 + 4}" font-size="12" font-family="${fontFamily}" fill="#475569">${escapeXml(formatter(value))}</text>
98
112
  `;
99
113
  }).join('')}
100
114
  `);
101
115
  }
102
116
 
103
- function stackedBarChart(data, { title, segments }) {
117
+ function stackedBarChart(data, { title, segments, formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) {
104
118
  const width = 980;
105
119
  const height = 380;
106
120
  const margin = { top: 52, right: 30, bottom: 56, left: 74 };
@@ -114,13 +128,13 @@ function stackedBarChart(data, { title, segments }) {
114
128
 
115
129
  return svgDoc(width, height, `
116
130
  <rect width="${width}" height="${height}" rx="24" fill="#ffffff"/>
117
- <text x="${margin.left}" y="30" font-size="18" font-family="Arial, sans-serif" font-weight="700" fill="#122033">${escapeXml(title)}</text>
131
+ <text x="${margin.left}" y="30" font-size="18" font-family="${fontFamily}" font-weight="700" fill="#122033">${escapeXml(title)}</text>
118
132
  ${Array.from({ length: 5 }, (_, index) => {
119
133
  const value = (maxValue / 4) * index;
120
134
  const y = margin.top + plotHeight - (value / maxValue) * plotHeight;
121
135
  return `
122
136
  <line x1="${margin.left}" y1="${y}" x2="${margin.left + plotWidth}" y2="${y}" stroke="#e6edf5" stroke-width="1"/>
123
- <text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" font-size="11" font-family="Arial, sans-serif" fill="#5c6b7e">${escapeXml(Math.round(value / 1000).toLocaleString('de-CH'))}k</text>
137
+ <text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" font-size="11" font-family="${fontFamily}" fill="#5c6b7e">${escapeXml(formatter(value))}</text>
124
138
  `;
125
139
  }).join('')}
126
140
  ${data.map((entry, index) => {
@@ -134,13 +148,13 @@ function stackedBarChart(data, { title, segments }) {
134
148
  return `<rect x="${x}" y="${y}" width="${barWidth}" height="${h}" rx="6" fill="${segment.color}"/>`;
135
149
  }).join('');
136
150
  const label = index % labelStep === 0 || index === data.length - 1
137
- ? `<text x="${x + barWidth / 2}" y="${height - 18}" text-anchor="middle" font-size="11" font-family="Arial, sans-serif" fill="#5c6b7e">${escapeXml(entry.label)}</text>`
151
+ ? `<text x="${x + barWidth / 2}" y="${height - 18}" text-anchor="middle" font-size="11" font-family="${fontFamily}" fill="#5c6b7e">${escapeXml(entry.label)}</text>`
138
152
  : '';
139
153
  return `${rects}${label}`;
140
154
  }).join('')}
141
155
  ${segments.map((segment, index) => `
142
- <rect x="${margin.left + index * 156}" y="${height - 34}" width="12" height="12" rx="3" fill="${segment.color}"/>
143
- <text x="${margin.left + 18 + index * 156}" y="${height - 24}" font-size="11" font-family="Arial, sans-serif" fill="#334155">${escapeXml(segment.label)}</text>
156
+ <rect x="${margin.left + (index % 3) * 156}" y="${height - 34 - Math.floor(index / 3) * 18}" width="12" height="12" rx="3" fill="${segment.color}"/>
157
+ <text x="${margin.left + 18 + (index % 3) * 156}" y="${height - 24 - Math.floor(index / 3) * 18}" font-size="11" font-family="${fontFamily}" fill="#334155">${escapeXml(segment.label)}</text>
144
158
  `).join('')}
145
159
  `);
146
160
  }
@@ -2,7 +2,8 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
5
- const { buildReportData, formatDateAxis } = require('./utils');
5
+ const { buildReportData, formatCompactAxis, formatDateAxis } = require('./utils');
6
+ const { translate } = require('./i18n');
6
7
  const { horizontalBarChart, lineChart, stackedBarChart } = require('./charts');
7
8
 
8
9
  function ensureTypstInstalled() {
@@ -33,7 +34,7 @@ function compileTypst(workingDir, typPath, pdfPath) {
33
34
  resolve();
34
35
  return;
35
36
  }
36
- reject(new Error(stderr.trim() || 'Typst compilation failed.'));
37
+ reject(new Error(`Typst compilation failed: ${stderr.trim() || 'unknown error'}`));
37
38
  });
38
39
  });
39
40
  }
@@ -48,7 +49,7 @@ function buildTemplate() {
48
49
  fill: rgb("#f3f6f9"),
49
50
  )
50
51
 
51
- #set text(font: "Arial", lang: if report.meta.language == "en" { "en" } else { "de" })
52
+ #set text(font: ("Liberation Sans", "DejaVu Sans", "Arial"), lang: if report.meta.language == "en" { "en" } else { "de" })
52
53
  #set par(justify: false, leading: 0.55em)
53
54
 
54
55
  #let ink = rgb("#102132")
@@ -76,6 +77,18 @@ function buildTemplate() {
76
77
  ],
77
78
  )
78
79
 
80
+ #let insight-card(title, body, tone: accent) = rect(
81
+ inset: 11pt,
82
+ radius: 14pt,
83
+ fill: panel,
84
+ stroke: (paint: line, thickness: 0.8pt),
85
+ [
86
+ #text(size: 9pt, fill: tone, weight: "bold")[#title]
87
+ #v(4pt)
88
+ #text(size: 9.6pt, fill: ink)[#body]
89
+ ],
90
+ )
91
+
79
92
  #show heading.where(level: 1): it => block(above: 0pt, below: 10pt)[
80
93
  #text(size: 24pt, fill: ink, weight: "bold")[#it.body]
81
94
  ]
@@ -91,7 +104,7 @@ function buildTemplate() {
91
104
 
92
105
  #box(fill: rgb("#f0f6ff"), inset: 16pt, radius: 18pt, width: 100%)[
93
106
  #align(left)[
94
- #text(size: 9pt, fill: accent, weight: "bold")[TTDash PDF Report]
107
+ #text(size: 9pt, fill: accent, weight: "bold")[#report.text.headerEyebrow]
95
108
  #v(6pt)
96
109
  #text(size: 26pt, fill: ink, weight: "bold")[#report.meta.reportTitle]
97
110
  #v(4pt)
@@ -100,9 +113,9 @@ function buildTemplate() {
100
113
  #grid(
101
114
  columns: (1fr, 1fr, 1fr),
102
115
  gutter: 8pt,
103
- metric-card(if report.meta.language == "en" { "Date range" } else { "Zeitraum" }, report.labels.dateRangeText),
104
- metric-card(if report.meta.language == "en" { "View" } else { "Ansicht" }, report.meta.filterSummary.viewMode),
105
- metric-card(if report.meta.language == "en" { "Generated" } else { "Generiert" }, report.meta.generatedAtLabel),
116
+ metric-card(report.text.fields.dateRange, report.labels.dateRangeText),
117
+ metric-card(report.text.fields.view, report.meta.filterSummary.viewMode),
118
+ metric-card(report.text.fields.generated, report.meta.generatedAtLabel),
106
119
  )
107
120
  ]
108
121
  ]
@@ -115,9 +128,24 @@ function buildTemplate() {
115
128
  ..report.summaryCards.map(card => metric-card(card.label, card.value, note: card.note, tone: if card.tone == "warn" { warn } else if card.tone == "good" { good } else { accent })),
116
129
  )
117
130
 
131
+ #if report.insights.items.len() > 0 [
132
+ #v(12pt)
133
+ = #report.text.sections.insights
134
+
135
+ #grid(
136
+ columns: (1fr, 1fr),
137
+ gutter: 8pt,
138
+ ..report.insights.items.map(item => insight-card(
139
+ item.title,
140
+ item.body,
141
+ tone: if item.tone == "warn" { warn } else if item.tone == "good" { good } else { accent },
142
+ )),
143
+ )
144
+ ]
145
+
118
146
  #v(12pt)
119
147
 
120
- = #if report.meta.language == "en" { "Overview" } else { "Überblick" }
148
+ = #report.text.sections.overview
121
149
 
122
150
  #grid(
123
151
  columns: (1fr, 1fr),
@@ -136,107 +164,95 @@ function buildTemplate() {
136
164
  #image("token-trend.svg", width: 100%)
137
165
  ]
138
166
 
167
+ #pagebreak()
168
+
139
169
  #v(12pt)
140
170
 
141
- = #if report.meta.language == "en" { "Filters" } else { "Filter" }
171
+ = #report.text.sections.filters
142
172
 
143
173
  #grid(
144
- columns: (1fr, 1fr, 1fr),
174
+ columns: (1fr, 1fr),
145
175
  gutter: 8pt,
146
- metric-card(if report.meta.language == "en" { "Month" } else { "Monat" }, if report.meta.filterSummary.selectedMonthLabel != none { report.meta.filterSummary.selectedMonthLabel } else { if report.meta.language == "en" { "All" } else { "Alle" } }),
147
- metric-card(if report.meta.language == "en" { "Selected providers" } else { "Ausgewählte Provider" }, if report.meta.filterSummary.selectedProviders.len() > 0 { report.meta.filterSummary.selectedProviders.join(", ") } else { if report.meta.language == "en" { "All" } else { "Alle" } }),
148
- metric-card(if report.meta.language == "en" { "Selected models" } else { "Ausgewählte Modelle" }, if report.meta.filterSummary.selectedModels.len() > 0 { report.meta.filterSummary.selectedModels.join(", ") } else { if report.meta.language == "en" { "All" } else { "Alle" } }),
149
- metric-card(if report.meta.language == "en" { "Start date" } else { "Startdatum" }, if report.meta.filterSummary.startDateLabel != none { report.meta.filterSummary.startDateLabel } else { if report.meta.language == "en" { "No filter" } else { "Kein Filter" } }),
150
- metric-card(if report.meta.language == "en" { "End date" } else { "Enddatum" }, if report.meta.filterSummary.endDateLabel != none { report.meta.filterSummary.endDateLabel } else { if report.meta.language == "en" { "No filter" } else { "Kein Filter" } }),
176
+ metric-card(report.text.fields.month, report.meta.filterSummary.selectedMonthLabel),
177
+ metric-card(report.text.fields.selectedProviders, report.meta.filterSummary.selectedProvidersLabel),
178
+ metric-card(report.text.fields.selectedModels, report.meta.filterSummary.selectedModelsLabel),
179
+ metric-card(report.text.fields.startDate, report.meta.filterSummary.startDateLabel),
180
+ metric-card(report.text.fields.endDate, report.meta.filterSummary.endDateLabel),
151
181
  )
152
182
 
153
183
  #v(10pt)
154
184
 
155
- = #if report.meta.language == "en" { "Models & Providers" } else { "Modelle & Provider" }
185
+ = #report.text.sections.modelsProviders
186
+
187
+ #if report.topModels.len() > 0 or report.providers.len() > 0 [
188
+ #grid(
189
+ columns: (1fr, 1fr),
190
+ gutter: 10pt,
191
+ rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
192
+ #text(size: 12pt, weight: "bold", fill: ink)[#report.text.tables.topModels]
193
+ #v(6pt)
194
+ #set text(size: 8.8pt)
195
+ #table(
196
+ columns: (2.2fr, 1.4fr, 1fr, 0.9fr),
197
+ column-gutter: 8pt,
198
+ align: (x, y) => if x < 2 { left } else { right },
199
+ table.header([*#report.text.tables.columns.model*], [*#report.text.tables.columns.provider*], [*#report.text.tables.columns.cost*], [*#report.text.tables.columns.requests*]),
200
+ ..report.topModels.map(model => (
201
+ [#model.name],
202
+ [#model.provider],
203
+ [#model.costLabel],
204
+ [#model.requestsLabel],
205
+ )).flatten(),
206
+ )
207
+ ],
208
+ rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
209
+ #text(size: 12pt, weight: "bold", fill: ink)[#report.text.tables.providers]
210
+ #v(6pt)
211
+ #set text(size: 8.8pt)
212
+ #table(
213
+ columns: (1.8fr, 1fr, 1fr, 1fr),
214
+ column-gutter: 8pt,
215
+ align: (x, y) => if x == 0 { left } else { right },
216
+ table.header([*#report.text.tables.columns.provider*], [*#report.text.tables.columns.cost*], [*#report.text.tables.columns.tokens*], [*#report.text.tables.columns.requests*]),
217
+ ..report.providers.map(provider => (
218
+ [#provider.name],
219
+ [#provider.costLabel],
220
+ [#provider.tokensLabel],
221
+ [#provider.requestsLabel],
222
+ )).flatten(),
223
+ )
224
+ ],
225
+ )
226
+ ]
156
227
 
157
- #grid(
158
- columns: (1fr, 1fr),
159
- gutter: 10pt,
160
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
161
- #text(size: 12pt, weight: "bold", fill: ink)[#if report.meta.language == "en" { "Top models" } else { "Top-Modelle" }]
162
- #v(6pt)
163
- #set text(size: 8.8pt)
164
- #table(
165
- columns: (2.5fr, 1.5fr, 1fr, 1fr),
166
- column-gutter: 8pt,
167
- align: (x, y) => if x < 2 { left } else { right },
168
- table.header([*#if report.meta.language == "en" { "Model" } else { "Modell" }*], [*Provider*], [*#if report.meta.language == "en" { "Cost" } else { "Kosten" }*], [*Requests*]),
169
- ..report.topModels.map(model => (
170
- [#model.name],
171
- [#model.provider],
172
- [#model.costLabel],
173
- [#model.requestsLabel],
174
- )).flatten(),
175
- )
176
- ],
177
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
178
- #text(size: 12pt, weight: "bold", fill: ink)[#if report.meta.language == "en" { "Providers" } else { "Provider" }]
179
- #v(6pt)
180
- #set text(size: 8.8pt)
228
+ #if report.recentPeriods.len() > 0 [
229
+ = #report.text.sections.recentPeriods
230
+
231
+ #rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
232
+ #set text(size: 8.9pt)
181
233
  #table(
182
- columns: (1.8fr, 1fr, 1fr, 1fr),
234
+ columns: (2fr, 1fr, 1fr, 1fr),
183
235
  column-gutter: 8pt,
184
236
  align: (x, y) => if x == 0 { left } else { right },
185
- table.header([*Provider*], [*#if report.meta.language == "en" { "Cost" } else { "Kosten" }*], [*Tokens*], [*Requests*]),
186
- ..report.providers.map(provider => (
187
- [#provider.name],
188
- [#provider.costLabel],
189
- [#provider.tokensLabel],
190
- [#provider.requestsLabel],
237
+ table.header([*#report.text.tables.columns.period*], [*#report.text.tables.columns.cost*], [*#report.text.tables.columns.tokens*], [*#report.text.tables.columns.requests*]),
238
+ ..report.recentPeriods.map(item => (
239
+ [#item.label],
240
+ [#item.costLabel],
241
+ [#item.tokensLabel],
242
+ [#item.requestsLabel],
191
243
  )).flatten(),
192
244
  )
193
- ],
194
- )
195
-
196
- #pagebreak()
197
-
198
- = #if report.meta.language == "en" { "Recent periods" } else { "Letzte Zeiträume" }
199
-
200
- #rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
201
- #set text(size: 8.9pt)
202
- #table(
203
- columns: (2fr, 1fr, 1fr, 1fr),
204
- column-gutter: 8pt,
205
- align: (x, y) => if x == 0 { left } else { right },
206
- table.header([*#if report.meta.language == "en" { "Period" } else { "Zeitraum" }*], [*#if report.meta.language == "en" { "Cost" } else { "Kosten" }*], [*Tokens*], [*Requests*]),
207
- ..report.recentPeriods.map(item => (
208
- [#item.label],
209
- [#item.costLabel],
210
- [#item.tokensLabel],
211
- [#item.requestsLabel],
212
- )).flatten(),
213
- )
245
+ ]
214
246
  ]
215
247
 
216
248
  #v(12pt)
217
249
 
218
- = #if report.meta.language == "en" { "Interpretation" } else { "Interpretation" }
250
+ = #report.text.sections.interpretation
219
251
 
220
252
  #rect(inset: 12pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
221
- #text(size: 10pt, fill: ink)[
222
- #if report.meta.language == "en" [
223
- This report is based on #report.meta.days daily raw entries and #report.meta.periods aggregated periods.
224
- The highest-cost period is #report.labels.topDay.
225
- The dominant model family is #report.labels.topModel, and the leading provider is #report.labels.topProvider.
226
- ] else [
227
- Der Report basiert auf #report.meta.days täglichen Rohdaten und #report.meta.periods aggregierten Perioden.
228
- Der kostenstärkste Zeitraum liegt bei #report.labels.topDay.
229
- Die dominanteste Modellfamilie ist #report.labels.topModel, der führende Provider ist #report.labels.topProvider.
230
- ]
231
- ]
253
+ #text(size: 10pt, fill: ink)[#report.interpretation.summary]
232
254
  #v(8pt)
233
- #text(size: 9pt, fill: muted)[
234
- #if report.meta.language == "en" [
235
- Created with TTDash v#report.meta.appVersion and server-side Typst compilation.
236
- ] else [
237
- Erstellt mit TTDash v#report.meta.appVersion und serverseitiger Typst-Kompilierung.
238
- ]
239
- ]
255
+ #text(size: 9pt, fill: muted)[#report.interpretation.footer]
240
256
  ]
241
257
  `;
242
258
  }
@@ -260,26 +276,27 @@ function createChartAssets(reportData) {
260
276
 
261
277
  return {
262
278
  'cost-trend.svg': lineChart(costTrend, {
263
- title: reportData.meta.language === 'en' ? 'Cost trend' : 'Kostenverlauf',
279
+ title: reportData.text.charts.costTrend,
264
280
  valueKey: 'cost',
265
- secondaryKey: reportData.meta.filterSummary.viewMode === 'daily' ? 'ma7' : null,
281
+ secondaryKey: reportData.meta.filterSummary.viewModeKey === 'daily' ? 'ma7' : null,
266
282
  formatter: (value) => `$${Math.round(value)}`,
267
283
  }),
268
284
  'top-models.svg': horizontalBarChart(topModels, {
269
- title: reportData.meta.language === 'en' ? 'Top models by cost' : 'Top-Modelle nach Kosten',
285
+ title: reportData.text.charts.topModels,
270
286
  getValue: (entry) => entry.cost,
271
287
  getLabel: (entry) => entry.name,
272
288
  getColor: (entry) => entry.color,
273
289
  formatter: (value) => `$${value.toFixed(value >= 100 ? 0 : 2)}`,
274
290
  }),
275
291
  'token-trend.svg': stackedBarChart(tokenTrend, {
276
- title: reportData.meta.language === 'en' ? 'Token mix by period' : 'Token-Mix pro Zeitraum',
292
+ title: reportData.text.charts.tokenTrend,
293
+ formatter: (value) => formatCompactAxis(value, reportData.meta.language),
277
294
  segments: [
278
- { key: 'input', label: 'Input', color: '#0f766e' },
279
- { key: 'output', label: 'Output', color: '#1d4ed8' },
280
- { key: 'cacheWrite', label: 'Cache Write', color: '#b45309' },
281
- { key: 'cacheRead', label: 'Cache Read', color: '#7c3aed' },
282
- { key: 'thinking', label: 'Thinking', color: '#be185d' },
295
+ { key: 'input', label: translate(reportData.meta.language, 'common.input'), color: '#0f766e' },
296
+ { key: 'output', label: translate(reportData.meta.language, 'common.output'), color: '#1d4ed8' },
297
+ { key: 'cacheWrite', label: translate(reportData.meta.language, 'common.cacheWrite'), color: '#b45309' },
298
+ { key: 'cacheRead', label: translate(reportData.meta.language, 'common.cacheRead'), color: '#7c3aed' },
299
+ { key: 'thinking', label: translate(reportData.meta.language, 'common.thinking'), color: '#be185d' },
283
300
  ],
284
301
  }),
285
302
  };
@@ -303,22 +320,25 @@ async function generatePdfReport(allDailyData, options = {}) {
303
320
  const pdfPath = path.join(tempDir, 'report.pdf');
304
321
  const jsonPath = path.join(tempDir, 'report.json');
305
322
 
306
- writeTextFile(typPath, buildTemplate());
307
- writeTextFile(jsonPath, JSON.stringify(reportData, null, 2));
323
+ try {
324
+ writeTextFile(typPath, buildTemplate());
325
+ writeTextFile(jsonPath, JSON.stringify(reportData, null, 2));
308
326
 
309
- const charts = createChartAssets(reportData);
310
- for (const [filename, content] of Object.entries(charts)) {
311
- writeTextFile(path.join(tempDir, filename), content);
312
- }
327
+ const charts = createChartAssets(reportData);
328
+ for (const [filename, content] of Object.entries(charts)) {
329
+ writeTextFile(path.join(tempDir, filename), content);
330
+ }
313
331
 
314
- await compileTypst(tempDir, typPath, pdfPath);
332
+ await compileTypst(tempDir, typPath, pdfPath);
315
333
 
316
- return {
317
- pdfPath,
318
- tempDir,
319
- filename: `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf`,
320
- reportData,
321
- };
334
+ return {
335
+ buffer: fs.readFileSync(pdfPath),
336
+ filename: `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf`,
337
+ reportData,
338
+ };
339
+ } finally {
340
+ fs.rmSync(tempDir, { recursive: true, force: true });
341
+ }
322
342
  }
323
343
 
324
344
  module.exports = {