tokentracker-cli 0.77.2 → 0.79.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.
Files changed (101) hide show
  1. package/README.de.md +21 -0
  2. package/README.md +21 -0
  3. package/dashboard/dist/achievements/README.md +9 -0
  4. package/dashboard/dist/achievements/arctic-code-vault-contributor.png +0 -0
  5. package/dashboard/dist/achievements/galaxy-brain.png +0 -0
  6. package/dashboard/dist/achievements/heart-on-your-sleeve.png +0 -0
  7. package/dashboard/dist/achievements/open-sourcerer.png +0 -0
  8. package/dashboard/dist/achievements/pair-extraordinaire.png +0 -0
  9. package/dashboard/dist/achievements/proxima-pioneer.png +0 -0
  10. package/dashboard/dist/achievements/proxima-staffshipper.png +0 -0
  11. package/dashboard/dist/achievements/proxima-staffuser.png +0 -0
  12. package/dashboard/dist/achievements/public-sponsor.png +0 -0
  13. package/dashboard/dist/achievements/pull-shark.png +0 -0
  14. package/dashboard/dist/achievements/quickdraw.png +0 -0
  15. package/dashboard/dist/achievements/starstruck.png +0 -0
  16. package/dashboard/dist/assets/AchievementBadge-BxETKM1a.js +1 -0
  17. package/dashboard/dist/assets/AchievementsPage-BnsI7sbt.js +2 -0
  18. package/dashboard/dist/assets/AchievementsSection-BjKtfATU.js +1 -0
  19. package/dashboard/dist/assets/{ActivityHeatmap-CH6m_ZJM.js → ActivityHeatmap-B2NacPjc.js} +1 -1
  20. package/dashboard/dist/assets/BadgeDetailModal-BMNUpuro.js +1 -0
  21. package/dashboard/dist/assets/{Card-D_aKGDr7.js → Card-Y5og8UqF.js} +1 -1
  22. package/dashboard/dist/assets/{ClawdAnimated-vRiBiNhb.js → ClawdAnimated-4EGVuTbr.js} +1 -1
  23. package/dashboard/dist/assets/{CommunityStatsModal-CjetJZ-5.js → CommunityStatsModal-DgQ5Cv9h.js} +1 -1
  24. package/dashboard/dist/assets/ConfirmModal-DNDMMsf7.js +1 -0
  25. package/dashboard/dist/assets/Controls-BzQP6AmV.js +1 -0
  26. package/dashboard/dist/assets/DashboardPage-C5nQV_pg.js +19 -0
  27. package/dashboard/dist/assets/DevicePage-Hc411Mif.js +1 -0
  28. package/dashboard/dist/assets/{DialogClose-hhTDeRp1.js → DialogClose-DG2e5iky.js} +1 -1
  29. package/dashboard/dist/assets/DialogDescription-CD7W4GGZ.js +1 -0
  30. package/dashboard/dist/assets/{DialogTitle-CA5znwms.js → DialogTitle-BlZXQboq.js} +1 -1
  31. package/dashboard/dist/assets/{FadeIn--ff4ZbC-.js → FadeIn-bR4Q4FyK.js} +1 -1
  32. package/dashboard/dist/assets/{HeaderGithubStar-CTZvanhz.js → HeaderGithubStar-iujOMm58.js} +1 -1
  33. package/dashboard/dist/assets/{IpCheckPage-Bg47gNVu.js → IpCheckPage-BVb5O-Nc.js} +4 -4
  34. package/dashboard/dist/assets/{LandingPage-DZ8Mz38n.js → LandingPage-B5JCSXIZ.js} +1 -1
  35. package/dashboard/dist/assets/LeaderboardAvatar-C5o48_SY.js +1 -0
  36. package/dashboard/dist/assets/LeaderboardPage-CNOPBahO.js +2 -0
  37. package/dashboard/dist/assets/LeaderboardProfileModal-Buo6oZZk.js +73 -0
  38. package/dashboard/dist/assets/{LeaderboardProfilePage-C4bGXJCE.js → LeaderboardProfilePage-DljcZHf7.js} +3 -3
  39. package/dashboard/dist/assets/{LimitsPage-BAQ10X55.js → LimitsPage-BOaJ4wfo.js} +1 -1
  40. package/dashboard/dist/assets/{LocalOnlyNotice-gtHCn7Mx.js → LocalOnlyNotice-BBxetUgv.js} +1 -1
  41. package/dashboard/dist/assets/LoginPage-LYriDN1G.js +1 -0
  42. package/dashboard/dist/assets/PetPage-CPPVItLo.js +1 -0
  43. package/dashboard/dist/assets/{PopoverPopup-CQxxgUXk.js → PopoverPopup-iP8jGLgj.js} +1 -1
  44. package/dashboard/dist/assets/ResetPasswordPage-BqAwHe1w.js +1 -0
  45. package/dashboard/dist/assets/Select-DdpTxWI0.js +1 -0
  46. package/dashboard/dist/assets/{SelectItemText-2LjzGvo5.js → SelectItemText-Cpno_HPs.js} +1 -1
  47. package/dashboard/dist/assets/SettingsPage-DtKCHKrt.js +1 -0
  48. package/dashboard/dist/assets/SkillsPage-D5vXnEab.js +1 -0
  49. package/dashboard/dist/assets/WidgetsPage-BIPU747m.js +1 -0
  50. package/dashboard/dist/assets/{WrappedPage-YxshTScX.js → WrappedPage-VfTcO6cY.js} +1 -1
  51. package/dashboard/dist/assets/{agent-logos-SeuxwQsp.js → agent-logos-sYidWnQM.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-up-right-Bm3t-iA0.js → arrow-up-right-Dt35aRs9.js} +1 -1
  53. package/dashboard/dist/assets/check-B2kgoEGi.js +1 -0
  54. package/dashboard/dist/assets/{copy-DUNz-xWm.js → copy-BdlX1Dcz.js} +1 -1
  55. package/dashboard/dist/assets/{download-COSLX73O.js → download-C4750cH0.js} +1 -1
  56. package/dashboard/dist/assets/flame-cYIuY1Z6.js +1 -0
  57. package/dashboard/dist/assets/{format-tokens-CR28AiQO.js → format-tokens-Ci6ia4_b.js} +1 -1
  58. package/dashboard/dist/assets/{info-Cm7V_KDO.js → info-Hy8GSxzK.js} +1 -1
  59. package/dashboard/dist/assets/{link-2-CYqDGrkv.js → link-2-CHfhV9M_.js} +1 -1
  60. package/dashboard/dist/assets/{loader-circle-6XAdy2ec.js → loader-circle-OYZpObE-.js} +1 -1
  61. package/dashboard/dist/assets/main--YQ6HWXP.js +1237 -0
  62. package/dashboard/dist/assets/main-BY13exWE.css +1 -0
  63. package/dashboard/dist/assets/{trash-2-DKLewOQb.js → trash-2-DFV8X7qB.js} +1 -1
  64. package/dashboard/dist/assets/{use-community-stats-AdPaun74.js → use-community-stats-BzlENlsN.js} +1 -1
  65. package/dashboard/dist/assets/{use-limits-display-prefs-Bau4ykMw.js → use-limits-display-prefs-Bgxvo849.js} +1 -1
  66. package/dashboard/dist/assets/use-native-settings-Cz5XgWcy.js +1 -0
  67. package/dashboard/dist/assets/{use-ordered-list-BVaEJaWa.js → use-ordered-list-N591-3J4.js} +2 -2
  68. package/dashboard/dist/assets/{use-quality-per-dollar-pref-Htlc-uFe.js → use-quality-per-dollar-pref-CW332Qtp.js} +1 -1
  69. package/dashboard/dist/assets/use-transform-BNrGbsUe.js +1 -0
  70. package/dashboard/dist/assets/{use-usage-limits-BnzWTJQS.js → use-usage-limits-BL1n1H9j.js} +1 -1
  71. package/dashboard/dist/assets/{useCurrency-bd6TLRsc.js → useCurrency-B5QegAgS.js} +1 -1
  72. package/dashboard/dist/assets/{useScrollLock-ajoWrfku.js → useScrollLock-CcorrDyq.js} +1 -1
  73. package/dashboard/dist/assets/zap-C_DbBJ3s.js +1 -0
  74. package/dashboard/dist/index.html +2 -2
  75. package/dashboard/dist/share.html +2 -2
  76. package/package.json +1 -1
  77. package/src/commands/status.js +119 -12
  78. package/src/commands/sync.js +158 -83
  79. package/src/lib/install-resolver.js +1 -0
  80. package/src/lib/local-api.js +315 -56
  81. package/src/lib/pricing/seed-snapshot.json +1 -1
  82. package/src/lib/rollout.js +18 -2
  83. package/dashboard/dist/assets/ConfirmModal-C6bZw6FZ.js +0 -1
  84. package/dashboard/dist/assets/Controls-Cj22oGL3.js +0 -1
  85. package/dashboard/dist/assets/DashboardPage-DPxiQkQw.js +0 -19
  86. package/dashboard/dist/assets/DevicePage-YAo4ert4.js +0 -1
  87. package/dashboard/dist/assets/DialogDescription-gxEpcUbJ.js +0 -1
  88. package/dashboard/dist/assets/LeaderboardAvatar-3T57GKKK.js +0 -1
  89. package/dashboard/dist/assets/LeaderboardPage-wP_yCU9I.js +0 -2
  90. package/dashboard/dist/assets/LeaderboardProfileModal-CfVMENby.js +0 -72
  91. package/dashboard/dist/assets/LoginPage-TXvIBzoW.js +0 -1
  92. package/dashboard/dist/assets/PetPage-Dj1jrxYe.js +0 -1
  93. package/dashboard/dist/assets/ResetPasswordPage-tN0tmSfP.js +0 -1
  94. package/dashboard/dist/assets/Select-DnbHYPS4.js +0 -1
  95. package/dashboard/dist/assets/SettingsPage-DosKS_L6.js +0 -1
  96. package/dashboard/dist/assets/SkillsPage-DaCJ25vT.js +0 -1
  97. package/dashboard/dist/assets/WidgetsPage-CCGemQVD.js +0 -1
  98. package/dashboard/dist/assets/check-D8Owfjyw.js +0 -1
  99. package/dashboard/dist/assets/main-BCstrDYE.js +0 -1172
  100. package/dashboard/dist/assets/main-BYdJ5vWR.css +0 -1
  101. package/dashboard/dist/assets/use-native-settings-CidwlprT.js +0 -1
