@roastcodes/ttdash 6.2.0 → 6.2.2

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.
Files changed (70) hide show
  1. package/dist/assets/AnimatedBarFill-Da354-1y.js +1 -0
  2. package/dist/assets/AnomalyDetection-D06kgR_V.js +1 -0
  3. package/dist/assets/{AutoImportModal-C8gA0_mL.js → AutoImportModal-CuzbrUVc.js} +3 -3
  4. package/dist/assets/CacheROI-fJ1paHaT.js +1 -0
  5. package/dist/assets/ChartCard-CS4Kbe6r.js +2 -0
  6. package/dist/assets/ChartLegend-Vzg_GmF-.js +1 -0
  7. package/dist/assets/CorrelationAnalysis-DX-ZQKVJ.js +1 -0
  8. package/dist/assets/CostByModelOverTime-BRo1PCRo.js +1 -0
  9. package/dist/assets/CostByWeekday-BJwEWkmF.js +1 -0
  10. package/dist/assets/CostForecast-BxICbpzM.js +1 -0
  11. package/dist/assets/CumulativeCost-BdlOTRDX.js +1 -0
  12. package/dist/assets/CustomTooltip-CEd8U-a3.js +1 -0
  13. package/dist/assets/DistributionAnalysis-Cw8bRtt9.js +1 -0
  14. package/dist/assets/DrillDownModal-BT2rFk_U.js +1 -0
  15. package/dist/assets/HelpPanel-Ct4CbfBg.js +1 -0
  16. package/dist/assets/InfoButton-CyA48Vlx.js +1 -0
  17. package/dist/assets/InfoHeading-CFbl45C9.js +1 -0
  18. package/dist/assets/MetricCard-CLFpH1l1.js +1 -0
  19. package/dist/assets/ModelEfficiency-CO7sQQnT.js +1 -0
  20. package/dist/assets/ModelMix-C4P2SpQZ.js +1 -0
  21. package/dist/assets/PeriodComparison-BogfA2IN.js +1 -0
  22. package/dist/assets/ProviderEfficiency-CY4HJ7Fy.js +1 -0
  23. package/dist/assets/ProviderLimitsSection-BcCrH2XG.js +1 -0
  24. package/dist/assets/RecentDays-K0Uzk7UU.js +1 -0
  25. package/dist/assets/RequestCacheHitRateByModel-8ip50S7V.js +1 -0
  26. package/dist/assets/RequestQuality-ByYXyzn8.js +1 -0
  27. package/dist/assets/RequestsOverTime-B4yB-x_5.js +1 -0
  28. package/dist/assets/SettingsModal-Badl8kHp.js +1 -0
  29. package/dist/assets/TokenEfficiency-Bhdj4v75.js +1 -0
  30. package/dist/assets/TokenTypes-CaVA8FXP.js +1 -0
  31. package/dist/assets/TokensOverTime-BA7W_OfG.js +1 -0
  32. package/dist/assets/app-settings-EFIYxqQU.js +1 -0
  33. package/dist/assets/button-olcuyddH.js +1 -0
  34. package/dist/assets/calculations-BJw_4KdI.js +1 -0
  35. package/dist/assets/card-CskYMXga.js +1 -0
  36. package/dist/assets/chart-theme-BdE8bEiN.js +1 -0
  37. package/dist/assets/constants-DOCA3QtR.js +1 -0
  38. package/dist/assets/dialog-D4LfH66U.js +1 -0
  39. package/dist/assets/formatted-value-DuGw8WPX.js +1 -0
  40. package/dist/assets/formatters-BzLLk0br.js +1 -0
  41. package/dist/assets/help-content-Cuf5l3_6.js +1 -0
  42. package/dist/assets/i18n-D1WJ4-W9.js +1 -0
  43. package/dist/assets/index-8kk0H-lQ.css +2 -0
  44. package/dist/assets/index-D6_2fCBR.js +3 -0
  45. package/dist/assets/model-utils-BIvGAFGz.js +1 -0
  46. package/dist/assets/motion-vendor-BNFhNARW.js +9 -0
  47. package/dist/assets/provider-limits-BgKrr7k7.js +1 -0
  48. package/dist/assets/section-header-KpKzq-Qa.js +1 -0
  49. package/dist/assets/ui-vendor-DOI7LsxH.js +45 -0
  50. package/dist/assets/useTranslation-JH30oWEP.js +1 -0
  51. package/dist/index.html +27 -8
  52. package/package.json +15 -5
  53. package/server/report/index.js +13 -11
  54. package/server.js +281 -56
  55. package/shared/dashboard-domain.d.ts +12 -0
  56. package/shared/dashboard-domain.js +79 -0
  57. package/shared/dashboard-types.d.ts +4 -0
  58. package/shared/model-colors.d.ts +7 -0
  59. package/shared/model-colors.js +27 -0
  60. package/src/locales/de/common.json +51 -5
  61. package/src/locales/en/common.json +51 -4
  62. package/dist/assets/CustomTooltip-CdIOw3Ep.js +0 -1
  63. package/dist/assets/DrillDownModal-d6hcut-I.js +0 -1
  64. package/dist/assets/button-B26tLVFw.js +0 -1
  65. package/dist/assets/dialog-CA-ZSHjK.js +0 -1
  66. package/dist/assets/index-BkGSNAne.css +0 -2
  67. package/dist/assets/index-CMtAn7c8.js +0 -4
  68. package/dist/assets/motion-vendor-BXI2L__C.js +0 -1
  69. package/dist/assets/ui-vendor-BGjRFQGY.js +0 -45
  70. /package/dist/assets/{icons-vendor-z59La6A4.js → icons-vendor-CIvT_-Pb.js} +0 -0
