mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
286 lines
9.7 KiB
TypeScript
286 lines
9.7 KiB
TypeScript
/**
|
||
* 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<T>(
|
||
params: URLSearchParams,
|
||
keys: {
|
||
arrays: readonly string[];
|
||
numbers: readonly string[];
|
||
strings: readonly string[];
|
||
},
|
||
): T {
|
||
const query: Record<string, unknown> = {};
|
||
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);
|
||
}
|