@@ -44,6 +44,11 @@ const {
44
44
 
45
45
  const { computeCodexContextBreakdown } = require("./codex-context-breakdown");
46
46
 
47
+ const {
48
+ deriveProjectKeyFromRef,
49
+ CLAUDE_MEM_OBSERVER_PROJECT_REF,
50
+ } = require("./rollout");
51
+
47
52
  // ---------------------------------------------------------------------------
48
53
  // Queue data helpers
49
54
  // ---------------------------------------------------------------------------
@@ -53,6 +58,38 @@ function resolveQueuePath() {
53
58
  return path.join(home, ".tokentracker", "tracker", "queue.jsonl");
54
59
  }
55
60
 
61
+ // Pseudo-project written by the pre-fix claude-mem observer attribution;
62
+ // mirrored Claude Code sessions, not a real repository. Excluded at read
63
+ // time so historical rows never surface on the Project Usage panel. Derived
64
+ // from rollout.js's ref so a rename there cannot silently desync the filter.
65
+ const CLAUDE_MEM_OBSERVER_PROJECT_KEY = deriveProjectKeyFromRef(
66
+ CLAUDE_MEM_OBSERVER_PROJECT_REF,
67
+ );
68
+ const PROJECT_USAGE_MAX_ENTRIES = 10;
69
+
70
+ // Shared by project-usage-summary and project-usage-detail: reads the
71
+ // deduped project bucket log, drops the claude-mem pseudo project, and
72
+ // builds the from/to day-range predicate. Callers compute each row's day
73
+ // key ONCE (rowDayKey constructs an Intl.DateTimeFormat per call — the
74
+ // dominant per-row cost) and reuse it for both the range check and any
75
+ // day bucketing.
76
+ function readProjectUsageContext(qp, url) {
77
+ const from = url.searchParams.get("from") || "";
78
+ const to = url.searchParams.get("to") || "";
79
+ const timeZoneContext = getTimeZoneContext(url);
80
+ const hasRange = Boolean(from || to);
81
+ const dayInRange = (day) => {
82
+ if (!hasRange) return true;
83
+ if (!day) return false;
84
+ return (!from || day >= from) && (!to || day <= to);
85
+ };
86
+ const projectQueuePath = path.join(path.dirname(qp), "project.queue.jsonl");
87
+ const projectRows = readProjectQueueData(projectQueuePath).filter(
88
+ (row) => row.project_key !== CLAUDE_MEM_OBSERVER_PROJECT_KEY,
89
+ );
90
+ return { from, to, timeZoneContext, hasRange, dayInRange, projectRows };
91
+ }
92
+
56
93
  function readProjectQueueData(projectQueuePath) {
57
94
  let raw;
58
95
  try {
@@ -327,6 +364,104 @@ function scopedQueueRows(queuePath, url) {
327
364
  };
328
365
  }
329
366
 
367
+ // ── Local achievements ───────────────────────────────────────────────────────
368
+ // Local-only badges (the cloud nine live in scripts/ops/user-badges.sql).
369
+ // Thresholds are ordered bronze → silver → gold → diamond. This module is the
370
+ // single server-side home for LOCAL thresholds; the dashboard renders whatever
371
+ // the payload says and embeds none of these numbers.
372
+ const LOCAL_BADGE_THRESHOLDS = {
373
+ project_hopper: [3, 5, 10, 20], // distinct projects
374
+ project_devotion: [1000000, 10000000, 100000000, 1000000000], // max tokens in one project
375
+ night_owl: [5, 20, 60, 150], // active hour buckets between 00:00–05:59 local
376
+ };
377
+
378
+ const LOCAL_TIER_KEYS = ["bronze", "silver", "gold", "diamond"];
379
+
380
+ /**
381
+ * Compute the local badge set from deduped queue rows.
382
+ * Rows are replayed in hour_start order so each tier's `achieved` timestamp is
383
+ * the hour at which the running metric first crossed that threshold. Local
384
+ * time (night_owl) follows the caller's tz query params like every other
385
+ * usage endpoint.
386
+ */
387
+ function computeLocalAchievements(queueRows, projectRows, { timeZoneContext } = {}) {
388
+ const sortByHour = (rows) =>
389
+ rows
390
+ .filter((row) => row && row.hour_start)
391
+ .slice()
392
+ .sort((a, b) => String(a.hour_start).localeCompare(String(b.hour_start)));
393
+
394
+ const trackers = {
395
+ project_hopper: { value: 0, achieved: {}, meta: {} },
396
+ project_devotion: { value: 0, achieved: {}, meta: {} },
397
+ night_owl: { value: 0, achieved: {}, meta: {} },
398
+ };
399
+
400
+ const bump = (badgeId, newValue, atIso, meta) => {
401
+ const tracker = trackers[badgeId];
402
+ if (newValue <= tracker.value) return;
403
+ tracker.value = newValue;
404
+ if (meta) tracker.meta = meta;
405
+ const thresholds = LOCAL_BADGE_THRESHOLDS[badgeId];
406
+ for (let i = 0; i < thresholds.length; i += 1) {
407
+ const tierKey = LOCAL_TIER_KEYS[i];
408
+ if (newValue >= thresholds[i] && !tracker.achieved[tierKey]) {
409
+ tracker.achieved[tierKey] = atIso;
410
+ }
411
+ }
412
+ };
413
+
414
+ const seenProjects = new Set();
415
+ const perProjectTokens = new Map();
416
+ for (const row of sortByHour(projectRows || [])) {
417
+ const projectKey = row.project_key;
418
+ const tokens = Number(row.total_tokens || 0);
419
+ if (!projectKey || tokens <= 0) continue;
420
+ if (!seenProjects.has(projectKey)) {
421
+ seenProjects.add(projectKey);
422
+ bump("project_hopper", seenProjects.size, row.hour_start);
423
+ }
424
+ const running = (perProjectTokens.get(projectKey) || 0) + tokens;
425
+ perProjectTokens.set(projectKey, running);
426
+ if (running > trackers.project_devotion.value) {
427
+ bump("project_devotion", running, row.hour_start, { project_key: projectKey });
428
+ }
429
+ }
430
+
431
+ const nightHours = new Set();
432
+ for (const row of sortByHour(queueRows || [])) {
433
+ if (Number(row.total_tokens || 0) <= 0) continue;
434
+ if (nightHours.has(row.hour_start)) continue;
435
+ const parts = getZonedParts(new Date(row.hour_start), timeZoneContext || {});
436
+ if (!parts || parts.hour >= 6) continue;
437
+ nightHours.add(row.hour_start);
438
+ bump("night_owl", nightHours.size, row.hour_start);
439
+ }
440
+
441
+ return Object.entries(LOCAL_BADGE_THRESHOLDS).map(([badgeId, thresholds]) => {
442
+ const tracker = trackers[badgeId];
443
+ let tier = 0;
444
+ for (let i = 0; i < thresholds.length; i += 1) {
445
+ if (tracker.value >= thresholds[i]) tier = i + 1;
446
+ }
447
+ return {
448
+ id: badgeId,
449
+ tier,
450
+ metric_value: tracker.value,
451
+ thresholds: thresholds.slice(),
452
+ lower_is_better: false,
453
+ next_threshold: tier >= 4 ? null : thresholds[tier],
454
+ achieved: {
455
+ bronze: tracker.achieved.bronze || null,
456
+ silver: tracker.achieved.silver || null,
457
+ gold: tracker.achieved.gold || null,
458
+ diamond: tracker.achieved.diamond || null,
459
+ },
460
+ meta: tracker.meta,
461
+ };
462
+ });
463
+ }
464
+
330
465
  function getTimeZoneContext(url) {
331
466
  const tz = String(url.searchParams.get("tz") || "").trim();
332
467
  const rawOffset = Number(url.searchParams.get("tz_offset_minutes"));
@@ -1912,76 +2047,185 @@ function createLocalApiHandler({ queuePath }) {
1912
2047
  // hour_start). Falling back to "session-file count × total tokens"
1913
2048
  // (the old behavior) produced pure fiction: every short-and-hot
1914
2049
  // project got the same weight as every long-and-cold one.
1915
- const projectQueuePath = path.join(
1916
- path.dirname(qp),
1917
- "project.queue.jsonl",
1918
- );
1919
- const projectRows = readProjectQueueData(projectQueuePath);
1920
-
1921
- const byProject = new Map();
1922
- for (const row of projectRows) {
1923
- const key = row.project_key || "unknown";
1924
- if (!byProject.has(key)) {
1925
- byProject.set(key, {
1926
- project_key: key,
1927
- project_ref: row.project_ref || key,
1928
- total_tokens: 0,
1929
- billable_total_tokens: 0,
1930
- });
1931
- }
1932
- const agg = byProject.get(key);
1933
- agg.total_tokens += Number(row.total_tokens || 0);
1934
- agg.billable_total_tokens += Number(row.total_tokens || 0);
1935
- if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
1936
- }
1937
-
1938
- // If no project-attributed rows exist yet (user hasn't synced project
1939
- // attribution, or never used a project-capable CLI), fall back to
1940
- // per-source aggregation over the main queue so the panel isn't
1941
- // totally empty. This path used to also exist for the non-empty case
1942
- // and produce wrong numbers; keep it only as the empty fallback.
1943
- let entries;
1944
- if (byProject.size === 0) {
1945
- const rows = readQueueData(qp);
1946
- const bySrc = new Map();
2050
+ const limitParam = Number(url.searchParams.get("limit"));
2051
+ const limit = Number.isFinite(limitParam) && limitParam > 0
2052
+ ? Math.min(Math.floor(limitParam), PROJECT_USAGE_MAX_ENTRIES)
2053
+ : PROJECT_USAGE_MAX_ENTRIES;
2054
+ const { timeZoneContext, hasRange, dayInRange, projectRows } =
2055
+ readProjectUsageContext(qp, url);
2056
+
2057
+ const aggregateEntries = (rows, keyOf, refOf) => {
2058
+ const byKey = new Map();
1947
2059
  for (const row of rows) {
1948
- const src = row.source || "unknown";
1949
- if (!bySrc.has(src)) {
1950
- bySrc.set(src, {
1951
- project_key: src,
1952
- // Synthetic source-only row: leave project_ref empty rather than
1953
- // fabricating `https://${src}.ai`, which resolves to unrelated
1954
- // domains (e.g. codex.ai, cursor.ai) and was sent to the
1955
- // dashboard as a clickable href before v0.11.1 / this commit.
1956
- project_ref: "",
2060
+ if (hasRange && !dayInRange(rowDayKey(row, timeZoneContext))) continue;
2061
+ const key = keyOf(row);
2062
+ if (!byKey.has(key)) {
2063
+ byKey.set(key, {
2064
+ project_key: key,
2065
+ project_ref: refOf(row),
1957
2066
  total_tokens: 0,
1958
2067
  billable_total_tokens: 0,
2068
+ input_tokens: 0,
2069
+ output_tokens: 0,
2070
+ cached_input_tokens: 0,
2071
+ cache_creation_input_tokens: 0,
2072
+ reasoning_output_tokens: 0,
2073
+ conversation_count: 0,
2074
+ sourceTotals: new Map(),
1959
2075
  });
1960
2076
  }
1961
- bySrc.get(src).total_tokens += row.total_tokens || 0;
1962
- bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
2077
+ const agg = byKey.get(key);
2078
+ agg.total_tokens += Number(row.total_tokens || 0);
2079
+ agg.billable_total_tokens += Number(
2080
+ row.billable_total_tokens ?? row.total_tokens ?? 0,
2081
+ );
2082
+ agg.input_tokens += Number(row.input_tokens || 0);
2083
+ agg.output_tokens += Number(row.output_tokens || 0);
2084
+ agg.cached_input_tokens += Number(row.cached_input_tokens || 0);
2085
+ agg.cache_creation_input_tokens += Number(row.cache_creation_input_tokens || 0);
2086
+ agg.reasoning_output_tokens += Number(row.reasoning_output_tokens || 0);
2087
+ agg.conversation_count += Number(row.conversation_count || 0);
2088
+ if (!agg.project_ref) agg.project_ref = refOf(row);
2089
+ const src = row.source || "unknown";
2090
+ agg.sourceTotals.set(
2091
+ src,
2092
+ (agg.sourceTotals.get(src) || 0) + Number(row.total_tokens || 0),
2093
+ );
1963
2094
  }
1964
- entries = Array.from(bySrc.values())
1965
- .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
1966
- .map((e) => ({
1967
- ...e,
1968
- total_tokens: String(e.total_tokens),
1969
- billable_total_tokens: String(e.billable_total_tokens),
1970
- }));
1971
- } else {
1972
- entries = Array.from(byProject.values())
2095
+ return Array.from(byKey.values())
1973
2096
  .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
1974
- .map((e) => ({
1975
- ...e,
1976
- total_tokens: String(e.total_tokens),
1977
- billable_total_tokens: String(e.billable_total_tokens),
2097
+ .slice(0, limit)
2098
+ .map(({ sourceTotals, ...entry }) => ({
2099
+ ...entry,
2100
+ total_tokens: String(entry.total_tokens),
2101
+ billable_total_tokens: String(entry.billable_total_tokens),
2102
+ sources: Array.from(sourceTotals.entries())
2103
+ .sort((a, b) => b[1] - a[1])
2104
+ .map(([source, totalTokens]) => ({ source, total_tokens: totalTokens })),
1978
2105
  }));
2106
+ };
2107
+
2108
+ let entries = aggregateEntries(
2109
+ projectRows,
2110
+ (row) => row.project_key || "unknown",
2111
+ (row) => row.project_ref || "",
2112
+ );
2113
+
2114
+ // If no project-attributed rows exist yet (user hasn't synced project
2115
+ // attribution, or never used a project-capable CLI), fall back to
2116
+ // per-source aggregation over the main queue so the panel isn't
2117
+ // totally empty. This path used to also exist for the non-empty case
2118
+ // and produce wrong numbers; keep it only as the empty fallback.
2119
+ if (entries.length === 0 && projectRows.length === 0) {
2120
+ entries = aggregateEntries(
2121
+ readQueueData(qp),
2122
+ (row) => row.source || "unknown",
2123
+ // Synthetic source-only row: leave project_ref empty rather than
2124
+ // fabricating `https://${src}.ai`, which resolves to unrelated
2125
+ // domains (e.g. codex.ai, cursor.ai) and was sent to the
2126
+ // dashboard as a clickable href before v0.11.1 / this commit.
2127
+ () => "",
2128
+ );
1979
2129
  }
1980
2130
 
1981
2131
  json(res, { generated_at: new Date().toISOString(), entries });
1982
2132
  return true;
1983
2133
  }
1984
2134
 
2135
+ // --- project-usage-detail ---
2136
+ // Per-project drill-down for the Project Usage modal: daily series,
2137
+ // per-source breakdown, and aggregate stats. Everything comes from the
2138
+ // local project bucket log — no external requests, no git access.
2139
+ if (p === "/functions/tokentracker-project-usage-detail") {
2140
+ const projectKey = url.searchParams.get("project_key") || "";
2141
+ if (!projectKey) {
2142
+ json(res, { error: "missing_project_key" }, 400);
2143
+ return true;
2144
+ }
2145
+ const { from, to, timeZoneContext, dayInRange, projectRows } =
2146
+ readProjectUsageContext(qp, url);
2147
+
2148
+ const emptyMeasures = () => ({
2149
+ total_tokens: 0,
2150
+ billable_total_tokens: 0,
2151
+ input_tokens: 0,
2152
+ output_tokens: 0,
2153
+ cached_input_tokens: 0,
2154
+ cache_creation_input_tokens: 0,
2155
+ reasoning_output_tokens: 0,
2156
+ conversation_count: 0,
2157
+ });
2158
+ const addMeasures = (acc, row) => {
2159
+ acc.total_tokens += Number(row.total_tokens || 0);
2160
+ acc.billable_total_tokens += Number(
2161
+ row.billable_total_tokens ?? row.total_tokens ?? 0,
2162
+ );
2163
+ acc.input_tokens += Number(row.input_tokens || 0);
2164
+ acc.output_tokens += Number(row.output_tokens || 0);
2165
+ acc.cached_input_tokens += Number(row.cached_input_tokens || 0);
2166
+ acc.cache_creation_input_tokens += Number(row.cache_creation_input_tokens || 0);
2167
+ acc.reasoning_output_tokens += Number(row.reasoning_output_tokens || 0);
2168
+ acc.conversation_count += Number(row.conversation_count || 0);
2169
+ };
2170
+
2171
+ let projectRef = "";
2172
+ const totals = emptyMeasures();
2173
+ const byDay = new Map();
2174
+ const bySource = new Map();
2175
+ let rangeTotalTokens = 0;
2176
+
2177
+ for (const row of projectRows) {
2178
+ const day = rowDayKey(row, timeZoneContext);
2179
+ if (!dayInRange(day)) continue;
2180
+ rangeTotalTokens += Number(row.total_tokens || 0);
2181
+ if (row.project_key !== projectKey) continue;
2182
+ if (!projectRef && row.project_ref) projectRef = row.project_ref;
2183
+ addMeasures(totals, row);
2184
+
2185
+ const src = row.source || "unknown";
2186
+ if (day) {
2187
+ // `models` keyed by source (project rows carry no model column) —
2188
+ // it feeds TrendMonitor's stacked-segment slot so the modal chart
2189
+ // colors by provider exactly like the dashboard's Usage Trend.
2190
+ if (!byDay.has(day)) byDay.set(day, { day, ...emptyMeasures(), models: {} });
2191
+ const dayAgg = byDay.get(day);
2192
+ addMeasures(dayAgg, row);
2193
+ dayAgg.models[src] = (dayAgg.models[src] || 0) + Number(row.total_tokens || 0);
2194
+ }
2195
+
2196
+ if (!bySource.has(src)) {
2197
+ bySource.set(src, {
2198
+ source: src,
2199
+ total_tokens: 0,
2200
+ conversation_count: 0,
2201
+ dayKeys: new Set(),
2202
+ });
2203
+ }
2204
+ const srcAgg = bySource.get(src);
2205
+ srcAgg.total_tokens += Number(row.total_tokens || 0);
2206
+ srcAgg.conversation_count += Number(row.conversation_count || 0);
2207
+ if (day) srcAgg.dayKeys.add(day);
2208
+ }
2209
+
2210
+ json(res, {
2211
+ generated_at: new Date().toISOString(),
2212
+ from,
2213
+ to,
2214
+ project_key: projectKey,
2215
+ project_ref: projectRef,
2216
+ totals,
2217
+ days_active: byDay.size,
2218
+ // All-project total over the same range so the client can render
2219
+ // "share of everything you tracked" without a second request.
2220
+ range_total_tokens: rangeTotalTokens,
2221
+ daily: Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day)),
2222
+ sources: Array.from(bySource.values())
2223
+ .sort((a, b) => b.total_tokens - a.total_tokens)
2224
+ .map(({ dayKeys, ...src }) => ({ ...src, days_active: dayKeys.size })),
2225
+ });
2226
+ return true;
2227
+ }
2228
+
1985
2229
  // --- user-status (stub) ---
1986
2230
  if (p === "/functions/tokentracker-user-status") {
1987
2231
  json(res, {
@@ -2312,6 +2556,18 @@ function createLocalApiHandler({ queuePath }) {
2312
2556
  return true;
2313
2557
  }
2314
2558
 
2559
+ // --- achievements (local badges) ---
2560
+ if (p === "/functions/tokentracker-achievements") {
2561
+ const timeZoneContext = getTimeZoneContext(url);
2562
+ const queueRows = readQueueData(qp);
2563
+ const { projectRows } = readProjectUsageContext(qp, url);
2564
+ json(res, {
2565
+ generated_at: new Date().toISOString(),
2566
+ achievements: computeLocalAchievements(queueRows, projectRows, { timeZoneContext }),
2567
+ });
2568
+ return true;
2569
+ }
2570
+
2315
2571
  // --- usage-limits ---
2316
2572
  if (p === "/functions/tokentracker-usage-limits") {
2317
2573
  const { getUsageLimits, resetUsageLimitsCache } = require("./usage-limits");
@@ -2353,4 +2609,7 @@ module.exports = {
2353
2609
  // to the machine, not the hostname.
2354
2610
  getOrCreateMachineId,
2355
2611
  computeStableMachineId,
2612
+ // Local achievement compute — exported for test/local-achievements.test.js.
2613
+ computeLocalAchievements,
2614
+ LOCAL_BADGE_THRESHOLDS,
2356
2615
  };