Files
emaui/libs/ui/src/lib/data/report-format.ts

286 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: '3160 days', count: Math.max(0, expiringIn60 - expiringIn30) },
{ label: '6190 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);
}