@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.
@@ -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,47 @@ 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
+ {
296
+ key: 'input',
297
+ label: translate(reportData.meta.language, 'common.input'),
298
+ color: '#0f766e',
299
+ },
300
+ {
301
+ key: 'output',
302
+ label: translate(reportData.meta.language, 'common.output'),
303
+ color: '#1d4ed8',
304
+ },
305
+ {
306
+ key: 'cacheWrite',
307
+ label: translate(reportData.meta.language, 'common.cacheWrite'),
308
+ color: '#b45309',
309
+ },
310
+ {
311
+ key: 'cacheRead',
312
+ label: translate(reportData.meta.language, 'common.cacheRead'),
313
+ color: '#7c3aed',
314
+ },
315
+ {
316
+ key: 'thinking',
317
+ label: translate(reportData.meta.language, 'common.thinking'),
318
+ color: '#be185d',
319
+ },
283
320
  ],
284
321
  }),
285
322
  };
@@ -303,22 +340,25 @@ async function generatePdfReport(allDailyData, options = {}) {
303
340
  const pdfPath = path.join(tempDir, 'report.pdf');
304
341
  const jsonPath = path.join(tempDir, 'report.json');
305
342
 
306
- writeTextFile(typPath, buildTemplate());
307
- writeTextFile(jsonPath, JSON.stringify(reportData, null, 2));
343
+ try {
344
+ writeTextFile(typPath, buildTemplate());
345
+ writeTextFile(jsonPath, JSON.stringify(reportData, null, 2));
308
346
 
309
- const charts = createChartAssets(reportData);
310
- for (const [filename, content] of Object.entries(charts)) {
311
- writeTextFile(path.join(tempDir, filename), content);
312
- }
347
+ const charts = createChartAssets(reportData);
348
+ for (const [filename, content] of Object.entries(charts)) {
349
+ writeTextFile(path.join(tempDir, filename), content);
350
+ }
313
351
 
314
- await compileTypst(tempDir, typPath, pdfPath);
352
+ await compileTypst(tempDir, typPath, pdfPath);
315
353
 
316
- return {
317
- pdfPath,
318
- tempDir,
319
- filename: `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf`,
320
- reportData,
321
- };
354
+ return {
355
+ buffer: fs.readFileSync(pdfPath),
356
+ filename: `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf`,
357
+ reportData,
358
+ };
359
+ } finally {
360
+ fs.rmSync(tempDir, { recursive: true, force: true });
361
+ }
322
362
  }
323
363
 
324
364
  module.exports = {