@roastcodes/ttdash 6.1.8 → 6.2.0

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,17 +8,17 @@
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-DDw3UUhU.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-CMtAn7c8.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">
15
15
  <link rel="modulepreload" crossorigin href="/assets/motion-vendor-BXI2L__C.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/ui-vendor-BGjRFQGY.js">
17
- <link rel="modulepreload" crossorigin href="/assets/icons-vendor-DFoaijFJ.js">
18
- <link rel="modulepreload" crossorigin href="/assets/dialog-Cn1m7WhC.js">
19
- <link rel="modulepreload" crossorigin href="/assets/button-D7Ib8H7t.js">
20
- <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-BxopDd3O.js">
21
- <link rel="stylesheet" crossorigin href="/assets/index-g2F-z39N.css">
17
+ <link rel="modulepreload" crossorigin href="/assets/icons-vendor-z59La6A4.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/dialog-CA-ZSHjK.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/button-B26tLVFw.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-CdIOw3Ep.js">
21
+ <link rel="stylesheet" crossorigin href="/assets/index-BkGSNAne.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.8",
3
+ "version": "6.2.0",
4
4
  "description": "Local-first dashboard and CLI for toktrack usage data",
5
5
  "main": "server.js",
6
6
  "repository": {
@@ -44,6 +44,7 @@
44
44
  "server.js",
45
45
  "usage-normalizer.js",
46
46
  "server/",
47
+ "shared/",
47
48
  "src/locales/",
48
49
  "dist/"
49
50
  ],
@@ -103,6 +104,7 @@
103
104
  "vitest": "^4.1.3"
104
105
  },