package/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const http = require('http');
4
4
  const fs = require('fs');
5
+ const fsPromises = require('fs/promises');
5
6
  const os = require('os');
6
7
  const path = require('path');
7
8
  const readline = require('readline/promises');
@@ -271,6 +272,9 @@ const BACKGROUND_LOG_DIR = path.join(APP_PATHS.cacheDir, 'background');
271
272
  const BACKGROUND_INSTANCES_LOCK_DIR = path.join(APP_PATHS.configDir, 'background-instances.lock');
272
273
  const BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS = 5000;
273
274
  const BACKGROUND_INSTANCES_LOCK_STALE_MS = 10000;
275
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = 10000;
276
+ const FILE_MUTATION_LOCK_STALE_MS = 30000;
277
+ const fileMutationLocks = new Map();
274
278
 
275
279
  const MIME_TYPES = {
276
280
  '.html': 'text/html; charset=utf-8',
@@ -314,6 +318,214 @@ function writeJsonAtomic(filePath, data) {
314
318
  fs.renameSync(tempPath, filePath);
315
319
  }
316
320
 
321
+ async function writeJsonAtomicAsync(filePath, data) {
322
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
323
+ let tempPathCreated = false;
324
+
325
+ try {
326
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true, mode: SECURE_DIR_MODE });
327
+ tempPathCreated = true;
328
+ await fsPromises.writeFile(tempPath, JSON.stringify(data, null, 2), {
329
+ mode: SECURE_FILE_MODE,
330
+ });
331
+
332
+ if (!IS_WINDOWS) {
333
+ await fsPromises.chmod(tempPath, SECURE_FILE_MODE);
334
+ }
335
+
336
+ await fsPromises.rename(tempPath, filePath);
337
+ } catch (error) {
338
+ if (tempPathCreated) {
339
+ try {
340
+ await fsPromises.unlink(tempPath);
341
+ } catch (unlinkError) {
342
+ if (unlinkError?.code !== 'ENOENT') {
343
+ // Ignore temp-file cleanup failures so the original error wins.
344
+ }
345
+ }
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ async function unlinkIfExists(filePath) {
352
+ try {
353
+ await fsPromises.unlink(filePath);
354
+ } catch (error) {
355
+ if (error?.code !== 'ENOENT') {
356
+ throw error;
357
+ }
358
+ }
359
+ }
360
+
361
+ function getFileMutationLockDir(filePath) {
362
+ return `${filePath}.lock`;
363
+ }
364
+
365
+ function getFileMutationLockOwnerPath(lockDir) {
366
+ return path.join(lockDir, 'owner.json');
367
+ }
368
+
369
+ async function removeFileMutationLockDir(lockDir) {
370
+ try {
371
+ await fsPromises.rm(lockDir, { recursive: true, force: true });
372
+ } catch (error) {
373
+ if (error?.code !== 'ENOENT') {
374
+ throw error;
375
+ }
376
+ }
377
+ }
378
+
379
+ async function writeFileMutationLockOwner(lockDir) {
380
+ const ownerPath = getFileMutationLockOwnerPath(lockDir);
381
+ const owner = {
382
+ pid: process.pid,
383
+ createdAt: new Date().toISOString(),
384
+ instanceId: RUNTIME_INSTANCE.id,
385
+ };
386
+ await fsPromises.writeFile(ownerPath, JSON.stringify(owner, null, 2), {
387
+ mode: SECURE_FILE_MODE,
388
+ });
389
+ if (!IS_WINDOWS) {
390
+ await fsPromises.chmod(ownerPath, SECURE_FILE_MODE);
391
+ }
392
+ }
393
+
394
+ async function shouldReapFileMutationLock(lockDir) {
395
+ const ownerPath = getFileMutationLockOwnerPath(lockDir);
396
+ let owner = null;
397
+
398
+ try {
399
+ const rawOwner = await fsPromises.readFile(ownerPath, 'utf-8');
400
+ owner = JSON.parse(rawOwner);
401
+ } catch (error) {
402
+ if (error?.code !== 'ENOENT') {
403
+ // Fall back to age-based cleanup if the owner metadata is missing or malformed.
404
+ }
405
+ }
406
+
407
+ try {
408
+ const ownerCreatedAt = owner?.createdAt ? Date.parse(owner.createdAt) : Number.NaN;
409
+ const stats = await fsPromises.stat(lockDir);
410
+ const lockAgeMs = Number.isFinite(ownerCreatedAt)
411
+ ? Date.now() - ownerCreatedAt
412
+ : Date.now() - stats.mtimeMs;
413
+
414
+ if (lockAgeMs > FILE_MUTATION_LOCK_STALE_MS) {
415
+ return true;
416
+ }
417
+
418
+ if (Number.isInteger(owner?.pid)) {
419
+ return !isProcessRunning(owner.pid);
420
+ }
421
+
422
+ return false;
423
+ } catch (error) {
424
+ if (error?.code === 'ENOENT') {
425
+ return false;
426
+ }
427
+ throw error;
428
+ }
429
+ }
430
+
431
+ async function withCrossProcessFileMutationLock(
432
+ filePath,
433
+ operation,
434
+ timeoutMs = FILE_MUTATION_LOCK_TIMEOUT_MS,
435
+ ) {
436
+ const lockDir = getFileMutationLockDir(filePath);
437
+ const startedAt = Date.now();
438
+
439
+ while (true) {
440
+ try {
441
+ await fsPromises.mkdir(path.dirname(lockDir), {
442
+ recursive: true,
443
+ mode: SECURE_DIR_MODE,
444
+ });
445
+ await fsPromises.mkdir(lockDir, { mode: SECURE_DIR_MODE });
446
+ if (!IS_WINDOWS) {
447
+ await fsPromises.chmod(lockDir, SECURE_DIR_MODE);
448
+ }
449
+
450
+ try {
451
+ await writeFileMutationLockOwner(lockDir);
452
+ } catch (error) {
453
+ await removeFileMutationLockDir(lockDir).catch(() => undefined);
454
+ throw error;
455
+ }
456
+
457
+ break;
458
+ } catch (error) {
459
+ if (!error || error.code !== 'EEXIST') {
460
+ throw error;
461
+ }
462
+
463
+ if (await shouldReapFileMutationLock(lockDir)) {
464
+ await removeFileMutationLockDir(lockDir).catch(() => undefined);
465
+ continue;
466
+ }
467
+
468
+ if (Date.now() - startedAt >= timeoutMs) {
469
+ throw new Error(`Could not acquire file mutation lock for ${path.basename(filePath)}.`, {
470
+ cause: error,
471
+ });
472
+ }
473
+
474
+ await sleep(50);
475
+ }
476
+ }
477
+
478
+ try {
479
+ return await operation();
480
+ } finally {
481
+ try {
482
+ await removeFileMutationLockDir(lockDir);
483
+ } catch {
484
+ // Ignore cleanup races so the original operation result wins.
485
+ }
486
+ }
487
+ }
488
+
489
+ async function withFileMutationLock(filePath, operation) {
490
+ const previous = fileMutationLocks.get(filePath) || Promise.resolve();
491
+ let releaseCurrent;
492
+ const current = new Promise((resolve) => {
493
+ releaseCurrent = resolve;
494
+ });
495
+
496
+ fileMutationLocks.set(filePath, current);
497
+
498
+ await previous.catch(() => undefined);
499
+
500
+ try {
501
+ return await withCrossProcessFileMutationLock(filePath, operation);
502
+ } finally {
503
+ releaseCurrent();
504
+ if (fileMutationLocks.get(filePath) === current) {
505
+ fileMutationLocks.delete(filePath);
506
+ }
507
+ }
508
+ }
509
+
510
+ async function withOrderedFileMutationLocks(filePaths, operation) {
511
+ const uniquePaths = Array.from(new Set(filePaths)).sort();
512
+
513
+ const runWithLock = async (index) => {
514
+ if (index >= uniquePaths.length) {
515
+ return operation();
516
+ }
517
+
518
+ const filePath = uniquePaths[index];
519
+ return withFileMutationLock(filePath, () => runWithLock(index + 1));
520
+ };
521
+
522
+ return runWithLock(0);
523
+ }
524
+
525
+ async function withSettingsAndDataMutationLock(operation) {
526
+ return withOrderedFileMutationLocks([SETTINGS_FILE, DATA_FILE], operation);
527
+ }
528
+
317
529
  function sleep(ms) {
318
530
  return new Promise((resolve) => setTimeout(resolve, ms));
319
531
  }
@@ -1389,8 +1601,8 @@ function readData() {
1389
1601
  }
1390
1602
  }
1391
1603
 
1392
- function writeData(data) {
1393
- writeJsonAtomic(DATA_FILE, data);
1604
+ async function writeData(data) {
1605
+ await writeJsonAtomicAsync(DATA_FILE, data);
1394
1606
  }
1395
1607
 
1396
1608
  function readSettings() {
@@ -1420,53 +1632,43 @@ function readSettingsForWrite() {
1420
1632
  }
1421
1633
  }
1422
1634
 
1423
- function writeSettings(settings) {
1424
- writeJsonAtomic(SETTINGS_FILE, normalizeSettings(settings));
1635
+ async function writeSettings(settings) {
1636
+ await writeJsonAtomicAsync(SETTINGS_FILE, normalizeSettings(settings));
1425
1637
  }
1426
1638
 
1427
- function updateSettings(patch) {
1639
+ async function updateDataLoadState(patch) {
1428
1640
  const current = readSettingsForWrite();
1429
1641
  const next = {
1430
1642
  ...current,
1431
- ...(patch && typeof patch === 'object' ? patch : {}),
1643
+ ...patch,
1432
1644
  };
1433
1645
 
1434
- if (patch && Object.prototype.hasOwnProperty.call(patch, 'providerLimits')) {
1435
- next.providerLimits = normalizeProviderLimits(patch.providerLimits);
1436
- } else {
1437
- next.providerLimits = current.providerLimits;
1438
- }
1439
-
1440
- next.language = normalizeLanguage(next.language);
1441
- next.theme = normalizeTheme(next.theme);
1442
-
1443
- writeSettings(next);
1646
+ await writeSettings(next);
1444
1647
  return toSettingsResponse(next);
1445
1648
  }
1446
1649
 
1447
- function recordDataLoad(source) {
1448
- const current = readSettingsForWrite();
1449
- const next = {
1450
- ...current,
1451
- lastLoadedAt: new Date().toISOString(),
1452
- lastLoadSource: source,
1453
- };
1650
+ async function updateSettings(patch) {
1651
+ return withFileMutationLock(SETTINGS_FILE, async () => {
1652
+ const current = readSettingsForWrite();
1653
+ const next = {
1654
+ ...current,
1655
+ ...(patch && typeof patch === 'object' ? patch : {}),
1656
+ };
1454
1657
 
1455
- writeSettings(next);
1456
- return toSettingsResponse(next);
1457
- }
1658
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'providerLimits')) {
1659
+ next.providerLimits = normalizeProviderLimits(patch.providerLimits);
1660
+ } else {
1661
+ next.providerLimits = current.providerLimits;
1662
+ }
1458
1663
 
1459
- function clearDataLoadState() {
1460
- const current = readSettingsForWrite();
1461
- const next = {
1462
- ...current,
1463
- lastLoadedAt: null,
1464
- lastLoadSource: null,
1465
- };
1664
+ next.language = normalizeLanguage(next.language);
1665
+ next.theme = normalizeTheme(next.theme);
1466
1666
 
1467
- writeSettings(next);
1468
- return toSettingsResponse(next);
1667
+ await writeSettings(next);
1668
+ return toSettingsResponse(next);
1669
+ });
1469
1670
  }
1671
+
1470
1672
  const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({
1471
1673
  apiPrefix: API_PREFIX,
1472
1674
  maxBodySize: MAX_BODY_SIZE,
@@ -1647,8 +1849,13 @@ async function performAutoImport({
1647
1849
  });
1648
1850
 
1649
1851
  const normalized = normalizeIncomingData(JSON.parse(rawJson));
1650
- writeData(normalized);
1651
- recordDataLoad(source);
1852
+ await withSettingsAndDataMutationLock(async () => {
1853
+ await writeData(normalized);
1854
+ await updateDataLoadState({
1855
+ lastLoadedAt: new Date().toISOString(),
1856
+ lastLoadSource: source,
1857
+ });
1858
+ });
1652
1859
 
1653
1860
  return {
1654
1861
  days: normalized.daily.length,
@@ -1743,12 +1950,13 @@ const server = http.createServer(async (req, res) => {
1743
1950
  if (validationError) {
1744
1951
  return json(res, validationError.status, { message: validationError.message });
1745
1952
  }
1746
- try {
1747
- fs.unlinkSync(DATA_FILE);
1748
- } catch {
1749
- // Ignore missing data files during reset.
1750
- }
1751
- clearDataLoadState();
1953
+ await withSettingsAndDataMutationLock(async () => {
1954
+ await unlinkIfExists(DATA_FILE);
1955
+ await updateDataLoadState({
1956
+ lastLoadedAt: null,
1957
+ lastLoadSource: null,
1958
+ });
1959
+ });
1752
1960
  return json(res, 200, { success: true });
1753
1961
  }
1754
1962
  return json(res, 405, { message: 'Method Not Allowed' });
@@ -1786,11 +1994,9 @@ const server = http.createServer(async (req, res) => {
1786
1994
  if (validationError) {
1787
1995
  return json(res, validationError.status, { message: validationError.message });
1788
1996
  }
1789
- try {
1790
- fs.unlinkSync(SETTINGS_FILE);
1791
- } catch {
1792
- // Ignore missing settings files during reset.
1793
- }
1997
+ await withFileMutationLock(SETTINGS_FILE, async () => {
1998
+ await unlinkIfExists(SETTINGS_FILE);
1999
+ });
1794
2000
  return json(res, 200, { success: true, settings: readSettings() });
1795
2001
  }
1796
2002
 
@@ -1801,7 +2007,7 @@ const server = http.createServer(async (req, res) => {
1801
2007
  }
1802
2008
  try {
1803
2009
  const body = await readBody(req);
1804
- return json(res, 200, updateSettings(body));
2010
+ return json(res, 200, await updateSettings(body));
1805
2011
  } catch (e) {
1806
2012
  if (isPayloadTooLargeError(e)) {
1807
2013
  return json(res, 413, { message: 'Settings request too large' });
@@ -1826,7 +2032,9 @@ const server = http.createServer(async (req, res) => {
1826
2032
  try {
1827
2033
  const body = await readBody(req);
1828
2034
  const importedSettings = normalizeSettings(extractSettingsImportPayload(body));
1829
- writeSettings(importedSettings);
2035
+ await withFileMutationLock(SETTINGS_FILE, async () => {
2036
+ await writeSettings(importedSettings);
2037
+ });
1830
2038
  return json(res, 200, toSettingsResponse(importedSettings));
1831
2039
  } catch (e) {
1832
2040
  if (isPayloadTooLargeError(e)) {
@@ -1846,8 +2054,13 @@ const server = http.createServer(async (req, res) => {
1846
2054
  try {
1847
2055
  const body = await readBody(req);
1848
2056
  const normalized = normalizeIncomingData(body);
1849
- writeData(normalized);
1850
- recordDataLoad('file');
2057
+ await withSettingsAndDataMutationLock(async () => {
2058
+ await writeData(normalized);
2059
+ await updateDataLoadState({
2060
+ lastLoadedAt: new Date().toISOString(),
2061
+ lastLoadSource: 'file',
2062
+ });
2063
+ });
1851
2064
  const days = normalized.daily.length;
1852
2065
  const totalCost = normalized.totals.totalCost;
1853
2066
  return json(res, 200, { days, totalCost });
@@ -1875,10 +2088,16 @@ const server = http.createServer(async (req, res) => {
1875
2088
  try {
1876
2089
  const body = await readBody(req);
1877
2090
  const importedData = normalizeIncomingData(extractUsageImportPayload(body));
1878
- const currentData = readData();
1879
- const result = mergeUsageData(currentData, importedData);
1880
- writeData(result.data);
1881
- recordDataLoad('file');
2091
+ const result = await withSettingsAndDataMutationLock(async () => {
2092
+ const currentData = readData();
2093
+ const merged = mergeUsageData(currentData, importedData);
2094
+ await writeData(merged.data);
2095
+ await updateDataLoadState({
2096
+ lastLoadedAt: new Date().toISOString(),
2097
+ lastLoadSource: 'file',
2098
+ });
2099
+ return merged;
2100
+ });
1882
2101
  return json(res, 200, result.summary);
1883
2102
  } catch (e) {
1884
2103
  if (isPayloadTooLargeError(e)) {
@@ -2096,6 +2315,12 @@ module.exports = {
2096
2315
  commandExists,
2097
2316
  getExecutableName,
2098
2317
  listenOnAvailablePort,
2318
+ getFileMutationLockDir,
2319
+ unlinkIfExists,
2320
+ writeJsonAtomicAsync,
2321
+ withFileMutationLock,
2322
+ withOrderedFileMutationLocks,
2323
+ getPendingFileMutationLockCount: () => fileMutationLocks.size,
2099
2324
  },
2100
2325
  };
2101
2326
 
@@ -1,19 +1,31 @@
1
1
  import type { DailyUsage, DashboardMetrics, ViewMode } from './dashboard-types'
2
2
 
3
+ /** Aggregates usage rows to the requested dashboard view mode. */
3
4
  export function aggregateToDailyFormat(data: DailyUsage[], viewMode: ViewMode): DailyUsage[]
5
+ /** Returns the busiest rolling seven-day window by cost. */
4
6
  export function computeBusiestWeek(
5
7
  data: DailyUsage[],
6
8
  ): { start: string; end: string; cost: number } | null
9
+ /** Computes the core dashboard metrics for a dataset. */
7
10
  export function computeMetrics(data: DailyUsage[]): DashboardMetrics
11
+ /** Computes a simple moving average over numeric values. */
8
12
  export function computeMovingAverage(
9
13
  values: Array<number | undefined>,
10
14
  window?: number,
11
15
  ): Array<number | undefined>
16
+ /** Computes the relative week-over-week cost change. */
12
17
  export function computeWeekOverWeekChange(data: DailyUsage[]): number | null
18
+ /** Filters usage rows by an inclusive ISO date range. */
13
19
  export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[]
20
+ /** Filters usage rows to entries that contain selected models. */
14
21
  export function filterByModels(data: DailyUsage[], selectedModels: string[]): DailyUsage[]
22
+ /** Filters usage rows to a specific calendar month. */
15
23
  export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[]
24
+ /** Filters usage rows to entries that contain selected providers. */
16
25
  export function filterByProviders(data: DailyUsage[], selectedProviders: string[]): DailyUsage[]
26
+ /** Resolves the provider name for a model identifier. */
17
27
  export function getModelProvider(raw: string): string
28
+ /** Normalizes raw model names to their dashboard label. */
18
29
  export function normalizeModelName(raw: string): string
30
+ /** Sorts usage rows in ascending date order. */
19
31
  export function sortByDate(data: DailyUsage[]): DailyUsage[]
@@ -132,6 +132,12 @@ function parseOSeries(name) {
132
132
  return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`
133
133
  }
134
134
 
135
+ /**
136
+ * Normalizes raw model names to their dashboard label.
137
+ *
138
+ * @param raw - The raw model identifier.
139
+ * @returns The normalized display name.
140
+ */
135
141
  function normalizeModelName(raw) {
136
142
  const canonical = canonicalizeModelName(raw)
137
143
 
@@ -178,6 +184,12 @@ function normalizeModelName(raw) {
178
184
  return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '')
179
185
  }
180
186
 
187
+ /**
188
+ * Resolves the provider name for a model identifier.
189
+ *
190
+ * @param raw - The raw model identifier.
191
+ * @returns The normalized provider name.
192
+ */
181
193
  function getModelProvider(raw) {
182
194
  const canonical = canonicalizeModelName(raw)
183
195
  for (const matcher of PROVIDER_MATCHERS) {
@@ -223,6 +235,14 @@ function recalculateDayFromBreakdowns(day, filteredBreakdowns) {
223
235
  }
224
236
  }
225
237
 
238
+ /**
239
+ * Filters usage rows by an inclusive ISO date range.
240
+ *
241
+ * @param data - The source usage rows.
242
+ * @param start - The optional start date.
243
+ * @param end - The optional end date.
244
+ * @returns The filtered usage rows.
245
+ */
226
246
  function filterByDateRange(data, start, end) {
227
247
  return data.filter((entry) => {
228
248
  if (start && entry.date < start) return false
@@ -231,6 +251,13 @@ function filterByDateRange(data, start, end) {
231
251
  })
232
252
  }
233
253
 
254
+ /**
255
+ * Filters usage rows to entries that contain selected models.
256
+ *
257
+ * @param data - The source usage rows.
258
+ * @param selectedModels - The normalized model names to keep.
259
+ * @returns The filtered usage rows.
260
+ */
234
261
  function filterByModels(data, selectedModels) {
235
262
  if (!selectedModels || selectedModels.length === 0) return data
236
263
  const selected = new Set(selectedModels)
@@ -247,6 +274,13 @@ function filterByModels(data, selectedModels) {
247
274
  .filter(Boolean)
248
275
  }
249
276
 
277
+ /**
278
+ * Filters usage rows to entries that contain selected providers.
279
+ *
280
+ * @param data - The source usage rows.
281
+ * @param selectedProviders - The provider names to keep.
282
+ * @returns The filtered usage rows.
283
+ */
250
284
  function filterByProviders(data, selectedProviders) {
251
285
  if (!selectedProviders || selectedProviders.length === 0) return data
252
286
  const selected = new Set(selectedProviders)
@@ -263,15 +297,35 @@ function filterByProviders(data, selectedProviders) {
263
297
  .filter(Boolean)
264
298
  }
265
299
 
300
+ /**
301
+ * Filters usage rows to a specific calendar month.
302
+ *
303
+ * @param data - The source usage rows.
304
+ * @param month - The month in YYYY-MM format.
305
+ * @returns The filtered usage rows.
306
+ */
266
307
  function filterByMonth(data, month) {
267
308
  if (!month) return data
268
309
  return data.filter((entry) => entry.date.startsWith(month))
269
310
  }
270
311
 
312
+ /**
313
+ * Sorts usage rows in ascending date order.
314
+ *
315
+ * @param data - The source usage rows.
316
+ * @returns A date-sorted copy of the input.
317
+ */
271
318
  function sortByDate(data) {
272
319
  return [...data].sort((left, right) => left.date.localeCompare(right.date))
273
320
  }
274
321
 
322
+ /**
323
+ * Aggregates usage rows to the requested dashboard view mode.
324
+ *
325
+ * @param data - The source daily usage rows.
326
+ * @param viewMode - The target aggregation mode.
327
+ * @returns The aggregated usage rows.
328
+ */
275
329
  function aggregateToDailyFormat(data, viewMode) {
276
330
  if (viewMode === 'daily') return data
277
331
 
@@ -309,6 +363,13 @@ function aggregateToDailyFormat(data, viewMode) {
309
363
  return Array.from(groups.values()).sort((left, right) => left.date.localeCompare(right.date))
310
364
  }
311
365
 
366
+ /**
367
+ * Computes a simple moving average over numeric values.
368
+ *
369
+ * @param values - The source numeric values.
370
+ * @param window - The moving average window size.
371
+ * @returns The moving-average series.
372
+ */
312
373
  function computeMovingAverage(values, window = 7) {
313
374
  const result = Array(values.length)
314
375
  let sum = 0
@@ -343,6 +404,12 @@ function stdDev(values) {
343
404
  return Math.sqrt(variance)
344
405
  }
345
406
 
407
+ /**
408
+ * Returns the busiest rolling seven-day window by cost.
409
+ *
410
+ * @param data - The source usage rows.
411
+ * @returns The top seven-day window or null when unavailable.
412
+ */
346
413
  function computeBusiestWeek(data) {
347
414
  const sorted = data
348
415
  .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date))
@@ -383,6 +450,12 @@ function computeBusiestWeek(data) {
383
450
  return bestWindow
384
451
  }
385
452
 
453
+ /**
454
+ * Computes the relative week-over-week cost change.
455
+ *
456
+ * @param data - The source usage rows.
457
+ * @returns The relative week-over-week delta.
458
+ */
386
459
  function computeWeekOverWeekChange(data) {
387
460
  if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null
388
461
  if (data.length < 14) return null
@@ -395,6 +468,12 @@ function computeWeekOverWeekChange(data) {
395
468
  return ((lastSum - prevSum) / prevSum) * 100
396
469
  }
397
470
 
471
+ /**
472
+ * Computes the core dashboard metrics for a dataset.
473
+ *
474
+ * @param data - The source usage rows.
475
+ * @returns The derived dashboard metrics.
476
+ */
398
477
  function computeMetrics(data) {
399
478
  if (data.length === 0) {
400
479
  return {
@@ -1,3 +1,4 @@
1
+ /** Describes per-model usage totals for one period. */
1
2
  export interface ModelBreakdown {
2
3
  modelName: string
3
4
  inputTokens: number
@@ -9,6 +10,7 @@ export interface ModelBreakdown {
9
10
  requestCount: number
10
11
  }
11
12
 
13
+ /** Describes aggregated usage for one daily, monthly, or yearly period. */
12
14
  export interface DailyUsage {
13
15
  date: string
14
16
  inputTokens: number
@@ -24,8 +26,10 @@ export interface DailyUsage {
24
26
  _aggregatedDays?: number
25
27
  }
26
28
 
29
+ /** Lists the supported dashboard aggregation modes. */
27
30
  export type ViewMode = 'daily' | 'monthly' | 'yearly'
28
31
 
32
+ /** Collects high-level metrics derived from the current dataset. */
29
33
  export interface DashboardMetrics {
30
34
  totalCost: number
31
35
  totalTokens: number
@@ -1,17 +1,24 @@
1
+ /** Lists the supported shared model color themes. */
1
2
  export type ModelColorTheme = 'dark' | 'light'
2
3
 
4
+ /** Describes the HSL values assigned to one model color. */
3
5
  export interface ModelColorSpec {
4
6
  h: number
5
7
  s: number
6
8
  l: number
7
9
  }
8
10
 
11
+ /** Configures shared model color resolution. */
9
12
  export interface ModelColorOptions {
10
13
  theme?: ModelColorTheme
11
14
  alpha?: number
12
15
  }
13
16
 
17
+ /** Normalizes an unknown theme value to a supported color theme. */
14
18
  export function normalizeTheme(theme?: string): ModelColorTheme
19
+ /** Returns the shared color spec for a normalized model name. */
15
20
  export function getModelColorSpec(name: string, options?: ModelColorOptions): ModelColorSpec
21
+ /** Returns the shared model color as an HSL string. */
16
22
  export function getModelColor(name: string, options?: ModelColorOptions): string
23
+ /** Returns the shared model color as an RGB string. */
17
24
  export function getModelColorRgb(name: string, options?: ModelColorOptions): string