@eifi1/ui-kit 0.45.0 → 0.46.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 +23 -12
- package/dist/admin/admin-area-labels.d.ts +9 -0
- package/dist/admin/admin-area-labels.js +5 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card.d.ts +3 -2
- package/dist/admin/mail-test-card.js +3 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +18 -2
- package/dist/components/data-table-row-actions.js +86 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.js +9 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +43 -2
- package/dist/components/data-table.js +9 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +64 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/german.js +41 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +40 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +41 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +41 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +41 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +41 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +41 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +27 -0
- package/dist/notifications/device-zone-report.js +24 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +11 -5
- package/dist/notifications/feedback-notification-types.js +2 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +77 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +41 -7
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -2
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +33 -0
- package/dist/offline/local-first.js +39 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +26 -1
- package/dist/offline/query-persistence.js +27 -23
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline.d.ts +5 -2
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.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/sync/db.d.ts +76 -0
- package/dist/sync/db.js +24 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +124 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +30 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +22 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +50 -6
- package/dist/tour/tour.js.map +1 -1
- package/package.json +19 -1
- package/src/admin/admin-area-labels.ts +13 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card.tsx +7 -3
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +21 -2
- package/src/components/data-table.tsx +51 -6
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +102 -14
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/german.ts +44 -4
- package/src/i18n/kit-labels.tsx +18 -1
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +44 -4
- package/src/i18n/locales/fr.ts +44 -4
- package/src/i18n/locales/hu.ts +44 -4
- package/src/i18n/locales/it.ts +44 -4
- package/src/i18n/locales/zh.ts +44 -2
- package/src/i18n/review.ts +3 -1
- package/src/index.ts +42 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +10 -4
- package/src/notifications/device-zone-report.ts +54 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +12 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +304 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +69 -11
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +5 -2
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +76 -0
- package/src/offline/query-persistence.ts +57 -24
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline.ts +7 -1
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +3 -0
- package/src/sync/db.ts +103 -0
- package/src/sync/engine.ts +699 -0
- package/src/sync/hooks.ts +93 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +100 -7
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. 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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/format.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLocale, useKitTimeZone } 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 zone of the date printed past `absoluteAfterDays` (0.46). Left out: the\n * device's. */\n timeZone?: string;\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 // 0.46 (§10.3): the provider's `timeZone` for timestamps; a call's own wins, and a bare\n // \"YYYY-MM-DD\" ignores it (formatDate).\n const timeZone = useKitTimeZone();\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, timeZone, ...options }),\n formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options }),\n }),\n [kitLocale, timeZone],\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,27 @@
|
|
|
1
|
+
export interface DeviceZoneReportOptions {
|
|
2
|
+
/** The account's `device_timezone` from `/auth/me`; `undefined` while it loads (no
|
|
3
|
+
* report until it is known). */
|
|
4
|
+
stored: string | null | undefined;
|
|
5
|
+
/** `PATCH /auth/me {device_timezone}` with this device's zone. */
|
|
6
|
+
report: (deviceTimezone: string) => Promise<unknown> | void;
|
|
7
|
+
/** A demo account: never reported (the server ignores it too, §10.3 Settled). */
|
|
8
|
+
isDemo?: boolean;
|
|
9
|
+
/** The signed-in account (its user id): the once-per-start guard is per account, so a
|
|
10
|
+
* second person signed in on a shared PC in the same start is reported too. Without it
|
|
11
|
+
* the guard is cleared when `stored` goes `undefined` (signed out). */
|
|
12
|
+
account?: string | number | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Report this device's zone to the account (0.46, §10.3): the account's zone follows the
|
|
16
|
+
* device last used, else `Europe/Zurich`; screens keep showing device time. Once per start
|
|
17
|
+
* of the app, and only when it differs from the stored `device_timezone` — a bench PC in Zurich never
|
|
18
|
+
* PATCHes on a sign-in — and never for a demo. Per account: a sign-out (`stored` back to
|
|
19
|
+
* `undefined`) clears the guard, and `account` keys it. The report IS the account's zone: it is
|
|
20
|
+
* automatic only (§10.3, amended 2026-10-20), so nothing is ever picked. A failed report
|
|
21
|
+
* is not retried in the same start; the next start tries again. Sign-up sends the zone
|
|
22
|
+
* itself (`RegisterRequest.device_timezone`).
|
|
23
|
+
*
|
|
24
|
+
* useDeviceZoneReport({ stored: me?.device_timezone, isDemo: me?.is_demo, account: me?.id,
|
|
25
|
+
* report: async (zone) => setUser((await api.patch("/auth/me", { device_timezone: zone })).data) });
|
|
26
|
+
*/
|
|
27
|
+
export declare function useDeviceZoneReport({ stored, report, isDemo, account }: DeviceZoneReportOptions): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { settle } from "../account/account-parts.js";
|
|
4
|
+
import { deviceTimeZone } from "./time-zone-select.js";
|
|
5
|
+
const reportedThisStart = /* @__PURE__ */ new Set();
|
|
6
|
+
function useDeviceZoneReport({ stored, report, isDemo = false, account = null }) {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (stored === void 0) {
|
|
9
|
+
reportedThisStart.clear();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (isDemo) return;
|
|
13
|
+
const device = deviceTimeZone();
|
|
14
|
+
if (device === stored) return;
|
|
15
|
+
const key = `${account ?? ""}|${stored ?? ""}\u2192${device}`;
|
|
16
|
+
if (reportedThisStart.has(key)) return;
|
|
17
|
+
reportedThisStart.add(key);
|
|
18
|
+
void settle(() => report(device)).catch(() => void 0);
|
|
19
|
+
}, [stored, isDemo, account]);
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
useDeviceZoneReport
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=device-zone-report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/device-zone-report.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\nimport { settle } from \"../account/account-parts\";\nimport { deviceTimeZone } from \"./time-zone-select\";\n\nexport interface DeviceZoneReportOptions {\n /** The account's `device_timezone` from `/auth/me`; `undefined` while it loads (no\n * report until it is known). */\n stored: string | null | undefined;\n /** `PATCH /auth/me {device_timezone}` with this device's zone. */\n report: (deviceTimezone: string) => Promise<unknown> | void;\n /** A demo account: never reported (the server ignores it too, §10.3 Settled). */\n isDemo?: boolean;\n /** The signed-in account (its user id): the once-per-start guard is per account, so a\n * second person signed in on a shared PC in the same start is reported too. Without it\n * the guard is cleared when `stored` goes `undefined` (signed out). */\n account?: string | number | null;\n}\n\n/** The (account, stored, device) triples already reported in this start of the app. */\nconst reportedThisStart = new Set<string>();\n\n/**\n * Report this device's zone to the account (0.46, §10.3): the account's zone follows the\n * device last used, else `Europe/Zurich`; screens keep showing device time. Once per start\n * of the app, and only when it differs from the stored `device_timezone` — a bench PC in Zurich never\n * PATCHes on a sign-in — and never for a demo. Per account: a sign-out (`stored` back to\n * `undefined`) clears the guard, and `account` keys it. The report IS the account's zone: it is\n * automatic only (§10.3, amended 2026-10-20), so nothing is ever picked. A failed report\n * is not retried in the same start; the next start tries again. Sign-up sends the zone\n * itself (`RegisterRequest.device_timezone`).\n *\n * useDeviceZoneReport({ stored: me?.device_timezone, isDemo: me?.is_demo, account: me?.id,\n * report: async (zone) => setUser((await api.patch(\"/auth/me\", { device_timezone: zone })).data) });\n */\nexport function useDeviceZoneReport({ stored, report, isDemo = false, account = null }: DeviceZoneReportOptions): void {\n useEffect(() => {\n if (stored === undefined) {\n // Signed out (or still loading): the next account in this start is reported afresh.\n reportedThisStart.clear();\n return;\n }\n if (isDemo) return;\n const device = deviceTimeZone();\n if (device === stored) return;\n const key = `${account ?? \"\"}|${stored ?? \"\"}→${device}`;\n if (reportedThisStart.has(key)) return;\n reportedThisStart.add(key);\n void settle(() => report(device)).catch(() => undefined);\n // `report` is the app's request function: a new closure each render must not report\n // again, so only the values decide.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [stored, isDemo, account]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAE1B,SAAS,cAAc;AACvB,SAAS,sBAAsB;AAiB/B,MAAM,oBAAoB,oBAAI,IAAY;AAenC,SAAS,oBAAoB,EAAE,QAAQ,QAAQ,SAAS,OAAO,UAAU,KAAK,GAAkC;AACrH,YAAU,MAAM;AACd,QAAI,WAAW,QAAW;AAExB,wBAAkB,MAAM;AACxB;AAAA,IACF;AACA,QAAI,OAAQ;AACZ,UAAM,SAAS,eAAe;AAC9B,QAAI,WAAW,OAAQ;AACvB,UAAM,MAAM,GAAG,WAAW,EAAE,IAAI,UAAU,EAAE,SAAI,MAAM;AACtD,QAAI,kBAAkB,IAAI,GAAG,EAAG;AAChC,sBAAkB,IAAI,GAAG;AACzB,SAAK,OAAO,MAAM,OAAO,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,EAIzD,GAAG,CAAC,QAAQ,QAAQ,OAAO,CAAC;AAC9B;","names":[]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `feedbackNotificationTypes` — the setting words of the
|
|
2
|
+
* `feedbackNotificationTypes` — the setting words of the feedback notification types
|
|
3
3
|
* (docs/feedback-harmonization.md §9.11). In the kit, not in each app, because two apps
|
|
4
4
|
* declare them (keksdose, Kurvenschmiede; kastlan `feedback_answered` alone). The mail and
|
|
5
5
|
* push texts stay the app's.
|
|
@@ -11,5 +11,11 @@ export interface FeedbackNotificationTypesLabels {
|
|
|
11
11
|
/** `feedback_comment`: a visible comment on such a report. */
|
|
12
12
|
comment: string;
|
|
13
13
|
commentDescription: string;
|
|
14
|
+
/** 0.46 (feedback §11): `feedback_received`, an admin's alert for a new report, a
|
|
15
|
+
* rework and a new crash. */
|
|
16
|
+
received: string;
|
|
17
|
+
receivedDescription: string;
|
|
18
|
+
/** A heading the apps may put over the admin's types ("Admin"). */
|
|
19
|
+
adminGroup: string;
|
|
14
20
|
}
|
|
15
21
|
export declare const DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS: FeedbackNotificationTypesLabels;
|
|
@@ -3,7 +3,10 @@ const DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS = {
|
|
|
3
3
|
answered: "Answers to your reports",
|
|
4
4
|
answeredDescription: "When a report you wrote or commented on gets an answer.",
|
|
5
5
|
comment: "Comments on reports you follow",
|
|
6
|
-
commentDescription: "When someone comments on a report you wrote or commented on."
|
|
6
|
+
commentDescription: "When someone comments on a report you wrote or commented on.",
|
|
7
|
+
received: "New feedback and reworks",
|
|
8
|
+
receivedDescription: "When someone files a report or sends one back.",
|
|
9
|
+
adminGroup: "Admin"
|
|
7
10
|
};
|
|
8
11
|
export {
|
|
9
12
|
DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/feedback-notification-types-labels.ts"],"sourcesContent":["// The English words of `useFeedbackNotificationTypes` (round 0.38), in a module with no\n// component code, as every `*-labels.ts`: the English catalogue imports them alone.\n\n/**\n * `feedbackNotificationTypes` — the setting words of the
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/feedback-notification-types-labels.ts"],"sourcesContent":["// The English words of `useFeedbackNotificationTypes` (round 0.38), in a module with no\n// component code, as every `*-labels.ts`: the English catalogue imports them alone.\n\n/**\n * `feedbackNotificationTypes` — the setting words of the feedback notification types\n * (docs/feedback-harmonization.md §9.11). In the kit, not in each app, because two apps\n * declare them (keksdose, Kurvenschmiede; kastlan `feedback_answered` alone). The mail and\n * push texts stay the app's.\n */\nexport interface FeedbackNotificationTypesLabels {\n /** `feedback_answered`: a reply on a report the person wrote or commented on. */\n answered: string;\n answeredDescription: string;\n /** `feedback_comment`: a visible comment on such a report. */\n comment: string;\n commentDescription: string;\n /** 0.46 (feedback §11): `feedback_received`, an admin's alert for a new report, a\n * rework and a new crash. */\n received: string;\n receivedDescription: string;\n /** A heading the apps may put over the admin's types (\"Admin\"). */\n adminGroup: string;\n}\n\nexport const DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS: FeedbackNotificationTypesLabels = {\n answered: \"Answers to your reports\",\n answeredDescription: \"When a report you wrote or commented on gets an answer.\",\n comment: \"Comments on reports you follow\",\n commentDescription: \"When someone comments on a report you wrote or commented on.\",\n received: \"New feedback and reworks\",\n receivedDescription: \"When someone files a report or sends one back.\",\n adminGroup: \"Admin\",\n};\n"],"mappings":";AAwBO,MAAM,6CAA8E;AAAA,EACzF,UAAU;AAAA,EACV,qBAAqB;AAAA,EACrB,SAAS;AAAA,EACT,oBAAoB;AAAA,EACpB,UAAU;AAAA,EACV,qBAAqB;AAAA,EACrB,YAAY;AACd;","names":[]}
|
|
@@ -3,9 +3,9 @@ import type { NotificationTypeEntry } from "./notification-types.js";
|
|
|
3
3
|
import { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS, type FeedbackNotificationTypesLabels } from "./feedback-notification-types-labels.js";
|
|
4
4
|
export { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS };
|
|
5
5
|
export type { FeedbackNotificationTypesLabels };
|
|
6
|
-
/** The
|
|
7
|
-
* `FEEDBACK_COMMENT` name them. */
|
|
8
|
-
export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment";
|
|
6
|
+
/** The feedback notification ids (§9.11, §11), as server-kit's `FEEDBACK_ANSWERED`,
|
|
7
|
+
* `FEEDBACK_COMMENT` and `FEEDBACK_RECEIVED` (0.46: an admin's alert) name them. */
|
|
8
|
+
export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment" | "feedback_received";
|
|
9
9
|
/**
|
|
10
10
|
* The setting words of the two feedback notification types (round 0.38,
|
|
11
11
|
* docs/feedback-harmonization.md §9.11), for `NotificationTypesSetting`'s `types`:
|
|
@@ -20,9 +20,15 @@ export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment
|
|
|
20
20
|
* - **"Comments on reports you follow"** (`feedback_comment`): a visible comment, to the
|
|
21
21
|
* author and the earlier commenters.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
23
|
+
* - **"New feedback and reworks"** (`feedback_received`, 0.46, feedback §11): to the
|
|
24
|
+
* admins, when someone files a report or sends one back for rework. (A crash is mailed
|
|
25
|
+
* only inside the bundle where an app runs the queue, so the text names no crash.)
|
|
26
|
+
* `labels.adminGroup` ("Admin") is a heading an app may put over it.
|
|
27
|
+
*
|
|
28
|
+
* Every entry is always returned: the card draws a type only when the server answered
|
|
24
29
|
* it (notifications §2.1), so kastlan, which does not declare `feedback_comment` while its
|
|
25
|
-
* public parts are off, shows the first alone
|
|
30
|
+
* public parts are off, shows the first alone, and a person who is no admin is never
|
|
31
|
+
* answered `feedback_received`. Words: `<UiKitProvider labels={{
|
|
26
32
|
* feedbackNotificationTypes }}>` over English, `labels` over both.
|
|
27
33
|
*/
|
|
28
34
|
export declare function useFeedbackNotificationTypes(labels?: LabelOverride<FeedbackNotificationTypesLabels>): NotificationTypeEntry<FeedbackNotificationTypeId>[];
|
|
@@ -9,7 +9,8 @@ function useFeedbackNotificationTypes(labels) {
|
|
|
9
9
|
return useMemo(
|
|
10
10
|
() => [
|
|
11
11
|
{ id: "feedback_answered", label: words.answered, description: words.answeredDescription },
|
|
12
|
-
{ id: "feedback_comment", label: words.comment, description: words.commentDescription }
|
|
12
|
+
{ id: "feedback_comment", label: words.comment, description: words.commentDescription },
|
|
13
|
+
{ id: "feedback_received", label: words.received, description: words.receivedDescription }
|
|
13
14
|
],
|
|
14
15
|
[words]
|
|
15
16
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/feedback-notification-types.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport type { NotificationTypeEntry } from \"./notification-types\";\nimport {\n DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS,\n type FeedbackNotificationTypesLabels,\n} from \"./feedback-notification-types-labels\";\n\nexport { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS };\nexport type { FeedbackNotificationTypesLabels };\n\n/** The
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/feedback-notification-types.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport type { NotificationTypeEntry } from \"./notification-types\";\nimport {\n DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS,\n type FeedbackNotificationTypesLabels,\n} from \"./feedback-notification-types-labels\";\n\nexport { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS };\nexport type { FeedbackNotificationTypesLabels };\n\n/** The feedback notification ids (§9.11, §11), as server-kit's `FEEDBACK_ANSWERED`,\n * `FEEDBACK_COMMENT` and `FEEDBACK_RECEIVED` (0.46: an admin's alert) name them. */\nexport type FeedbackNotificationTypeId = \"feedback_answered\" | \"feedback_comment\" | \"feedback_received\";\n\n/**\n * The setting words of the two feedback notification types (round 0.38,\n * docs/feedback-harmonization.md §9.11), for `NotificationTypesSetting`'s `types`:\n *\n * ```tsx\n * const feedbackTypes = useFeedbackNotificationTypes();\n * <NotificationTypesSetting types={[...appTypes, ...feedbackTypes]} … />\n * ```\n *\n * - **\"Answers to your reports\"** (`feedback_answered`): a reply from the admin or a\n * loop, to the author and every earlier commenter.\n * - **\"Comments on reports you follow\"** (`feedback_comment`): a visible comment, to the\n * author and the earlier commenters.\n *\n * - **\"New feedback and reworks\"** (`feedback_received`, 0.46, feedback §11): to the\n * admins, when someone files a report or sends one back for rework. (A crash is mailed\n * only inside the bundle where an app runs the queue, so the text names no crash.)\n * `labels.adminGroup` (\"Admin\") is a heading an app may put over it.\n *\n * Every entry is always returned: the card draws a type only when the server answered\n * it (notifications §2.1), so kastlan, which does not declare `feedback_comment` while its\n * public parts are off, shows the first alone, and a person who is no admin is never\n * answered `feedback_received`. Words: `<UiKitProvider labels={{\n * feedbackNotificationTypes }}>` over English, `labels` over both.\n */\nexport function useFeedbackNotificationTypes(\n labels?: LabelOverride<FeedbackNotificationTypesLabels>,\n): NotificationTypeEntry<FeedbackNotificationTypeId>[] {\n const words = useKitLabels(\"feedbackNotificationTypes\", DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS, labels);\n return useMemo(\n () => [\n { id: \"feedback_answered\", label: words.answered, description: words.answeredDescription },\n { id: \"feedback_comment\", label: words.comment, description: words.commentDescription },\n { id: \"feedback_received\", label: words.received, description: words.receivedDescription },\n ],\n [words],\n );\n}\n"],"mappings":";AAAA,SAAS,eAAe;AACxB,SAAS,oBAAwC;AAEjD;AAAA,EACE;AAAA,OAEK;AAkCA,SAAS,6BACd,QACqD;AACrD,QAAM,QAAQ,aAAa,6BAA6B,4CAA4C,MAAM;AAC1G,SAAO;AAAA,IACL,MAAM;AAAA,MACJ,EAAE,IAAI,qBAAqB,OAAO,MAAM,UAAU,aAAa,MAAM,oBAAoB;AAAA,MACzF,EAAE,IAAI,oBAAoB,OAAO,MAAM,SAAS,aAAa,MAAM,mBAAmB;AAAA,MACtF,EAAE,IAAI,qBAAqB,OAAO,MAAM,UAAU,aAAa,MAAM,oBAAoB;AAAA,IAC3F;AAAA,IACA,CAAC,KAAK;AAAA,EACR;AACF;","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import { type LabelOverride } from "../i18n/kit-labels.js";
|
|
3
|
+
import type { NotificationsLabels } from "./notifications-labels.js";
|
|
4
|
+
import type { NotificationInboxItem } from "./notification-inbox.js";
|
|
5
|
+
export interface NotificationBellProps<Id extends string | number = string | number> {
|
|
6
|
+
/** The inbox, newest first (the bell shows the first 20); `undefined` while it loads. */
|
|
7
|
+
items: readonly NotificationInboxItem<Id>[] | undefined;
|
|
8
|
+
/** Every unread row of the account (`unread_count`): the badge. */
|
|
9
|
+
unreadCount: number;
|
|
10
|
+
/** Open a row: the app marks it read and routes to `item.url` (kastlan switches the
|
|
11
|
+
* acting company first). The panel closes. */
|
|
12
|
+
onOpen: (item: NotificationInboxItem<Id>) => void;
|
|
13
|
+
/** The app's inbox page or card, for "All notifications". */
|
|
14
|
+
href: string;
|
|
15
|
+
/** The row's glyph for a type, as {@link NotificationInbox}'s; `Bell` by default. */
|
|
16
|
+
icon?: (type: string) => LucideIcon | undefined;
|
|
17
|
+
/** A row's time. Default: the provider's locale and `timeZone`, date and time. */
|
|
18
|
+
formatDate?: (iso: string) => string;
|
|
19
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The top bar's bell (0.46, docs/notifications-harmonization.md §10.1), for
|
|
23
|
+
* `TopBarActions`' `notifications` slot, right before the account menu: an icon button
|
|
24
|
+
* with the unread count as a badge ("99+" past 99), opening the newest 20 rows in the
|
|
25
|
+
* kit's top-bar panel (`TopBarActionMenu`) with "All notifications" under them.
|
|
26
|
+
*
|
|
27
|
+
* **Optional per app.** "One badge per signal": an app shows the bell or an unread dot on
|
|
28
|
+
* the avatar, never both — keksdose keeps its avatar dot and no bell; kastlan's inbox
|
|
29
|
+
* lives in the bell. Shown to every signed-in account in an app that keeps an inbox, the
|
|
30
|
+
* demo included. Props only, as the inbox list: poll the count every
|
|
31
|
+
* {@link INBOX_BADGE_INTERVAL}.
|
|
32
|
+
*/
|
|
33
|
+
export declare function NotificationBell<Id extends string | number = string | number>({ items, unreadCount, onOpen, href, icon, formatDate: formatDateProp, labels: labelsProp, }: NotificationBellProps<Id>): import("react").JSX.Element;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Bell } from "lucide-react";
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
5
|
+
import { useKitFormat } from "../lib/format.js";
|
|
6
|
+
import { DEFAULT_LIST_LABELS } from "../components/list-labels.js";
|
|
7
|
+
import { StatusDot } from "../components/status-dot.js";
|
|
8
|
+
import { TopBarActionMenu } from "../shell/topbar-action-menu.js";
|
|
9
|
+
import { useNotificationsLabels } from "./notification-parts.js";
|
|
10
|
+
const BELL_ROWS = 20;
|
|
11
|
+
const badgeText = (count) => count > 99 ? "99+" : String(Math.floor(count));
|
|
12
|
+
function NotificationBell({
|
|
13
|
+
items,
|
|
14
|
+
unreadCount,
|
|
15
|
+
onOpen,
|
|
16
|
+
href,
|
|
17
|
+
icon,
|
|
18
|
+
formatDate: formatDateProp,
|
|
19
|
+
labels: labelsProp
|
|
20
|
+
}) {
|
|
21
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
22
|
+
const list = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
23
|
+
const { formatDate } = useKitFormat();
|
|
24
|
+
const timeOf = (iso) => formatDateProp ? formatDateProp(iso) : formatDate(iso, "dateTime");
|
|
25
|
+
const unread = unreadCount > 0;
|
|
26
|
+
const shown = (items ?? []).slice(0, BELL_ROWS);
|
|
27
|
+
const entries = shown.map((item) => {
|
|
28
|
+
const Icon = icon?.(item.notification_type) ?? Bell;
|
|
29
|
+
const isUnread = item.read_at === null;
|
|
30
|
+
return {
|
|
31
|
+
key: `item-${String(item.id)}`,
|
|
32
|
+
icon: /* @__PURE__ */ jsx(Icon, { "aria-hidden": true, className: "size-4" }),
|
|
33
|
+
label: /* @__PURE__ */ jsxs("span", { className: "block min-w-0", "data-bell-item": String(item.id), children: [
|
|
34
|
+
item.company && /* @__PURE__ */ jsx("span", { className: "block truncate text-caption text-muted", children: item.company }),
|
|
35
|
+
/* @__PURE__ */ jsx("span", { className: isUnread ? "block font-semibold" : "block", children: item.title }),
|
|
36
|
+
/* @__PURE__ */ jsx("span", { className: "block line-clamp-2 text-xs text-secondary", children: item.body }),
|
|
37
|
+
/* @__PURE__ */ jsx("span", { className: "block text-caption text-muted", children: timeOf(item.created_at) })
|
|
38
|
+
] }),
|
|
39
|
+
trailing: isUnread ? /* @__PURE__ */ jsx(StatusDot, { tone: "brand", "aria-label": list.unread }) : void 0,
|
|
40
|
+
onSelect: () => onOpen(item)
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
if (entries.length > 0) entries.push({ kind: "divider", key: "divider" });
|
|
44
|
+
entries.push({ kind: "link", key: "all", to: href, label: labels.allNotifications });
|
|
45
|
+
return /* @__PURE__ */ jsx(
|
|
46
|
+
TopBarActionMenu,
|
|
47
|
+
{
|
|
48
|
+
"aria-label": labels.bellLabel,
|
|
49
|
+
triggerAttributes: { "data-notification-bell": "" },
|
|
50
|
+
trigger: () => /* @__PURE__ */ jsxs("span", { className: "relative inline-flex", "data-unread": unread ? unreadCount : void 0, children: [
|
|
51
|
+
/* @__PURE__ */ jsx(Bell, { "aria-hidden": true, className: "size-5" }),
|
|
52
|
+
unread && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
53
|
+
/* @__PURE__ */ jsx(
|
|
54
|
+
"span",
|
|
55
|
+
{
|
|
56
|
+
"aria-hidden": true,
|
|
57
|
+
"data-badge": "",
|
|
58
|
+
className: "pointer-events-none absolute -end-2 -top-1.5 h-4 min-w-4 rounded-full bg-danger px-1 text-center text-micro font-semibold leading-4 text-danger-contrast",
|
|
59
|
+
children: badgeText(unreadCount)
|
|
60
|
+
}
|
|
61
|
+
),
|
|
62
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.bellUnread(unreadCount) })
|
|
63
|
+
] })
|
|
64
|
+
] }),
|
|
65
|
+
header: shown.length === 0 ? { title: labels.inboxTitle, subtitle: items === void 0 ? labels.inboxLoading : labels.inboxEmpty } : void 0,
|
|
66
|
+
entries,
|
|
67
|
+
panelClassName: "w-80 max-w-[calc(100vw-2rem)]"
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
NotificationBell
|
|
73
|
+
};
|
|
74
|
+
//# sourceMappingURL=notification-bell.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/notification-bell.tsx"],"sourcesContent":["import { Bell } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useKitFormat } from \"../lib/format\";\nimport { DEFAULT_LIST_LABELS } from \"../components/list-labels\";\nimport { StatusDot } from \"../components/status-dot\";\nimport { TopBarActionMenu } from \"../shell/topbar-action-menu\";\nimport type { TopBarMenuEntry } from \"../shell/topbar-action-menu\";\nimport { useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationInboxItem } from \"./notification-inbox\";\n\n/** How many rows the bell's panel shows: the newest 20 (§10.1). */\nconst BELL_ROWS = 20;\n\nexport interface NotificationBellProps<Id extends string | number = string | number> {\n /** The inbox, newest first (the bell shows the first 20); `undefined` while it loads. */\n items: readonly NotificationInboxItem<Id>[] | undefined;\n /** Every unread row of the account (`unread_count`): the badge. */\n unreadCount: number;\n /** Open a row: the app marks it read and routes to `item.url` (kastlan switches the\n * acting company first). The panel closes. */\n onOpen: (item: NotificationInboxItem<Id>) => void;\n /** The app's inbox page or card, for \"All notifications\". */\n href: string;\n /** The row's glyph for a type, as {@link NotificationInbox}'s; `Bell` by default. */\n icon?: (type: string) => LucideIcon | undefined;\n /** A row's time. Default: the provider's locale and `timeZone`, date and time. */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/** The badge's text: \"99+\" past 99. */\nconst badgeText = (count: number) => (count > 99 ? \"99+\" : String(Math.floor(count)));\n\n/**\n * The top bar's bell (0.46, docs/notifications-harmonization.md §10.1), for\n * `TopBarActions`' `notifications` slot, right before the account menu: an icon button\n * with the unread count as a badge (\"99+\" past 99), opening the newest 20 rows in the\n * kit's top-bar panel (`TopBarActionMenu`) with \"All notifications\" under them.\n *\n * **Optional per app.** \"One badge per signal\": an app shows the bell or an unread dot on\n * the avatar, never both — keksdose keeps its avatar dot and no bell; kastlan's inbox\n * lives in the bell. Shown to every signed-in account in an app that keeps an inbox, the\n * demo included. Props only, as the inbox list: poll the count every\n * {@link INBOX_BADGE_INTERVAL}.\n */\nexport function NotificationBell<Id extends string | number = string | number>({\n items,\n unreadCount,\n onOpen,\n href,\n icon,\n formatDate: formatDateProp,\n labels: labelsProp,\n}: NotificationBellProps<Id>) {\n const labels = useNotificationsLabels(labelsProp);\n const list = useKitLabels(\"list\", DEFAULT_LIST_LABELS);\n const { formatDate } = useKitFormat();\n const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"dateTime\"));\n const unread = unreadCount > 0;\n const shown = (items ?? []).slice(0, BELL_ROWS);\n\n const entries: TopBarMenuEntry[] = shown.map((item) => {\n const Icon = icon?.(item.notification_type) ?? Bell;\n const isUnread = item.read_at === null;\n return {\n key: `item-${String(item.id)}`,\n icon: <Icon aria-hidden className=\"size-4\" />,\n label: (\n <span className=\"block min-w-0\" data-bell-item={String(item.id)}>\n {item.company && <span className=\"block truncate text-caption text-muted\">{item.company}</span>}\n <span className={isUnread ? \"block font-semibold\" : \"block\"}>{item.title}</span>\n <span className=\"block line-clamp-2 text-xs text-secondary\">{item.body}</span>\n <span className=\"block text-caption text-muted\">{timeOf(item.created_at)}</span>\n </span>\n ),\n trailing: isUnread ? <StatusDot tone=\"brand\" aria-label={list.unread} /> : undefined,\n onSelect: () => onOpen(item),\n };\n });\n if (entries.length > 0) entries.push({ kind: \"divider\", key: \"divider\" });\n entries.push({ kind: \"link\", key: \"all\", to: href, label: labels.allNotifications });\n\n return (\n <TopBarActionMenu\n aria-label={labels.bellLabel}\n triggerAttributes={{ \"data-notification-bell\": \"\" }}\n trigger={() => (\n <span className=\"relative inline-flex\" data-unread={unread ? unreadCount : undefined}>\n <Bell aria-hidden className=\"size-5\" />\n {unread && (\n <>\n <span\n aria-hidden\n data-badge=\"\"\n className=\"pointer-events-none absolute -end-2 -top-1.5 h-4 min-w-4 rounded-full bg-danger px-1 text-center text-micro font-semibold leading-4 text-danger-contrast\"\n >\n {badgeText(unreadCount)}\n </span>\n <span className=\"sr-only\">{labels.bellUnread(unreadCount)}</span>\n </>\n )}\n </span>\n )}\n header={\n shown.length === 0\n ? { title: labels.inboxTitle, subtitle: items === undefined ? labels.inboxLoading : labels.inboxEmpty }\n : undefined\n }\n entries={entries}\n panelClassName=\"w-80 max-w-[calc(100vw-2rem)]\"\n />\n );\n}\n"],"mappings":";AAqEY,SAwBA,UAxBA,KAEJ,YAFI;AArEZ,SAAS,YAAY;AAGrB,SAAS,oBAAwC;AACjD,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AACpC,SAAS,iBAAiB;AAC1B,SAAS,wBAAwB;AAEjC,SAAS,8BAA8B;AAKvC,MAAM,YAAY;AAoBlB,MAAM,YAAY,CAAC,UAAmB,QAAQ,KAAK,QAAQ,OAAO,KAAK,MAAM,KAAK,CAAC;AAc5E,SAAS,iBAA+D;AAAA,EAC7E;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AACV,GAA8B;AAC5B,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,OAAO,aAAa,QAAQ,mBAAmB;AACrD,QAAM,EAAE,WAAW,IAAI,aAAa;AACpC,QAAM,SAAS,CAAC,QAAiB,iBAAiB,eAAe,GAAG,IAAI,WAAW,KAAK,UAAU;AAClG,QAAM,SAAS,cAAc;AAC7B,QAAM,SAAS,SAAS,CAAC,GAAG,MAAM,GAAG,SAAS;AAE9C,QAAM,UAA6B,MAAM,IAAI,CAAC,SAAS;AACrD,UAAM,OAAO,OAAO,KAAK,iBAAiB,KAAK;AAC/C,UAAM,WAAW,KAAK,YAAY;AAClC,WAAO;AAAA,MACL,KAAK,QAAQ,OAAO,KAAK,EAAE,CAAC;AAAA,MAC5B,MAAM,oBAAC,QAAK,eAAW,MAAC,WAAU,UAAS;AAAA,MAC3C,OACE,qBAAC,UAAK,WAAU,iBAAgB,kBAAgB,OAAO,KAAK,EAAE,GAC3D;AAAA,aAAK,WAAW,oBAAC,UAAK,WAAU,0CAA0C,eAAK,SAAQ;AAAA,QACxF,oBAAC,UAAK,WAAW,WAAW,wBAAwB,SAAU,eAAK,OAAM;AAAA,QACzE,oBAAC,UAAK,WAAU,6CAA6C,eAAK,MAAK;AAAA,QACvE,oBAAC,UAAK,WAAU,iCAAiC,iBAAO,KAAK,UAAU,GAAE;AAAA,SAC3E;AAAA,MAEF,UAAU,WAAW,oBAAC,aAAU,MAAK,SAAQ,cAAY,KAAK,QAAQ,IAAK;AAAA,MAC3E,UAAU,MAAM,OAAO,IAAI;AAAA,IAC7B;AAAA,EACF,CAAC;AACD,MAAI,QAAQ,SAAS,EAAG,SAAQ,KAAK,EAAE,MAAM,WAAW,KAAK,UAAU,CAAC;AACxE,UAAQ,KAAK,EAAE,MAAM,QAAQ,KAAK,OAAO,IAAI,MAAM,OAAO,OAAO,iBAAiB,CAAC;AAEnF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,OAAO;AAAA,MACnB,mBAAmB,EAAE,0BAA0B,GAAG;AAAA,MAClD,SAAS,MACP,qBAAC,UAAK,WAAU,wBAAuB,eAAa,SAAS,cAAc,QACzE;AAAA,4BAAC,QAAK,eAAW,MAAC,WAAU,UAAS;AAAA,QACpC,UACC,iCACE;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,eAAW;AAAA,cACX,cAAW;AAAA,cACX,WAAU;AAAA,cAET,oBAAU,WAAW;AAAA;AAAA,UACxB;AAAA,UACA,oBAAC,UAAK,WAAU,WAAW,iBAAO,WAAW,WAAW,GAAE;AAAA,WAC5D;AAAA,SAEJ;AAAA,MAEF,QACE,MAAM,WAAW,IACb,EAAE,OAAO,OAAO,YAAY,UAAU,UAAU,SAAY,OAAO,eAAe,OAAO,WAAW,IACpG;AAAA,MAEN;AAAA,MACA,gBAAe;AAAA;AAAA,EACjB;AAEJ;","names":[]}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
2
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
|
+
import { type LabelOverride } from "../i18n/kit-labels.js";
|
|
4
|
+
import type { NotificationsLabels } from "./notifications-labels.js";
|
|
5
|
+
/**
|
|
6
|
+
* One inbox row as `GET /notifications/inbox` answers it (server-kit `InboxItem`, §3.7):
|
|
7
|
+
* the title and body already in the recipient's language.
|
|
8
|
+
*/
|
|
9
|
+
export interface NotificationInboxItem<Id extends string | number = string | number> {
|
|
10
|
+
id: Id;
|
|
11
|
+
/** The type id, for the row's icon. */
|
|
12
|
+
notification_type: string;
|
|
13
|
+
title: string;
|
|
14
|
+
body: string;
|
|
15
|
+
/** A path in the app, an absolute URL, or `null` (nothing to open). */
|
|
16
|
+
url: string | null;
|
|
17
|
+
/** ISO date-time. */
|
|
18
|
+
created_at: string;
|
|
19
|
+
/** ISO date-time, or `null` while unread. */
|
|
20
|
+
read_at: string | null;
|
|
21
|
+
/** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on
|
|
22
|
+
* the row — kastlan's inbox holds every company's rows. */
|
|
23
|
+
company?: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface NotificationInboxProps<Id extends string | number = string | number> extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
|
|
26
|
+
/** The rows, newest first; `undefined` while the first page loads. */
|
|
27
|
+
items: readonly NotificationInboxItem<Id>[] | undefined;
|
|
28
|
+
/** Every unread row of the account (`unread_count`), not only this page's. */
|
|
29
|
+
unreadCount: number;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
/** `POST /notifications/inbox/read {ids}` — these rows, or `null` for all. */
|
|
32
|
+
onMarkRead: (ids: Id[] | null) => Promise<unknown> | void;
|
|
33
|
+
/** `DELETE /notifications/inbox`. Asked first, in the header. */
|
|
34
|
+
onClear: () => Promise<unknown> | void;
|
|
35
|
+
/**
|
|
36
|
+
* Open a row: the kit marks it read first, then calls this, and the app routes to
|
|
37
|
+
* `item.url` (kastlan switches the acting company first). Every row is a button then.
|
|
38
|
+
* Left out, a row with a `url` is a real link — an in-app path through the provider's
|
|
39
|
+
* router link, an absolute URL in a new tab — that marks itself read on a click or a
|
|
40
|
+
* middle click (keksdose #451), and a row without one marks itself read.
|
|
41
|
+
*/
|
|
42
|
+
onOpen?: (item: NotificationInboxItem<Id>) => void;
|
|
43
|
+
/** The row's glyph for a type — the app's map (keksdose's `TYPE_ICONS`). `undefined`,
|
|
44
|
+
* or a type it does not know (a retired one), is the `Bell`. */
|
|
45
|
+
icon?: (type: string) => LucideIcon | undefined;
|
|
46
|
+
/** More rows than shown: a "Show more" row calls `onLoadMore` (the API pages by
|
|
47
|
+
* `offset`). */
|
|
48
|
+
hasMore?: boolean;
|
|
49
|
+
onLoadMore?: () => void;
|
|
50
|
+
/** The next page is loading. */
|
|
51
|
+
loadingMore?: boolean;
|
|
52
|
+
/** A row's time. Default: the provider's locale and `timeZone`, date and time. */
|
|
53
|
+
formatDate?: (iso: string) => string;
|
|
54
|
+
/** The heading. Default `labels.inboxTitle` ("Inbox"). */
|
|
55
|
+
title?: ReactNode;
|
|
56
|
+
/** The app's own words for a failed request, or `undefined` for the kit's `failed`. */
|
|
57
|
+
describeError?: (error: unknown) => ReactNode | undefined;
|
|
58
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The in-app inbox (0.46, docs/notifications-harmonization.md §10.1), keksdose's list
|
|
62
|
+
* lifted: newest first, an icon per type (`Bell` for one the app does not map), the
|
|
63
|
+
* title and two lines of body, the time, and — kastlan — the company the row is about.
|
|
64
|
+
* Unread rows are bold with a dot. Props only: the app keeps the data (poll the badge
|
|
65
|
+
* every {@link INBOX_BADGE_INTERVAL}, invalidate on its worker's push message).
|
|
66
|
+
*
|
|
67
|
+
* - **Opening a row marks it read** (see `onOpen`).
|
|
68
|
+
* - **"Mark all as read"** while anything is unread; **"Clear all"** asks first, in the
|
|
69
|
+
* header — no `ConfirmProvider` needed — with focus on Cancel.
|
|
70
|
+
* - **"Show more"** with `hasMore`; the list scrolls inside a box of at most
|
|
71
|
+
* `min(24rem, 70vh)`, so it never pushes the settings below it off the page.
|
|
72
|
+
*
|
|
73
|
+
* Under a write lock the header's actions are `commit` controls exempt from billing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function NotificationInbox<Id extends string | number = string | number>({ items, unreadCount, loading, onMarkRead, onClear, onOpen, icon, hasMore, onLoadMore, loadingMore, formatDate: formatDateProp, title, describeError, labels: labelsProp, className, ...rest }: NotificationInboxProps<Id>): import("react").JSX.Element;
|
|
76
|
+
/** How often an app polls the unread count for the badge (keksdose's 60 s, `limit:1`). */
|
|
77
|
+
export declare const INBOX_BADGE_INTERVAL = 60000;
|