Labels That Can Lie: When Status Vocabulary Lives in Two Places
A duplicated STATUS_MAP on the public page now reads labels from one lib: exhaustive types, an honest fallback for unknown values, countKey enforced.
TL;DR
The public status page broke because it kept its own four item map while the backend and shared lib handle six statuses. The fix makes STATUS_LABEL in layanan-flow the single source of truth, with TypeScript enforcing completeness and a runtime fallback to neutral raw text. CountKey is now required, so future status additions propagate automatically without silent display bugs.
I was checking the KotaPortal public status page that afternoon when one ticket stopped me: its status rendered as plain raw text, unstyled, while the exact same status showed up as a neat green badge in the admin dashboard. My first guess was a broken badge component, some missing CSS class. The truth was less cosmetic. The public page had never learned that status, because it kept a label dictionary of its own, and that dictionary was aging.
That duplicate dictionary was the problem. The public page carried its own STATUS_MAP with the four old statuses: pending, reviewed, approved, and rejected. Meanwhile the layanan-flow lib elsewhere in the app had long owned six statuses, the same ones the backend's transition machine enforces. The backend sends all six, and the map on the public page only knows four of them. The result is not a red error; unknown values render as raw text. And this was not one unlucky spot: both the ticket list and the ticket-search result card on the same page read the twin dictionary, so the wrong display shipped twice.
The Problem Was Never the Colors
Swapping four entries for six solves nothing, because next quarter there will be seven. I moved label ownership to one place: STATUS_LABEL in the layanan-flow lib, the same lib behind the transition-map mirror. The public page now calls it instead of keeping its own copy. This is the principle the Redux style guide writes down neatly: keep stored data minimal and derive the rest [7]. A dictionary duplicated across components is how drift starts; one owner, no imitations.
The type side got locked down too. The badge styles are now an exhaustive Record keyed by the LayananStatus union: the moment a new status joins the union, TypeScript complains about the missing entry [3]. A union type is just a type formed from other types, representing values that may be any one of them [8], so the compiler knows the exact complete list. The one honest weakness of this strategy: types live at compile time. Once the code becomes JavaScript, a runtime guard still matters.
An Honest Fallback, and Numbers the Compiler Guards
The isLayananStatus guard closes that runtime gap. Known value? Take the label and colors from the lib. Foreign value? Render the raw string with a neutral color. No guessing a label from a similar-looking status, and the citizen can still read the original value. Anyone who opened a ticket mid-migration sees something consistent: either the official label or the honest raw text. For null or undefined values, the nullish coalescing operator takes its right-hand side only when the left side is nullish [12], so the fallback runs clean without nested ifs. Failing controlled and honest beats displaying wrong.
One small change I used to underestimate: countKey on the admin STATUS_TABS is now required. It used to be optional, so a new tab could be born without its number and nobody noticed until someone stared at the dashboard. Now the build fails first. The compiler is the gatekeeper, not human diligence, and the cost is zero since static types never reach runtime [3]. For the ticket list, keys stay in place so React can track which items changed when the list refreshes [10], and every data-testid survived the refactor untouched. However large the refactor, the automated tests must keep their handles.
I will take plain-but-right over colorful-but-lying every time. The label dictionary now lives in exactly one place, the compiler guards its completeness, and when the vocabulary grows again, the public page only needs to follow the lib. No second dictionary left to chase.