@poodle64/ui 2026.8.15 → 2026.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +367 -14
  2. package/dist/components/ui/button/button.svelte +13 -8
  3. package/dist/components/ui/checkbox/checkbox.svelte +23 -1
  4. package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
  5. package/dist/components/ui/form/form-button.svelte +7 -0
  6. package/dist/components/ui/form/form-button.svelte.d.ts +4 -0
  7. package/dist/components/ui/form/form-description.svelte +18 -0
  8. package/dist/components/ui/form/form-description.svelte.d.ts +4 -0
  9. package/dist/components/ui/form/form-element-field.svelte +25 -0
  10. package/dist/components/ui/form/form-element-field.svelte.d.ts +28 -0
  11. package/dist/components/ui/form/form-field-errors.svelte +31 -0
  12. package/dist/components/ui/form/form-field-errors.svelte.d.ts +8 -0
  13. package/dist/components/ui/form/form-field.svelte +25 -0
  14. package/dist/components/ui/form/form-field.svelte.d.ts +28 -0
  15. package/dist/components/ui/form/form-fieldset.svelte +16 -0
  16. package/dist/components/ui/form/form-fieldset.svelte.d.ts +27 -0
  17. package/dist/components/ui/form/form-label.svelte +25 -0
  18. package/dist/components/ui/form/form-label.svelte.d.ts +4 -0
  19. package/dist/components/ui/form/form-legend.svelte +17 -0
  20. package/dist/components/ui/form/form-legend.svelte.d.ts +4 -0
  21. package/dist/components/ui/form/index.d.ts +11 -0
  22. package/dist/components/ui/form/index.js +13 -0
  23. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  24. package/dist/components/ui/page-header/page-header.svelte +70 -33
  25. package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
  26. package/dist/components/ui/switch/index.d.ts +2 -2
  27. package/dist/components/ui/switch/index.js +1 -1
  28. package/dist/components/ui/switch/switch.svelte +38 -5
  29. package/dist/components/ui/switch/switch.svelte.d.ts +7 -1
  30. package/dist/components/ui/tabs/tabs-trigger.svelte +1 -1
  31. package/dist/format.d.ts +222 -0
  32. package/dist/format.js +422 -0
  33. package/dist/styles.css +310 -15
  34. package/package.json +16 -2
  35. package/registry/component-map.json +74 -4
  36. package/registry/component-map.md +18 -2
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The household's Australian value formatters.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Two apps had hand-rolled the same job — `godswood/frontend/src/lib/utils/
7
+ * formatters.ts` and `pebblestone/frontend/src/lib/utils/format.ts` — and had
8
+ * already drifted on every decision that matters: whether a percentage arrives
9
+ * as `4.5` or `0.045`, whether money arrives as dollars or as integer cents,
10
+ * whether a date renders `19 Dec 2024` or `19/12/2024`, and whether a missing
11
+ * value reads `N/A` or `-`. All four are user-visible, and the fleet is
12
+ * entirely Australian, so the disagreement bought nothing.
13
+ *
14
+ * WHAT IS IN AND WHAT IS NOT
15
+ * --------------------------
16
+ * In: every formatter for a value class BOTH apps format — money, dates and
17
+ * times, percentages, plain numbers — including the variants only one app has
18
+ * today, because a value class the package owns it owns completely. Splitting
19
+ * money across two homes is how the drift started.
20
+ *
21
+ * Out: formatters for a value class only ONE app has, which are a domain
22
+ * vocabulary rather than a shared value class — loan repayment frequencies and
23
+ * AI model IDs (godswood), file sizes (godswood), pager arithmetic and the
24
+ * per-line GST recompute for bill approvals (pebblestone, and bound to Xero tax
25
+ * codes besides). Relative time ("2 hours ago") is out too: it lives on
26
+ * `date-fns` in the one app that has it, and a display formatter is not worth
27
+ * making that a dependency of every consumer of this package.
28
+ *
29
+ * THE CONVENTIONS THESE ENCODE
30
+ * ----------------------------
31
+ * - `en-AU`, AUD, and `Australia/Brisbane` wherever a timezone is implied. The
32
+ * zone is pinned rather than taken from the browser: the household's books
33
+ * are kept in AEST, so a laptop in another zone should not renumber them.
34
+ * - Dates render `DD Mon YYYY` or, on request, `DD/MM/YYYY` — both sanctioned
35
+ * Australian forms.
36
+ * - A negative amount carries its sign OUTSIDE the symbol: `-$1,234.56`, never
37
+ * `$-1,234.56`.
38
+ * - A missing value renders `N/A`, and every formatter takes a `fallback` to
39
+ * say otherwise. `-` is deliberately not the default: beside a money column
40
+ * it reads as a minus sign, which is the one wrong meaning available.
41
+ *
42
+ * No dependencies, no DOM: this module is `Intl` and arithmetic.
43
+ */
44
+ /** The one locale every household app formats in. */
45
+ export declare const AU_LOCALE = "en-AU";
46
+ /** The one timezone the household's records are kept in. */
47
+ export declare const AU_TIME_ZONE = "Australia/Brisbane";
48
+ export interface CurrencyOptions {
49
+ /**
50
+ * Fraction digits. Defaults to 2: dropping cents is a loss of fidelity the
51
+ * caller should have to ask for, not the default that quietly rounds
52
+ * $1,234.56 up to $1,235. Pass `0` for a dashboard figure.
53
+ */
54
+ decimals?: number;
55
+ /** Rendered when the value is null, undefined, empty or not a number. */
56
+ fallback?: string;
57
+ /**
58
+ * ISO 4217 code. Defaults to AUD, which renders as a bare `$`; a non-AUD
59
+ * currency renders disambiguated (`USD 1,234.56`) rather than as another
60
+ * `$`, because an app that holds a foreign account is exactly the app that
61
+ * must not confuse the two.
62
+ */
63
+ currency?: string;
64
+ }
65
+ /**
66
+ * Format a DOLLAR value as currency.
67
+ *
68
+ * @example
69
+ * formatCurrency(1234.56) // '$1,234.56'
70
+ * formatCurrency('1234.56', { decimals: 0 }) // '$1,235'
71
+ * formatCurrency(-500, { decimals: 0 }) // '-$500'
72
+ * formatCurrency(null) // 'N/A'
73
+ */
74
+ export declare function formatCurrency(value: string | number | null | undefined, options?: CurrencyOptions): string;
75
+ /**
76
+ * Format an INTEGER-CENTS value as currency.
77
+ *
78
+ * Money held as integer cents is the correct storage for money, and the app
79
+ * that does it should not have to divide by 100 at every call site and hope
80
+ * the float lands.
81
+ *
82
+ * @example
83
+ * formatCurrencyFromCents(123456) // '$1,234.56'
84
+ * formatCurrencyFromCents(-123456, { decimals: 0 }) // '-$1,235'
85
+ * formatCurrencyFromCents(null) // 'N/A'
86
+ */
87
+ export declare function formatCurrencyFromCents(cents: number | null | undefined, options?: CurrencyOptions): string;
88
+ /** Convert a dollar value to whole cents. */
89
+ export declare function dollarsToCents(dollars: number): number;
90
+ /**
91
+ * Abbreviate a money value for a chart axis or a dense tile.
92
+ *
93
+ * A y-axis tick has room for four or five characters, not for `$1,109,057.64`.
94
+ * Full precision belongs in the tooltip and the table; the axis exists to tell
95
+ * the reader which order of magnitude they are looking at.
96
+ *
97
+ * @example
98
+ * compactCurrency(1_109_057.64) // '$1.1m'
99
+ * compactCurrency(-1_500_000) // '-$1.5m'
100
+ * compactCurrency(12_400) // '$12k'
101
+ */
102
+ export declare function compactCurrency(value: number | null | undefined, options?: {
103
+ currency?: string;
104
+ fallback?: string;
105
+ }): string;
106
+ /**
107
+ * Format a money figure that arrives as a STRING, without ever parsing it to a
108
+ * JS number.
109
+ *
110
+ * A securities tax P&L reaches a tax return, so a figure must render with the
111
+ * exact digits the API sent — `parseFloat` rounds at 2^53 and quietly changes
112
+ * cents on a nine-figure total. This groups the integer part with thousands
113
+ * separators and preserves the fractional part verbatim, so the displayed
114
+ * string is faithful to the source decimal whatever its magnitude.
115
+ *
116
+ * @example
117
+ * formatCurrencyString('1234.56') // '$1,234.56'
118
+ * formatCurrencyString('-1234567.89') // '-$1,234,567.89'
119
+ * formatCurrencyString('0') // '$0'
120
+ * formatCurrencyString(null) // 'N/A'
121
+ */
122
+ export declare function formatCurrencyString(value: string | null | undefined, options?: {
123
+ fallback?: string;
124
+ }): string;
125
+ /**
126
+ * Report the sign of a money value without parsing a string to a number — the
127
+ * float-free companion to `formatCurrencyString`, for choosing a tone class.
128
+ */
129
+ export declare function isNegativeMoney(value: string | number | null | undefined): boolean;
130
+ /**
131
+ * Format a number with thousands separators.
132
+ *
133
+ * @example
134
+ * formatNumber(1234567) // '1,234,567'
135
+ * formatNumber(1234.567, { decimals: 2 }) // '1,234.57'
136
+ * formatNumber(null) // 'N/A'
137
+ */
138
+ export declare function formatNumber(value: number | string | null | undefined, options?: {
139
+ decimals?: number;
140
+ fallback?: string;
141
+ }): string;
142
+ /**
143
+ * Format a value that is ALREADY in percentage points: `4.5` renders `4.5%`.
144
+ *
145
+ * Its counterpart for a 0–1 ratio is `formatRatioAsPercentage`. The two are
146
+ * named apart on purpose — the apps disagreed about which one `formatPercent`
147
+ * meant, and picking either spelling for both would make a 100x error a
148
+ * one-character mistake.
149
+ *
150
+ * @example
151
+ * formatPercentage(4.5) // '4.5%'
152
+ * formatPercentage('4.5', { decimals: 2 }) // '4.50%'
153
+ * formatPercentage(null) // 'N/A'
154
+ */
155
+ export declare function formatPercentage(value: number | string | null | undefined, options?: {
156
+ decimals?: number;
157
+ fallback?: string;
158
+ }): string;
159
+ /**
160
+ * Format a 0–1 RATIO as a percentage: `0.045` renders `4.5%`.
161
+ *
162
+ * @example
163
+ * formatRatioAsPercentage(0.045) // '4.5%'
164
+ * formatRatioAsPercentage(1) // '100.0%'
165
+ * formatRatioAsPercentage(null) // 'N/A'
166
+ */
167
+ export declare function formatRatioAsPercentage(value: number | string | null | undefined, options?: {
168
+ decimals?: number;
169
+ fallback?: string;
170
+ }): string;
171
+ /** `19 Dec 2024`, `19 December 2024`, or `19/12/2024`. */
172
+ export type DateFormat = 'short' | 'long' | 'numeric';
173
+ export interface DateOptions {
174
+ format?: DateFormat;
175
+ /** Rendered when the value is null, undefined or empty. */
176
+ fallback?: string;
177
+ /** IANA zone a timestamp is read in. Defaults to `Australia/Brisbane`. */
178
+ timeZone?: string;
179
+ }
180
+ /**
181
+ * Parse a timestamp the API returned.
182
+ *
183
+ * A backend that stores naive UTC (`datetime.now(UTC).replace(tzinfo=None)`)
184
+ * and serialises it with no offset hands JavaScript a string it reads as LOCAL
185
+ * time: in Brisbane that lands every stored moment ten hours early, so anything
186
+ * after 2pm shows the wrong DAY, not merely the wrong hour. An offset-less
187
+ * value carrying a time is therefore read as UTC; one that carries an offset is
188
+ * left alone, and a date-only value is left alone too (appending a `Z` to it
189
+ * produces an Invalid Date).
190
+ */
191
+ export declare function parseApiDate(iso: string): Date;
192
+ /**
193
+ * Format a date for an Australian reader.
194
+ *
195
+ * A date-only value (`YYYY-MM-DD`) renders the day it NAMES, with no timezone
196
+ * conversion — a date is not an instant, and converting one is how a booking
197
+ * dated the 1st shows as the 31st. A value carrying a time is an instant, and
198
+ * is read in `timeZone` (`Australia/Brisbane` by default, not the browser's
199
+ * zone, so the same record reads the same on a laptop in another country).
200
+ *
201
+ * A string that will not parse is returned unchanged rather than hidden behind
202
+ * the fallback: an unexpected date format is worth seeing.
203
+ *
204
+ * @example
205
+ * formatDate('2024-12-19') // '19 Dec 2024'
206
+ * formatDate('2024-12-19', { format: 'long' }) // '19 December 2024'
207
+ * formatDate('2024-12-19', { format: 'numeric' }) // '19/12/2024'
208
+ * formatDate(null) // 'N/A'
209
+ */
210
+ export declare function formatDate(value: string | Date | null | undefined, options?: DateOptions): string;
211
+ /**
212
+ * Format a timestamp as a date and a 24-hour time: `19 Dec 2024, 14:05`.
213
+ *
214
+ * 24 hours, not `2:05 pm`: it is unambiguous and it aligns in a column, which
215
+ * is where a timestamp almost always sits.
216
+ *
217
+ * @example
218
+ * formatDateTime('2024-12-19T04:05:00') // '19 Dec 2024, 14:05'
219
+ * formatDateTime('2024-12-19T04:05:00Z', { format: 'numeric' }) // '19/12/2024, 14:05'
220
+ * formatDateTime(null) // 'N/A'
221
+ */
222
+ export declare function formatDateTime(value: string | Date | null | undefined, options?: DateOptions): string;
package/dist/format.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * The household's Australian value formatters.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Two apps had hand-rolled the same job — `godswood/frontend/src/lib/utils/
7
+ * formatters.ts` and `pebblestone/frontend/src/lib/utils/format.ts` — and had
8
+ * already drifted on every decision that matters: whether a percentage arrives
9
+ * as `4.5` or `0.045`, whether money arrives as dollars or as integer cents,
10
+ * whether a date renders `19 Dec 2024` or `19/12/2024`, and whether a missing
11
+ * value reads `N/A` or `-`. All four are user-visible, and the fleet is
12
+ * entirely Australian, so the disagreement bought nothing.
13
+ *
14
+ * WHAT IS IN AND WHAT IS NOT
15
+ * --------------------------
16
+ * In: every formatter for a value class BOTH apps format — money, dates and
17
+ * times, percentages, plain numbers — including the variants only one app has
18
+ * today, because a value class the package owns it owns completely. Splitting
19
+ * money across two homes is how the drift started.
20
+ *
21
+ * Out: formatters for a value class only ONE app has, which are a domain
22
+ * vocabulary rather than a shared value class — loan repayment frequencies and
23
+ * AI model IDs (godswood), file sizes (godswood), pager arithmetic and the
24
+ * per-line GST recompute for bill approvals (pebblestone, and bound to Xero tax
25
+ * codes besides). Relative time ("2 hours ago") is out too: it lives on
26
+ * `date-fns` in the one app that has it, and a display formatter is not worth
27
+ * making that a dependency of every consumer of this package.
28
+ *
29
+ * THE CONVENTIONS THESE ENCODE
30
+ * ----------------------------
31
+ * - `en-AU`, AUD, and `Australia/Brisbane` wherever a timezone is implied. The
32
+ * zone is pinned rather than taken from the browser: the household's books
33
+ * are kept in AEST, so a laptop in another zone should not renumber them.
34
+ * - Dates render `DD Mon YYYY` or, on request, `DD/MM/YYYY` — both sanctioned
35
+ * Australian forms.
36
+ * - A negative amount carries its sign OUTSIDE the symbol: `-$1,234.56`, never
37
+ * `$-1,234.56`.
38
+ * - A missing value renders `N/A`, and every formatter takes a `fallback` to
39
+ * say otherwise. `-` is deliberately not the default: beside a money column
40
+ * it reads as a minus sign, which is the one wrong meaning available.
41
+ *
42
+ * No dependencies, no DOM: this module is `Intl` and arithmetic.
43
+ */
44
+ /** The one locale every household app formats in. */
45
+ export const AU_LOCALE = 'en-AU';
46
+ /** The one timezone the household's records are kept in. */
47
+ export const AU_TIME_ZONE = 'Australia/Brisbane';
48
+ /** What a formatter renders when it has nothing to render. */
49
+ const FALLBACK = 'N/A';
50
+ /**
51
+ * Coerce an API value to a finite number, or null.
52
+ *
53
+ * `Number` rather than `parseFloat`, deliberately: `parseFloat('12abc')` is 12,
54
+ * which renders corrupt data as a plausible figure. A value that is not wholly
55
+ * numeric falls back instead.
56
+ */
57
+ function toFiniteNumber(value) {
58
+ if (value === null || value === undefined)
59
+ return null;
60
+ if (typeof value === 'number')
61
+ return Number.isFinite(value) ? value : null;
62
+ const trimmed = value.trim();
63
+ if (trimmed === '')
64
+ return null;
65
+ const num = Number(trimmed);
66
+ return Number.isFinite(num) ? num : null;
67
+ }
68
+ /**
69
+ * Format a DOLLAR value as currency.
70
+ *
71
+ * @example
72
+ * formatCurrency(1234.56) // '$1,234.56'
73
+ * formatCurrency('1234.56', { decimals: 0 }) // '$1,235'
74
+ * formatCurrency(-500, { decimals: 0 }) // '-$500'
75
+ * formatCurrency(null) // 'N/A'
76
+ */
77
+ export function formatCurrency(value, options = {}) {
78
+ const { decimals = 2, fallback = FALLBACK, currency = 'AUD' } = options;
79
+ const num = toFiniteNumber(value);
80
+ if (num === null)
81
+ return fallback;
82
+ return new Intl.NumberFormat(AU_LOCALE, {
83
+ style: 'currency',
84
+ currency,
85
+ minimumFractionDigits: decimals,
86
+ maximumFractionDigits: decimals
87
+ }).format(num);
88
+ }
89
+ /**
90
+ * Format an INTEGER-CENTS value as currency.
91
+ *
92
+ * Money held as integer cents is the correct storage for money, and the app
93
+ * that does it should not have to divide by 100 at every call site and hope
94
+ * the float lands.
95
+ *
96
+ * @example
97
+ * formatCurrencyFromCents(123456) // '$1,234.56'
98
+ * formatCurrencyFromCents(-123456, { decimals: 0 }) // '-$1,235'
99
+ * formatCurrencyFromCents(null) // 'N/A'
100
+ */
101
+ export function formatCurrencyFromCents(cents, options = {}) {
102
+ if (cents === null || cents === undefined || !Number.isFinite(cents)) {
103
+ return options.fallback ?? FALLBACK;
104
+ }
105
+ return formatCurrency(cents / 100, options);
106
+ }
107
+ /** Convert a dollar value to whole cents. */
108
+ export function dollarsToCents(dollars) {
109
+ return Math.round(dollars * 100);
110
+ }
111
+ /**
112
+ * The currency prefix `formatCurrency` would use — `$` for AUD, `USD ` for
113
+ * USD — so the compact form and the full form never disagree about the symbol.
114
+ */
115
+ function currencyPrefix(currency) {
116
+ const parts = new Intl.NumberFormat(AU_LOCALE, {
117
+ style: 'currency',
118
+ currency,
119
+ minimumFractionDigits: 0,
120
+ maximumFractionDigits: 0
121
+ }).formatToParts(0);
122
+ let prefix = '';
123
+ for (const part of parts) {
124
+ if (part.type === 'integer')
125
+ break;
126
+ if (part.type === 'currency' || part.type === 'literal')
127
+ prefix += part.value;
128
+ }
129
+ return prefix;
130
+ }
131
+ /**
132
+ * Abbreviate a money value for a chart axis or a dense tile.
133
+ *
134
+ * A y-axis tick has room for four or five characters, not for `$1,109,057.64`.
135
+ * Full precision belongs in the tooltip and the table; the axis exists to tell
136
+ * the reader which order of magnitude they are looking at.
137
+ *
138
+ * @example
139
+ * compactCurrency(1_109_057.64) // '$1.1m'
140
+ * compactCurrency(-1_500_000) // '-$1.5m'
141
+ * compactCurrency(12_400) // '$12k'
142
+ */
143
+ export function compactCurrency(value, options = {}) {
144
+ const { currency = 'AUD', fallback = FALLBACK } = options;
145
+ if (value === null || value === undefined || !Number.isFinite(value))
146
+ return fallback;
147
+ const prefix = currencyPrefix(currency);
148
+ // Sign outside the symbol, as everywhere else here: `-$1.5m`, not `$-1.5m`.
149
+ const sign = value < 0 ? '-' : '';
150
+ const abs = Math.abs(value);
151
+ if (abs >= 1_000_000)
152
+ return `${sign}${prefix}${(abs / 1_000_000).toFixed(1)}m`;
153
+ if (abs >= 1_000)
154
+ return `${sign}${prefix}${Math.round(abs / 1_000)}k`;
155
+ return `${sign}${prefix}${Math.round(abs)}`;
156
+ }
157
+ /**
158
+ * Format a money figure that arrives as a STRING, without ever parsing it to a
159
+ * JS number.
160
+ *
161
+ * A securities tax P&L reaches a tax return, so a figure must render with the
162
+ * exact digits the API sent — `parseFloat` rounds at 2^53 and quietly changes
163
+ * cents on a nine-figure total. This groups the integer part with thousands
164
+ * separators and preserves the fractional part verbatim, so the displayed
165
+ * string is faithful to the source decimal whatever its magnitude.
166
+ *
167
+ * @example
168
+ * formatCurrencyString('1234.56') // '$1,234.56'
169
+ * formatCurrencyString('-1234567.89') // '-$1,234,567.89'
170
+ * formatCurrencyString('0') // '$0'
171
+ * formatCurrencyString(null) // 'N/A'
172
+ */
173
+ export function formatCurrencyString(value, options = {}) {
174
+ const { fallback = FALLBACK } = options;
175
+ if (value === null || value === undefined)
176
+ return fallback;
177
+ const trimmed = String(value).trim();
178
+ if (trimmed === '' || trimmed === 'NaN')
179
+ return fallback;
180
+ let negative = false;
181
+ let body = trimmed;
182
+ if (body.startsWith('-')) {
183
+ negative = true;
184
+ body = body.slice(1);
185
+ }
186
+ else if (body.startsWith('+')) {
187
+ body = body.slice(1);
188
+ }
189
+ const [rawInt, rawFrac] = body.split('.');
190
+ const intPart = (rawInt ?? '').replace(/[^0-9]/g, '') || '0';
191
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
192
+ let out = grouped;
193
+ if (rawFrac !== undefined)
194
+ out += `.${rawFrac}`;
195
+ return `${negative ? '-' : ''}$${out}`;
196
+ }
197
+ /**
198
+ * Report the sign of a money value without parsing a string to a number — the
199
+ * float-free companion to `formatCurrencyString`, for choosing a tone class.
200
+ */
201
+ export function isNegativeMoney(value) {
202
+ if (value === null || value === undefined)
203
+ return false;
204
+ if (typeof value === 'number')
205
+ return value < 0;
206
+ return value.trim().startsWith('-');
207
+ }
208
+ // --------------------------------------------------------------------------- //
209
+ // Numbers and percentages //
210
+ // --------------------------------------------------------------------------- //
211
+ /**
212
+ * Format a number with thousands separators.
213
+ *
214
+ * @example
215
+ * formatNumber(1234567) // '1,234,567'
216
+ * formatNumber(1234.567, { decimals: 2 }) // '1,234.57'
217
+ * formatNumber(null) // 'N/A'
218
+ */
219
+ export function formatNumber(value, options = {}) {
220
+ const { decimals = 0, fallback = FALLBACK } = options;
221
+ const num = toFiniteNumber(value);
222
+ if (num === null)
223
+ return fallback;
224
+ return new Intl.NumberFormat(AU_LOCALE, {
225
+ minimumFractionDigits: decimals,
226
+ maximumFractionDigits: decimals
227
+ }).format(num);
228
+ }
229
+ /**
230
+ * Format a value that is ALREADY in percentage points: `4.5` renders `4.5%`.
231
+ *
232
+ * Its counterpart for a 0–1 ratio is `formatRatioAsPercentage`. The two are
233
+ * named apart on purpose — the apps disagreed about which one `formatPercent`
234
+ * meant, and picking either spelling for both would make a 100x error a
235
+ * one-character mistake.
236
+ *
237
+ * @example
238
+ * formatPercentage(4.5) // '4.5%'
239
+ * formatPercentage('4.5', { decimals: 2 }) // '4.50%'
240
+ * formatPercentage(null) // 'N/A'
241
+ */
242
+ export function formatPercentage(value, options = {}) {
243
+ const { decimals = 1, fallback = FALLBACK } = options;
244
+ const num = toFiniteNumber(value);
245
+ if (num === null)
246
+ return fallback;
247
+ return `${num.toFixed(decimals)}%`;
248
+ }
249
+ /**
250
+ * Format a 0–1 RATIO as a percentage: `0.045` renders `4.5%`.
251
+ *
252
+ * @example
253
+ * formatRatioAsPercentage(0.045) // '4.5%'
254
+ * formatRatioAsPercentage(1) // '100.0%'
255
+ * formatRatioAsPercentage(null) // 'N/A'
256
+ */
257
+ export function formatRatioAsPercentage(value, options = {}) {
258
+ const { decimals = 1, fallback = FALLBACK } = options;
259
+ const num = toFiniteNumber(value);
260
+ if (num === null)
261
+ return fallback;
262
+ return `${(num * 100).toFixed(decimals)}%`;
263
+ }
264
+ // --------------------------------------------------------------------------- //
265
+ // Dates and times //
266
+ // --------------------------------------------------------------------------- //
267
+ // Intl `en-AU` short months are not uniformly three characters: June, July and
268
+ // Sept come back four. Force a canonical abbreviation so a date column aligns.
269
+ const SHORT_MONTHS = [
270
+ 'Jan',
271
+ 'Feb',
272
+ 'Mar',
273
+ 'Apr',
274
+ 'May',
275
+ 'Jun',
276
+ 'Jul',
277
+ 'Aug',
278
+ 'Sep',
279
+ 'Oct',
280
+ 'Nov',
281
+ 'Dec'
282
+ ];
283
+ const LONG_MONTHS = [
284
+ 'January',
285
+ 'February',
286
+ 'March',
287
+ 'April',
288
+ 'May',
289
+ 'June',
290
+ 'July',
291
+ 'August',
292
+ 'September',
293
+ 'October',
294
+ 'November',
295
+ 'December'
296
+ ];
297
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
298
+ /**
299
+ * Parse a timestamp the API returned.
300
+ *
301
+ * A backend that stores naive UTC (`datetime.now(UTC).replace(tzinfo=None)`)
302
+ * and serialises it with no offset hands JavaScript a string it reads as LOCAL
303
+ * time: in Brisbane that lands every stored moment ten hours early, so anything
304
+ * after 2pm shows the wrong DAY, not merely the wrong hour. An offset-less
305
+ * value carrying a time is therefore read as UTC; one that carries an offset is
306
+ * left alone, and a date-only value is left alone too (appending a `Z` to it
307
+ * produces an Invalid Date).
308
+ */
309
+ export function parseApiDate(iso) {
310
+ const hasTime = iso.includes('T');
311
+ const hasOffset = /([zZ]|[+-]\d{2}:?\d{2})$/.test(iso);
312
+ return new Date(hasTime && !hasOffset ? `${iso}Z` : iso);
313
+ }
314
+ /** The calendar fields of an instant, as read in `timeZone`. */
315
+ function zonedParts(date, timeZone) {
316
+ const parts = new Intl.DateTimeFormat(AU_LOCALE, {
317
+ timeZone,
318
+ year: 'numeric',
319
+ month: '2-digit',
320
+ day: '2-digit',
321
+ hour: '2-digit',
322
+ minute: '2-digit',
323
+ hourCycle: 'h23'
324
+ }).formatToParts(date);
325
+ const value = (type) => parts.find((part) => part.type === type)?.value ?? '';
326
+ return {
327
+ day: Number(value('day')),
328
+ month: Number(value('month')),
329
+ year: Number(value('year')),
330
+ hour: value('hour'),
331
+ minute: value('minute')
332
+ };
333
+ }
334
+ /** Render already-resolved calendar fields in one of the three date shapes. */
335
+ function renderDate(day, month, year, format) {
336
+ const dd = String(day).padStart(2, '0');
337
+ if (format === 'numeric') {
338
+ return `${dd}/${String(month).padStart(2, '0')}/${year}`;
339
+ }
340
+ const name = (format === 'long' ? LONG_MONTHS : SHORT_MONTHS)[month - 1];
341
+ return `${dd} ${name} ${year}`;
342
+ }
343
+ /**
344
+ * Format a date for an Australian reader.
345
+ *
346
+ * A date-only value (`YYYY-MM-DD`) renders the day it NAMES, with no timezone
347
+ * conversion — a date is not an instant, and converting one is how a booking
348
+ * dated the 1st shows as the 31st. A value carrying a time is an instant, and
349
+ * is read in `timeZone` (`Australia/Brisbane` by default, not the browser's
350
+ * zone, so the same record reads the same on a laptop in another country).
351
+ *
352
+ * A string that will not parse is returned unchanged rather than hidden behind
353
+ * the fallback: an unexpected date format is worth seeing.
354
+ *
355
+ * @example
356
+ * formatDate('2024-12-19') // '19 Dec 2024'
357
+ * formatDate('2024-12-19', { format: 'long' }) // '19 December 2024'
358
+ * formatDate('2024-12-19', { format: 'numeric' }) // '19/12/2024'
359
+ * formatDate(null) // 'N/A'
360
+ */
361
+ export function formatDate(value, options = {}) {
362
+ const { format = 'short', fallback = FALLBACK, timeZone = AU_TIME_ZONE } = options;
363
+ if (value === null || value === undefined)
364
+ return fallback;
365
+ if (typeof value === 'string') {
366
+ const trimmed = value.trim();
367
+ if (trimmed === '')
368
+ return fallback;
369
+ const dateOnly = DATE_ONLY.exec(trimmed);
370
+ if (dateOnly) {
371
+ const year = Number(dateOnly[1]);
372
+ const month = Number(dateOnly[2]);
373
+ const day = Number(dateOnly[3]);
374
+ // A well-shaped but impossible date (2024-13-45) is returned as sent.
375
+ if (month < 1 || month > 12 || day < 1 || day > 31)
376
+ return trimmed;
377
+ return renderDate(day, month, year, format);
378
+ }
379
+ const parsed = parseApiDate(trimmed);
380
+ if (Number.isNaN(parsed.getTime()))
381
+ return trimmed;
382
+ const parts = zonedParts(parsed, timeZone);
383
+ return renderDate(parts.day, parts.month, parts.year, format);
384
+ }
385
+ if (Number.isNaN(value.getTime()))
386
+ return fallback;
387
+ const parts = zonedParts(value, timeZone);
388
+ return renderDate(parts.day, parts.month, parts.year, format);
389
+ }
390
+ /**
391
+ * Format a timestamp as a date and a 24-hour time: `19 Dec 2024, 14:05`.
392
+ *
393
+ * 24 hours, not `2:05 pm`: it is unambiguous and it aligns in a column, which
394
+ * is where a timestamp almost always sits.
395
+ *
396
+ * @example
397
+ * formatDateTime('2024-12-19T04:05:00') // '19 Dec 2024, 14:05'
398
+ * formatDateTime('2024-12-19T04:05:00Z', { format: 'numeric' }) // '19/12/2024, 14:05'
399
+ * formatDateTime(null) // 'N/A'
400
+ */
401
+ export function formatDateTime(value, options = {}) {
402
+ const { format = 'short', fallback = FALLBACK, timeZone = AU_TIME_ZONE } = options;
403
+ if (value === null || value === undefined)
404
+ return fallback;
405
+ let instant;
406
+ if (typeof value === 'string') {
407
+ const trimmed = value.trim();
408
+ if (trimmed === '')
409
+ return fallback;
410
+ instant = parseApiDate(trimmed);
411
+ if (Number.isNaN(instant.getTime()))
412
+ return trimmed;
413
+ }
414
+ else {
415
+ instant = value;
416
+ if (Number.isNaN(instant.getTime()))
417
+ return fallback;
418
+ }
419
+ const parts = zonedParts(instant, timeZone);
420
+ const date = renderDate(parts.day, parts.month, parts.year, format);
421
+ return `${date}, ${parts.hour}:${parts.minute}`;
422
+ }