105
106
  "dependencies": {
107
+ "cross-spawn": "^7.0.6",
106
108
  "i18next": "^26.0.3",
107
109
  "react-i18next": "^17.0.2",
108
110
  "react-is": "^19.2.4"
@@ -0,0 +1,165 @@
1
+ function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) {
2
+ function readBody(req) {
3
+ return new Promise((resolve, reject) => {
4
+ const chunks = [];
5
+ let totalSize = 0;
6
+ let settled = false;
7
+
8
+ const cleanup = () => {
9
+ req.off('data', onData);
10
+ req.off('end', onEnd);
11
+ req.off('error', onError);
12
+ req.off('close', onClose);
13
+ };
14
+
15
+ const rejectOnce = (error) => {
16
+ if (settled) return;
17
+ settled = true;
18
+ cleanup();
19
+ reject(error);
20
+ };
21
+
22
+ const resolveOnce = (value) => {
23
+ if (settled) return;
24
+ settled = true;
25
+ cleanup();
26
+ resolve(value);
27
+ };
28
+
29
+ const onData = (chunk) => {
30
+ totalSize += chunk.length;
31
+ if (totalSize > maxBodySize) {
32
+ const error = new Error('Payload too large');
33
+ error.code = 'PAYLOAD_TOO_LARGE';
34
+ rejectOnce(error);
35
+ req.resume();
36
+ return;
37
+ }
38
+ chunks.push(chunk);
39
+ };
40
+
41
+ const onEnd = () => {
42
+ try {
43
+ resolveOnce(JSON.parse(Buffer.concat(chunks).toString()));
44
+ } catch (error) {
45
+ rejectOnce(error);
46
+ }
47
+ };
48
+
49
+ const onError = (error) => {
50
+ if (settled && error && error.code === 'ECONNRESET') {
51
+ return;
52
+ }
53
+ rejectOnce(error);
54
+ };
55
+
56
+ const onClose = () => {
57
+ if (!req.readableEnded) {
58
+ rejectOnce(new Error('Request body stream closed before the payload finished'));
59
+ }
60
+ };
61
+
62
+ req.on('data', onData);
63
+ req.on('end', onEnd);
64
+ req.on('error', onError);
65
+ req.on('close', onClose);
66
+ });
67
+ }
68
+
69
+ function json(res, status, data) {
70
+ res.writeHead(status, {
71
+ 'Content-Type': 'application/json; charset=utf-8',
72
+ ...securityHeaders,
73
+ });
74
+ res.end(JSON.stringify(data));
75
+ }
76
+
77
+ function sendBuffer(res, status, headers, buffer) {
78
+ res.writeHead(status, {
79
+ 'Content-Length': buffer.length,
80
+ ...headers,
81
+ ...securityHeaders,
82
+ });
83
+ res.end(buffer);
84
+ }
85
+
86
+ function resolveApiPath(pathname) {
87
+ if (pathname === apiPrefix) {
88
+ return '/';
89
+ }
90
+ if (pathname.startsWith(apiPrefix + '/')) {
91
+ return pathname.slice(apiPrefix.length);
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function getHeaderValue(req, name) {
97
+ const value = req.headers[name];
98
+ if (Array.isArray(value)) {
99
+ return value[0] || '';
100
+ }
101
+ return typeof value === 'string' ? value : '';
102
+ }
103
+
104
+ function hasJsonContentType(req) {
105
+ const contentType = getHeaderValue(req, 'content-type');
106
+ if (!contentType) {
107
+ return false;
108
+ }
109
+
110
+ return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json';
111
+ }
112
+
113
+ function hasTrustedOrigin(req) {
114
+ const originHeader = getHeaderValue(req, 'origin').trim();
115
+ if (!originHeader) {
116
+ return true;
117
+ }
118
+
119
+ const hostHeader = getHeaderValue(req, 'host').trim();
120
+ if (!hostHeader || originHeader === 'null') {
121
+ return false;
122
+ }
123
+
124
+ try {
125
+ const origin = new URL(originHeader);
126
+ return origin.host === hostHeader;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ function isCrossSiteFetch(req) {
133
+ return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site';
134
+ }
135
+
136
+ function validateMutationRequest(req, { requiresJsonContentType = false } = {}) {
137
+ if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) {
138
+ return {
139
+ status: 403,
140
+ message: 'Cross-site requests are not allowed',
141
+ };
142
+ }
143
+
144
+ if (requiresJsonContentType && !hasJsonContentType(req)) {
145
+ return {
146
+ status: 415,
147
+ message: 'Content-Type must be application/json',
148
+ };
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ return {
155
+ readBody,
156
+ json,
157
+ sendBuffer,
158
+ resolveApiPath,
159
+ validateMutationRequest,
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ createHttpUtils,
165
+ };
@@ -0,0 +1,12 @@
1
+ const TOP_MODEL_CHART_LABEL_MAX_LENGTH = 34;
2
+
3
+ function truncateTopModelChartLabel(value) {
4
+ const stringValue = String(value || '');
5
+ if (stringValue.length <= TOP_MODEL_CHART_LABEL_MAX_LENGTH) return stringValue;
6
+ return `${stringValue.slice(0, Math.max(1, TOP_MODEL_CHART_LABEL_MAX_LENGTH - 1)).trimEnd()}…`;
7
+ }
8
+
9
+ module.exports = {
10
+ TOP_MODEL_CHART_LABEL_MAX_LENGTH,
11
+ truncateTopModelChartLabel,
12
+ };
@@ -1,3 +1,5 @@
1
+ const { truncateTopModelChartLabel } = require('./chart-labels');
2
+
1
3
  function escapeXml(value) {
2
4
  return String(value)
3
5
  .replace(/&/g, '&amp;')
@@ -19,12 +21,6 @@ ${body}
19
21
 
20
22
  const DEFAULT_FONT_FAMILY = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
21
23
 
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
24
  function lineChart(
29
25
  data,
30
26
  {
@@ -136,7 +132,7 @@ function horizontalBarChart(
136
132
  top: 46,
137
133
  right: 100,
138
134
  bottom: 24,
139
- left: clamp(180 + longestLabelLength * 3.4, 220, 320),
135
+ left: clamp(180 + longestLabelLength * 3.4, 220, 360),
140
136
  };
141
137
  const plotWidth = width - margin.left - margin.right;
142
138
  const barGap = 18;
@@ -158,7 +154,7 @@ function horizontalBarChart(
158
154
  const value = getValue(entry);
159
155
  const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth);
160
156
  return `
161
- <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>
157
+ <text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(truncateTopModelChartLabel(getLabel(entry)))}</text>
162
158
  <rect x="${margin.left}" y="${y}" width="${plotWidth}" height="${barHeight}" rx="12" fill="#eef3f8"/>
163
159
  <rect x="${margin.left}" y="${y}" width="${barWidth}" height="${barHeight}" rx="12" fill="${getColor(entry)}"/>
164
160
  <text x="${margin.left + plotWidth + 12}" y="${y + barHeight / 2 + 4}" font-size="12" font-family="${fontFamily}" fill="#475569">${escapeXml(formatter(value))}</text>
@@ -3,7 +3,7 @@ const os = require('os');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
5
5
  const { buildReportData, formatCompactAxis, formatDateAxis } = require('./utils');
6
- const { translate } = require('./i18n');
6
+ const { getLocale, translate } = require('./i18n');
7
7
  const { horizontalBarChart, lineChart, stackedBarChart } = require('./charts');
8
8
 
9
9
  function ensureTypstInstalled() {
@@ -39,10 +39,41 @@ function compileTypst(workingDir, typPath, pdfPath) {
39
39
  });
40
40
  }
41
41
 
42
+ function formatCostAxisValue(value, language = 'de') {
43
+ const numericValue = Number(value) || 0;
44
+ const absoluteValue = Math.abs(numericValue);
45
+ const locale = getLocale(language);
46
+
47
+ if (absoluteValue >= 100) {
48
+ return `$${Math.round(numericValue).toLocaleString(locale)}`;
49
+ }
50
+
51
+ if (absoluteValue >= 10) {
52
+ return `$${numericValue.toLocaleString(locale, {
53
+ minimumFractionDigits: 0,
54
+ maximumFractionDigits: 1,
55
+ })}`;
56
+ }
57
+
58
+ if (absoluteValue >= 1) {
59
+ return `$${numericValue.toLocaleString(locale, {
60
+ minimumFractionDigits: 0,
61
+ maximumFractionDigits: 2,
62
+ })}`;
63
+ }
64
+
65
+ return `$${numericValue.toLocaleString(locale, {
66
+ minimumFractionDigits: 2,
67
+ maximumFractionDigits: 2,
68
+ })}`;
69
+ }
70
+
42
71
  function buildTemplate() {
43
72
  return `
44
73
  #let report = json("report.json")
45
74
 
75
+ #set document(title: report.meta.reportTitle)
76
+
46
77
  #set page(
47
78
  paper: "a4",
48
79
  margin: (x: 14mm, y: 16mm),
@@ -56,10 +87,10 @@ function buildTemplate() {
56
87
  #let muted = rgb("#5c6b7e")
57
88
  #let panel = rgb("#ffffff")
58
89
  #let line = rgb("#d9e2ec")
59
- #let accent = rgb("#1d6fd8")
90
+ #let accent = rgb("#175fc0")
60
91
  #let accent-soft = rgb("#eaf2ff")
61
92
  #let good = rgb("#16825d")
62
- #let warn = rgb("#c67700")
93
+ #let warn = rgb("#9a5a00")
63
94
 
64
95
  #let metric-card(label, value, note: none, tone: accent) = rect(
65
96
  inset: 10pt,
@@ -89,6 +120,22 @@ function buildTemplate() {
89
120
  ],
90
121
  )
91
122
 
123
+ #let chart-panel(file, alt, summary, note: none) = rect(
124
+ inset: 10pt,
125
+ radius: 14pt,
126
+ fill: panel,
127
+ stroke: (paint: line, thickness: 0.8pt),
128
+ [
129
+ #image(file, width: 100%, alt: alt)
130
+ #v(6pt)
131
+ #text(size: 8.7pt, fill: muted)[#summary]
132
+ #if note != none [
133
+ #v(4pt)
134
+ #text(size: 8.5pt, fill: muted)[#note]
135
+ ]
136
+ ],
137
+ )
138
+
92
139
  #show heading.where(level: 1): it => block(above: 0pt, below: 10pt)[
93
140
  #text(size: 24pt, fill: ink, weight: "bold")[#it.body]
94
141
  ]
@@ -150,21 +197,26 @@ function buildTemplate() {
150
197
  #grid(
151
198
  columns: (1fr, 1fr),
152
199
  gutter: 10pt,
153
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
154
- #image("cost-trend.svg", width: 100%)
155
- ],
156
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
157
- #image("top-models.svg", width: 100%)
158
- ],
200
+ chart-panel(
201
+ "cost-trend.svg",
202
+ report.chartDescriptions.costTrend.alt,
203
+ report.chartDescriptions.costTrend.summary,
204
+ ),
205
+ chart-panel(
206
+ "top-models.svg",
207
+ report.chartDescriptions.topModels.alt,
208
+ report.chartDescriptions.topModels.summary,
209
+ note: report.chartDescriptions.topModels.fullNamesNote,
210
+ ),
159
211
  )
160
212
 
161
213
  #v(10pt)
162
214
 
163
- #rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
164
- #image("token-trend.svg", width: 100%)
165
- ]
166
-
167
- #pagebreak()
215
+ #chart-panel(
216
+ "token-trend.svg",
217
+ report.chartDescriptions.tokenTrend.alt,
218
+ report.chartDescriptions.tokenTrend.summary,
219
+ )
168
220
 
169
221
  #v(12pt)
170
222
 
@@ -279,14 +331,14 @@ function createChartAssets(reportData) {
279
331
  title: reportData.text.charts.costTrend,
280
332
  valueKey: 'cost',
281
333
  secondaryKey: reportData.meta.filterSummary.viewModeKey === 'daily' ? 'ma7' : null,
282
- formatter: (value) => `$${Math.round(value)}`,
334
+ formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
283
335
  }),
284
336
  'top-models.svg': horizontalBarChart(topModels, {
285
337
  title: reportData.text.charts.topModels,
286
338
  getValue: (entry) => entry.cost,
287
339
  getLabel: (entry) => entry.name,
288
340
  getColor: (entry) => entry.color,
289
- formatter: (value) => `$${value.toFixed(value >= 100 ? 0 : 2)}`,
341
+ formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
290
342
  }),
291
343
  'token-trend.svg': stackedBarChart(tokenTrend, {
292
344
  title: reportData.text.charts.tokenTrend,
@@ -363,4 +415,9 @@ async function generatePdfReport(allDailyData, options = {}) {
363
415
 
364
416
  module.exports = {
365
417
  generatePdfReport,
418
+ __test__: {
419
+ buildTemplate,
420
+ createChartAssets,
421
+ formatCostAxisValue,
422
+ },
366
423
  };