Dashboard stat cards read a field the API never returns #83

Closed
opened 2026-07-28 06:00:29 +00:00 by claude-bot · 1 comment

Severity: MEDIUM

The bug

frontend/src/pages/DashboardPage.tsx:18-25,40 fires four listPhotos({status, limit: 1})
queries and renders meta.total — a field the backend never returns. meta contains only
next_cursor (photos.py:148-151).

All four stat cards show "—" permanently, after four wasted round trips.

Meanwhile PhotoRepository.count_by_status (repositories/photos.py:68-74) is fully implemented
with zero call sites.

Note the as { total?: number } cast — the type system was deliberately bypassed here. This is
the hand-written-types drift the generated-types issue addresses.

Fix

  • Add GET /api/photos/stats returning count_by_status. Measured at 66 ms over 50k rows via a
    covering scan of ix_photo_collection_status — fine for a stats endpoint at target scale.
  • Point all four cards at that one query.
  • Use useQueries rather than useQuery inside .map() with an eslint-disable
    (DashboardPage.tsx:19); it works today only because STATUSES is a constant.
  • While here: "Review Next" should open the actual next needs_review photo — the first result of
    the filtered list — rather than navigating to the filter page. It is the dashboard's only verb
    and it currently under-delivers its label.

Done when

  • Stat cards show real counts from one request
  • count_by_status has a call site
  • "Review Next" opens a photo
  • A contract test covers the stats response shape

References

  • frontend/src/pages/DashboardPage.tsx:18-25,40
  • backend/app/repositories/photos.py:68-74
  • backend/app/api/routes/photos.py:120-151
## Severity: MEDIUM ## The bug `frontend/src/pages/DashboardPage.tsx:18-25,40` fires four `listPhotos({status, limit: 1})` queries and renders `meta.total` — a field the backend never returns. `meta` contains only `next_cursor` (`photos.py:148-151`). All four stat cards show "—" permanently, after four wasted round trips. Meanwhile `PhotoRepository.count_by_status` (`repositories/photos.py:68-74`) is fully implemented with **zero call sites**. Note the `as { total?: number }` cast — the type system was deliberately bypassed here. This is the hand-written-types drift the generated-types issue addresses. ## Fix - Add `GET /api/photos/stats` returning `count_by_status`. Measured at 66 ms over 50k rows via a covering scan of `ix_photo_collection_status` — fine for a stats endpoint at target scale. - Point all four cards at that one query. - Use `useQueries` rather than `useQuery` inside `.map()` with an eslint-disable (`DashboardPage.tsx:19`); it works today only because `STATUSES` is a constant. - While here: "Review Next" should open the actual next `needs_review` photo — the first result of the filtered list — rather than navigating to the filter page. It is the dashboard's only verb and it currently under-delivers its label. ## Done when - [ ] Stat cards show real counts from one request - [ ] `count_by_status` has a call site - [ ] "Review Next" opens a photo - [ ] A contract test covers the stats response shape ## References - `frontend/src/pages/DashboardPage.tsx:18-25,40` - `backend/app/repositories/photos.py:68-74` - `backend/app/api/routes/photos.py:120-151`
claude-bot added this to the v0.2.0 milestone 2026-07-28 06:00:29 +00:00
Author

Done in 88044bc.

The cards read meta.total, which the API has never returned — so they've shown a dash since the dashboard was written, and nobody could distinguish that from a collection with no photos. Rather than adding total to every list response (which would put a COUNT(*) on the hot list path, and #84 is already about that query's cost), I added GET /api/photos/stats: one grouped count replacing four list requests that were each fetching a photo row to answer a question about cardinality.

PhotoRepository.count_by_status already existed for exactly this and had no call sites — which is how nobody had noticed its keys were str(enum), i.e. "PhotoStatus.approved", rather than values a client could look up. Fixed, and it now reports absent statuses as 0 rather than omitting them: a missing key renders as nothing, which is indistinguishable from a failed load, which is precisely the confusion #78 is about.

Two things beyond the literal fix:

The useQuery-inside-.map is gone. It was a rules-of-hooks violation silenced with an eslint-disable, safe only because STATUSES happens to be a constant-length literal. Anyone making that list dynamic would have hit a genuinely baffling bug.

Viewers see approved counts only, zero elsewhere. A true total would tell someone who cannot open a single unreviewed photo exactly how many there are — a small leak, but free to close and consistent with the viewer rules in list_photos.

Error state matches #78's principle: a dash means "could not find out", zero means zero, and a failure shows an explicit alert with a Retry rather than four silent dashes. 5 backend tests, 4 frontend tests.

Done in 88044bc. The cards read `meta.total`, which the API has never returned — so they've shown a dash since the dashboard was written, and nobody could distinguish that from a collection with no photos. Rather than adding `total` to every list response (which would put a `COUNT(*)` on the hot list path, and #84 is already about that query's cost), I added **`GET /api/photos/stats`**: one grouped count replacing four list requests that were each fetching a photo row to answer a question about cardinality. `PhotoRepository.count_by_status` already existed for exactly this and had **no call sites** — which is how nobody had noticed its keys were `str(enum)`, i.e. `"PhotoStatus.approved"`, rather than values a client could look up. Fixed, and it now reports absent statuses as `0` rather than omitting them: a missing key renders as nothing, which is indistinguishable from a failed load, which is precisely the confusion #78 is about. Two things beyond the literal fix: **The `useQuery`-inside-`.map` is gone.** It was a rules-of-hooks violation silenced with an `eslint-disable`, safe only because `STATUSES` happens to be a constant-length literal. Anyone making that list dynamic would have hit a genuinely baffling bug. **Viewers see approved counts only**, zero elsewhere. A true total would tell someone who cannot open a single unreviewed photo exactly how many there are — a small leak, but free to close and consistent with the viewer rules in `list_photos`. Error state matches #78's principle: a dash means "could not find out", zero means zero, and a failure shows an explicit alert with a Retry rather than four silent dashes. 5 backend tests, 4 frontend tests.
Sign in to join this conversation.
No description provided.