From 3b0e5b1eb4f87ce052a7c11d45a08acb69964f1e Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 11:39:47 +0000 Subject: [PATCH] refactor: replace hash-based license badge coloring with a deterministic, collision-resistant palette based on sort order --- .../components/PersonalDocumentsCard.tsx | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx index b15a46869..4a2813ecc 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx @@ -49,32 +49,62 @@ const SEARCH_DEBOUNCE_MS = 300; /** * Badge colours for licence types. * - * Red and yellow are left out on purpose: they read as a problem, and a - * licence type is not one. Green is out too — it means verified elsewhere in - * the backoffice. + * Red is left out: it reads as a problem, and a licence type is not one. + * Everything else the theme offers is in, because the point of the colour is + * telling two licence types apart at a glance. */ const SCOPE_COLORS = [ - 'grape', - 'violet', - 'indigo', - 'cyan', - 'teal', - 'pink', - 'orange', 'blue', + 'grape', + 'teal', + 'orange', + 'violet', + 'cyan', + 'pink', + 'lime', + 'indigo', + 'green', + 'yellow', + 'gray', ]; +/** Doubles the palette: the same hue, a visibly different badge. */ +const SCOPE_VARIANTS = ['light', 'outline'] as const; + /** - * The same licence type keeps the same colour on every row and every visit — - * derived from its id rather than its position, so a colour is something the - * eye can learn instead of a lottery per render. + * A colour per licence type, assigned by position in the catalogue. + * + * Hashing the id looked tidier and was wrong: eight buckets over sixteen + * licence types collide by the pigeonhole principle, so Vessel Registration + * and Freight Forwarder came out the same colour and the badge stopped + * carrying information. Walking the sorted catalogue instead gives every type + * a distinct colour until the palette runs out, and only then repeats a hue in + * the other variant — 24 distinct badges before any two can look alike. + * + * Sorted by `sortOrder` so the assignment is the same for every officer and + * survives a refresh; a type added later takes the next free style rather than + * reshuffling the ones already learned. */ -function scopeColor(licenseTypeId: string): string { - let hash = 0; - for (let i = 0; i < licenseTypeId.length; i += 1) { - hash = (hash * 31 + licenseTypeId.charCodeAt(i)) % 997; - } - return SCOPE_COLORS[hash % SCOPE_COLORS.length]; +function buildScopeStyles( + types: { id: string; sortOrder: number }[], +): Map { + const styles = new Map< + string, + { color: string; variant: (typeof SCOPE_VARIANTS)[number] } + >(); + types + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .forEach((type, index) => { + styles.set(type.id, { + color: SCOPE_COLORS[index % SCOPE_COLORS.length], + variant: + SCOPE_VARIANTS[ + Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length + ], + }); + }); + return styles; } /** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */ @@ -173,6 +203,11 @@ export function PersonalDocumentsCard() { setPageIndex(0); } + const scopeStyles = useMemo( + () => buildScopeStyles(licenseTypes?.items ?? []), + [licenseTypes], + ); + const typeName = (id: string) => { const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id); return found ? localized(found.name) || found.key : id; @@ -206,11 +241,15 @@ export function PersonalDocumentsCard() { ) : ( - {row.original.scope.map((id) => ( - - {typeName(id)} - - ))} + {row.original.scope.map((id) => { + // A type the catalogue no longer lists still needs a badge. + const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' }; + return ( + + {typeName(id)} + + ); + })} ), }, @@ -274,9 +313,9 @@ export function PersonalDocumentsCard() { ), }, ], - // `typeName` closes over the licence-type list, which `localized` also reads. + // `typeName` and `scopeStyles` both close over the licence-type list. // eslint-disable-next-line react-hooks/exhaustive-deps - [t, localized, licenseTypes], + [t, localized, licenseTypes, scopeStyles], ); /**