Skip to content

feat(projects): the panel ignored every filter the client sent - #118

Merged
pitimon merged 1 commit into
mainfrom
feat/102-projects-filters-and-cost
Jul 25, 2026
Merged

feat(projects): the panel ignored every filter the client sent#118
pitimon merged 1 commit into
mainfrom
feat/102-projects-filters-and-cost

Conversation

@pitimon

@pitimon pitimon commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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 aggregated project.queue.jsonl unconditionally.

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_start is already on every row, and this uses the same getTimeZoneContext + rowDayKey shape 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, 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, 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

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 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 edited ProjectUsagePanel first — 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) with buildProjectFilters (23), findUnattributedSources (13), aggregateBySource (22), aggregateByProject (19), rankProjectEntries (9). The whole thing is now testable without standing up a server.

Test plan

  • ci:local exit 0 — 924 root tests (+10), 258 dashboard (+2)
  • A date window narrows the panel; without one, everything still counts
  • An absent from or absent to returns results rather than nothing
  • source filter; limit truncates after ranking so it keeps the biggest; a nonsense limit (0, -3, abc, empty) is ignored rather than emptying the panel
  • Unattributed sources are listed, exclude sources that are attributed, and follow the window
  • The fallback path is filtered too, and still refuses to fabricate a project_ref (https://${src}.ai resolves to unrelated domains and was once a clickable href)
  • An empty window returns no entries rather than quietly falling back to all-time
  • Two component tests: the notice renders with the source names, and is absent when everything is attributed

Not in this PR

Adding model to project rows (project|source|hourproject|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.

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).
@pitimon
pitimon merged commit f5331c2 into main Jul 25, 2026
1 check passed
@pitimon
pitimon deleted the feat/102-projects-filters-and-cost branch July 25, 2026 22:49
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant