/** * Presentation rules shared by the authority's dashboards (the vessel * register report and the seafarer services report). * * Kept here rather than in either feature folder because the two screens are * meant to read as one product: the same em dash for "no answer", the same * palette assigned in the same order, the same muted treatment for the * bookkeeping slices. Two copies would drift the first time one of them gained * a colour. * * Nothing in this file knows what a vessel or a seafarer is — the * domain-specific labels stay in each feature's own `report-format.ts`, which * re-exports this module so a component has one import. */ import { ethMonthName, toEthDateTime } from '@ema-platform/shared'; /** One slice of a breakdown chart, as every report's API returns it. */ export interface ReportBreakdownItem { key: string; label: string; count: number; percentage: number; } /** Nothing measurable is not zero — an em dash says so without lying. */ export const DASH = '—'; /** * A figure the API may legitimately have no answer for. * * An average is null on an empty register and a rate is null until something * has been decided; rendering either as 0 would report a fleet that weighs * nothing and a service that approves nobody. */ export function formatNumber( value: number | null | undefined, options: { decimals?: number; suffix?: string } = {}, ): string { if (value === null || value === undefined || Number.isNaN(value)) return DASH; const text = value.toLocaleString(undefined, { minimumFractionDigits: options.decimals ?? 0, maximumFractionDigits: options.decimals ?? 0, }); return options.suffix ? `${text}${options.suffix}` : text; } export function formatPercent(value: number | null | undefined): string { return value === null || value === undefined ? DASH : `${formatNumber(value, { decimals: 1 })}%`; } export function formatMoney(value: number, currency: string): string { return `${formatNumber(value, { decimals: 2 })} ${currency}`; } /** A signed delta for the change-vs-previous chip. */ export function formatDelta(value: number | null): string { if (value === null) return DASH; const sign = value > 0 ? '+' : ''; return `${sign}${formatNumber(value, { decimals: 1 })}%`; } export function deltaColor(value: number | null): string { if (value === null || value === 0) return 'gray'; return value > 0 ? 'teal' : 'red'; } /** * Days as a readable duration. * * Sea time and processing times are both days on the wire, but "487 d" is not * a figure anyone reads as "a year and four months", which is the unit * certificate eligibility is argued in. */ export function formatDays(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(value)) return DASH; if (value < 31) return `${formatNumber(value)} d`; if (value < 365) return `${formatNumber(value / 30.44, { decimals: 1 })} mo`; return `${formatNumber(value / 365.25, { decimals: 1 })} yr`; } /** Cumulative expiry counts, as the disjoint bands a chart can stack. */ export interface CumulativeExpiry { expiringIn30: number; expiringIn60: number; expiringIn90: number; } /** * An API's expiry counts are cumulative — a certificate due in eleven days is * inside the 30-, 60- and 90-day figures, which is how a renewals desk reads * them. Stacked side by side in a chart that reads as three separate groups, * so they are differenced into disjoint bands first. */ export function expiryBands( counts: CumulativeExpiry, ): Array<{ label: string; count: number }> { const { expiringIn30, expiringIn60, expiringIn90 } = counts; return [ { label: 'Within 30 days', count: expiringIn30 }, // Math.max guards against a server that ever answers non-monotonically — // a negative bar is worse than a zero one. { label: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) }, { label: '61–90 days', count: Math.max(0, expiringIn90 - expiringIn60) }, ]; } /** Red inside a week, orange inside a month, otherwise unremarkable. */ export function expiryUrgency(daysToExpiry: number): string { if (daysToExpiry <= 7) return 'red'; if (daysToExpiry <= 30) return 'orange'; return 'gray'; } /** * Officer ids are IAM uuids, which make useless axis labels. Until the * dashboards have a name lookup, shorten them and keep "UNASSIGNED" readable. */ export function officerLabel(key: string): string { if (key === 'UNASSIGNED') return 'Unassigned'; return key.length > 8 ? `${key.slice(0, 8)}…` : key; } /** * Chart colours, assigned by position so a slice keeps its colour between * renders. Mantine's palette rather than invented hex codes, so the charts * follow the theme the rest of the app is built on. */ const PALETTE = [ 'var(--mantine-color-blue-6)', 'var(--mantine-color-teal-6)', 'var(--mantine-color-orange-6)', 'var(--mantine-color-grape-6)', 'var(--mantine-color-cyan-6)', 'var(--mantine-color-lime-7)', 'var(--mantine-color-pink-6)', 'var(--mantine-color-indigo-6)', ]; const MUTED = 'var(--mantine-color-gray-5)'; /** * "Unknown" and "Other" are bookkeeping slices rather than findings, so they * always take the muted colour instead of competing with the real categories * for one of the bright ones. */ export function sliceColor(item: ReportBreakdownItem, index: number): string { if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED; return PALETTE[index % PALETTE.length]; } /** * Bucket keys are ISO dates; the axis wants something a human reads. * * Under Amharic the tick is the Ethiopian month (and day), the way every other * date in the app already renders through `dateDisplayer` — a chart whose axis * says "Sep 2026" beside a table that says "መስከረም 2019" is two calendars on one * screen. Bucket keys are UTC midnights, so the day is embedded at UTC noon * first: `toEthDateTime` reads local Y/M/D, and in a positive-offset timezone * a UTC midnight is still the previous local day. */ export function formatBucket( bucket: string, granularity: 'DAY' | 'WEEK' | 'MONTH', language = 'en', ): string { const date = new Date(bucket); if (Number.isNaN(date.getTime())) return bucket; if (language.startsWith('am')) { const local = new Date( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 12, ); const eth = toEthDateTime(local); const month = ethMonthName(local); return granularity === 'MONTH' ? `${month} ${eth.year}` : `${month} ${eth.date}`; } if (granularity === 'MONTH') { return date.toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC', }); } return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', timeZone: 'UTC', }); } /** The default window the APIs apply when none is given: the last 12 months. */ export function defaultRange(now: Date): [Date, Date] { const from = new Date( Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()), ); return [from, now]; } export const ISO_DAY_LENGTH = 10; export const toIsoDay = (date: Date): string => date.toISOString().slice(0, ISO_DAY_LENGTH); /** * The filter state as URL search params, so a filtered dashboard is a * shareable link rather than something the next person has to rebuild. * * Empty arrays and blank strings are dropped rather than serialised, which * keeps an untouched dashboard's URL clean and lets the API apply its own * defaults instead of being handed an empty filter to honour. */ export function queryToSearchParams(query: object): URLSearchParams { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) { if (value.length === 0) continue; params.set(key, value.join(',')); } else { params.set(key, String(value)); } } return params; } /** * The inverse, for restoring state from a shared link. * * The key lists are the caller's, because they are the report's filter * contract: a key this report does not understand is dropped rather than * forwarded to fail the API's validation pipe. */ export function searchParamsToQuery( params: URLSearchParams, keys: { arrays: readonly string[]; numbers: readonly string[]; strings: readonly string[]; }, ): T { const query: Record = {}; for (const key of keys.arrays) { const raw = params.get(key); if (raw) query[key] = raw.split(',').filter(Boolean); } for (const key of keys.numbers) { const raw = params.get(key); // An unparseable number in a hand-edited URL is ignored rather than sent // on to fail the API's validation pipe. if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) { query[key] = Number(raw); } } for (const key of keys.strings) { const raw = params.get(key); if (raw) query[key] = raw; } const granularity = params.get('granularity'); if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') { query.granularity = granularity; } return query as T; } /** * The multi-select options a filter offers, taken from the breakdown the last * response carried — there is no lookup endpoint for flag states, ports or * nationalities, and the register is the only place that knows which ones are * in use. * * "Unknown" is dropped: it stands for a missing value, and there is nothing to * filter the register down to. */ export function optionsFrom(items: ReportBreakdownItem[] | undefined): string[] { return (items ?? []) .filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER') .map((item) => item.key); }