Per-Status Counters in Admin Stats: Numbers From the Contract
Per-status snapshot counters joined AdminLayananStats additively; legacy fields kept, UI derives numbers from stats, no more meta.total tricks.
TL;DR
The dashboard showed dashes because the stats API only counted two of six statuses, leaving legacy fields at zero. The fix kept the old fields and added six current snapshot counters, with regenerated types now requiring a count for every tab and card. Counts respect user scope, and the queue badge now reads the submission counter directly with lightweight polling.
The first time I opened the KotaPortal admin dashboard that morning, the stat cards stared back at me with plain dashes. The queue tab had no number at all. Six status cards on the screen, and only two of them carried a figure. My first guess was lazy frontend code: maybe the state was stale, maybe a refactor broke the parsing. I even tried deriving the numbers client-side from a list response. Dead end, because the data had never been sent.
The real problem sat in the API contract. The AdminLayananStats endpoint only exposed counters for two statuses, plus four legacy fields named pending, reviewed, approved, and rejected that are always zero on new data after migration 000063. For the other four statuses in the six-step pipeline? No number anywhere in the response.
Additive Growth, Not a Contract Rewrite
The lesson here is not "add a field whenever you need one". Adding an enumerated value is not a compatible change for clients that assume they already know every possible value [2]. So the path had to be additive: the old fields stayed untouched, and the new ones joined them. I kept those legacy counters even though they read zero on all new data. It sounds like wasted space, but it is the conservative move that keeps the old contract intact for anything still reading it; what changed only grew, nothing shifted meaning.
In their place I added six current-snapshot counters, one per status from submission to done. The model comment states it plainly: these numbers reflect rows currently in each status, not a cumulative history. A snapshot answers "how many rows are in status X right now", which is a different question from period counters computed from the transition history. In PostgreSQL, every SQL statement already sees a snapshot of the data at one point in time [1], so "current state" is a fair thing to ask a database for.
Codegen and Deriving Numbers at the Top Level
With the backend contract settled, the frontend got its own cleanup. This continues the transition-map mirror work I wrote about earlier. I regenerated the v1.d.ts types file straight from openapi.yaml, because static types generated from an OpenAPI schema carry zero runtime cost [6]. No more buttons calling fields that do not exist; the compiler catches a misspelled field name before it ships.
With types narrowed through type guards [3], I restructured STATUS_TABS and STAT_CARDS. Previously only two tabs had a countKey and the rest rendered dashes. Now the property is required on every item. A new tab without a number fails the build instead of failing a manager's morning check.
Data transformation for rendering moved to the top level of the component, out of Effects [4]. Following the Redux guidance, keep stored data minimal and derive the rest [7]. The clearest win was the queue badge: it used a listLayanan call with limit 1 purely to steal the metadata total, and now it simply reads the submission counter from the same stats object. One source of numbers, consumed in three places: cards, tabs, and the menu badge.
Scope Still Applies, the Badge Stays Fresh
One detail could not slip through: bidang scope. The per-status counters respect the acting user's scope. In the test, I moved four seed rows into four different statuses, then checked global and per-bidang counters side by side; a scoped count only follows its own rows. A bidang-scoped admin cannot count tickets outside their area, even though every role runs the same one query function.
Finally, the queue badge keeps refreshing on a schedule. setInterval is the standard tool for repeated calls at a fixed delay [5], and the interval is cleaned up on unmount. No websocket, no push channel. For a queue size, light per-minute polling is enough, and it is honest about its limits: a briefly stale number still beats a number that was never right.