feat(projects): the panel ignored every filter the client sent - #118
Merged
Conversation
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).
This was referenced Jul 25, 2026
pitimon
added a commit
that referenced
this pull request
Jul 25, 2026
…afe (#121) Closes the cost half of issue 102. The surfacing half shipped in #118. Project rows were keyed project|source|hour and carried NO model, so per-repo cost was not merely unimplemented — it was absent from the data model. The README's "which repo, which model, and which hour" promised a repo x model join that had never existed. The key is now project|source|model|hour and the row carries the model, so computeRowCost — the same function the rest of the product prices with — works per project. A repo total and a model total cannot disagree, because they come from one function. THE TRANSITION IS THE WHOLE JOB, and it is the part that can be wrong in a way nobody notices. A legacy row (project|source|hour) and a new row (project|source|model|hour) describe the SAME bucket at different granularity. Keep both and you double-count; drop either and you lose real usage. Neither is acceptable, and neither is what happens here. The bucket state in cursors.json is re-keyed on load: an old bucket becomes the `unknown` slot for its hour, keeping its running total AND its queuedKey. It then stops receiving new usage — that goes to the model-specific bucket — so it is frozen at its pre-upgrade total and the two sum to the true number. The reader keys model-less rows to the same `unknown` slot, so a legacy row and its re-keyed bucket are one identity rather than two. Without the re-key the old bucket would strand: its key never matches again, new usage restarts from zero under the model key, and the reader still sees the old row. That is the double count, and it is why this is a state migration rather than a key change. Migration runs in normalizeProjectState, which every parser goes through, so one call covers all seven. Idempotent — a four-segment key is skipped — and it refuses to clobber an existing unattributed bucket, leaving the legacy one in place for a human rather than silently discarding either. COST IS HONEST ABOUT WHAT IT CANNOT PRICE. A row with no model prices at 0 and is counted as `unattributed_tokens` rather than folded into the total. The panel shows "no model recorded" instead of "$0.00" for a repo that is entirely pre-model, and "$3.00+" for one that is partly priced. Pricing those at zero would render "recorded before we recorded models" as "this cost nothing" — the exact tier issue 94 was added to avoid. 19 new tests. The mixed-era ones are the point: a legacy row and a model row for one bucket count exactly once each; repeated appends of one key still collapse; two models in one hour are two slots; a stranded bucket keeps its total and its dedup marker through the re-key; running the migration twice changes nothing. ci:local exit 0: 948 root tests (+15), 266 dashboard (+4). Co-authored-by: itarun.p <itarun.p@somapait.com>
This was referenced Jul 26, 2026
Merged
pitimon
added a commit
that referenced
this pull request
Jul 26, 2026
15 commits since v0.39.43. Tracker: #125. User-facing: - The Projects panel honours the date range. It read no query parameter at all, so picking "24h" narrowed every other card while Projects kept showing all-time totals with nothing on screen saying so (#118). - Per-repo cost, with rows written before the change named as unpriced rather than shown as a confident $0 (#121). - Sources with no per-repo attribution are named, instead of their absence reading as "that tool cost nothing here" (#118). - A plan-value card answering what README:32 already promised, labelled as list-price-equivalent rather than a saving (#122). - The Codex quota chip no longer vanishes on a 401 (#119). - `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install (#117). Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar proxy per-hop revalidation, port-aware allowlisting and a real download cap (#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of the product gate (#115), and a usage-limits fixture capture tool (#124). Version bumped in all four lockstep locations by the `version` hook: package.json, package-lock.json, both TokenTrackerBar targets, and the Windows csproj. validate:version-lockstep passes. WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync after upgrading, re-keying project buckets from project|source|hour to project|source|model|hour. Without that the old bucket strands and its usage is counted twice. Verified read-only against a real 2,015-bucket state — every key migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015 queuedKey markers survived, the loss of which would re-append every row. That is one machine; a differently-shaped state is the residual risk. ci:local exit 0: 972 root tests, 302 dashboard. Co-authored-by: itarun.p <itarun.p@somapait.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses the surfacing half of #102. Not the cost half — see the bottom.
The client has always sent
from/to/source/limit/timeZone(dashboard/src/lib/api.ts:90-109). The handler read none of them and aggregatedproject.queue.jsonlunconditionally.Concrete failure, as #102 describes it: pick "24h", every other card narrows, Projects keeps 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_startis already on every row, and this uses the samegetTimeZoneContext+rowDayKeyshape as the other range-filtered handlers, 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, andday <= ""is false for every day — the panel would have gone blank whenever only one bound was set. An absent bound means no bound, with a test for each direction.The per-source fallback path ignored the window too, and that is the path a user sees before project attribution has ever synced. Filtered as well.
Sources with no per-repo attribution are now named
projectBucketsQueuedexists 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 panel under-reported while looking complete.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. I editedProjectUsagePanelfirst — the ui-hardcode ratchet is what surfaced that the change was going into a component nothing mounts. Reverted.Structure
The handler block reached 129 lines once it stopped ignoring the query string, so the aggregation is extracted and exported:
buildProjectUsageSummary(36) withbuildProjectFilters(23),findUnattributedSources(13),aggregateBySource(22),aggregateByProject(19),rankProjectEntries(9). The whole thing is now testable without standing up a server.Test plan
ci:localexit 0 — 924 root tests (+10), 258 dashboard (+2)fromor absenttoreturns results rather than nothingsourcefilter;limittruncates after ranking so it keeps the biggest; a nonsense limit (0,-3,abc, empty) is ignored rather than emptying the panelproject_ref(https://${src}.airesolves to unrelated domains and was once a clickable href)Not in this PR
Adding
modelto project rows (project|source|hour→project|source|model|hour) and the per-repo cost that unlocks. That is a data-model change carrying the mixed-era double-counting risk #102 flags — legacy and new rows describe the same bucket at different granularity, and summing both double-counts. It wants its own PR, its own keep-last rule, and a fixture containing both eras.Two guardrail false positives, reworded rather than baselined
The ui-hardcode scanner reads
length > 0 ? (as a JSX text node, and reads a three-digit issue reference as a hex colour. The second one tripped on the comment explaining the first.