@eifi1/ui-kit 0.44.0 → 0.45.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.
- package/README.md +10 -6
- package/dist/admin/admin-action-log-labels.js +9 -0
- package/dist/admin/admin-action-log-labels.js.map +1 -1
- package/dist/admin/admin-action-log.d.ts +18 -1
- package/dist/admin/admin-action-log.js +25 -16
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.d.ts +6 -0
- package/dist/admin/admin-area-labels.js +1 -0
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/mail-test-card-labels.d.ts +3 -0
- package/dist/admin/mail-test-card-labels.js +17 -0
- package/dist/admin/mail-test-card-labels.js.map +1 -0
- package/dist/admin/mail-test-card.d.ts +74 -0
- package/dist/admin/mail-test-card.js +80 -0
- package/dist/admin/mail-test-card.js.map +1 -0
- package/dist/components/data-table-mobile.js +17 -2
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +2 -0
- package/dist/components/data-table-shared.js +8 -0
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.js +1 -6
- package/dist/components/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +40 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +13 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +57 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +40 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +40 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +40 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +40 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +40 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +13 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/format.js +6 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
- package/dist/offline/pwa-update-prompt-labels.js +9 -0
- package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
- package/dist/offline/pwa-update-prompt.d.ts +32 -0
- package/dist/offline/pwa-update-prompt.js +36 -0
- package/dist/offline/pwa-update-prompt.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +100 -0
- package/dist/offline/query-persistence.js +179 -0
- package/dist/offline/query-persistence.js.map +1 -0
- package/dist/offline/shared-computer-labels.d.ts +2 -0
- package/dist/offline/shared-computer-labels.js +9 -0
- package/dist/offline/shared-computer-labels.js.map +1 -0
- package/dist/offline/shared-computer.d.ts +44 -0
- package/dist/offline/shared-computer.js +51 -0
- package/dist/offline/shared-computer.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
- package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
- package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
- package/dist/offline/sign-out-pending-dialog.js +66 -0
- package/dist/offline/sign-out-pending-dialog.js.map +1 -0
- package/dist/offline.d.ts +8 -0
- package/dist/offline.js +24 -0
- package/dist/offline.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +24 -0
- package/dist/shell/admin-menu.js +12 -3
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue.d.ts +7 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour.js +4 -2
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +99 -1
- package/dist/tour/use-tour-progress.js +228 -12
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +2 -0
- package/dist/tour.js.map +1 -1
- package/package.json +25 -1
- package/src/admin/admin-action-log-labels.ts +9 -0
- package/src/admin/admin-action-log.tsx +41 -5
- package/src/admin/admin-area-labels.ts +7 -0
- package/src/admin/mail-test-card-labels.ts +19 -0
- package/src/admin/mail-test-card.tsx +186 -0
- package/src/components/data-table-mobile.tsx +26 -3
- package/src/components/data-table-shared.tsx +18 -1
- package/src/components/data-table.tsx +1 -15
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/german.ts +46 -0
- package/src/i18n/kit-labels.tsx +13 -0
- package/src/i18n/locales/en.ts +12 -0
- package/src/i18n/locales/es.ts +46 -0
- package/src/i18n/locales/fr.ts +46 -0
- package/src/i18n/locales/hu.ts +42 -0
- package/src/i18n/locales/it.ts +46 -0
- package/src/i18n/locales/zh.ts +40 -0
- package/src/i18n/review.ts +12 -0
- package/src/index.ts +3 -0
- package/src/lib/format.ts +13 -2
- package/src/offline/pwa-update-prompt-labels.ts +9 -0
- package/src/offline/pwa-update-prompt.tsx +72 -0
- package/src/offline/query-persistence.ts +323 -0
- package/src/offline/shared-computer-labels.ts +8 -0
- package/src/offline/shared-computer.tsx +87 -0
- package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
- package/src/offline/sign-out-pending-dialog.tsx +135 -0
- package/src/offline.ts +26 -0
- package/src/shell/admin-menu.tsx +40 -3
- package/src/shell.ts +4 -0
- package/src/tour/tour-catalogue.tsx +7 -1
- package/src/tour/tour.tsx +5 -2
- package/src/tour/use-tour-progress.ts +378 -12
- package/src/tour.ts +3 -0
package/dist/lib/format.js
CHANGED
|
@@ -66,9 +66,10 @@ const DATE_STYLES = {
|
|
|
66
66
|
time: { timeStyle: "short" },
|
|
67
67
|
monthYear: { month: "long", year: "numeric" }
|
|
68
68
|
};
|
|
69
|
+
const BARE_ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
69
70
|
function toDate(value) {
|
|
70
71
|
if (value === null || value === void 0 || value === "") return null;
|
|
71
|
-
if (typeof value === "string" &&
|
|
72
|
+
if (typeof value === "string" && BARE_ISO_DAY.test(value)) return parseIsoDate(value);
|
|
72
73
|
const d = value instanceof Date ? value : new Date(value);
|
|
73
74
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
74
75
|
}
|
|
@@ -86,6 +87,10 @@ function formatDate(value, style = "medium", options = {}) {
|
|
|
86
87
|
const d = toDate(value);
|
|
87
88
|
if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;
|
|
88
89
|
const base = typeof style === "string" ? DATE_STYLES[style] : style;
|
|
90
|
+
if (typeof value === "string" && BARE_ISO_DAY.test(value)) {
|
|
91
|
+
const day = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
92
|
+
return dateFormatter(options.locale, { ...base, timeZone: "UTC" }).format(day);
|
|
93
|
+
}
|
|
89
94
|
return dateFormatter(options.locale, options.timeZone ? { ...base, timeZone: options.timeZone } : base).format(d);
|
|
90
95
|
}
|
|
91
96
|
const SECOND = 1e3;
|
package/dist/lib/format.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/format.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { parseIsoDate } from \"./dates\";\nimport { roundHalfAway } from \"./round\";\n\n/**\n * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,\n * for the figures an app prints OUTSIDE a kit component.\n *\n * Each app grew its own: kastlan's `utils/formatters.ts` (a Swiss-pinned\n * `formatNumber`/`formatCurrency`, a date-fns `formatDate`, and a `formatRelativeTime`\n * whose \"5m ago\" / \"3h ago\" / \"2d ago\" stayed English in every language), its tenancy\n * `format.ts` (`formatChf`, a `formatPct` gluing a \"+\" onto `toFixed(2)`), keksdose's\n * own set. Every one of them is `Intl` with a locale bolted on, so the only thing\n * worth sharing is the locale — and the kit already has one, the provider's.\n *\n * The plain functions take a `locale` (none: the runtime default, as every `Intl` API\n * reads `undefined`). Inside a component, {@link useKitFormat} hands back the same\n * functions bound to the `<UiKitProvider locale>`, so a page formats exactly like the\n * kit components on it.\n *\n * A missing value — `null`, `undefined`, `NaN`, an unparseable date — formats as\n * `empty` (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or\n * \"Invalid Date\".\n */\n\n/** What every formatter here prints for a missing value, unless told otherwise. */\nexport const EMPTY_FORMATTED_VALUE = \"—\";\n\n/** Fraction digits: a number pins both the minimum and the maximum (`2` → \"3.10\");\n * an object sets them apart (a meter reading, 1–3). */\nexport type FormatDigits = number | { min?: number; max?: number };\n\ninterface BaseOptions {\n /** BCP 47 tag. Left out: the runtime default (the hook: the provider's). */\n locale?: string;\n /** Printed for a missing value. Default {@link EMPTY_FORMATTED_VALUE}. */\n empty?: string;\n}\n\n/** The options every number formatter shares (money and percent carry their own unit). */\nexport interface FormatNumberBaseOptions extends BaseOptions {\n digits?: FormatDigits;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** `\"exceptZero\"` for a change (\"+3\", \"−3\", \"0\"). Default: the minus only. */\n signDisplay?: Intl.NumberFormatOptions[\"signDisplay\"];\n /** Any further `Intl.NumberFormat` options, merged over the ones above. */\n options?: Intl.NumberFormatOptions;\n}\n\n/** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */\nexport interface FormatNumberOptions extends FormatNumberBaseOptions {\n /**\n * A unit printed after the figure, joined by a narrow no-break space (U+202F):\n * \"3.400 N\", \"12 kg\", \"4,5 m²\". lenkbank (P8) prints forces, masses and lengths in\n * every table and summary, and each call site glued `${n} N` together with a plain\n * space — which wraps the \"N\" onto the next line in a narrow column and reads loose\n * beside the digit grouping. The narrow no-break space is the SI brochure's and the\n * Swiss typesetting rule's separator between value and unit, and it never breaks.\n *\n * Free text, not `Intl`'s `style: \"unit\"`: that one only knows the ECMA-402\n * sanctioned units (no \"N\", no \"kN\", no \"m³/h\"), so an app would still be gluing.\n * A missing value prints `empty` ALONE — \"— N\" says there is a newton-shaped\n * nothing, which is noise in a column that already heads its unit.\n */\n unit?: string;\n}\n\n/** Narrow no-break space (U+202F): between a figure and its {@link FormatNumberOptions.unit}. */\nconst UNIT_SEPARATOR = \"\\u202F\";\n\nexport interface FormatMoneyOptions extends FormatNumberBaseOptions {\n /** `\"symbol\"` (default), `\"narrowSymbol\"`, `\"code\"` (\"CHF 12.00\") or `\"name\"`. */\n currencyDisplay?: Intl.NumberFormatOptions[\"currencyDisplay\"];\n}\n\nexport interface FormatPercentOptions extends FormatNumberBaseOptions {\n /**\n * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read\n * it — `0.12` is 12 %. `false`: it is already a percentage — `12` is 12 %\n * (kastlan's rent `change_pct`).\n */\n ratio?: boolean;\n}\n\n// One formatter per option set: a table formats the same way thousands of times, and\n// constructing an Intl formatter is the expensive part.\nconst numberFormatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = numberFormatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n numberFormatters.set(key, f);\n }\n return f;\n}\n\nfunction digitOptions(digits: FormatDigits | undefined): Intl.NumberFormatOptions {\n if (digits === undefined) return {};\n if (typeof digits === \"number\") return { minimumFractionDigits: digits, maximumFractionDigits: digits };\n return {\n ...(digits.min !== undefined ? { minimumFractionDigits: digits.min } : null),\n ...(digits.max !== undefined ? { maximumFractionDigits: digits.max } : null),\n };\n}\n\nfunction numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {\n return {\n ...digitOptions(o.digits),\n ...(o.compact ? { notation: \"compact\" } : null),\n ...(o.signDisplay ? { signDisplay: o.signDisplay } : null),\n ...o.options,\n };\n}\n\nfunction isMissing(value: number | null | undefined): value is null | undefined {\n // ±Infinity too (lenkbank, adopting 0.13): a division by zero upstream is a missing\n // figure, and \"∞\" printed in a money or measurement column reads like data.\n return value === null || value === undefined || !Number.isFinite(value);\n}\n\n/** A number in `locale`: \"1,234.5\", \"1.234,5\", \"1’234.5\" — with `unit`, \"3.400 N\". */\nexport function formatNumber(value: number | null | undefined, options: FormatNumberOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = numberFormatter(options.locale, numberOptions(options)).format(value);\n return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;\n}\n\n/** An amount of `currency` (ISO 4217) in `locale`: \"CHF 1’234.50\", \"1.234,50 €\". */\nexport function formatMoney(\n value: number | null | undefined,\n currency: string,\n options: FormatMoneyOptions = {},\n): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n return numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n }).format(value);\n}\n\n/** A percentage in `locale` (\"12%\", \"12 %\", \"+1.25%\"). See {@link FormatPercentOptions.ratio}\n * for what `value` means. Default digits: at most one. */\nexport function formatPercent(value: number | null | undefined, options: FormatPercentOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? value / 100 : value;\n return numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n }).format(ratio);\n}\n\n/* ── Dates ─────────────────────────────────────────────────────────────────── */\n\n/**\n * The shapes a date is printed in, each a set of `Intl.DateTimeFormat` options:\n * - `short` / `medium` / `long` / `full` — the date alone, at that `dateStyle`\n * (\"08/07/2026\", \"8 Jul 2026\", \"8 July 2026\", \"Wednesday, 8 July 2026\" in en-GB).\n * - `dateTime` — medium date and short time: a timestamp (\"8 Jul 2026, 14:05\").\n * - `time` — the short time alone (\"14:05\").\n * - `monthYear` — \"July 2026\".\n *\n * Or pass `Intl.DateTimeFormatOptions` for anything else. A date-fns PATTERN\n * (\"dd.MM.yyyy\") is deliberately not accepted: a pattern is one locale's order baked\n * into a string, which is how an English page ends up with German dates.\n */\nexport type FormatDateStyle = \"short\" | \"medium\" | \"long\" | \"full\" | \"dateTime\" | \"time\" | \"monthYear\";\n\nconst DATE_STYLES: Record<FormatDateStyle, Intl.DateTimeFormatOptions> = {\n short: { dateStyle: \"short\" },\n medium: { dateStyle: \"medium\" },\n long: { dateStyle: \"long\" },\n full: { dateStyle: \"full\" },\n dateTime: { dateStyle: \"medium\", timeStyle: \"short\" },\n time: { timeStyle: \"short\" },\n monthYear: { month: \"long\", year: \"numeric\" },\n};\n\nexport interface FormatDateOptions extends BaseOptions {\n /** An IANA zone (\"Europe/Zurich\") for a timestamp. Left out: the device's. */\n timeZone?: string;\n}\n\n/** What {@link formatDate} and {@link formatRelativeTime} accept. */\nexport type DateInput = string | number | Date | null | undefined;\n\n/**\n * `value` as a Date, or null. A bare \"YYYY-MM-DD\" is a CALENDAR day and is read on the\n * local calendar (`parseIsoDate`) — `new Date(\"2026-07-08\")` is UTC midnight, which is\n * the 7th anywhere west of Greenwich. Anything else (a full ISO timestamp, epoch\n * milliseconds, a Date) is an instant and goes through `Date` as is.\n */\nexport function toDate(value: DateInput): Date | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value === \"string\" && /^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\nfunction dateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = dateFormatters.get(key);\n if (!f) {\n f = new Intl.DateTimeFormat(locale, options);\n dateFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * A date or timestamp in `locale`. Takes an ISO date (\"2026-07-08\", read on the local\n * calendar), an ISO timestamp (\"2026-07-08T14:05:00Z\"), epoch milliseconds or a Date.\n * `style` defaults to `medium`; see {@link FormatDateStyle}.\n *\n * `formatIsoDate` (`@eifi1/ui-kit/dates`) remains for a plain \"YYYY-MM-DD\" with an\n * explicit locale; this is its superset for timestamps and the named styles.\n */\nexport function formatDate(\n value: DateInput,\n style: FormatDateStyle | Intl.DateTimeFormatOptions = \"medium\",\n options: FormatDateOptions = {},\n): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const base = typeof style === \"string\" ? DATE_STYLES[style] : style;\n return dateFormatter(options.locale, options.timeZone ? { ...base, timeZone: options.timeZone } : base).format(d);\n}\n\nexport interface FormatRelativeTimeOptions extends BaseOptions {\n /** The moment it is measured from. Default: now. */\n now?: DateInput;\n /** `\"auto\"` (default): \"yesterday\", \"now\", \"next week\". `\"always\"`: \"1 day ago\". */\n numeric?: Intl.RelativeTimeFormatNumeric;\n /** `\"long\"` (default) \"3 hours ago\", `\"short\"` \"3 hr. ago\", `\"narrow\"` \"3h ago\". */\n style?: Intl.RelativeTimeFormatStyle;\n /**\n * Past this many days either way, print the date instead ({@link formatDate} in\n * `absoluteStyle`) — kastlan's feed switches to the date after a week, because \"5\n * weeks ago\" makes a reader do sums. Default: never.\n */\n absoluteAfterDays?: number;\n /** The style of that date. Default `medium`. */\n absoluteStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;\n}\n\nconst SECOND = 1000;\nconst MINUTE = 60 * SECOND;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\n/** [the largest |difference| a unit is used for, the unit, its length]. */\nconst RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [\n [45 * SECOND, \"second\", SECOND],\n [45 * MINUTE, \"minute\", MINUTE],\n [22 * HOUR, \"hour\", HOUR],\n [6.5 * DAY, \"day\", DAY],\n [26 * DAY, \"week\", 7 * DAY],\n [320 * DAY, \"month\", 30.44 * DAY],\n [Infinity, \"year\", 365.25 * DAY],\n];\n\nconst relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();\nfunction relativeFormatter(\n locale: string | undefined,\n numeric: Intl.RelativeTimeFormatNumeric,\n style: Intl.RelativeTimeFormatStyle,\n): Intl.RelativeTimeFormat {\n const key = `${locale ?? \"\"}|${numeric}|${style}`;\n let f = relativeFormatters.get(key);\n if (!f) {\n f = new Intl.RelativeTimeFormat(locale, { numeric, style });\n relativeFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * How long ago (or until) `value` is, in `locale`'s words, through\n * `Intl.RelativeTimeFormat`: \"3 hours ago\", \"vor 3 Stunden\", \"il y a 3 heures\",\n * \"in 2 days\", and under 45 seconds \"now\". The unit is picked by size — seconds,\n * minutes, hours, days, weeks, months, years — and the count is rounded.\n */\nexport function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const now = toDate(options.now ?? new Date()) ?? new Date();\n const diff = d.getTime() - now.getTime();\n const abs = Math.abs(diff);\n if (options.absoluteAfterDays !== undefined && abs > options.absoluteAfterDays * DAY) {\n return formatDate(d, options.absoluteStyle ?? \"medium\", options);\n }\n const rtf = relativeFormatter(options.locale, options.numeric ?? \"auto\", options.style ?? \"long\");\n const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;\n // Under 45 seconds is \"now\" rather than \"12 seconds ago\": a feed that ticks through\n // the seconds is noise, and nothing reads a timestamp to the second.\n const count = unit === \"second\" ? 0 : Math.round(diff / size);\n // `+ 0` folds -0 into 0: `format(-0, …)` says \"0 seconds ago\" instead of \"now\".\n return rtf.format(count + 0, unit);\n}\n\n/* ── Bound to the provider ─────────────────────────────────────────────────── */\n\ntype WithoutLocale<T> = Omit<T, \"locale\"> & { locale?: string };\n\n/** What {@link useKitFormat} returns: the formatters above with the provider's locale\n * as their default (an explicit `locale` in the options still wins). */\nexport interface KitFormat {\n /** The locale they format in — the provider's, or `undefined` (runtime default). */\n locale: string | undefined;\n formatNumber: (value: number | null | undefined, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (\n value: number | null | undefined,\n currency: string,\n options?: WithoutLocale<FormatMoneyOptions>,\n ) => string;\n formatPercent: (value: number | null | undefined, options?: WithoutLocale<FormatPercentOptions>) => string;\n formatDate: (\n value: DateInput,\n style?: FormatDateStyle | Intl.DateTimeFormatOptions,\n options?: WithoutLocale<FormatDateOptions>,\n ) => string;\n formatRelativeTime: (value: DateInput, options?: WithoutLocale<FormatRelativeTimeOptions>) => string;\n}\n\n/**\n * The formatters, bound to the `<UiKitProvider locale>` (or to `locale`, which wins\n * over it — the same precedence as a component's `locale` prop):\n *\n * const { formatMoney, formatRelativeTime } = useKitFormat();\n * formatMoney(row.balance, \"CHF\");\n */\nexport function useKitFormat(locale?: string): KitFormat {\n const kitLocale = useKitLocale(locale);\n return useMemo<KitFormat>(\n () => ({\n locale: kitLocale,\n formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),\n formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),\n formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),\n formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, ...options }),\n formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, ...options }),\n }),\n [kitLocale],\n );\n}\n\n/* ── Currency minor units ──────────────────────────────────────────────────── */\n\n/**\n * The decimals a currency is written with — its minor unit: CHF and EUR 2, JPY 0,\n * KWD 3 — from `Intl`, or `undefined` for a code `Intl` does not accept. The rule\n * AmountInput settles a typed figure by, for a host that needs it outside the field.\n */\nexport function currencyMinorDigits(currency: string | null | undefined): number | undefined {\n if (!currency) return undefined;\n try {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).resolvedOptions().maximumFractionDigits;\n } catch {\n return undefined;\n }\n}\n\n/** The options form of {@link roundToCurrency}'s third argument. */\nexport interface RoundToCurrencyOptions {\n /** Decimals to round to, whatever the currency — the plain `digits` argument. */\n digits?: number;\n /**\n * Decimals for a currency `Intl` cannot resolve — no code yet (`null`, `\"\"`), or a\n * malformed one (\"EURO\", \"€\"). Left out, such a value comes back unchanged, as\n * before. Note that `Intl` resolves ANY well-formed three-letter code, a made-up\n * \"XYZ\" included (to 2), so this is the case of a missing or broken code, not of a\n * rare currency.\n */\n fallbackDigits?: number;\n}\n\n/**\n * `value` rounded to the currency's minor unit (or to `digits`), half away from zero,\n * in decimal arithmetic: 93.4213 CHF → 93.42, 1.005 EUR → 1.01 (`Math.round(1.005 *\n * 100)` is 100, since 1.005 is 1.00499… in binary). A non-finite value, or a currency\n * `Intl` does not know without `digits`, comes back unchanged (keksdose live #356: an\n * FX estimate set into an AmountInput showed four decimals) — unless the options form\n * gives `fallbackDigits`: `roundToCurrency(v, code, { fallbackDigits: 2 })` rounds a\n * figure whose currency is not chosen yet to cents rather than leaving 0.1 + 0.2 as is.\n * A finite value never comes back NaN or -0, however small or large (BUG-11):\n * 0.1 + 0.2 - 0.3 → 0, -0.004 → 0.\n */\nexport function roundToCurrency(\n value: number,\n currency: string | null | undefined,\n digits?: number | RoundToCurrencyOptions,\n): number {\n const options = typeof digits === \"object\" && digits !== null ? digits : { digits };\n const places = options.digits ?? currencyMinorDigits(currency) ?? options.fallbackDigits;\n if (places === undefined) return value;\n return roundHalfAway(value, places);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/format.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { parseIsoDate } from \"./dates\";\nimport { roundHalfAway } from \"./round\";\n\n/**\n * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,\n * for the figures an app prints OUTSIDE a kit component.\n *\n * Each app grew its own: kastlan's `utils/formatters.ts` (a Swiss-pinned\n * `formatNumber`/`formatCurrency`, a date-fns `formatDate`, and a `formatRelativeTime`\n * whose \"5m ago\" / \"3h ago\" / \"2d ago\" stayed English in every language), its tenancy\n * `format.ts` (`formatChf`, a `formatPct` gluing a \"+\" onto `toFixed(2)`), keksdose's\n * own set. Every one of them is `Intl` with a locale bolted on, so the only thing\n * worth sharing is the locale — and the kit already has one, the provider's.\n *\n * The plain functions take a `locale` (none: the runtime default, as every `Intl` API\n * reads `undefined`). Inside a component, {@link useKitFormat} hands back the same\n * functions bound to the `<UiKitProvider locale>`, so a page formats exactly like the\n * kit components on it.\n *\n * A missing value — `null`, `undefined`, `NaN`, an unparseable date — formats as\n * `empty` (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or\n * \"Invalid Date\".\n */\n\n/** What every formatter here prints for a missing value, unless told otherwise. */\nexport const EMPTY_FORMATTED_VALUE = \"—\";\n\n/** Fraction digits: a number pins both the minimum and the maximum (`2` → \"3.10\");\n * an object sets them apart (a meter reading, 1–3). */\nexport type FormatDigits = number | { min?: number; max?: number };\n\ninterface BaseOptions {\n /** BCP 47 tag. Left out: the runtime default (the hook: the provider's). */\n locale?: string;\n /** Printed for a missing value. Default {@link EMPTY_FORMATTED_VALUE}. */\n empty?: string;\n}\n\n/** The options every number formatter shares (money and percent carry their own unit). */\nexport interface FormatNumberBaseOptions extends BaseOptions {\n digits?: FormatDigits;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** `\"exceptZero\"` for a change (\"+3\", \"−3\", \"0\"). Default: the minus only. */\n signDisplay?: Intl.NumberFormatOptions[\"signDisplay\"];\n /** Any further `Intl.NumberFormat` options, merged over the ones above. */\n options?: Intl.NumberFormatOptions;\n}\n\n/** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */\nexport interface FormatNumberOptions extends FormatNumberBaseOptions {\n /**\n * A unit printed after the figure, joined by a narrow no-break space (U+202F):\n * \"3.400 N\", \"12 kg\", \"4,5 m²\". lenkbank (P8) prints forces, masses and lengths in\n * every table and summary, and each call site glued `${n} N` together with a plain\n * space — which wraps the \"N\" onto the next line in a narrow column and reads loose\n * beside the digit grouping. The narrow no-break space is the SI brochure's and the\n * Swiss typesetting rule's separator between value and unit, and it never breaks.\n *\n * Free text, not `Intl`'s `style: \"unit\"`: that one only knows the ECMA-402\n * sanctioned units (no \"N\", no \"kN\", no \"m³/h\"), so an app would still be gluing.\n * A missing value prints `empty` ALONE — \"— N\" says there is a newton-shaped\n * nothing, which is noise in a column that already heads its unit.\n */\n unit?: string;\n}\n\n/** Narrow no-break space (U+202F): between a figure and its {@link FormatNumberOptions.unit}. */\nconst UNIT_SEPARATOR = \"\\u202F\";\n\nexport interface FormatMoneyOptions extends FormatNumberBaseOptions {\n /** `\"symbol\"` (default), `\"narrowSymbol\"`, `\"code\"` (\"CHF 12.00\") or `\"name\"`. */\n currencyDisplay?: Intl.NumberFormatOptions[\"currencyDisplay\"];\n}\n\nexport interface FormatPercentOptions extends FormatNumberBaseOptions {\n /**\n * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read\n * it — `0.12` is 12 %. `false`: it is already a percentage — `12` is 12 %\n * (kastlan's rent `change_pct`).\n */\n ratio?: boolean;\n}\n\n// One formatter per option set: a table formats the same way thousands of times, and\n// constructing an Intl formatter is the expensive part.\nconst numberFormatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = numberFormatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n numberFormatters.set(key, f);\n }\n return f;\n}\n\nfunction digitOptions(digits: FormatDigits | undefined): Intl.NumberFormatOptions {\n if (digits === undefined) return {};\n if (typeof digits === \"number\") return { minimumFractionDigits: digits, maximumFractionDigits: digits };\n return {\n ...(digits.min !== undefined ? { minimumFractionDigits: digits.min } : null),\n ...(digits.max !== undefined ? { maximumFractionDigits: digits.max } : null),\n };\n}\n\nfunction numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {\n return {\n ...digitOptions(o.digits),\n ...(o.compact ? { notation: \"compact\" } : null),\n ...(o.signDisplay ? { signDisplay: o.signDisplay } : null),\n ...o.options,\n };\n}\n\nfunction isMissing(value: number | null | undefined): value is null | undefined {\n // ±Infinity too (lenkbank, adopting 0.13): a division by zero upstream is a missing\n // figure, and \"∞\" printed in a money or measurement column reads like data.\n return value === null || value === undefined || !Number.isFinite(value);\n}\n\n/** A number in `locale`: \"1,234.5\", \"1.234,5\", \"1’234.5\" — with `unit`, \"3.400 N\". */\nexport function formatNumber(value: number | null | undefined, options: FormatNumberOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = numberFormatter(options.locale, numberOptions(options)).format(value);\n return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;\n}\n\n/** An amount of `currency` (ISO 4217) in `locale`: \"CHF 1’234.50\", \"1.234,50 €\". */\nexport function formatMoney(\n value: number | null | undefined,\n currency: string,\n options: FormatMoneyOptions = {},\n): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n return numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n }).format(value);\n}\n\n/** A percentage in `locale` (\"12%\", \"12 %\", \"+1.25%\"). See {@link FormatPercentOptions.ratio}\n * for what `value` means. Default digits: at most one. */\nexport function formatPercent(value: number | null | undefined, options: FormatPercentOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? value / 100 : value;\n return numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n }).format(ratio);\n}\n\n/* ── Dates ─────────────────────────────────────────────────────────────────── */\n\n/**\n * The shapes a date is printed in, each a set of `Intl.DateTimeFormat` options:\n * - `short` / `medium` / `long` / `full` — the date alone, at that `dateStyle`\n * (\"08/07/2026\", \"8 Jul 2026\", \"8 July 2026\", \"Wednesday, 8 July 2026\" in en-GB).\n * - `dateTime` — medium date and short time: a timestamp (\"8 Jul 2026, 14:05\").\n * - `time` — the short time alone (\"14:05\").\n * - `monthYear` — \"July 2026\".\n *\n * Or pass `Intl.DateTimeFormatOptions` for anything else. A date-fns PATTERN\n * (\"dd.MM.yyyy\") is deliberately not accepted: a pattern is one locale's order baked\n * into a string, which is how an English page ends up with German dates.\n */\nexport type FormatDateStyle = \"short\" | \"medium\" | \"long\" | \"full\" | \"dateTime\" | \"time\" | \"monthYear\";\n\nconst DATE_STYLES: Record<FormatDateStyle, Intl.DateTimeFormatOptions> = {\n short: { dateStyle: \"short\" },\n medium: { dateStyle: \"medium\" },\n long: { dateStyle: \"long\" },\n full: { dateStyle: \"full\" },\n dateTime: { dateStyle: \"medium\", timeStyle: \"short\" },\n time: { timeStyle: \"short\" },\n monthYear: { month: \"long\", year: \"numeric\" },\n};\n\nexport interface FormatDateOptions extends BaseOptions {\n /** An IANA zone (\"Europe/Zurich\") for a timestamp. Left out: the device's. A bare\n * \"YYYY-MM-DD\" ignores it: a calendar day is the day it names in every zone. */\n timeZone?: string;\n}\n\n/** A bare ISO calendar day, `\"YYYY-MM-DD\"`. */\nconst BARE_ISO_DAY = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** What {@link formatDate} and {@link formatRelativeTime} accept. */\nexport type DateInput = string | number | Date | null | undefined;\n\n/**\n * `value` as a Date, or null. A bare \"YYYY-MM-DD\" is a CALENDAR day and is read on the\n * local calendar (`parseIsoDate`) — `new Date(\"2026-07-08\")` is UTC midnight, which is\n * the 7th anywhere west of Greenwich. Anything else (a full ISO timestamp, epoch\n * milliseconds, a Date) is an instant and goes through `Date` as is.\n */\nexport function toDate(value: DateInput): Date | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\nfunction dateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = dateFormatters.get(key);\n if (!f) {\n f = new Intl.DateTimeFormat(locale, options);\n dateFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * A date or timestamp in `locale`. Takes an ISO date (\"2026-07-08\", read on the local\n * calendar), an ISO timestamp (\"2026-07-08T14:05:00Z\"), epoch milliseconds or a Date.\n * `style` defaults to `medium`; see {@link FormatDateStyle}.\n *\n * `formatIsoDate` (`@eifi1/ui-kit/dates`) remains for a plain \"YYYY-MM-DD\" with an\n * explicit locale; this is its superset for timestamps and the named styles.\n */\nexport function formatDate(\n value: DateInput,\n style: FormatDateStyle | Intl.DateTimeFormatOptions = \"medium\",\n options: FormatDateOptions = {},\n): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const base = typeof style === \"string\" ? DATE_STYLES[style] : style;\n // A bare \"YYYY-MM-DD\" is a calendar day, which has no zone: it is the day it names\n // wherever it is read, so a `timeZone` (in `options` or the style) never moves it — the\n // device's local midnight shown in a zone west of it was the day before (round 0.45, N9).\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) {\n const day = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\n return dateFormatter(options.locale, { ...base, timeZone: \"UTC\" }).format(day);\n }\n return dateFormatter(options.locale, options.timeZone ? { ...base, timeZone: options.timeZone } : base).format(d);\n}\n\nexport interface FormatRelativeTimeOptions extends BaseOptions {\n /** The moment it is measured from. Default: now. */\n now?: DateInput;\n /** `\"auto\"` (default): \"yesterday\", \"now\", \"next week\". `\"always\"`: \"1 day ago\". */\n numeric?: Intl.RelativeTimeFormatNumeric;\n /** `\"long\"` (default) \"3 hours ago\", `\"short\"` \"3 hr. ago\", `\"narrow\"` \"3h ago\". */\n style?: Intl.RelativeTimeFormatStyle;\n /**\n * Past this many days either way, print the date instead ({@link formatDate} in\n * `absoluteStyle`) — kastlan's feed switches to the date after a week, because \"5\n * weeks ago\" makes a reader do sums. Default: never.\n */\n absoluteAfterDays?: number;\n /** The style of that date. Default `medium`. */\n absoluteStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;\n}\n\nconst SECOND = 1000;\nconst MINUTE = 60 * SECOND;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\n/** [the largest |difference| a unit is used for, the unit, its length]. */\nconst RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [\n [45 * SECOND, \"second\", SECOND],\n [45 * MINUTE, \"minute\", MINUTE],\n [22 * HOUR, \"hour\", HOUR],\n [6.5 * DAY, \"day\", DAY],\n [26 * DAY, \"week\", 7 * DAY],\n [320 * DAY, \"month\", 30.44 * DAY],\n [Infinity, \"year\", 365.25 * DAY],\n];\n\nconst relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();\nfunction relativeFormatter(\n locale: string | undefined,\n numeric: Intl.RelativeTimeFormatNumeric,\n style: Intl.RelativeTimeFormatStyle,\n): Intl.RelativeTimeFormat {\n const key = `${locale ?? \"\"}|${numeric}|${style}`;\n let f = relativeFormatters.get(key);\n if (!f) {\n f = new Intl.RelativeTimeFormat(locale, { numeric, style });\n relativeFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * How long ago (or until) `value` is, in `locale`'s words, through\n * `Intl.RelativeTimeFormat`: \"3 hours ago\", \"vor 3 Stunden\", \"il y a 3 heures\",\n * \"in 2 days\", and under 45 seconds \"now\". The unit is picked by size — seconds,\n * minutes, hours, days, weeks, months, years — and the count is rounded.\n */\nexport function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const now = toDate(options.now ?? new Date()) ?? new Date();\n const diff = d.getTime() - now.getTime();\n const abs = Math.abs(diff);\n if (options.absoluteAfterDays !== undefined && abs > options.absoluteAfterDays * DAY) {\n return formatDate(d, options.absoluteStyle ?? \"medium\", options);\n }\n const rtf = relativeFormatter(options.locale, options.numeric ?? \"auto\", options.style ?? \"long\");\n const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;\n // Under 45 seconds is \"now\" rather than \"12 seconds ago\": a feed that ticks through\n // the seconds is noise, and nothing reads a timestamp to the second.\n const count = unit === \"second\" ? 0 : Math.round(diff / size);\n // `+ 0` folds -0 into 0: `format(-0, …)` says \"0 seconds ago\" instead of \"now\".\n return rtf.format(count + 0, unit);\n}\n\n/* ── Bound to the provider ─────────────────────────────────────────────────── */\n\ntype WithoutLocale<T> = Omit<T, \"locale\"> & { locale?: string };\n\n/** What {@link useKitFormat} returns: the formatters above with the provider's locale\n * as their default (an explicit `locale` in the options still wins). */\nexport interface KitFormat {\n /** The locale they format in — the provider's, or `undefined` (runtime default). */\n locale: string | undefined;\n formatNumber: (value: number | null | undefined, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (\n value: number | null | undefined,\n currency: string,\n options?: WithoutLocale<FormatMoneyOptions>,\n ) => string;\n formatPercent: (value: number | null | undefined, options?: WithoutLocale<FormatPercentOptions>) => string;\n formatDate: (\n value: DateInput,\n style?: FormatDateStyle | Intl.DateTimeFormatOptions,\n options?: WithoutLocale<FormatDateOptions>,\n ) => string;\n formatRelativeTime: (value: DateInput, options?: WithoutLocale<FormatRelativeTimeOptions>) => string;\n}\n\n/**\n * The formatters, bound to the `<UiKitProvider locale>` (or to `locale`, which wins\n * over it — the same precedence as a component's `locale` prop):\n *\n * const { formatMoney, formatRelativeTime } = useKitFormat();\n * formatMoney(row.balance, \"CHF\");\n */\nexport function useKitFormat(locale?: string): KitFormat {\n const kitLocale = useKitLocale(locale);\n return useMemo<KitFormat>(\n () => ({\n locale: kitLocale,\n formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),\n formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),\n formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),\n formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, ...options }),\n formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, ...options }),\n }),\n [kitLocale],\n );\n}\n\n/* ── Currency minor units ──────────────────────────────────────────────────── */\n\n/**\n * The decimals a currency is written with — its minor unit: CHF and EUR 2, JPY 0,\n * KWD 3 — from `Intl`, or `undefined` for a code `Intl` does not accept. The rule\n * AmountInput settles a typed figure by, for a host that needs it outside the field.\n */\nexport function currencyMinorDigits(currency: string | null | undefined): number | undefined {\n if (!currency) return undefined;\n try {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).resolvedOptions().maximumFractionDigits;\n } catch {\n return undefined;\n }\n}\n\n/** The options form of {@link roundToCurrency}'s third argument. */\nexport interface RoundToCurrencyOptions {\n /** Decimals to round to, whatever the currency — the plain `digits` argument. */\n digits?: number;\n /**\n * Decimals for a currency `Intl` cannot resolve — no code yet (`null`, `\"\"`), or a\n * malformed one (\"EURO\", \"€\"). Left out, such a value comes back unchanged, as\n * before. Note that `Intl` resolves ANY well-formed three-letter code, a made-up\n * \"XYZ\" included (to 2), so this is the case of a missing or broken code, not of a\n * rare currency.\n */\n fallbackDigits?: number;\n}\n\n/**\n * `value` rounded to the currency's minor unit (or to `digits`), half away from zero,\n * in decimal arithmetic: 93.4213 CHF → 93.42, 1.005 EUR → 1.01 (`Math.round(1.005 *\n * 100)` is 100, since 1.005 is 1.00499… in binary). A non-finite value, or a currency\n * `Intl` does not know without `digits`, comes back unchanged (keksdose live #356: an\n * FX estimate set into an AmountInput showed four decimals) — unless the options form\n * gives `fallbackDigits`: `roundToCurrency(v, code, { fallbackDigits: 2 })` rounds a\n * figure whose currency is not chosen yet to cents rather than leaving 0.1 + 0.2 as is.\n * A finite value never comes back NaN or -0, however small or large (BUG-11):\n * 0.1 + 0.2 - 0.3 → 0, -0.004 → 0.\n */\nexport function roundToCurrency(\n value: number,\n currency: string | null | undefined,\n digits?: number | RoundToCurrencyOptions,\n): number {\n const options = typeof digits === \"object\" && digits !== null ? digits : { digits };\n const places = options.digits ?? currencyMinorDigits(currency) ?? options.fallbackDigits;\n if (places === undefined) return value;\n return roundHalfAway(value, places);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/pwa-update-prompt-labels.ts"],"sourcesContent":["// The English words of ./pwa-update-prompt, in a module with no component code (0.34, KS U8).\n// keksdose's reviewed words (pwa.updateAvailable, pwa.reload) are the canon.\n\nimport type { PwaUpdatePromptLabels } from \"./pwa-update-prompt\";\n\nexport const DEFAULT_PWA_UPDATE_PROMPT_LABELS: PwaUpdatePromptLabels = {\n updateAvailable: \"A new version is available.\",\n reload: \"Reload\",\n};\n"],"mappings":";AAKO,MAAM,mCAA0D;AAAA,EACrE,iBAAiB;AAAA,EACjB,QAAQ;AACV;","names":[]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type LabelOverride } from "../i18n/kit-labels.js";
|
|
2
|
+
import { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./pwa-update-prompt-labels.js";
|
|
3
|
+
/**
|
|
4
|
+
* "A new version is available. — Reload": keksdose's service-worker toast
|
|
5
|
+
* (app/pwa-update-prompt.tsx) as a kit component (docs/local-first-harmonization.md §1.5
|
|
6
|
+
* D8). The app keeps `useRegisterSW` (vite-plugin-pwa, `registerType: "prompt"`) and hands
|
|
7
|
+
* its state in: `needRefresh` from it, `onReload` = `updateServiceWorker(true)`,
|
|
8
|
+
* `onDismiss` = `setNeedRefresh(false)`.
|
|
9
|
+
*
|
|
10
|
+
* `hold` defers the prompt while a reload would hurt — a handover being signed, a tour
|
|
11
|
+
* running. Nothing shows while it holds; the toast appears once it drops. A hold that
|
|
12
|
+
* starts while the toast is up takes it down again until it drops.
|
|
13
|
+
*
|
|
14
|
+
* Renders nothing itself: the toast needs a mounted `<Toaster>`.
|
|
15
|
+
*/
|
|
16
|
+
export interface PwaUpdatePromptLabels {
|
|
17
|
+
updateAvailable: string;
|
|
18
|
+
reload: string;
|
|
19
|
+
}
|
|
20
|
+
export { DEFAULT_PWA_UPDATE_PROMPT_LABELS };
|
|
21
|
+
export interface PwaUpdatePromptProps {
|
|
22
|
+
/** A new service worker is waiting. */
|
|
23
|
+
needRefresh: boolean;
|
|
24
|
+
/** Activates it and reloads — `updateServiceWorker(true)`. */
|
|
25
|
+
onReload: () => void;
|
|
26
|
+
/** The toast was closed without reloading. */
|
|
27
|
+
onDismiss?: () => void;
|
|
28
|
+
/** Wait: a handover is open, a tour runs. */
|
|
29
|
+
hold?: boolean;
|
|
30
|
+
labels?: LabelOverride<PwaUpdatePromptLabels>;
|
|
31
|
+
}
|
|
32
|
+
export declare function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold, labels: labelsProp }: PwaUpdatePromptProps): null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
4
|
+
import { toast } from "../components/toast.js";
|
|
5
|
+
import { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./pwa-update-prompt-labels.js";
|
|
6
|
+
const PWA_UPDATE_TOAST_ID = "pwa-update";
|
|
7
|
+
function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold = false, labels: labelsProp }) {
|
|
8
|
+
const labels = useKitLabels("pwaUpdate", DEFAULT_PWA_UPDATE_PROMPT_LABELS, labelsProp);
|
|
9
|
+
const handlers = useRef({ onReload, onDismiss });
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
handlers.current = { onReload, onDismiss };
|
|
12
|
+
});
|
|
13
|
+
const show = needRefresh && !hold;
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!show) return;
|
|
16
|
+
let ours = false;
|
|
17
|
+
toast(labels.updateAvailable, {
|
|
18
|
+
id: PWA_UPDATE_TOAST_ID,
|
|
19
|
+
duration: Infinity,
|
|
20
|
+
action: { label: labels.reload, onClick: () => handlers.current.onReload() },
|
|
21
|
+
onDismiss: () => {
|
|
22
|
+
if (!ours) handlers.current.onDismiss?.();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
ours = true;
|
|
27
|
+
toast.dismiss(PWA_UPDATE_TOAST_ID);
|
|
28
|
+
};
|
|
29
|
+
}, [show, labels.updateAvailable, labels.reload]);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_PWA_UPDATE_PROMPT_LABELS,
|
|
34
|
+
PwaUpdatePrompt
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=pwa-update-prompt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/pwa-update-prompt.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { toast } from \"../components/toast\";\nimport { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./pwa-update-prompt-labels\";\n\n/**\n * \"A new version is available. — Reload\": keksdose's service-worker toast\n * (app/pwa-update-prompt.tsx) as a kit component (docs/local-first-harmonization.md §1.5\n * D8). The app keeps `useRegisterSW` (vite-plugin-pwa, `registerType: \"prompt\"`) and hands\n * its state in: `needRefresh` from it, `onReload` = `updateServiceWorker(true)`,\n * `onDismiss` = `setNeedRefresh(false)`.\n *\n * `hold` defers the prompt while a reload would hurt — a handover being signed, a tour\n * running. Nothing shows while it holds; the toast appears once it drops. A hold that\n * starts while the toast is up takes it down again until it drops.\n *\n * Renders nothing itself: the toast needs a mounted `<Toaster>`.\n */\nexport interface PwaUpdatePromptLabels {\n updateAvailable: string;\n reload: string;\n}\n\n// Defined in ./pwa-update-prompt-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_PWA_UPDATE_PROMPT_LABELS };\n\nexport interface PwaUpdatePromptProps {\n /** A new service worker is waiting. */\n needRefresh: boolean;\n /** Activates it and reloads — `updateServiceWorker(true)`. */\n onReload: () => void;\n /** The toast was closed without reloading. */\n onDismiss?: () => void;\n /** Wait: a handover is open, a tour runs. */\n hold?: boolean;\n labels?: LabelOverride<PwaUpdatePromptLabels>;\n}\n\n/** The toast's id: one prompt at a time, replaced in place. */\nconst PWA_UPDATE_TOAST_ID = \"pwa-update\";\n\nexport function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold = false, labels: labelsProp }: PwaUpdatePromptProps) {\n const labels = useKitLabels(\"pwaUpdate\", DEFAULT_PWA_UPDATE_PROMPT_LABELS, labelsProp);\n // The callbacks are read at click time, so a new function each render does not\n // re-raise the toast.\n const handlers = useRef({ onReload, onDismiss });\n useEffect(() => {\n handlers.current = { onReload, onDismiss };\n });\n\n const show = needRefresh && !hold;\n useEffect(() => {\n if (!show) return;\n // sonner calls `onDismiss` for a programmatic dismiss too: the one this effect makes\n // when a hold starts must not tell the app the person waved the update away.\n let ours = false;\n toast(labels.updateAvailable, {\n id: PWA_UPDATE_TOAST_ID,\n duration: Infinity,\n action: { label: labels.reload, onClick: () => handlers.current.onReload() },\n onDismiss: () => {\n if (!ours) handlers.current.onDismiss?.();\n },\n });\n return () => {\n ours = true;\n toast.dismiss(PWA_UPDATE_TOAST_ID);\n };\n }, [show, labels.updateAvailable, labels.reload]);\n\n return null;\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAClC,SAAS,oBAAwC;AACjD,SAAS,aAAa;AACtB,SAAS,wCAAwC;AAoCjD,MAAM,sBAAsB;AAErB,SAAS,gBAAgB,EAAE,aAAa,UAAU,WAAW,OAAO,OAAO,QAAQ,WAAW,GAAyB;AAC5H,QAAM,SAAS,aAAa,aAAa,kCAAkC,UAAU;AAGrF,QAAM,WAAW,OAAO,EAAE,UAAU,UAAU,CAAC;AAC/C,YAAU,MAAM;AACd,aAAS,UAAU,EAAE,UAAU,UAAU;AAAA,EAC3C,CAAC;AAED,QAAM,OAAO,eAAe,CAAC;AAC7B,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AAGX,QAAI,OAAO;AACX,UAAM,OAAO,iBAAiB;AAAA,MAC5B,IAAI;AAAA,MACJ,UAAU;AAAA,MACV,QAAQ,EAAE,OAAO,OAAO,QAAQ,SAAS,MAAM,SAAS,QAAQ,SAAS,EAAE;AAAA,MAC3E,WAAW,MAAM;AACf,YAAI,CAAC,KAAM,UAAS,QAAQ,YAAY;AAAA,MAC1C;AAAA,IACF,CAAC;AACD,WAAO,MAAM;AACX,aAAO;AACP,YAAM,QAAQ,mBAAmB;AAAA,IACnC;AAAA,EACF,GAAG,CAAC,MAAM,OAAO,iBAAiB,OAAO,MAAM,CAAC;AAEhD,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { PersistQueryClientOptions } from "@tanstack/react-query-persist-client";
|
|
3
|
+
/**
|
|
4
|
+
* The persisted TanStack Query cache, keksdose's policy as a kit helper
|
|
5
|
+
* (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's
|
|
6
|
+
* shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the
|
|
7
|
+
* plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps
|
|
8
|
+
* paused mutations. On top: a key per account (and company), a scrub list that removes
|
|
9
|
+
* secret fields from payloads that are otherwise wanted offline, and `removeStale` for
|
|
10
|
+
* another account's leftovers.
|
|
11
|
+
*
|
|
12
|
+
* The only module of the kit that imports TanStack and idb-keyval: both are optional peers
|
|
13
|
+
* of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).
|
|
14
|
+
*/
|
|
15
|
+
/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */
|
|
16
|
+
export type ScrubField = string | RegExp;
|
|
17
|
+
/**
|
|
18
|
+
* Removed from every query's data, at any depth, before it is persisted (D4, D7): bank
|
|
19
|
+
* account numbers and the handover protocol's signatures. A page restored offline shows
|
|
20
|
+
* such a field as missing ("Needs a connection").
|
|
21
|
+
*/
|
|
22
|
+
export declare const DEFAULT_SCRUB_FIELDS: readonly ScrubField[];
|
|
23
|
+
/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */
|
|
24
|
+
export interface QueryPersistenceStorage {
|
|
25
|
+
getItem: (key: string) => Promise<string | null | undefined>;
|
|
26
|
+
setItem: (key: string, value: string) => Promise<unknown>;
|
|
27
|
+
removeItem: (key: string) => Promise<unknown>;
|
|
28
|
+
/** Every key in the store — what `removeStale` walks. */
|
|
29
|
+
keys: () => Promise<readonly unknown[]>;
|
|
30
|
+
}
|
|
31
|
+
export interface QueryPersistenceOptions {
|
|
32
|
+
/** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */
|
|
33
|
+
app: string;
|
|
34
|
+
/** The signed-in account. One cache per account (D5). */
|
|
35
|
+
user: string | number;
|
|
36
|
+
/** The acting company, where the app has several (kastlan): the switch changes the key,
|
|
37
|
+
* so no wipe and no cross-company leak — provided the app switches as
|
|
38
|
+
* {@link QueryPersistence.leave} says (leave, then remount the provider per key). */
|
|
39
|
+
company?: string | number | null;
|
|
40
|
+
/** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key
|
|
41
|
+
* whose second segment is `"admin"`. `false` keeps the query out. */
|
|
42
|
+
shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;
|
|
43
|
+
/** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */
|
|
44
|
+
denyRoots?: readonly string[];
|
|
45
|
+
/** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.
|
|
46
|
+
* A paused mutation whose variables carry one of these fields is never persisted (a
|
|
47
|
+
* scrubbed mutation would send something else), and one an older build wrote is dropped
|
|
48
|
+
* on restore. */
|
|
49
|
+
scrubFields?: readonly ScrubField[];
|
|
50
|
+
/** Told when a query's data is not plain JSON and is therefore not persisted. Default:
|
|
51
|
+
* `console.error`, as keksdose's `cacheSafeOrWarn`. */
|
|
52
|
+
onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;
|
|
53
|
+
/** Changes with each deploy (the app version), so a new build discards the old cache. */
|
|
54
|
+
buster: string;
|
|
55
|
+
/** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's
|
|
56
|
+
* `gcTime` must be at least this, or restore drops every query. */
|
|
57
|
+
maxAge: number;
|
|
58
|
+
/** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */
|
|
59
|
+
storage?: QueryPersistenceStorage;
|
|
60
|
+
/** Default 1000 ms, as keksdose. */
|
|
61
|
+
throttleTime?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One persisted cache, for one key. **Mount it per key**: the provider subscribes once and
|
|
65
|
+
* ignores a changed `persistOptions`, so give it the key as its React key —
|
|
66
|
+
*
|
|
67
|
+
* ```tsx
|
|
68
|
+
* <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* — and create the persistence once the account (and company) is known (`useMemo` on
|
|
72
|
+
* them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call
|
|
73
|
+
* {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare
|
|
74
|
+
* `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next
|
|
75
|
+
* company's) cache under the old key.
|
|
76
|
+
*/
|
|
77
|
+
export interface QueryPersistence {
|
|
78
|
+
/** `<app>-query-cache-<user>[-<company>]`. */
|
|
79
|
+
key: string;
|
|
80
|
+
/** Everything `<PersistQueryClientProvider persistOptions>` needs. */
|
|
81
|
+
persistOptions: Omit<PersistQueryClientOptions, "queryClient">;
|
|
82
|
+
/** Sign-out: stops writing under this key, empties the in-memory cache and deletes this
|
|
83
|
+
* key's persisted copy. Best-effort; never throws. */
|
|
84
|
+
clear: (queryClient: QueryClient) => Promise<void>;
|
|
85
|
+
/** The company switch (or any change of key without a sign-out): stops writing under this
|
|
86
|
+
* key — a save already scheduled is dropped too — and empties the in-memory cache, so
|
|
87
|
+
* the next key's provider starts clean. The persisted copy stays for the way back. A
|
|
88
|
+
* provider that mounts this persistence again re-arms it with its restore. Never throws. */
|
|
89
|
+
leave: (queryClient: QueryClient) => Promise<void>;
|
|
90
|
+
/** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another
|
|
91
|
+
* account's, another company's). Other apps' keys and fresh ones stay. Never throws. */
|
|
92
|
+
removeStale: () => Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* True when `data` still means the same thing after JSON.stringify and JSON.parse —
|
|
96
|
+
* keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a
|
|
97
|
+
* non-finite number, a function: all refused. An `undefined` property survives.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isCacheSafe(data: unknown, depth?: number): boolean;
|
|
100
|
+
export declare function createQueryPersistence({ app, user, company, shouldPersistKey, denyRoots, scrubFields, onUnsafe, buster, maxAge, storage, throttleTime, }: QueryPersistenceOptions): QueryPersistence;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
|
3
|
+
import { del, get, keys, set } from "idb-keyval";
|
|
4
|
+
const DEFAULT_SCRUB_FIELDS = Object.freeze([
|
|
5
|
+
"iban",
|
|
6
|
+
"qr_iban",
|
|
7
|
+
"default_qr_iban",
|
|
8
|
+
/^signature_/
|
|
9
|
+
]);
|
|
10
|
+
const idbStorage = {
|
|
11
|
+
getItem: (key) => get(key),
|
|
12
|
+
setItem: (key, value) => set(key, value),
|
|
13
|
+
removeItem: (key) => del(key),
|
|
14
|
+
keys: () => keys()
|
|
15
|
+
};
|
|
16
|
+
function isCacheSafe(data, depth = 0) {
|
|
17
|
+
if (depth > 12) return false;
|
|
18
|
+
if (data === null) return true;
|
|
19
|
+
const t = typeof data;
|
|
20
|
+
if (t === "string" || t === "boolean") return true;
|
|
21
|
+
if (t === "number") return Number.isFinite(data);
|
|
22
|
+
if (t !== "object") return false;
|
|
23
|
+
if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));
|
|
24
|
+
const proto = Object.getPrototypeOf(data);
|
|
25
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
26
|
+
return Object.values(data).every((v) => v === void 0 || isCacheSafe(v, depth + 1));
|
|
27
|
+
}
|
|
28
|
+
function scrub(data, fields) {
|
|
29
|
+
if (fields.length === 0 || data === null || typeof data !== "object") return data;
|
|
30
|
+
if (Array.isArray(data)) return data.map((v) => scrub(v, fields));
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [name, value] of Object.entries(data)) {
|
|
33
|
+
if (fields.some((f) => typeof f === "string" ? f === name : f.test(name))) continue;
|
|
34
|
+
out[name] = scrub(value, fields);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const defaultOnUnsafe = (queryKey) => {
|
|
39
|
+
console.error(
|
|
40
|
+
`[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON (a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build the rich object at the call site.`
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
function createQueryPersistence({
|
|
44
|
+
app,
|
|
45
|
+
user,
|
|
46
|
+
company,
|
|
47
|
+
shouldPersistKey,
|
|
48
|
+
denyRoots = [],
|
|
49
|
+
scrubFields = DEFAULT_SCRUB_FIELDS,
|
|
50
|
+
onUnsafe = defaultOnUnsafe,
|
|
51
|
+
buster,
|
|
52
|
+
maxAge,
|
|
53
|
+
storage = idbStorage,
|
|
54
|
+
throttleTime = 1e3
|
|
55
|
+
}) {
|
|
56
|
+
const prefix = `${app}-query-cache-`;
|
|
57
|
+
const key = `${prefix}${user}${company === void 0 || company === null || company === "" ? "" : `-${company}`}`;
|
|
58
|
+
const denied = new Set(denyRoots);
|
|
59
|
+
let retired = false;
|
|
60
|
+
const keyAllowed = (queryKey) => !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);
|
|
61
|
+
const shouldDehydrateQuery = (query) => {
|
|
62
|
+
if (query.state.status !== "success" || !keyAllowed(query.queryKey)) return false;
|
|
63
|
+
if (isCacheSafe(query.state.data)) return true;
|
|
64
|
+
onUnsafe(query.queryKey, query.state.data);
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
const restorable = (entry) => {
|
|
68
|
+
if (!entry || typeof entry !== "object") return null;
|
|
69
|
+
const { queryKey, state } = entry;
|
|
70
|
+
if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;
|
|
71
|
+
if (!isCacheSafe(state?.data)) return null;
|
|
72
|
+
return withScrubbedData(entry);
|
|
73
|
+
};
|
|
74
|
+
const carriesScrubbed = (value, depth = 0) => {
|
|
75
|
+
if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== "object") return false;
|
|
76
|
+
if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));
|
|
77
|
+
return Object.entries(value).some(
|
|
78
|
+
([name, v]) => scrubFields.some((f) => typeof f === "string" ? f === name : f.test(name)) || carriesScrubbed(v, depth + 1)
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
const shouldDehydrateMutation = (mutation) => mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
|
|
82
|
+
const withScrubbedData = (entry) => {
|
|
83
|
+
const query = entry;
|
|
84
|
+
if (!query.state || !("data" in query.state)) return entry;
|
|
85
|
+
return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };
|
|
86
|
+
};
|
|
87
|
+
const serialize = (client) => JSON.stringify({
|
|
88
|
+
...client,
|
|
89
|
+
clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) }
|
|
90
|
+
});
|
|
91
|
+
const deserialize = (raw) => {
|
|
92
|
+
const empty = { timestamp: 0, buster: "", clientState: { mutations: [], queries: [] } };
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (!parsed || typeof parsed !== "object") return empty;
|
|
96
|
+
const { timestamp, buster: stored, clientState } = parsed;
|
|
97
|
+
if (typeof timestamp !== "number" || typeof stored !== "string") return empty;
|
|
98
|
+
if (!clientState || typeof clientState !== "object" || !Array.isArray(clientState.queries)) return empty;
|
|
99
|
+
return {
|
|
100
|
+
timestamp,
|
|
101
|
+
buster: stored,
|
|
102
|
+
clientState: {
|
|
103
|
+
mutations: Array.isArray(clientState.mutations) ? clientState.mutations.filter(
|
|
104
|
+
(m) => !carriesScrubbed(m?.state?.variables)
|
|
105
|
+
) : [],
|
|
106
|
+
queries: clientState.queries.map(restorable).filter((q) => q !== null)
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return empty;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const persister = createAsyncStoragePersister({
|
|
114
|
+
storage: {
|
|
115
|
+
// Only the provider's restore reads: mounting this persistence again re-arms it.
|
|
116
|
+
getItem: async (k) => {
|
|
117
|
+
retired = false;
|
|
118
|
+
return await storage.getItem(k) ?? null;
|
|
119
|
+
},
|
|
120
|
+
setItem: async (k, v) => {
|
|
121
|
+
if (!retired) await storage.setItem(k, v);
|
|
122
|
+
},
|
|
123
|
+
removeItem: async (k) => {
|
|
124
|
+
await storage.removeItem(k);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
key,
|
|
128
|
+
throttleTime,
|
|
129
|
+
serialize,
|
|
130
|
+
deserialize
|
|
131
|
+
});
|
|
132
|
+
const leave = async (queryClient) => {
|
|
133
|
+
retired = true;
|
|
134
|
+
try {
|
|
135
|
+
queryClient.clear();
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const clear = async (queryClient) => {
|
|
140
|
+
retired = true;
|
|
141
|
+
try {
|
|
142
|
+
queryClient.clear();
|
|
143
|
+
await persister.removeClient();
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const removeStale = async () => {
|
|
148
|
+
try {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
for (const k of await storage.keys()) {
|
|
151
|
+
if (typeof k !== "string" || !k.startsWith(prefix) || k === key) continue;
|
|
152
|
+
let timestamp = 0;
|
|
153
|
+
try {
|
|
154
|
+
const raw = await storage.getItem(k);
|
|
155
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
156
|
+
if (parsed && typeof parsed === "object" && typeof parsed.timestamp === "number") {
|
|
157
|
+
timestamp = parsed.timestamp;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
if (now - timestamp > maxAge) await storage.removeItem(k);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
key,
|
|
168
|
+
persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },
|
|
169
|
+
clear,
|
|
170
|
+
leave,
|
|
171
|
+
removeStale
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
DEFAULT_SCRUB_FIELDS,
|
|
176
|
+
createQueryPersistence,
|
|
177
|
+
isCacheSafe
|
|
178
|
+
};
|
|
179
|
+
//# sourceMappingURL=query-persistence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/query-persistence.ts"],"sourcesContent":["import { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { PersistedClient, PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\";\nimport { del, get, keys, set } from \"idb-keyval\";\n\n/**\n * The persisted TanStack Query cache, keksdose's policy as a kit helper\n * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's\n * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the\n * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps\n * paused mutations. On top: a key per account (and company), a scrub list that removes\n * secret fields from payloads that are otherwise wanted offline, and `removeStale` for\n * another account's leftovers.\n *\n * The only module of the kit that imports TanStack and idb-keyval: both are optional peers\n * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).\n */\n\n/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */\nexport type ScrubField = string | RegExp;\n\n/**\n * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank\n * account numbers and the handover protocol's signatures. A page restored offline shows\n * such a field as missing (\"Needs a connection\").\n */\nexport const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([\n \"iban\",\n \"qr_iban\",\n \"default_qr_iban\",\n /^signature_/,\n]);\n\n/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */\nexport interface QueryPersistenceStorage {\n getItem: (key: string) => Promise<string | null | undefined>;\n setItem: (key: string, value: string) => Promise<unknown>;\n removeItem: (key: string) => Promise<unknown>;\n /** Every key in the store — what `removeStale` walks. */\n keys: () => Promise<readonly unknown[]>;\n}\n\nexport interface QueryPersistenceOptions {\n /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */\n app: string;\n /** The signed-in account. One cache per account (D5). */\n user: string | number;\n /** The acting company, where the app has several (kastlan): the switch changes the key,\n * so no wipe and no cross-company leak — provided the app switches as\n * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */\n company?: string | number | null;\n /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key\n * whose second segment is `\"admin\"`. `false` keeps the query out. */\n shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;\n /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */\n denyRoots?: readonly string[];\n /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.\n * A paused mutation whose variables carry one of these fields is never persisted (a\n * scrubbed mutation would send something else), and one an older build wrote is dropped\n * on restore. */\n scrubFields?: readonly ScrubField[];\n /** Told when a query's data is not plain JSON and is therefore not persisted. Default:\n * `console.error`, as keksdose's `cacheSafeOrWarn`. */\n onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;\n /** Changes with each deploy (the app version), so a new build discards the old cache. */\n buster: string;\n /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's\n * `gcTime` must be at least this, or restore drops every query. */\n maxAge: number;\n /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */\n storage?: QueryPersistenceStorage;\n /** Default 1000 ms, as keksdose. */\n throttleTime?: number;\n}\n\n/**\n * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and\n * ignores a changed `persistOptions`, so give it the key as its React key —\n *\n * ```tsx\n * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>\n * ```\n *\n * — and create the persistence once the account (and company) is known (`useMemo` on\n * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call\n * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare\n * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next\n * company's) cache under the old key.\n */\nexport interface QueryPersistence {\n /** `<app>-query-cache-<user>[-<company>]`. */\n key: string;\n /** Everything `<PersistQueryClientProvider persistOptions>` needs. */\n persistOptions: Omit<PersistQueryClientOptions, \"queryClient\">;\n /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this\n * key's persisted copy. Best-effort; never throws. */\n clear: (queryClient: QueryClient) => Promise<void>;\n /** The company switch (or any change of key without a sign-out): stops writing under this\n * key — a save already scheduled is dropped too — and empties the in-memory cache, so\n * the next key's provider starts clean. The persisted copy stays for the way back. A\n * provider that mounts this persistence again re-arms it with its restore. Never throws. */\n leave: (queryClient: QueryClient) => Promise<void>;\n /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another\n * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */\n removeStale: () => Promise<void>;\n}\n\nconst idbStorage: QueryPersistenceStorage = {\n getItem: (key) => get<string>(key),\n setItem: (key, value) => set(key, value),\n removeItem: (key) => del(key),\n keys: () => keys(),\n};\n\n/**\n * True when `data` still means the same thing after JSON.stringify and JSON.parse —\n * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a\n * non-finite number, a function: all refused. An `undefined` property survives.\n */\nexport function isCacheSafe(data: unknown, depth = 0): boolean {\n if (depth > 12) return false;\n if (data === null) return true;\n const t = typeof data;\n if (t === \"string\" || t === \"boolean\") return true;\n if (t === \"number\") return Number.isFinite(data as number);\n if (t !== \"object\") return false;\n if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));\n const proto: unknown = Object.getPrototypeOf(data);\n if (proto !== Object.prototype && proto !== null) return false;\n return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));\n}\n\n/** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */\nfunction scrub(data: unknown, fields: readonly ScrubField[]): unknown {\n if (fields.length === 0 || data === null || typeof data !== \"object\") return data;\n if (Array.isArray(data)) return data.map((v) => scrub(v, fields));\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(data as Record<string, unknown>)) {\n if (fields.some((f) => (typeof f === \"string\" ? f === name : f.test(name)))) continue;\n out[name] = scrub(value, fields);\n }\n return out;\n}\n\nconst defaultOnUnsafe = (queryKey: readonly unknown[]) => {\n console.error(\n `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +\n `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +\n `the rich object at the call site.`,\n );\n};\n\nexport function createQueryPersistence({\n app,\n user,\n company,\n shouldPersistKey,\n denyRoots = [],\n scrubFields = DEFAULT_SCRUB_FIELDS,\n onUnsafe = defaultOnUnsafe,\n buster,\n maxAge,\n storage = idbStorage,\n throttleTime = 1000,\n}: QueryPersistenceOptions): QueryPersistence {\n const prefix = `${app}-query-cache-`;\n const key = `${prefix}${user}${company === undefined || company === null || company === \"\" ? \"\" : `-${company}`}`;\n const denied = new Set(denyRoots);\n // Set by `leave` and `clear`: a write under this key after the person left it (a\n // throttled save of the emptied cache) is dropped. A restore re-arms it.\n let retired = false;\n\n const keyAllowed = (queryKey: readonly unknown[]) =>\n !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);\n\n /** The write side: a success, an allowed key, plain JSON (or the warning). */\n const shouldDehydrateQuery = (query: {\n queryKey: readonly unknown[];\n state: { status: string; data?: unknown };\n }): boolean => {\n if (query.state.status !== \"success\" || !keyAllowed(query.queryKey)) return false;\n if (isCacheSafe(query.state.data)) return true;\n onUnsafe(query.queryKey, query.state.data);\n return false;\n };\n\n /** One persisted query entry, scrubbed; `null` when it may not be restored. */\n const restorable = (entry: unknown): unknown => {\n if (!entry || typeof entry !== \"object\") return null;\n const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };\n if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;\n if (!isCacheSafe(state?.data)) return null;\n return withScrubbedData(entry);\n };\n\n /** Whether `value` has a scrubbed field's name anywhere in it. */\n const carriesScrubbed = (value: unknown, depth = 0): boolean => {\n if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== \"object\") return false;\n if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));\n return Object.entries(value as Record<string, unknown>).some(\n ([name, v]) =>\n scrubFields.some((f) => (typeof f === \"string\" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),\n );\n };\n\n /** The write side for mutations: TanStack's default (paused ones), minus any whose\n * variables carry a scrubbed field (S6). */\n const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>\n mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);\n\n const withScrubbedData = (entry: unknown): unknown => {\n const query = entry as { state?: { data?: unknown } };\n if (!query.state || !(\"data\" in query.state)) return entry;\n return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };\n };\n\n // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open\n // shows it), the disk never sees it. Mutations are a paused write queue and are kept\n // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is\n // not written at all (`shouldDehydrateMutation`).\n const serialize = (client: PersistedClient): string =>\n JSON.stringify({\n ...client,\n clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },\n });\n\n // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build\n // wrote what this one may deny. Never throws; an unreadable blob comes back with\n // `timestamp: 0`, which the restore treats as unusable and removes.\n const deserialize = (raw: string): PersistedClient => {\n const empty: PersistedClient = { timestamp: 0, buster: \"\", clientState: { mutations: [], queries: [] } };\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\") return empty;\n const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;\n if (typeof timestamp !== \"number\" || typeof stored !== \"string\") return empty;\n if (!clientState || typeof clientState !== \"object\" || !Array.isArray(clientState.queries)) return empty;\n return {\n timestamp,\n buster: stored,\n clientState: {\n mutations: Array.isArray(clientState.mutations)\n ? clientState.mutations.filter(\n (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),\n )\n : [],\n queries: clientState.queries.map(restorable).filter((q) => q !== null) as PersistedClient[\"clientState\"][\"queries\"],\n },\n };\n } catch {\n return empty;\n }\n };\n\n const persister = createAsyncStoragePersister({\n storage: {\n // Only the provider's restore reads: mounting this persistence again re-arms it.\n getItem: async (k) => {\n retired = false;\n return (await storage.getItem(k)) ?? null;\n },\n setItem: async (k, v) => {\n if (!retired) await storage.setItem(k, v);\n },\n removeItem: async (k) => {\n await storage.removeItem(k);\n },\n },\n key,\n throttleTime,\n serialize,\n deserialize,\n });\n\n const leave = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n } catch {\n /* best-effort */\n }\n };\n\n const clear = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n await persister.removeClient();\n } catch {\n /* best-effort: private mode / IndexedDB unavailable */\n }\n };\n\n const removeStale = async () => {\n try {\n const now = Date.now();\n for (const k of await storage.keys()) {\n if (typeof k !== \"string\" || !k.startsWith(prefix) || k === key) continue;\n let timestamp = 0;\n try {\n const raw = await storage.getItem(k);\n const parsed: unknown = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\" && typeof (parsed as PersistedClient).timestamp === \"number\") {\n timestamp = (parsed as PersistedClient).timestamp;\n }\n } catch {\n /* unreadable: stale */\n }\n if (now - timestamp > maxAge) await storage.removeItem(k);\n }\n } catch {\n /* best-effort */\n }\n };\n\n return {\n key,\n persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },\n clear,\n leave,\n removeStale,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
const DEFAULT_SHARED_COMPUTER_LABELS = {
|
|
3
|
+
label: "This is a shared computer",
|
|
4
|
+
hint: "No copy of your data stays on this device. Pages then open only with a connection."
|
|
5
|
+
};
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_SHARED_COMPUTER_LABELS
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=shared-computer-labels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/shared-computer-labels.ts"],"sourcesContent":["// The English words of ./shared-computer, in a module with no component code (0.34, KS U8).\n\nimport type { SharedComputerLabels } from \"./shared-computer\";\n\nexport const DEFAULT_SHARED_COMPUTER_LABELS: SharedComputerLabels = {\n label: \"This is a shared computer\",\n hint: \"No copy of your data stays on this device. Pages then open only with a connection.\",\n};\n"],"mappings":";AAIO,MAAM,iCAAuD;AAAA,EAClE,OAAO;AAAA,EACP,MAAM;AACR;","names":[]}
|