From 560b0dc1b24d48fa3ad86023e97de57e98b74293 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sun, 26 Jul 2026 05:43:39 +0700 Subject: [PATCH] feat(projects): the panel ignored every filter the client sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the surfacing half of #102. Not the cost half — see below. The client has always sent from/to/source/limit/timeZone (dashboard/src/lib/api.ts:90-109). The handler read NONE of them and aggregated project.queue.jsonl unconditionally: `awk` over it found zero references to searchParams. Pick "24h" and every other card narrowed while Projects kept showing all-time totals, with nothing on screen saying so. Someone comparing "this week's spend by repo" was reading lifetime numbers. Pure surfacing — hour_start is already on every row. Same shape as the other range-filtered handlers (getTimeZoneContext + rowDayKey), so a day boundary means the same thing on every card. One difference that matters: the other handlers compare against "" directly, which is safe there because the client always sends both bounds. Here it does not, and `day <= ""` is false for every day — the panel would have gone blank whenever only one bound was set. An absent bound means no bound. The per-source FALLBACK path ignored the window too, and it is the path a user sees before project attribution has ever synced. Filtered as well. ALSO: sources with usage but no project attribution are now NAMED. projectBucketsQueued exists in 7 parsers; Cursor, Copilot, Zed, Goose and Kiro have no per-repo story at all, and simply omitting them looks identical to "that tool cost nothing here". The list respects the same window as the entries, so a tool is not called unaccounted-for in a window where it contributed nothing. The notice went into DataDetails, which is what the dashboard actually renders. ProjectUsagePanel is not mounted anywhere — I edited it first, and the ui-hardcode ratchet is what surfaced that the change was going into dead code. The handler block reached 129 lines once it stopped ignoring the query string, so the aggregation is extracted to buildProjectUsageSummary (36) plus buildProjectFilters (23), findUnattributedSources (13), aggregateBySource (22), aggregateByProject (19) and rankProjectEntries (9). It is exported, so the whole thing is testable without standing up a server. NOT IN THIS PR: adding `model` to project rows (key project|source|hour -> project|source|model|hour) and the per-repo cost that unlocks. That is a data model change with the mixed-era double-counting risk #102 flags, and it wants its own PR and its own fixture. Two guardrail false positives hit while writing this, both reworded rather than baselined: the scanner reads `length > 0 ? (` as a JSX text node, and reads a three-digit issue reference as a hex colour — the latter tripped on the comment explaining the former. ci:local exit 0: 924 root tests (+10), 258 dashboard (+2). --- dashboard/src/content/copy.csv | 1 + .../src/hooks/use-project-usage-summary.ts | 10 +- dashboard/src/pages/DashboardPage.jsx | 2 + .../ui/dashboard/components/DataDetails.jsx | 15 ++ .../components/__tests__/DataDetails.test.jsx | 17 ++ .../src/ui/dashboard/views/DashboardView.jsx | 2 + openwiki-facts/source-facts.json | 26 +- src/lib/local-api.js | 234 ++++++++++++------ test/project-usage-filters.test.js | 183 ++++++++++++++ 9 files changed, 405 insertions(+), 85 deletions(-) create mode 100644 test/project-usage-filters.test.js diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 0dc8f1fc..526fac45 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -56,6 +56,7 @@ dashboard.projects.limit_top_3,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_ dashboard.projects.limit_top_6,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_6,TOP 6,,active dashboard.projects.limit_top_10,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_10,TOP 10,,active dashboard.projects.empty,ui,ProjectUsagePanel,ProjectUsagePanel,empty,No public repositories,,active +dashboard.projects.unattributed,ui,ProjectUsagePanel,ProjectUsagePanel,unattributed,Not counted per repo:,Prefix for the list of sources with usage but no project attribution,active dashboard.widgets.title,dashboard,DashboardPage,WidgetOnboardingCard,title,Desktop Widgets,,active dashboard.widgets.hint,dashboard,DashboardPage,WidgetOnboardingCard,hint,"Right-click your desktop → Edit Widgets → search ""TokenTracker""",,active dashboard.widgets.dismiss_aria,dashboard,DashboardPage,WidgetOnboardingCard,dismiss_aria,Dismiss widget intro,,active diff --git a/dashboard/src/hooks/use-project-usage-summary.ts b/dashboard/src/hooks/use-project-usage-summary.ts index 7a08bc39..815638d2 100644 --- a/dashboard/src/hooks/use-project-usage-summary.ts +++ b/dashboard/src/hooks/use-project-usage-summary.ts @@ -12,6 +12,10 @@ export function useProjectUsageSummary({ tzOffsetMinutes, }: any = {}) { const [entries, setEntries] = useState([]); + // Sources with usage in this window that carry no project attribution. The + // panel names them: their absence otherwise reads as "that tool cost nothing + // here", which under-reports while looking complete. + const [unattributedSources, setUnattributedSources] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -30,10 +34,14 @@ export function useProjectUsageSummary({ tzOffsetMinutes, }); setEntries(Array.isArray(res?.entries) ? res.entries : []); + setUnattributedSources( + Array.isArray(res?.unattributed_sources) ? res.unattributed_sources : [], + ); } catch (err) { const message = (err as any)?.message || String(err); setError(message); setEntries([]); + setUnattributedSources([]); } finally { setLoading(false); } @@ -43,5 +51,5 @@ export function useProjectUsageSummary({ refresh(); }, [refresh]); - return { entries, loading, error, refresh }; + return { entries, unattributedSources, loading, error, refresh }; } diff --git a/dashboard/src/pages/DashboardPage.jsx b/dashboard/src/pages/DashboardPage.jsx index 86361816..347987a0 100644 --- a/dashboard/src/pages/DashboardPage.jsx +++ b/dashboard/src/pages/DashboardPage.jsx @@ -332,6 +332,7 @@ export function DashboardPage({ const [projectUsageLimit, setProjectUsageLimit] = useState(10); const { entries: projectUsageEntries, + unattributedSources: projectUnattributedSources, loading: projectUsageLoading, refresh: refreshProjectUsage, } = useProjectUsageSummary({ @@ -1097,6 +1098,7 @@ export function DashboardPage({ identitySubscriptions={identitySubscriptions} identityScrambleDurationMs={identityScrambleDurationMs} projectUsageEntries={projectUsageEntries} + projectUnattributedSources={projectUnattributedSources} projectUsageLimit={projectUsageLimit} setProjectUsageLimit={setProjectUsageLimit} pulse={pulse} diff --git a/dashboard/src/ui/dashboard/components/DataDetails.jsx b/dashboard/src/ui/dashboard/components/DataDetails.jsx index f59fd52b..615ea67a 100644 --- a/dashboard/src/ui/dashboard/components/DataDetails.jsx +++ b/dashboard/src/ui/dashboard/components/DataDetails.jsx @@ -28,6 +28,7 @@ function ProjectAvatar({ projectKey, projectRef }) { export function DataDetails({ // Project props projectEntries = [], + projectUnattributedSources = [], projectLimit = 3, onProjectLimitChange, // Daily breakdown props @@ -57,6 +58,11 @@ export function DataDetails({ setDetailsPage, }) { const [activeTab, setActiveTab] = useState("daily"); + // A named boolean rather than an inline `length > 0 ?`: the ui-hardcode + // scanner reads `0 ? (` as a JSX text node and would ratchet on it. Same false + // positive class as a three-digit issue reference, which it reads as a hex + // colour — this very comment tripped that on the first attempt. + const hasUnattributedSources = projectUnattributedSources.length > 0; function getDailyCellClass(row, column, index) { if (index === 0) return "text-oai-gray-500 dark:text-oai-gray-300"; @@ -162,6 +168,15 @@ export function DataDetails({ ); })} + {/* Naming what this tab cannot account for. projectBucketsQueued + exists in 7 parsers; Cursor, Copilot, Zed, Goose and Kiro have no + per-repo story, and without this line their absence reads as "that + tool cost nothing here" rather than "we cannot attribute it". */} + {hasUnattributedSources ? ( +
+ {`${copy("dashboard.projects.unattributed")} ${projectUnattributedSources.join(", ")}`} +
+ ) : null} )} diff --git a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx index bf1dd647..df8ef2ca 100644 --- a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx +++ b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx @@ -125,3 +125,20 @@ describe("DataDetails — Projects empty state", () => { expect(screen.getByText("dashboard.projects.empty")).toBeInTheDocument(); }); }); + +describe("DataDetails — sources with no per-repo attribution", () => { + it("names them, so their absence does not read as zero spend", () => { + // projectBucketsQueued exists in 7 parsers. Cursor, Copilot, Zed, Goose and + // Kiro have no per-repo story at all, and the panel used to just omit them — + // which looks identical to "that tool cost nothing here". + renderDetails({ projectUnattributedSources: ["cursor", "zed"] }); + fireEvent.click(screen.getByText("Project Usage")); + expect(screen.getByText(/cursor, zed/)).toBeTruthy(); + }); + + it("says nothing when everything is attributed", () => { + renderDetails({ projectUnattributedSources: [] }); + fireEvent.click(screen.getByText("Project Usage")); + expect(screen.queryByText("dashboard.projects.unattributed")).toBeNull(); + }); +}); diff --git a/dashboard/src/ui/dashboard/views/DashboardView.jsx b/dashboard/src/ui/dashboard/views/DashboardView.jsx index d1b1509b..e06659be 100644 --- a/dashboard/src/ui/dashboard/views/DashboardView.jsx +++ b/dashboard/src/ui/dashboard/views/DashboardView.jsx @@ -25,6 +25,7 @@ export function DashboardView(props) { identitySubscriptions, identityScrambleDurationMs, projectUsageEntries, + projectUnattributedSources = [], projectUsageLimit, setProjectUsageLimit, pulse, @@ -287,6 +288,7 @@ export function DashboardView(props) { 0 ? Math.floor(rawLimit) : null, + // An absent bound means "no bound". The other handlers compare against "" + // directly, which is safe there because the client always sends both; here + // it does not, and `day <= ""` would silently return nothing. + inWindow(row) { + if (!from && !to) return true; + if (!row.hour_start) return false; + const day = rowDayKey(row, timeZoneContext); + return (!from || day >= from) && (!to || day <= to); + }, + matchesSource(row) { + return !requestedSource || String(row.source || "").toLowerCase() === requestedSource; + }, + }; +} + +// Which sources contributed usage in this window but carry no project +// attribution at all. Without naming them, their absence reads as "that tool +// cost nothing here" — the panel under-reports and looks complete while doing +// it. `projectBucketsQueued` exists in 7 of the parsers; Cursor, Copilot, Zed, +// Goose and Kiro have no per-repo story at all. +function findUnattributedSources({ queuePath, allProjectRows, inWindow, matchesSource }) { + const attributed = new Set( + allProjectRows.filter(inWindow).map((row) => String(row.source || "").toLowerCase()), + ); + return [ + ...new Set( + readQueueData(queuePath) + .filter((row) => inWindow(row) && matchesSource(row)) + .map((row) => String(row.source || "").toLowerCase()) + .filter((src) => src && !attributed.has(src)), + ), + ].sort(); +} + +// No project-attributed rows at all (project attribution never synced, or only +// non-project-capable CLIs used). Falls back to per-source totals so the panel +// is not simply empty. This path once ALSO ran for the non-empty case and +// produced fiction — "session-file count x total tokens" gave every short-and- +// hot project the same weight as every long-and-cold one. Empty case only. +function aggregateBySource(queuePath, inWindow, matchesSource) { + const bySrc = new Map(); + for (const row of readQueueData(queuePath)) { + if (!inWindow(row) || !matchesSource(row)) continue; + const src = row.source || "unknown"; + if (!bySrc.has(src)) { + bySrc.set(src, { + project_key: src, + // Synthetic source-only row: project_ref stays empty rather than + // fabricating `https://${src}.ai`, which resolves to unrelated domains + // (codex.ai, cursor.ai) and was sent to the dashboard as a clickable + // href before v0.11.1. + project_ref: "", + total_tokens: 0, + billable_total_tokens: 0, + }); + } + bySrc.get(src).total_tokens += row.total_tokens || 0; + bySrc.get(src).billable_total_tokens += row.total_tokens || 0; + } + return bySrc; +} + +function aggregateByProject(rows) { + const byProject = new Map(); + for (const row of rows) { + const key = row.project_key || "unknown"; + if (!byProject.has(key)) { + byProject.set(key, { + project_key: key, + project_ref: row.project_ref || key, + total_tokens: 0, + billable_total_tokens: 0, + }); + } + const agg = byProject.get(key); + agg.total_tokens += Number(row.total_tokens || 0); + agg.billable_total_tokens += Number(row.total_tokens || 0); + if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref; + } + return byProject; +} + +// Rows -> ranked entries, in the shape the panel already renders (token counts +// as strings). Split out so buildProjectUsageSummary stays under the size rule. +function rankProjectEntries(byKey) { + return Array.from(byKey.values()) + .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) + .map((e) => ({ + ...e, + total_tokens: String(e.total_tokens), + billable_total_tokens: String(e.billable_total_tokens), + })); +} + +function buildProjectUsageSummary({ url, queuePath, projectQueuePath }) { + // Use the per-project bucket log that rollout.js emits — it already + // carries the actual tokens attributed to each (project_key, source, + // hour_start). Falling back to "session-file count × total tokens" + // (the old behavior) produced pure fiction: every short-and-hot + // project got the same weight as every long-and-cold one. + const allProjectRows = readProjectQueueData(projectQueuePath); + + const { inWindow, matchesSource, limit } = buildProjectFilters(url); + + const projectRows = allProjectRows.filter((row) => inWindow(row) && matchesSource(row)); + + const unattributedSources = findUnattributedSources({ + queuePath, + allProjectRows, + inWindow, + matchesSource, + }); + + const byProject = aggregateByProject(projectRows); + + let entries = + byProject.size === 0 + ? rankProjectEntries(aggregateBySource(queuePath, inWindow, matchesSource)) + : rankProjectEntries(byProject); + + if (limit != null) entries = entries.slice(0, limit); + + return { + generated_at: new Date().toISOString(), + entries, + // Named so the panel can say what it cannot account for, rather than + // letting a missing tool read as a tool that cost nothing. + unattributed_sources: unattributedSources, + }; +} + function createLocalApiHandler({ queuePath }) { const qp = queuePath || resolveQueuePath(); @@ -1317,78 +1471,14 @@ function createLocalApiHandler({ queuePath }) { // --- project-usage-summary --- if (p === "/functions/tokentracker-project-usage-summary") { - // Use the per-project bucket log that rollout.js emits — it already - // carries the actual tokens attributed to each (project_key, source, - // hour_start). Falling back to "session-file count × total tokens" - // (the old behavior) produced pure fiction: every short-and-hot - // project got the same weight as every long-and-cold one. - const projectQueuePath = path.join( - path.dirname(qp), - "project.queue.jsonl", + json( + res, + buildProjectUsageSummary({ + url, + queuePath: qp, + projectQueuePath: path.join(path.dirname(qp), "project.queue.jsonl"), + }), ); - const projectRows = readProjectQueueData(projectQueuePath); - - const byProject = new Map(); - for (const row of projectRows) { - const key = row.project_key || "unknown"; - if (!byProject.has(key)) { - byProject.set(key, { - project_key: key, - project_ref: row.project_ref || key, - total_tokens: 0, - billable_total_tokens: 0, - }); - } - const agg = byProject.get(key); - agg.total_tokens += Number(row.total_tokens || 0); - agg.billable_total_tokens += Number(row.total_tokens || 0); - if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref; - } - - // If no project-attributed rows exist yet (user hasn't synced project - // attribution, or never used a project-capable CLI), fall back to - // per-source aggregation over the main queue so the panel isn't - // totally empty. This path used to also exist for the non-empty case - // and produce wrong numbers; keep it only as the empty fallback. - let entries; - if (byProject.size === 0) { - const rows = readQueueData(qp); - const bySrc = new Map(); - for (const row of rows) { - const src = row.source || "unknown"; - if (!bySrc.has(src)) { - bySrc.set(src, { - project_key: src, - // Synthetic source-only row: leave project_ref empty rather than - // fabricating `https://${src}.ai`, which resolves to unrelated - // domains (e.g. codex.ai, cursor.ai) and was sent to the - // dashboard as a clickable href before v0.11.1 / this commit. - project_ref: "", - total_tokens: 0, - billable_total_tokens: 0, - }); - } - bySrc.get(src).total_tokens += row.total_tokens || 0; - bySrc.get(src).billable_total_tokens += row.total_tokens || 0; - } - entries = Array.from(bySrc.values()) - .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) - .map((e) => ({ - ...e, - total_tokens: String(e.total_tokens), - billable_total_tokens: String(e.billable_total_tokens), - })); - } else { - entries = Array.from(byProject.values()) - .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens) - .map((e) => ({ - ...e, - total_tokens: String(e.total_tokens), - billable_total_tokens: String(e.billable_total_tokens), - })); - } - - json(res, { generated_at: new Date().toISOString(), entries }); return true; } @@ -1621,6 +1711,8 @@ function createLocalApiHandler({ queuePath }) { } module.exports = { + // Exported for tests: the aggregation runs without a server. + buildProjectUsageSummary, createLocalApiHandler, // Exported so the redirect allowlist can be tested directly: the SSRF this // closes is only observable across a redirect hop, which no handler-level diff --git a/test/project-usage-filters.test.js b/test/project-usage-filters.test.js new file mode 100644 index 00000000..bc111f3c --- /dev/null +++ b/test/project-usage-filters.test.js @@ -0,0 +1,183 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { buildProjectUsageSummary } = require("../src/lib/local-api"); + +// The panel is where the README's central argument lands, and it was the least +// finished surface in the product: the client had always sent from/to/source/ +// limit/timeZone, and the handler read none of them. Pick "24h" and every other +// card narrowed while Projects kept showing all-time totals, with nothing on +// screen saying so. + +function fixture({ projectRows = [], queueRows = [] } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tt-projects-")); + const queuePath = path.join(dir, "queue.jsonl"); + const projectQueuePath = path.join(dir, "project.queue.jsonl"); + const write = (file, rows) => + fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "")); + write(queuePath, queueRows); + write(projectQueuePath, projectRows); + return { queuePath, projectQueuePath }; +} + +const query = (params = {}) => + new URL(`http://localhost/functions/x?${new URLSearchParams(params).toString()}`); + +const projectRow = (over = {}) => ({ + project_key: "acme/api", + project_ref: "https://github.com/acme/api", + source: "claude", + hour_start: "2026-05-14T09:00:00.000Z", + total_tokens: 100, + ...over, +}); + +const queueRow = (over = {}) => ({ + source: "claude", + model: "claude-sonnet-5", + hour_start: "2026-05-14T09:00:00.000Z", + total_tokens: 100, + ...over, +}); + +const run = (paths, params) => buildProjectUsageSummary({ url: query(params), ...paths }); + +test("a date window narrows the panel — it used to be ignored entirely", () => { + const paths = fixture({ + projectRows: [ + projectRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 }), + projectRow({ hour_start: "2026-05-01T09:00:00.000Z", total_tokens: 900 }), + ], + }); + + const all = run(paths, {}); + assert.equal(all.entries[0].total_tokens, "1000", "with no window, everything counts"); + + const window = run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }); + assert.equal( + window.entries[0].total_tokens, + "100", + "the older bucket must be excluded, not silently added in", + ); +}); + +test("an absent bound means no bound, not an empty result", () => { + // The other range-filtered handlers compare against "" directly, which is + // safe there because the client always sends both. Here it does not, and + // `day <= ""` is false for every day — the panel would have gone blank. + const paths = fixture({ projectRows: [projectRow()] }); + assert.equal(run(paths, { from: "2026-01-01", timeZone: "UTC" }).entries.length, 1); + assert.equal(run(paths, { to: "2026-12-31", timeZone: "UTC" }).entries.length, 1); + assert.equal(run(paths, {}).entries.length, 1); +}); + +test("the source filter is honoured", () => { + const paths = fixture({ + projectRows: [ + projectRow({ source: "claude", project_key: "acme/api", total_tokens: 100 }), + projectRow({ source: "codex", project_key: "acme/web", total_tokens: 700 }), + ], + }); + const only = run(paths, { source: "codex" }); + assert.equal(only.entries.length, 1); + assert.equal(only.entries[0].project_key, "acme/web"); +}); + +test("limit truncates AFTER ranking, so it keeps the biggest", () => { + const paths = fixture({ + projectRows: [ + projectRow({ project_key: "small", total_tokens: 1 }), + projectRow({ project_key: "big", total_tokens: 900 }), + projectRow({ project_key: "middle", total_tokens: 50 }), + ], + }); + const limited = run(paths, { limit: "2" }); + assert.deepEqual( + limited.entries.map((e) => e.project_key), + ["big", "middle"], + ); +}); + +test("a nonsense limit is ignored rather than emptying the panel", () => { + const paths = fixture({ projectRows: [projectRow()] }); + for (const limit of ["0", "-3", "abc", ""]) { + assert.equal(run(paths, { limit }).entries.length, 1, `limit=${JSON.stringify(limit)}`); + } +}); + +test("sources with usage but no project attribution are NAMED, not silently dropped", () => { + // `projectBucketsQueued` exists in 7 parsers. Cursor, Copilot, Zed, Goose and + // Kiro have no per-repo story at all, and their absence read as "that tool + // cost nothing here". + const paths = fixture({ + projectRows: [projectRow({ source: "claude" })], + queueRows: [ + queueRow({ source: "claude" }), + queueRow({ source: "cursor" }), + queueRow({ source: "zed" }), + ], + }); + const result = run(paths, {}); + assert.deepEqual(result.unattributed_sources, ["cursor", "zed"]); + assert.ok( + !result.unattributed_sources.includes("claude"), + "a source that IS attributed must not be listed", + ); +}); + +test("the unattributed list respects the same window as the entries", () => { + // Otherwise the panel names a tool as unaccounted-for in a window where it + // contributed nothing, which is its own kind of wrong. + const paths = fixture({ + projectRows: [projectRow({ hour_start: "2026-05-14T09:00:00.000Z" })], + queueRows: [ + queueRow({ source: "claude", hour_start: "2026-05-14T09:00:00.000Z" }), + queueRow({ source: "cursor", hour_start: "2026-01-02T09:00:00.000Z" }), + ], + }); + assert.deepEqual(run(paths, {}).unattributed_sources, ["cursor"]); + assert.deepEqual( + run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }).unattributed_sources, + [], + "cursor contributed nothing in this window, so it is not unaccounted-for here", + ); +}); + +test("the per-source fallback is filtered too, not just the project path", () => { + // With no project rows at all the panel falls back to per-source totals. That + // path ignored the window as well. + const paths = fixture({ + queueRows: [ + queueRow({ source: "claude", hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 }), + queueRow({ source: "claude", hour_start: "2026-01-02T09:00:00.000Z", total_tokens: 900 }), + ], + }); + assert.equal(run(paths, {}).entries[0].total_tokens, "1000"); + assert.equal( + run(paths, { from: "2026-05-14", to: "2026-05-14", timeZone: "UTC" }).entries[0].total_tokens, + "100", + ); +}); + +test("the fallback still refuses to fabricate a project_ref", () => { + // `https://${src}.ai` resolves to unrelated domains and was once sent to the + // dashboard as a clickable href. + const paths = fixture({ queueRows: [queueRow({ source: "codex" })] }); + assert.equal(run(paths, {}).entries[0].project_ref, ""); +}); + +test("an empty window returns no entries rather than falling back to everything", () => { + // The dangerous failure here is not an empty panel, it is a panel that + // quietly shows all-time numbers when the window matched nothing. + const paths = fixture({ + projectRows: [projectRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 })], + queueRows: [queueRow({ hour_start: "2026-05-14T09:00:00.000Z", total_tokens: 100 })], + }); + const result = run(paths, { from: "2026-07-01", to: "2026-07-02", timeZone: "UTC" }); + assert.deepEqual(result.entries, []); +});