@eifi1/ui-kit 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +52 -59
  2. package/dist/account/delete-account-setting.d.ts +3 -2
  3. package/dist/account/delete-account-setting.js.map +1 -1
  4. package/dist/auth/auth-errors.d.ts +36 -0
  5. package/dist/auth/auth-errors.js +20 -0
  6. package/dist/auth/auth-errors.js.map +1 -1
  7. package/dist/billing/cancel-subscription.d.ts +5 -14
  8. package/dist/billing/cancel-subscription.js +1 -3
  9. package/dist/billing/cancel-subscription.js.map +1 -1
  10. package/dist/billing/plan-card.d.ts +5 -13
  11. package/dist/billing/plan-card.js +3 -11
  12. package/dist/billing/plan-card.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +5 -14
  14. package/dist/billing/plan-switch.js +1 -3
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/chart.d.ts +3 -14
  17. package/dist/chart.js +64 -11
  18. package/dist/chart.js.map +1 -1
  19. package/dist/components/amount-input.d.ts +2 -1
  20. package/dist/components/amount-input.js.map +1 -1
  21. package/dist/components/calendar-heatmap.js +4 -2
  22. package/dist/components/calendar-heatmap.js.map +1 -1
  23. package/dist/components/currency-option-list.d.ts +2 -1
  24. package/dist/components/currency-option-list.js +10 -3
  25. package/dist/components/currency-option-list.js.map +1 -1
  26. package/dist/components/currency-select.d.ts +3 -1
  27. package/dist/components/currency-select.js.map +1 -1
  28. package/dist/components/danger-confirm.d.ts +22 -1
  29. package/dist/components/danger-confirm.js +50 -28
  30. package/dist/components/danger-confirm.js.map +1 -1
  31. package/dist/components/data-table-filter-popover.d.ts +2 -1
  32. package/dist/components/data-table-filter-popover.js +3 -2
  33. package/dist/components/data-table-filter-popover.js.map +1 -1
  34. package/dist/components/data-table-filters.d.ts +33 -3
  35. package/dist/components/data-table-filters.js +6 -4
  36. package/dist/components/data-table-filters.js.map +1 -1
  37. package/dist/components/date-field.d.ts +1 -1
  38. package/dist/components/date-field.js.map +1 -1
  39. package/dist/components/date-picker.d.ts +1 -1
  40. package/dist/components/date-picker.js.map +1 -1
  41. package/dist/components/date-range-picker.d.ts +4 -17
  42. package/dist/components/date-range-picker.js +2 -2
  43. package/dist/components/date-range-picker.js.map +1 -1
  44. package/dist/components/dialog-frame.d.ts +3 -1
  45. package/dist/components/dialog-frame.js +6 -2
  46. package/dist/components/dialog-frame.js.map +1 -1
  47. package/dist/components/mini-calendar.js +3 -2
  48. package/dist/components/mini-calendar.js.map +1 -1
  49. package/dist/components/modal.d.ts +27 -0
  50. package/dist/components/modal.js +5 -3
  51. package/dist/components/modal.js.map +1 -1
  52. package/dist/components/nav-pills.d.ts +1 -1
  53. package/dist/components/nav-pills.js.map +1 -1
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/series-chart-axes.d.ts +1 -1
  56. package/dist/components/series-chart-axes.js.map +1 -1
  57. package/dist/components/series-chart.d.ts +64 -19
  58. package/dist/components/series-chart.js +2 -1
  59. package/dist/components/series-chart.js.map +1 -1
  60. package/dist/components/stat-tile.d.ts +21 -2
  61. package/dist/components/stat-tile.js +4 -2
  62. package/dist/components/stat-tile.js.map +1 -1
  63. package/dist/components/treemap-labels.d.ts +6 -1
  64. package/dist/components/treemap-labels.js +2 -2
  65. package/dist/components/treemap-labels.js.map +1 -1
  66. package/dist/components/treemap.d.ts +29 -4
  67. package/dist/components/treemap.js +22 -9
  68. package/dist/components/treemap.js.map +1 -1
  69. package/dist/currencies.d.ts +16 -2
  70. package/dist/currencies.js +2 -1
  71. package/dist/currencies.js.map +1 -1
  72. package/dist/data-table.d.ts +1 -6
  73. package/dist/data-table.js +1 -9
  74. package/dist/data-table.js.map +1 -1
  75. package/dist/demo/demo-countdown.d.ts +0 -5
  76. package/dist/demo/demo-countdown.js +0 -4
  77. package/dist/demo/demo-countdown.js.map +1 -1
  78. package/dist/eslint/index.js +1 -1
  79. package/dist/feedback/feedback-context-labels.js +2 -1
  80. package/dist/feedback/feedback-context-labels.js.map +1 -1
  81. package/dist/feedback/feedback-context.d.ts +19 -3
  82. package/dist/feedback/feedback-context.js +12 -1
  83. package/dist/feedback/feedback-context.js.map +1 -1
  84. package/dist/feedback/feedback-diagnostics.d.ts +17 -0
  85. package/dist/feedback/feedback-diagnostics.js +27 -0
  86. package/dist/feedback/feedback-diagnostics.js.map +1 -0
  87. package/dist/feedback/feedback-dialog.d.ts +10 -0
  88. package/dist/feedback/feedback-dialog.js +6 -4
  89. package/dist/feedback/feedback-dialog.js.map +1 -1
  90. package/dist/feedback/feedback-mutation.d.ts +11 -23
  91. package/dist/feedback/feedback-mutation.js +1 -9
  92. package/dist/feedback/feedback-mutation.js.map +1 -1
  93. package/dist/feedback/feedback-public-list.d.ts +1 -6
  94. package/dist/feedback/feedback-public-list.js +1 -2
  95. package/dist/feedback/feedback-public-list.js.map +1 -1
  96. package/dist/feedback/feedback-record.d.ts +5 -0
  97. package/dist/feedback/feedback-record.js.map +1 -1
  98. package/dist/feedback/feedback-row-detail-labels.js +1 -8
  99. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  100. package/dist/feedback/feedback-row-detail.d.ts +6 -50
  101. package/dist/feedback/feedback-row-detail.js +3 -8
  102. package/dist/feedback/feedback-row-detail.js.map +1 -1
  103. package/dist/feedback/feedback-status-undo.d.ts +3 -19
  104. package/dist/feedback/feedback-status-undo.js.map +1 -1
  105. package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
  106. package/dist/feedback/feedback-submit-dialog.js +15 -3
  107. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  108. package/dist/feedback/feedback-submit.d.ts +30 -2
  109. package/dist/feedback/feedback-submit.js +5 -2
  110. package/dist/feedback/feedback-submit.js.map +1 -1
  111. package/dist/feedback/feedback-thread.d.ts +0 -9
  112. package/dist/feedback/feedback-thread.js.map +1 -1
  113. package/dist/feedback.d.ts +6 -40
  114. package/dist/feedback.js +88 -46
  115. package/dist/feedback.js.map +1 -1
  116. package/dist/hooks/use-hotkey.js +11 -1
  117. package/dist/hooks/use-hotkey.js.map +1 -1
  118. package/dist/i18n/defaults.js +0 -4
  119. package/dist/i18n/defaults.js.map +1 -1
  120. package/dist/i18n/german.js +5 -20
  121. package/dist/i18n/german.js.map +1 -1
  122. package/dist/i18n/kit-labels.d.ts +5 -14
  123. package/dist/i18n/kit-labels.js.map +1 -1
  124. package/dist/i18n/locales/en.js +5 -31
  125. package/dist/i18n/locales/en.js.map +3 -3
  126. package/dist/i18n/locales/es.js +5 -20
  127. package/dist/i18n/locales/es.js.map +1 -1
  128. package/dist/i18n/locales/fr.js +5 -20
  129. package/dist/i18n/locales/fr.js.map +1 -1
  130. package/dist/i18n/locales/hu.js +5 -20
  131. package/dist/i18n/locales/hu.js.map +1 -1
  132. package/dist/i18n/locales/it.js +5 -21
  133. package/dist/i18n/locales/it.js.map +1 -1
  134. package/dist/i18n/locales/zh.js +5 -20
  135. package/dist/i18n/locales/zh.js.map +1 -1
  136. package/dist/i18n/review.js +9 -4
  137. package/dist/i18n/review.js.map +1 -1
  138. package/dist/i18n/shared-labels.js +0 -1
  139. package/dist/i18n/shared-labels.js.map +1 -1
  140. package/dist/index.d.ts +18 -67
  141. package/dist/index.js +28 -103
  142. package/dist/index.js.map +1 -1
  143. package/dist/lib/dates.d.ts +7 -1
  144. package/dist/lib/dates.js +8 -1
  145. package/dist/lib/dates.js.map +1 -1
  146. package/dist/lib/format.d.ts +37 -2
  147. package/dist/lib/format.js +25 -1
  148. package/dist/lib/format.js.map +1 -1
  149. package/dist/lib/intl-cache.d.ts +4 -2
  150. package/dist/lib/intl-cache.js.map +1 -1
  151. package/dist/lib/translation-review.d.ts +26 -0
  152. package/dist/lib/translation-review.js +14 -0
  153. package/dist/lib/translation-review.js.map +1 -1
  154. package/dist/rhf/fields.d.ts +2 -2
  155. package/dist/rhf/fields.js.map +1 -1
  156. package/dist/search/search-index.d.ts +0 -6
  157. package/dist/search/search-index.js +0 -4
  158. package/dist/search/search-index.js.map +1 -1
  159. package/dist/search.d.ts +1 -8
  160. package/dist/search.js +2 -7
  161. package/dist/search.js.map +1 -1
  162. package/dist/shell/account-menu-language.d.ts +7 -21
  163. package/dist/shell/account-menu-language.js +1 -3
  164. package/dist/shell/account-menu-language.js.map +1 -1
  165. package/dist/shell/account-menu-text-size.d.ts +9 -23
  166. package/dist/shell/account-menu-text-size.js +2 -3
  167. package/dist/shell/account-menu-text-size.js.map +1 -1
  168. package/dist/shell/app-shell.d.ts +0 -10
  169. package/dist/shell/app-shell.js +1 -7
  170. package/dist/shell/app-shell.js.map +1 -1
  171. package/dist/shell/company-switcher.d.ts +1 -1
  172. package/dist/shell/company-switcher.js.map +1 -1
  173. package/dist/shell/dev-menu.d.ts +1 -1
  174. package/dist/shell/dev-menu.js.map +1 -1
  175. package/dist/shell/top-bar-parts-labels.d.ts +1 -1
  176. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  177. package/dist/shell.d.ts +7 -29
  178. package/dist/shell.js +12 -42
  179. package/dist/shell.js.map +1 -1
  180. package/dist/sync.d.ts +2 -6
  181. package/dist/sync.js +2 -15
  182. package/dist/sync.js.map +1 -1
  183. package/dist/theme/chart-palette.d.ts +0 -5
  184. package/dist/theme/chart-palette.js +0 -2
  185. package/dist/theme/chart-palette.js.map +1 -1
  186. package/package.json +1 -1
  187. package/src/account/delete-account-setting.tsx +3 -2
  188. package/src/auth/auth-errors.ts +69 -0
  189. package/src/billing/cancel-subscription.tsx +5 -16
  190. package/src/billing/plan-card.tsx +7 -24
  191. package/src/billing/plan-switch.tsx +5 -16
  192. package/src/chart.ts +62 -13
  193. package/src/components/amount-input.tsx +2 -1
  194. package/src/components/calendar-heatmap.tsx +8 -2
  195. package/src/components/currency-option-list.tsx +12 -4
  196. package/src/components/currency-select.tsx +3 -1
  197. package/src/components/danger-confirm.tsx +80 -27
  198. package/src/components/data-table-filter-popover.tsx +19 -11
  199. package/src/components/data-table-filters.ts +36 -8
  200. package/src/components/date-field.tsx +1 -1
  201. package/src/components/date-picker.tsx +0 -1
  202. package/src/components/date-range-picker.tsx +6 -22
  203. package/src/components/dialog-frame.tsx +12 -4
  204. package/src/components/mini-calendar.tsx +5 -2
  205. package/src/components/modal.tsx +36 -7
  206. package/src/components/nav-pills.tsx +1 -1
  207. package/src/components/number-input.tsx +4 -3
  208. package/src/components/series-chart-axes.ts +6 -1
  209. package/src/components/series-chart.tsx +78 -21
  210. package/src/components/stat-tile.tsx +32 -3
  211. package/src/components/treemap-labels.ts +11 -3
  212. package/src/components/treemap.tsx +65 -12
  213. package/src/currencies.ts +23 -3
  214. package/src/data-table.ts +1 -11
  215. package/src/demo/demo-countdown.ts +3 -8
  216. package/src/feedback/feedback-context-labels.ts +1 -0
  217. package/src/feedback/feedback-context.tsx +34 -2
  218. package/src/feedback/feedback-diagnostics.tsx +41 -0
  219. package/src/feedback/feedback-dialog.tsx +16 -4
  220. package/src/feedback/feedback-mutation.ts +12 -33
  221. package/src/feedback/feedback-public-list.tsx +1 -7
  222. package/src/feedback/feedback-record.ts +5 -0
  223. package/src/feedback/feedback-row-detail-labels.ts +1 -8
  224. package/src/feedback/feedback-row-detail.tsx +21 -58
  225. package/src/feedback/feedback-status-undo.ts +4 -18
  226. package/src/feedback/feedback-submit-dialog.tsx +15 -2
  227. package/src/feedback/feedback-submit.tsx +36 -3
  228. package/src/feedback/feedback-thread.tsx +0 -10
  229. package/src/feedback.ts +89 -48
  230. package/src/hooks/use-hotkey.ts +24 -1
  231. package/src/i18n/defaults.ts +0 -4
  232. package/src/i18n/german.ts +4 -19
  233. package/src/i18n/kit-labels.tsx +5 -14
  234. package/src/i18n/locales/es.ts +4 -19
  235. package/src/i18n/locales/fr.ts +4 -19
  236. package/src/i18n/locales/hu.ts +4 -19
  237. package/src/i18n/locales/it.ts +4 -20
  238. package/src/i18n/locales/zh.ts +4 -19
  239. package/src/i18n/review.ts +9 -4
  240. package/src/i18n/shared-labels.ts +0 -1
  241. package/src/index.ts +44 -114
  242. package/src/lib/dates.ts +24 -2
  243. package/src/lib/format.ts +66 -3
  244. package/src/lib/intl-cache.ts +4 -2
  245. package/src/lib/translation-review.ts +52 -0
  246. package/src/rhf/fields.tsx +2 -2
  247. package/src/search/search-index.ts +0 -15
  248. package/src/search.ts +8 -8
  249. package/src/shell/account-menu-language.tsx +7 -27
  250. package/src/shell/account-menu-text-size.tsx +13 -32
  251. package/src/shell/app-shell.tsx +0 -13
  252. package/src/shell/company-switcher.tsx +1 -1
  253. package/src/shell/dev-menu.tsx +1 -1
  254. package/src/shell/top-bar-parts-labels.ts +1 -1
  255. package/src/shell.ts +17 -44
  256. package/src/sync.ts +2 -15
  257. package/src/theme/chart-palette.ts +0 -2
  258. package/tokens.css +4 -4
  259. package/dist/billing/dialog-close.d.ts +0 -29
  260. package/dist/billing/dialog-close.js +0 -8
  261. package/dist/billing/dialog-close.js.map +0 -1
  262. package/dist/components/field-sync-labels.d.ts +0 -3
  263. package/dist/components/field-sync-labels.js +0 -12
  264. package/dist/components/field-sync-labels.js.map +0 -1
  265. package/dist/components/field-sync.d.ts +0 -186
  266. package/dist/components/field-sync.js +0 -269
  267. package/dist/components/field-sync.js.map +0 -1
  268. package/dist/components/section-nav.d.ts +0 -44
  269. package/dist/components/section-nav.js +0 -33
  270. package/dist/components/section-nav.js.map +0 -1
  271. package/dist/components/sign-chip-labels.d.ts +0 -3
  272. package/dist/components/sign-chip-labels.js +0 -11
  273. package/dist/components/sign-chip-labels.js.map +0 -1
  274. package/dist/components/sign-chip.d.ts +0 -77
  275. package/dist/components/sign-chip.js +0 -47
  276. package/dist/components/sign-chip.js.map +0 -1
  277. package/dist/lib/isin.d.ts +0 -38
  278. package/dist/lib/isin.js +0 -36
  279. package/dist/lib/isin.js.map +0 -1
  280. package/dist/shell/role-switcher.d.ts +0 -28
  281. package/dist/shell/role-switcher.js +0 -31
  282. package/dist/shell/role-switcher.js.map +0 -1
  283. package/src/billing/dialog-close.ts +0 -34
  284. package/src/components/field-sync-labels.ts +0 -14
  285. package/src/components/field-sync.tsx +0 -504
  286. package/src/components/section-nav.tsx +0 -72
  287. package/src/components/sign-chip-labels.ts +0 -13
  288. package/src/components/sign-chip.tsx +0 -133
  289. package/src/lib/isin.ts +0 -72
  290. package/src/shell/role-switcher.tsx +0 -53
@@ -40,7 +40,7 @@ function devWarnings() {
40
40
  return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
41
41
  }
42
42
  const warnedSites = /* @__PURE__ */ new Set();
43
- const OWN_FRAME = /\b(?:formatNumber|formatMoney|formatPercent|toFormattable|warnInvalid|callSite)\b/;
43
+ const OWN_FRAME = /\b(?:formatNumber|formatMoney|formatPercent|formatDuration|toFormattable|warnInvalid|callSite)\b/;
44
44
  function callSite() {
45
45
  const frames = (new Error().stack ?? "").split("\n").slice(1);
46
46
  return frames.find((frame) => !OWN_FRAME.test(frame))?.trim() ?? "";
@@ -104,6 +104,28 @@ function formatPercent(value, options = {}) {
104
104
  });
105
105
  return formatWithMinus(formatter, ratio, options.minus);
106
106
  }
107
+ const DURATION_UNITS = [
108
+ ["hour", 3600],
109
+ ["minute", 60],
110
+ ["second", 1]
111
+ ];
112
+ function formatDuration(seconds, options = {}) {
113
+ const n = toFormattable("formatDuration", seconds);
114
+ if (n === void 0) return options.empty ?? EMPTY_FORMATTED_VALUE;
115
+ const unitDisplay = options.unitDisplay ?? "narrow";
116
+ const negative = n < 0;
117
+ let rest = Math.trunc(Math.abs(n));
118
+ const parts = [];
119
+ for (const [unit, size] of DURATION_UNITS) {
120
+ const count = Math.floor(rest / size);
121
+ rest -= count * size;
122
+ if (parts.length === 0 && count === 0 && unit !== "second") continue;
123
+ const formatter = numberFormatter(options.locale, { style: "unit", unit, unitDisplay });
124
+ const signed = negative && parts.length === 0 && count > 0 ? -count : count;
125
+ parts.push(formatWithMinus(formatter, signed, options.minus));
126
+ }
127
+ return parts.join(" ");
128
+ }
107
129
  const DATE_STYLES = {
108
130
  short: { dateStyle: "short" },
109
131
  medium: { dateStyle: "medium" },
@@ -187,6 +209,7 @@ function useKitFormat(locale) {
187
209
  formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),
188
210
  formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),
189
211
  formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),
212
+ formatDuration: (seconds, options) => formatDuration(seconds, { locale: kitLocale, ...options }),
190
213
  formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, timeZone, ...options }),
191
214
  formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options })
192
215
  }),
@@ -211,6 +234,7 @@ export {
211
234
  EMPTY_FORMATTED_VALUE,
212
235
  currencyMinorDigits,
213
236
  formatDate,
237
+ formatDuration,
214
238
  formatMoney,
215
239
  formatNumber,
216
240
  formatPercent,
@@ -1 +1 @@
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 { dateFormatter } from \"./intl-cache\";\nimport { isBareIsoDay } from \"./iso-day\";\nimport { formatWithMinus, type MinusStyle } from \"./minus\";\nimport { roundHalfAway } from \"./round\";\n\nexport type { MinusStyle } from \"./minus\";\n// 0.50 (KIT-1 follow-up): the kit's minus for an app's OWN `Intl.NumberFormat` wrapper.\nexport { formatWithMinus } from \"./minus\";\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`, an unparseable date — formats as `empty`\n * (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or \"Invalid\n * Date\". A number may also arrive as a numeric STRING (0.49, KIT-2) — \"1850.00\", \"-12.5\",\n * \"0\": a Decimal field off a Python API — and is formatted like the number. Anything\n * else (a non-numeric string, an object, `NaN`, ±`Infinity`) prints `empty` too, but is\n * a bug upstream rather than a gap in the data, so a development build says so on the\n * console, once per call site. Zero is a figure: `0` and `\"0\"` print \"CHF 0.00\", never\n * the dash.\n *\n * A negative figure carries the typographic minus, U+2212 (0.49, KIT-1), in every\n * locale — `Intl` writes a hyphen in most. `minus: \"ascii\"` for text a machine reads\n * back (a CSV, the clipboard).\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 * The minus sign of a negative figure (0.49, KIT-1). `\"typographic\"` (default):\n * U+2212, as wide as \"+\" and the digits, as `SignedAmount` prints it — in every\n * locale, also where `Intl` writes a hyphen. `\"ascii\"`: U+002D, for an export a\n * machine parses back (a CSV download, the clipboard) — there, also pass\n * `options: { useGrouping: false }` or write the raw number.\n */\n minus?: MinusStyle;\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\n/**\n * What the number formatters take (0.49, KIT-2): a number, a numeric string (\"1850.00\",\n * \"-12.5\", \"1e3\" — what a Decimal arrives as in JSON), or `null` / `undefined` for no\n * value. A string is read with `Number()`, so a figure with more significant digits than\n * a double holds (about 15–17) is printed as its nearest double.\n */\nexport type FormattableNumber = number | string | null | undefined;\n\n/** A plain decimal: optional sign, digits with an optional point, an optional exponent.\n * No grouping marks, no decimal comma, no currency — those are formatted text, and\n * reading them back is a locale's guess. */\nconst NUMERIC_STRING = /^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?$/;\n\n/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a\n * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never\n * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nconst warnedSites = new Set<string>();\n/** This module's own frames, skipped to find the caller (\"Object.formatMoney\" is a\n * `useKitFormat` wrapper). */\nconst OWN_FRAME = /\\b(?:formatNumber|formatMoney|formatPercent|toFormattable|warnInvalid|callSite)\\b/;\n\n/** The first stack frame outside the formatters: where the bad value was passed. */\nfunction callSite(): string {\n const frames = (new Error().stack ?? \"\").split(\"\\n\").slice(1);\n return frames.find((frame) => !OWN_FRAME.test(frame))?.trim() ?? \"\";\n}\n\nfunction describeValue(value: unknown): string {\n if (typeof value === \"number\" || typeof value === \"bigint\" || typeof value === \"boolean\") return String(value);\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return Object.prototype.toString.call(value);\n }\n}\n\n/** A development build's console note about an invalid value — once per call site. */\nfunction warnInvalid(fn: string, value: unknown): void {\n if (!devWarnings()) return;\n const site = callSite();\n const key = `${fn}|${site || typeof value}`;\n if (warnedSites.has(key)) return;\n warnedSites.add(key);\n const type = Array.isArray(value) ? \"array\" : typeof value;\n console.warn(\n `[ui-kit] ${fn} got ${describeValue(value)} (${type}), which is not a number: printed the empty value. ` +\n \"Pass a number, a numeric string, or null/undefined for a missing value.\" +\n (site ? `\\n at ${site.replace(/^at /, \"\")}` : \"\"),\n );\n}\n\n/**\n * `value` as a finite number to format, or `undefined` to print `empty`. `null` and\n * `undefined` are a missing value and pass quietly; anything that is neither a finite\n * number nor a numeric string is an invalid one — ±Infinity included (lenkbank, adopting\n * 0.13: a division by zero upstream; \"∞\" in a money column reads like data) — and a\n * development build warns. `-0` is folded into `0`: zero carries no sign.\n */\nfunction toFormattable(fn: string, value: unknown): number | undefined {\n if (value === null || value === undefined) return undefined;\n let n: number | undefined;\n if (typeof value === \"number\") n = value;\n else if (typeof value === \"string\" && NUMERIC_STRING.test(value.trim())) n = Number(value.trim());\n if (n === undefined || !Number.isFinite(n)) {\n warnInvalid(fn, value);\n return undefined;\n }\n return n + 0;\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: FormattableNumber, options: FormatNumberOptions = {}): string {\n const n = toFormattable(\"formatNumber\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = formatWithMinus(numberFormatter(options.locale, numberOptions(options)), n, options.minus);\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(value: FormattableNumber, currency: string, options: FormatMoneyOptions = {}): string {\n const n = toFormattable(\"formatMoney\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const formatter = numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, n, options.minus);\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: FormattableNumber, options: FormatPercentOptions = {}): string {\n const n = toFormattable(\"formatPercent\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? n / 100 : n;\n const formatter = numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, ratio, options.minus);\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/** 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 (isBareIsoDay(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\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 (isBareIsoDay(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), and (0.51.7) the zone\n * whose calendar days \"yesterday\" and \"2 days ago\" count. Left out: the 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\n/**\n * `d`'s calendar day in `timeZone` (left out: the device's), as a whole number of days:\n * that day's UTC midnight over a day's length. Two of them subtract to whole calendar\n * days — a 23- or 25-hour DST day still counts one.\n */\nfunction dayNumber(d: Date, timeZone: string | undefined): number {\n if (!timeZone) return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY;\n // `en-US`: Gregorian, Latin digits — the parts are read back as numbers.\n const fields: Intl.DateTimeFormatOptions = { timeZone, year: \"numeric\", month: \"numeric\", day: \"numeric\" };\n const parts = dateFormatter(\"en-US\", fields).formatToParts(d);\n const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)?.value);\n return Date.UTC(part(\"year\"), part(\"month\") - 1, part(\"day\")) / 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; days are\n * CALENDAR days (0.51.7), in `timeZone` when given: \"yesterday\" is the day before today,\n * however few hours ago that was.\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 if (unit === \"day\") {\n // By the calendar, not in 24-hour units (refactor audit 2026-10-30, DSP-7): rounded\n // elapsed time made 00:10 seen at 23:50 \"yesterday\" and 01:00 yesterday seen at 23:30\n // \"2 days ago\", beside an exact date (DateMark's second line, the feedback thread's\n // tooltip) that said otherwise. Counted in `timeZone`, the zone that date is printed\n // in; a bare \"YYYY-MM-DD\" is the day it names wherever it is read, as in formatDate.\n // Still the same day (22 hours and more since just after midnight): in hours.\n const bareDay = isBareIsoDay(value);\n const days = dayNumber(d, bareDay ? undefined : options.timeZone) - dayNumber(now, options.timeZone);\n return days === 0 ? rtf.format(Math.round(diff / HOUR), \"hour\") : rtf.format(days, \"day\");\n }\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: FormattableNumber, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (value: FormattableNumber, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;\n formatPercent: (value: FormattableNumber, 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":["formatWithMinus"]}
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 { dateFormatter } from \"./intl-cache\";\nimport { isBareIsoDay } from \"./iso-day\";\nimport { formatWithMinus, type MinusStyle } from \"./minus\";\nimport { roundHalfAway } from \"./round\";\n\nexport type { MinusStyle } from \"./minus\";\n// 0.50 (KIT-1 follow-up): the kit's minus for an app's OWN `Intl.NumberFormat` wrapper.\nexport { formatWithMinus } from \"./minus\";\n\n/**\n * Number, money, percentage, duration (0.54), date and relative-time formatting — `Intl`,\n * in one place, 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`, an unparseable date — formats as `empty`\n * (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or \"Invalid\n * Date\". A number may also arrive as a numeric STRING (0.49, KIT-2) — \"1850.00\", \"-12.5\",\n * \"0\": a Decimal field off a Python API — and is formatted like the number. Anything\n * else (a non-numeric string, an object, `NaN`, ±`Infinity`) prints `empty` too, but is\n * a bug upstream rather than a gap in the data, so a development build says so on the\n * console, once per call site. Zero is a figure: `0` and `\"0\"` print \"CHF 0.00\", never\n * the dash.\n *\n * A negative figure carries the typographic minus, U+2212 (0.49, KIT-1), in every\n * locale — `Intl` writes a hyphen in most. `minus: \"ascii\"` for text a machine reads\n * back (a CSV, the clipboard).\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 * The minus sign of a negative figure (0.49, KIT-1). `\"typographic\"` (default):\n * U+2212, as wide as \"+\" and the digits, as `SignedAmount` prints it — in every\n * locale, also where `Intl` writes a hyphen. `\"ascii\"`: U+002D, for an export a\n * machine parses back (a CSV download, the clipboard) — there, also pass\n * `options: { useGrouping: false }` or write the raw number.\n */\n minus?: MinusStyle;\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 FormatDurationOptions extends BaseOptions {\n /**\n * How the units are written — `Intl.NumberFormat`'s `unitDisplay`, in `locale`:\n * - `\"narrow\"` (default): \"3m 12s\", \"3 Min. 12 Sek.\" (de-CH), \"3min 12s\" (fr) —\n * keksdose's scan readout;\n * - `\"short\"`: \"3 min 12 sec\", \"3 Min. 12 Sek.\", \"3 min 12 s\";\n * - `\"long\"`: \"3 minutes 12 seconds\", \"3 Minuten 12 Sekunden\", \"3 minutes 12 secondes\".\n */\n unitDisplay?: \"narrow\" | \"short\" | \"long\";\n /** The minus of a negative duration — {@link FormatNumberBaseOptions.minus}. */\n minus?: MinusStyle;\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\n/**\n * What the number formatters take (0.49, KIT-2): a number, a numeric string (\"1850.00\",\n * \"-12.5\", \"1e3\" — what a Decimal arrives as in JSON), or `null` / `undefined` for no\n * value. A string is read with `Number()`, so a figure with more significant digits than\n * a double holds (about 15–17) is printed as its nearest double.\n */\nexport type FormattableNumber = number | string | null | undefined;\n\n/** A plain decimal: optional sign, digits with an optional point, an optional exponent.\n * No grouping marks, no decimal comma, no currency — those are formatted text, and\n * reading them back is a locale's guess. */\nconst NUMERIC_STRING = /^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?$/;\n\n/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a\n * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never\n * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nconst warnedSites = new Set<string>();\n/** This module's own frames, skipped to find the caller (\"Object.formatMoney\" is a\n * `useKitFormat` wrapper). */\nconst OWN_FRAME = /\\b(?:formatNumber|formatMoney|formatPercent|formatDuration|toFormattable|warnInvalid|callSite)\\b/;\n\n/** The first stack frame outside the formatters: where the bad value was passed. */\nfunction callSite(): string {\n const frames = (new Error().stack ?? \"\").split(\"\\n\").slice(1);\n return frames.find((frame) => !OWN_FRAME.test(frame))?.trim() ?? \"\";\n}\n\nfunction describeValue(value: unknown): string {\n if (typeof value === \"number\" || typeof value === \"bigint\" || typeof value === \"boolean\") return String(value);\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return Object.prototype.toString.call(value);\n }\n}\n\n/** A development build's console note about an invalid value — once per call site. */\nfunction warnInvalid(fn: string, value: unknown): void {\n if (!devWarnings()) return;\n const site = callSite();\n const key = `${fn}|${site || typeof value}`;\n if (warnedSites.has(key)) return;\n warnedSites.add(key);\n const type = Array.isArray(value) ? \"array\" : typeof value;\n console.warn(\n `[ui-kit] ${fn} got ${describeValue(value)} (${type}), which is not a number: printed the empty value. ` +\n \"Pass a number, a numeric string, or null/undefined for a missing value.\" +\n (site ? `\\n at ${site.replace(/^at /, \"\")}` : \"\"),\n );\n}\n\n/**\n * `value` as a finite number to format, or `undefined` to print `empty`. `null` and\n * `undefined` are a missing value and pass quietly; anything that is neither a finite\n * number nor a numeric string is an invalid one — ±Infinity included (lenkbank, adopting\n * 0.13: a division by zero upstream; \"∞\" in a money column reads like data) — and a\n * development build warns. `-0` is folded into `0`: zero carries no sign.\n */\nfunction toFormattable(fn: string, value: unknown): number | undefined {\n if (value === null || value === undefined) return undefined;\n let n: number | undefined;\n if (typeof value === \"number\") n = value;\n else if (typeof value === \"string\" && NUMERIC_STRING.test(value.trim())) n = Number(value.trim());\n if (n === undefined || !Number.isFinite(n)) {\n warnInvalid(fn, value);\n return undefined;\n }\n return n + 0;\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: FormattableNumber, options: FormatNumberOptions = {}): string {\n const n = toFormattable(\"formatNumber\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = formatWithMinus(numberFormatter(options.locale, numberOptions(options)), n, options.minus);\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(value: FormattableNumber, currency: string, options: FormatMoneyOptions = {}): string {\n const n = toFormattable(\"formatMoney\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const formatter = numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, n, options.minus);\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: FormattableNumber, options: FormatPercentOptions = {}): string {\n const n = toFormattable(\"formatPercent\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? n / 100 : n;\n const formatter = numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, ratio, options.minus);\n}\n\n/** The units a duration is written in, largest first, with their length in seconds. */\nconst DURATION_UNITS = [\n [\"hour\", 3600],\n [\"minute\", 60],\n [\"second\", 1],\n] as const;\n\n/**\n * A number of SECONDS as an elapsed time in `locale`: \"42s\", \"3m 12s\", \"1h 0m 5s\" — and in\n * German \"3 Min. 12 Sek.\", in French \"3min 12s\" (keksdose audit follow-up, 2026-11-01:\n * keksdose's `formatElapsed`, the scan card's readout, moves here). Each unit is\n * `Intl.NumberFormat`'s `style: \"unit\"`, so the digits and the unit's spelling follow\n * `locale`; `unitDisplay` picks the form, narrow by default.\n *\n * - Seconds alone under a minute (\"0s\", \"59s\"); minutes and seconds under an hour (\"1m 0s\",\n * \"59m 59s\"); from an hour on, hours, minutes and seconds (\"1h 0m 0s\"). Hours are the\n * largest unit: a day and a bit is \"27h 46m 40s\". Every unit below the first is printed,\n * zero too, so a ticking readout keeps its shape from one second to the next.\n * - Whole seconds, cut toward zero: a timer reads \"59s\" until the minute is full.\n * - The parts are joined by a space, as the readout always was — not `Intl.ListFormat`'s\n * \"3 Min. und 12 Sek.\", which is a sentence rather than a figure.\n * - Negative: the minus on the first unit, as `Intl.DurationFormat` writes one — \"−3m 12s\",\n * with the kit's minus (`minus`). An elapsed time from a server's clock can come out a\n * second below zero; clamp it before, as keksdose's card does, when that is not news.\n * - `null` / `undefined` print `empty`; `NaN`, ±`Infinity` and a non-numeric string too,\n * with the formatters' development warning. A numeric string (\"185\") is a number.\n */\nexport function formatDuration(seconds: FormattableNumber, options: FormatDurationOptions = {}): string {\n const n = toFormattable(\"formatDuration\", seconds);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const unitDisplay = options.unitDisplay ?? \"narrow\";\n const negative = n < 0;\n let rest = Math.trunc(Math.abs(n));\n const parts: string[] = [];\n for (const [unit, size] of DURATION_UNITS) {\n const count = Math.floor(rest / size);\n rest -= count * size;\n // Units above the first that has a count are left out; seconds always print.\n if (parts.length === 0 && count === 0 && unit !== \"second\") continue;\n const formatter = numberFormatter(options.locale, { style: \"unit\", unit, unitDisplay });\n // `count > 0`: under a second either way is \"0s\", with no sign on it.\n const signed = negative && parts.length === 0 && count > 0 ? -count : count;\n parts.push(formatWithMinus(formatter, signed, options.minus));\n }\n return parts.join(\" \");\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/** 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 (isBareIsoDay(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\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 (isBareIsoDay(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), and (0.51.7) the zone\n * whose calendar days \"yesterday\" and \"2 days ago\" count. Left out: the 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\n/**\n * `d`'s calendar day in `timeZone` (left out: the device's), as a whole number of days:\n * that day's UTC midnight over a day's length. Two of them subtract to whole calendar\n * days — a 23- or 25-hour DST day still counts one.\n */\nfunction dayNumber(d: Date, timeZone: string | undefined): number {\n if (!timeZone) return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY;\n // `en-US`: Gregorian, Latin digits — the parts are read back as numbers.\n const fields: Intl.DateTimeFormatOptions = { timeZone, year: \"numeric\", month: \"numeric\", day: \"numeric\" };\n const parts = dateFormatter(\"en-US\", fields).formatToParts(d);\n const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)?.value);\n return Date.UTC(part(\"year\"), part(\"month\") - 1, part(\"day\")) / 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; days are\n * CALENDAR days (0.51.7), in `timeZone` when given: \"yesterday\" is the day before today,\n * however few hours ago that was.\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 if (unit === \"day\") {\n // By the calendar, not in 24-hour units (refactor audit 2026-10-30, DSP-7): rounded\n // elapsed time made 00:10 seen at 23:50 \"yesterday\" and 01:00 yesterday seen at 23:30\n // \"2 days ago\", beside an exact date (DateMark's second line, the feedback thread's\n // tooltip) that said otherwise. Counted in `timeZone`, the zone that date is printed\n // in; a bare \"YYYY-MM-DD\" is the day it names wherever it is read, as in formatDate.\n // Still the same day (22 hours and more since just after midnight): in hours.\n const bareDay = isBareIsoDay(value);\n const days = dayNumber(d, bareDay ? undefined : options.timeZone) - dayNumber(now, options.timeZone);\n return days === 0 ? rtf.format(Math.round(diff / HOUR), \"hour\") : rtf.format(days, \"day\");\n }\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: FormattableNumber, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (value: FormattableNumber, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;\n formatPercent: (value: FormattableNumber, options?: WithoutLocale<FormatPercentOptions>) => string;\n /** 0.54: {@link formatDuration}, a number of seconds as an elapsed time. */\n formatDuration: (seconds: FormattableNumber, options?: WithoutLocale<FormatDurationOptions>) => 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 formatDuration: (seconds, options) => formatDuration(seconds, { 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":["formatWithMinus"]}
@@ -4,10 +4,12 @@
4
4
  * Constructing the formatter is the expensive part: keksdose measured 392 ms per click on
5
5
  * a 270-row register when each cell built its own (dev#632), and kept its own renderer of
6
6
  * the date-format preference because the kit's built one per call (refactor audit
7
- * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one.
7
+ * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one, and
8
+ * since 0.54 `lib/dates`' `formatIsoDate` (keksdose audit follow-up, 2026-11-01).
8
9
  *
9
10
  * @internal Not part of the barrel — `lib/format` is re-exported whole, so the cache
10
- * lives apart from it.
11
+ * lives apart from it. It imports nothing, which `@eifi1/ui-kit/dates` (no package at
12
+ * all) relies on.
11
13
  */
12
14
  /** The cached formatter for `locale` and `options`. Throws as `Intl.DateTimeFormat` does
13
15
  * for a tag it refuses — and caches nothing then. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/intl-cache.ts"],"sourcesContent":["/**\n * One `Intl.DateTimeFormat` per locale and option set, for every date the kit prints.\n *\n * Constructing the formatter is the expensive part: keksdose measured 392 ms per click on\n * a 270-row register when each cell built its own (dev#632), and kept its own renderer of\n * the date-format preference because the kit's built one per call (refactor audit\n * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one.\n *\n * @internal Not part of the barrel — `lib/format` is re-exported whole, so the cache\n * lives apart from it.\n */\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\n\n/** The cached formatter for `locale` and `options`. Throws as `Intl.DateTimeFormat` does\n * for a tag it refuses — and caches nothing then. */\nexport function 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"],"mappings":";AAYA,MAAM,iBAAiB,oBAAI,IAAiC;AAIrD,SAAS,cAAc,QAA4B,SAA0D;AAClH,QAAM,MAAM,GAAG,UAAU,EAAE,IAAI,KAAK,UAAU,OAAO,CAAC;AACtD,MAAI,IAAI,eAAe,IAAI,GAAG;AAC9B,MAAI,CAAC,GAAG;AACN,QAAI,IAAI,KAAK,eAAe,QAAQ,OAAO;AAC3C,mBAAe,IAAI,KAAK,CAAC;AAAA,EAC3B;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/lib/intl-cache.ts"],"sourcesContent":["/**\n * One `Intl.DateTimeFormat` per locale and option set, for every date the kit prints.\n *\n * Constructing the formatter is the expensive part: keksdose measured 392 ms per click on\n * a 270-row register when each cell built its own (dev#632), and kept its own renderer of\n * the date-format preference because the kit's built one per call (refactor audit\n * 2026-10-30, DUP-2). `lib/format` and `lib/date-format` both format through this one, and\n * since 0.54 `lib/dates`' `formatIsoDate` (keksdose audit follow-up, 2026-11-01).\n *\n * @internal Not part of the barrel — `lib/format` is re-exported whole, so the cache\n * lives apart from it. It imports nothing, which `@eifi1/ui-kit/dates` (no package at\n * all) relies on.\n */\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\n\n/** The cached formatter for `locale` and `options`. Throws as `Intl.DateTimeFormat` does\n * for a tag it refuses — and caches nothing then. */\nexport function 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"],"mappings":";AAcA,MAAM,iBAAiB,oBAAI,IAAiC;AAIrD,SAAS,cAAc,QAA4B,SAA0D;AAClH,QAAM,MAAM,GAAG,UAAU,EAAE,IAAI,KAAK,UAAU,OAAO,CAAC;AACtD,MAAI,IAAI,eAAe,IAAI,GAAG;AAC9B,MAAI,CAAC,GAAG;AACN,QAAI,IAAI,KAAK,eAAe,QAAQ,OAAO;AAC3C,mBAAe,IAAI,KAAK,CAAC;AAAA,EAC3B;AACA,SAAO;AACT;","names":[]}
@@ -293,6 +293,32 @@ export declare function reviewUndo(rows: readonly TranslationRow[]): Translation
293
293
  export declare function mergeReviews<T extends TranslationReviewKey>(prev: readonly T[], written: readonly T[]): T[];
294
294
  /** The cached list without the verdicts on `cleared`. */
295
295
  export declare function dropReviews<T extends TranslationReviewKey>(prev: readonly T[], cleared: readonly TranslationReviewKey[]): T[];
296
+ /**
297
+ * A save of any size under server-kit's cap: `writes` in chunks of 1000, one request
298
+ * after the other, in their order, and every verdict the server answered with, in that
299
+ * order — to patch the page's cached list with {@link mergeReviews} once. `save` is the
300
+ * app's own request (`PUT /translations/reviews`), answering with the verdicts it wrote,
301
+ * in either spelling. No writes, no request: the server refuses an empty batch.
302
+ *
303
+ * A chunk that fails rejects with its error, and the chunks after it are not sent. What
304
+ * the chunks before it wrote stays written — the server has it — but is not handed back,
305
+ * so the page leaves its cache as it was and the panel shows the error; a second try
306
+ * writes those verdicts again, which changes nothing but the time on them.
307
+ *
308
+ * All three apps wrote this loop, with a `chunks` helper and `SAVE_CHUNK = 1000` of
309
+ * their own (kastlan reuse audit 2026-11-02, proposal 5):
310
+ *
311
+ * const written = await saveReviewsInChunks(writes.map(toApiWrite), (part) => translationsApi.save(part));
312
+ * patchCache((reviews) => mergeReviews(reviews, written));
313
+ */
314
+ export declare function saveReviewsInChunks<W extends TranslationReviewKey, R extends TranslationReviewKey>(writes: readonly W[], save: (chunk: W[]) => Promise<readonly R[]>): Promise<R[]>;
315
+ /**
316
+ * {@link saveReviewsInChunks} for a reset (`POST /translations/reviews/clear`): `keys` in
317
+ * chunks of 1000, one request after the other, and the keys themselves once every chunk
318
+ * is through — to drop from the cached list with {@link dropReviews} once. A failing
319
+ * chunk rejects as a save's does, and what the chunks before it cleared stays cleared.
320
+ */
321
+ export declare function clearReviewsInChunks<K extends TranslationReviewKey>(keys: readonly K[], clear: (chunk: K[]) => Promise<unknown>): Promise<K[]>;
296
322
  /**
297
323
  * What a phone card's swipe can be bound to in `TranslationReviewPanel`'s `swipe`
298
324
  * (keksdose — Marcel's live #377 rework, 2026-10-03: *"Add the swipe options to be shown
@@ -200,6 +200,18 @@ function dropReviews(prev, cleared) {
200
200
  const gone = new Set(cleared.map((r) => `${r.locale}|${r.key}`));
201
201
  return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));
202
202
  }
203
+ const REVIEW_CHUNK = 1e3;
204
+ async function saveReviewsInChunks(writes, save) {
205
+ const written = [];
206
+ for (let at = 0; at < writes.length; at += REVIEW_CHUNK) {
207
+ written.push(...await save(writes.slice(at, at + REVIEW_CHUNK)));
208
+ }
209
+ return written;
210
+ }
211
+ async function clearReviewsInChunks(keys, clear) {
212
+ for (let at = 0; at < keys.length; at += REVIEW_CHUNK) await clear(keys.slice(at, at + REVIEW_CHUNK));
213
+ return [...keys];
214
+ }
203
215
  const TRANSLATION_REVIEW_SWIPE_ACTIONS = ["approve", "edit", "clear"];
204
216
  const DEFAULT_TRANSLATION_REVIEW_SWIPE = Object.freeze({
205
217
  start: Object.freeze(["edit"]),
@@ -245,6 +257,7 @@ export {
245
257
  DEFAULT_TRANSLATION_REVIEW_SWIPE,
246
258
  REVIEW_STATUSES,
247
259
  TRANSLATION_REVIEW_SWIPE_ACTIONS,
260
+ clearReviewsInChunks,
248
261
  dropReviews,
249
262
  filterTranslationRows,
250
263
  flattenStrings,
@@ -260,6 +273,7 @@ export {
260
273
  reviewStatus,
261
274
  reviewUndo,
262
275
  reviewWrite,
276
+ saveReviewsInChunks,
263
277
  summariseRows,
264
278
  toApiWrite,
265
279
  translationCorrections,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/translation-review.ts"],"sourcesContent":["/**\n * The pure half of an app's translation review (H1 in docs/i18n-harmonization.md): from\n * the strings the app ships and the verdicts its server stores to one row per (locale,\n * key) with a status — keksdose's `features/translations/translation-catalogue.ts` and\n * kastlan's copy of it, which had grown apart by a fifth status and a second kind of key.\n *\n * WHY THE STRINGS ARE THE CATALOGUE. Nothing on the server lists which strings exist: a\n * verdict only says what somebody thought of one. So the rows are built from what the app\n * ships — its locale bundles, kastlan's document texts, the kit's own words\n * (`kitLabelStrings`) — and the review can never offer a string users do not see. A\n * verdict is stored WITH the wording it was given on, and a string that changed since is\n * noticed by comparing the two: `changed`, read it again, whatever the verdict was.\n *\n * WHAT STAYS IN THE APP. Which locale is read against which (keksdose: everything against\n * English, English against de-CH; kastlan: against German, German against English), where\n * the strings come from, who may review what (the locales and areas its server answers\n * with), and every request. The kit takes those as input and never derives them.\n *\n * camelCase, like the rest of the kit. The contract's wire shape is snake_case\n * (`reference_text`, `reviewer_name`, `reviewed_at`); {@link fromApiReview} and\n * {@link toApiWrite} are the two lines each app would otherwise write — both apps speak\n * the same contract, so the mapping is written once, here.\n */\n\nimport { matchesSearch, searchWords } from \"./text-match\";\n\n// ── Shapes ──────────────────────────────────────────────────────────────────────\n\nexport type TranslationVerdict = \"APPROVED\" | \"NEEDS_CHANGE\";\n\n/** One stored verdict: what a reviewer said about one string in one locale. */\nexport interface TranslationReview {\n locale: string;\n key: string;\n /** The wording that was judged — compared with today's to notice a change. Empty for\n * a string the locale lacked (a suggested translation for a `missing` row). */\n text: string;\n /** The reference wording at the time. `null` from a server that did not store it. */\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n /** The reviewer's wording — or, for a missing string, the translation to add. */\n suggestion: string | null;\n /** `null` once the reviewer's account has been erased. */\n reviewerName: string | null;\n /** ISO timestamp. */\n reviewedAt: string;\n}\n\n/** What a save sends: the verdict plus the wording it is given on. */\nexport interface TranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** What a reset sends: back to \"unreviewed\". */\nexport interface TranslationReviewKey {\n locale: string;\n key: string;\n}\n\n/**\n * Where a string stands, in the order a reviewer works through them: what is not there\n * at all, what nobody has read, what changed after it was read, what was sent back, and\n * what is done. `missing` never occurs for an app whose locales have every key\n * (keksdose); kastlan's French lacks 172 screen texts.\n */\nexport type ReviewStatus = \"missing\" | \"unreviewed\" | \"changed\" | \"needs_change\" | \"approved\";\n\nexport const REVIEW_STATUSES: readonly ReviewStatus[] = [\n \"missing\",\n \"unreviewed\",\n \"changed\",\n \"needs_change\",\n \"approved\",\n];\n\nexport interface TranslationRow {\n /** `${locale}|${key}` — unique across every locale on a page. A bar, not a colon:\n * kastlan's keys contain colons (`common:actions.save`). */\n id: string;\n key: string;\n /** The key's group: `budget` for `budget.rta`, `common` for `common:actions.save`,\n * `kit` for the kit's own words. See {@link keyNamespace}. */\n namespace: string;\n /** Where the string comes from, in the app's own words — kastlan's `screen` and\n * `documents`, keksdose's one source. `\"\"` when the app does not say. */\n source: string;\n locale: string;\n /** The wording users see today; empty when the locale lacks the string. */\n text: string;\n /** The same key in the reference locale. */\n reference: string;\n status: ReviewStatus;\n review: TranslationReview | null;\n /** The interpolations and tags differ from the reference's — `{{count}}` dropped, a\n * `<1>` link lost, a `{date}` renamed. A machine can check that much, so it does,\n * before a person reads. Never set on a missing string: it has nothing to lose. */\n placeholderMismatch: boolean;\n}\n\n// ── The wire contract ───────────────────────────────────────────────────────────\n\n/** A verdict as `GET /translations/reviews` returns it (H1's shared contract). Typed\n * structurally, so an app's generated OpenAPI type fits without a cast. */\nexport interface ApiTranslationReview {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n reviewer_name: string | null;\n /** server-kit 0.10: the name's two parts (`reviewer_name` is them in the reader's order). */\n reviewer_first_name?: string | null;\n reviewer_last_name?: string | null;\n reviewed_at: string;\n}\n\n/** `GET /translations/reviews` (server-kit `TranslationReviewsResponse`): the locales and\n * areas the caller may review (`areas` null: every area; `[\"kit\"]` for a review token)\n * and every stored verdict in them. Typed structurally, as {@link ApiTranslationReview}. */\nexport interface ApiTranslationReviewsResponse {\n locales: string[];\n areas?: string[] | null;\n reviews: ApiTranslationReview[];\n}\n\n/** One item of `PUT /translations/reviews`. */\nexport interface ApiTranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** A verdict from the server, in the kit's spelling. */\nexport function fromApiReview(review: ApiTranslationReview): TranslationReview {\n return {\n locale: review.locale,\n key: review.key,\n text: review.text,\n referenceText: review.reference_text,\n verdict: review.verdict,\n note: review.note,\n suggestion: review.suggestion,\n reviewerName: review.reviewer_name,\n reviewedAt: review.reviewed_at,\n };\n}\n\n/** A save, in the server's spelling. `TranslationReviewKey` needs no mapping — it is\n * `{ locale, key }` on both sides. */\nexport function toApiWrite(write: TranslationReviewWrite): ApiTranslationReviewWrite {\n return {\n locale: write.locale,\n key: write.key,\n text: write.text,\n reference_text: write.referenceText,\n verdict: write.verdict,\n note: write.note,\n suggestion: write.suggestion,\n };\n}\n\n// ── Strings ─────────────────────────────────────────────────────────────────────\n\n/**\n * A nested bundle as `key → string`, in the bundle's own order. Non-string leaves are\n * skipped rather than stringified into something nobody wrote.\n *\n * `prefix` is put in front of every key AS IS, so it names the separator too:\n * `flattenStrings(bundle)` is keksdose's `budget.rta`, `flattenStrings(tree, \"common:\")`\n * kastlan's `common:actions.save`, and `flattenStrings(kitLabelStrings(labels), \"kit.\")`\n * the kit's own words as `kit.combobox.resultCount(3)` — already flat, so only the prefix\n * is added.\n */\nexport function flattenStrings(tree: Readonly<Record<string, unknown>>, prefix = \"\"): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(tree)) {\n const path = `${prefix}${key}`;\n if (typeof value === \"string\") out[path] = value;\n else if (value && typeof value === \"object\" && !Array.isArray(value)) {\n Object.assign(out, flattenStrings(value as Record<string, unknown>, `${path}.`));\n }\n }\n return out;\n}\n\n/**\n * The key's group, for the namespace filter: the part before a colon when there is one\n * (kastlan's `common:actions.save` → `common`, `doc_text.invoice:due` →\n * `doc_text.invoice`), else the first dotted segment (keksdose's `budget.rta` →\n * `budget`, the kit's `kit.combobox.resultCount(3)` → `kit`). Only the part before a\n * sample's `(` is looked at, so a colon inside one cannot move the split.\n */\nexport function keyNamespace(key: string): string {\n const head = key.split(\"(\", 1)[0];\n const colon = head.indexOf(\":\");\n return colon >= 0 ? head.slice(0, colon) : head.split(\".\", 1)[0];\n}\n\n// i18next `{{x}}` (formatting options ignored: `{{x, number}}` is `{{x}}`); a Python\n// format field `{x}` / `{0}` / `{amount:.2f}` (kastlan's document texts), but not a\n// LaTeX argument (`\\textbf{x}` — a letter or backslash right before the brace); a\n// numbered or named tag (`<1>`, `<strong>`), not prose in angle brackets (\"<deleted\n// user>\"); and a nested `$t(key)`. `{{x}}` is tried first at its position and consumed\n// whole, so it is never counted a second time as `{x}`.\nconst PLACEHOLDER_RE =\n /\\{\\{\\s*([^}\\s,]+)[^}]*\\}\\}|(?<![\\\\A-Za-z])\\{([a-z_][a-z0-9_]*|\\d+)(?:[!:][^{}]*)?\\}|<\\/?(\\d+|[a-z][a-z0-9]*)\\s*\\/?>|\\$t\\(([^)]+)\\)/g;\n\n/** The placeholders in a string, as a sorted multiset: `{{count}}`, `{date}`, `<1>`,\n * `$t(common.save)`. */\nexport function placeholderTokens(text: string): string[] {\n const found: string[] = [];\n for (const m of text.matchAll(PLACEHOLDER_RE)) {\n if (m[1]) found.push(`{{${m[1]}}}`);\n // `{ref:retention}` (ui-kit 0.28's legal cross-reference) keeps its target: a\n // translation that points at another section changed what the sentence says. Every\n // other `{x:spec}` is a format spec, which a translation may change.\n else if (m[2]) found.push(m[2] === \"ref\" ? m[0] : `{${m[2]}}`);\n else if (m[3]) found.push(`<${m[3]}>`);\n else if (m[4]) found.push(`$t(${m[4]})`);\n }\n return found.sort();\n}\n\n/** Whether `text` has other placeholders than `reference` — dropped, renamed or added.\n * Moving one is fine: a translation reorders a sentence. */\nexport function placeholderMismatch(text: string, reference: string): boolean {\n const a = placeholderTokens(text);\n const b = placeholderTokens(reference);\n return a.length !== b.length || a.some((p, i) => p !== b[i]);\n}\n\n// ── Status ──────────────────────────────────────────────────────────────────────\n\n/**\n * A verdict counts only for the wording it was given on. If the string or its reference\n * changed since, it is `changed` — read it again — whatever the verdict was. A string the\n * locale lacks is `missing` until somebody suggests its translation (stored as\n * NEEDS_CHANGE on the empty text, so it reads `needs_change`, and `changed` once a\n * developer adds the string).\n */\nexport function reviewStatus(\n review: TranslationReview | null | undefined,\n text: string,\n reference: string,\n): ReviewStatus {\n if (!review) return text === \"\" && reference !== \"\" ? \"missing\" : \"unreviewed\";\n if (review.text !== text) return \"changed\";\n if (review.referenceText != null && review.referenceText !== reference) return \"changed\";\n return review.verdict === \"APPROVED\" ? \"approved\" : \"needs_change\";\n}\n\n/**\n * Whether `key` lies in `area`: the key IS the area, or sits under it — `legal.terms`\n * and kastlan's `legal:terms` are in `legal`, `legalese.title` is not.\n *\n * 0.28: the kit's own words of that area too — `kit.legal.sections.terms.law.body(…)`\n * is in `legal`. All three apps list the kit's labels under `kit.`\n * (`flattenStrings(kitLabelStrings(labels), \"kit.\")`), and since 0.28 the kit writes\n * the shared legal sections, so a lawyer with a `[\"legal\"]` grant who was not shown\n * `kit.legal.*` would not see half of the Privacy Policy — and the server would refuse\n * the verdicts (docs/legal-harmonization.md §7.5). server-kit's `in_areas` and the apps'\n * copies of it apply the same rule.\n */\nexport function keyInArea(key: string, area: string): boolean {\n return key === area || key.startsWith(`${area}.`) || key.startsWith(`${area}:`) || key.startsWith(`kit.${area}.`);\n}\n\n/**\n * The area of `areas` that `key` lies in ({@link keyInArea}), or `undefined` — the\n * translation page's grouping by the same rule the filter and the server use, so\n * `kit.legal.sections.privacy.rights.lead` is grouped under `legal` with the app's own\n * legal texts instead of under `kit.legal`:\n *\n * const namespaceOf = (key: string) => reviewAreaOf(key, [\"legal\"]) ?? ownNamespaceOf(key);\n *\n * Where two areas both hold the key (`legal` and `legal.privacy`), the longer — the more\n * specific — wins, whatever their order. `inArea` is the app's own rule, as for\n * {@link keyInAreas}.\n */\nexport function reviewAreaOf(\n key: string,\n areas: readonly string[],\n inArea: (key: string, area: string) => boolean = keyInArea,\n): string | undefined {\n let found: string | undefined;\n for (const area of areas) {\n if (inArea(key, area) && (found === undefined || area.length > found.length)) found = area;\n }\n return found;\n}\n\n/**\n * Whether `key` lies in one of `areas` — the scope a reviewer may be limited to\n * (keksdose: `[\"legal\"]`, a lawyer who judges the Imprint, the Privacy Policy and the\n * Terms and sees nothing else). `null`/`undefined` is every area. The server applies the\n * same rule and refuses verdicts outside; this decides only what is listed.\n */\nexport function keyInAreas(\n key: string,\n areas: readonly string[] | null | undefined,\n inArea: (key: string, area: string) => boolean = keyInArea,\n): boolean {\n return !areas || areas.some((area) => inArea(key, area));\n}\n\nexport interface TranslationRowsInput {\n locale: string;\n /** The locale's strings, `key → text` ({@link flattenStrings}). */\n strings: Readonly<Record<string, string>>;\n /** The reference locale's strings — whichever locale THIS app reads `locale` against. */\n reference: Readonly<Record<string, string>>;\n /** The verdicts: this locale's by key, or the whole list as the server sent it (other\n * locales' verdicts are skipped). */\n reviews?: ReadonlyMap<string, TranslationReview> | readonly TranslationReview[];\n /** Default {@link keyNamespace}. */\n namespaceOf?: (key: string) => string;\n /** The row's source, an app-defined word (kastlan: `doc_text.` keys are `documents`,\n * the rest `screen`). Default: none (`\"\"`). */\n sourceOf?: (key: string) => string;\n /** Keep only keys inside these areas ({@link keyInAreas}); `null` keeps every key. */\n areas?: readonly string[] | null;\n /** Default {@link keyInArea}. */\n inArea?: (key: string, area: string) => boolean;\n}\n\n/**\n * Every key of one locale, with its status: the reference's keys first, in its order, so\n * rows read the way the app's authors wrote them; a key only this locale has is appended\n * rather than dropped (a parity test should forbid it, but the page should not lie if one\n * slips through).\n */\nexport function translationRows({\n locale,\n strings,\n reference,\n reviews,\n namespaceOf: groupOf = keyNamespace,\n sourceOf,\n areas,\n inArea,\n}: TranslationRowsInput): TranslationRow[] {\n const byKey = new Map<string, TranslationReview>();\n if (reviews instanceof Map) {\n for (const [key, review] of reviews) byKey.set(key, review);\n } else if (reviews) {\n for (const review of reviews as readonly TranslationReview[]) {\n if (review.locale === locale) byKey.set(review.key, review);\n }\n }\n const keys = Object.keys(reference);\n for (const key of Object.keys(strings)) if (!(key in reference)) keys.push(key);\n return keys\n .filter((key) => keyInAreas(key, areas, inArea))\n .map((key) => {\n const text = strings[key] ?? \"\";\n const ref = reference[key] ?? \"\";\n const review = byKey.get(key) ?? null;\n return {\n id: `${locale}|${key}`,\n key,\n namespace: groupOf(key),\n source: sourceOf?.(key) ?? \"\",\n locale,\n text,\n reference: ref,\n status: reviewStatus(review, text, ref),\n review,\n placeholderMismatch: text !== \"\" && placeholderMismatch(text, ref),\n };\n });\n}\n\n/** How many rows stand where, and how many there are. */\nexport type TranslationSummary = Record<ReviewStatus, number> & { total: number };\n\nexport function summariseRows(rows: readonly TranslationRow[]): TranslationSummary {\n const summary: TranslationSummary = {\n missing: 0,\n unreviewed: 0,\n changed: 0,\n needs_change: 0,\n approved: 0,\n total: rows.length,\n };\n for (const row of rows) summary[row.status] += 1;\n return summary;\n}\n\n// ── Filtering ───────────────────────────────────────────────────────────────────\n\n/** The panel's filters. `\"all\"` switches one off. */\nexport interface TranslationReviewFilter {\n status: ReviewStatus | \"all\";\n source: string;\n namespace: string;\n /** Searched in the key, the text and the reference, folded as every kit list's search\n * is (`foldForSearch`): no case, no accents — \"uberweisung\" finds \"Überweisung\". Every\n * word on its own (0.52): \"budget assign\" finds `budget.rta`, \"Ready to Assign\". */\n query: string;\n placeholdersOnly: boolean;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_FILTER: TranslationReviewFilter = {\n status: \"all\",\n source: \"all\",\n namespace: \"all\",\n query: \"\",\n placeholdersOnly: false,\n};\n\n/**\n * The rows the filters leave. `locale` (0.52) folds the query and the texts as the kit's\n * other lists do — the panel passes the provider's; left out, the runtime's default.\n */\nexport function filterTranslationRows(\n rows: readonly TranslationRow[],\n filter: Partial<TranslationReviewFilter>,\n locale?: string,\n): TranslationRow[] {\n const f = { ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...filter };\n // Folded as every kit list's search is (DUP-5, refactor audit 2026-10), and since 0.52\n // word by word, each anywhere in the key, the text or the reference (0.51.4's rule;\n // refactor audit 2026-10-30, DUP-4: the whole query had to be one substring of one).\n const words = searchWords(f.query, locale);\n return rows.filter(\n (row) =>\n (f.status === \"all\" || row.status === f.status) &&\n (f.source === \"all\" || row.source === f.source) &&\n (f.namespace === \"all\" || row.namespace === f.namespace) &&\n (!f.placeholdersOnly || row.placeholderMismatch) &&\n matchesSearch(words, [row.key, row.text, row.reference], locale),\n );\n}\n\n// ── Grouping ────────────────────────────────────────────────────────────────────\n\n/** One group of rows — an area, a source — and how they stand. */\nexport interface TranslationRowGroup {\n key: string;\n rows: TranslationRow[];\n summary: TranslationSummary;\n}\n\n/**\n * Rows in groups, each group where its first row is — the reference's order, so the areas\n * read the way the app's authors wrote the bundle — and each group's rows in their own\n * order. keksdose live #377: a reviewer on a phone works area by area (\"the budget\n * strings, then the legal ones\"), and a flat list of 3859 rows is not a way to see\n * where an area stands.\n *\n * `groupOf` names a row's group: `(row) => row.namespace` for the areas,\n * `(row) => row.source` for kastlan's screen texts and documents.\n */\nexport function groupTranslationRows(\n rows: readonly TranslationRow[],\n groupOf: (row: TranslationRow) => string,\n): TranslationRowGroup[] {\n const byKey = new Map<string, TranslationRow[]>();\n for (const row of rows) {\n const key = groupOf(row);\n const list = byKey.get(key);\n if (list) list.push(row);\n else byKey.set(key, [row]);\n }\n return [...byKey].map(([key, list]) => ({ key, rows: list, summary: summariseRows(list) }));\n}\n\n/**\n * The rows a group's \"Approve the unreviewed\" takes: the ones nobody has given a verdict\n * and that have a text. Not `changed` — a string that moved since it was judged asks to\n * be READ again, which a bulk action is the opposite of — and not `missing`, which has\n * nothing to approve.\n */\nexport function unreviewedRows(rows: readonly TranslationRow[]): TranslationRow[] {\n return rows.filter((row) => row.status === \"unreviewed\" && row.text !== \"\");\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────────\n\n/** What gets stored for a verdict: the wording it was given on, so a later change of the\n * string (or of its reference) shows up as `changed`. */\nexport function reviewWrite(\n row: TranslationRow,\n verdict: TranslationVerdict,\n note: string | null = null,\n suggestion: string | null = null,\n): TranslationReviewWrite {\n return {\n locale: row.locale,\n key: row.key,\n text: row.text,\n referenceText: row.reference,\n verdict,\n note,\n suggestion,\n };\n}\n\n/** What takes a set of verdicts back: see {@link reviewUndo}. */\nexport interface TranslationReviewUndo {\n /** The verdicts the rows had before, to store again — each on the wording it was\n * given on, so a row that was `changed` reads `changed` again. */\n writes: TranslationReviewWrite[];\n /** The rows that had no verdict before: back to unreviewed. */\n clears: TranslationReviewKey[];\n}\n\n/**\n * How to take back what was just said about `rows` — the rows AS THEY WERE before the\n * write, with the verdicts they carried then. The panel's Undo toast (0.25, keksdose live\n * #377) runs it: a swipe on a phone approves in one movement, and a movement can be a\n * mistake.\n *\n * A row that had a verdict gets it stored again (its note, its suggestion, the wording it\n * was judged on); one that had none is cleared. The server stamps the restored verdict\n * with the person undoing and the time, as it stamps every write — the contract has no\n * way to write a verdict in somebody else's name, and should not.\n */\nexport function reviewUndo(rows: readonly TranslationRow[]): TranslationReviewUndo {\n const writes: TranslationReviewWrite[] = [];\n const clears: TranslationReviewKey[] = [];\n for (const row of rows) {\n const before = row.review;\n if (!before) {\n clears.push({ locale: row.locale, key: row.key });\n continue;\n }\n writes.push({\n locale: row.locale,\n key: row.key,\n text: before.text,\n referenceText: before.referenceText,\n verdict: before.verdict,\n note: before.note,\n suggestion: before.suggestion,\n });\n }\n return { writes, clears };\n}\n\n/** The app's cached verdict list with `written` in place of what was said before — a\n * save answers with the rows it wrote, so a page patches its copy instead of\n * downloading every verdict again after each click. Any spelling with `locale` and\n * `key` (the API's or the kit's). */\nexport function mergeReviews<T extends TranslationReviewKey>(prev: readonly T[], written: readonly T[]): T[] {\n const id = (r: TranslationReviewKey) => `${r.locale}|${r.key}`;\n const replaced = new Set(written.map(id));\n return [...prev.filter((r) => !replaced.has(id(r))), ...written];\n}\n\n/** The cached list without the verdicts on `cleared`. */\nexport function dropReviews<T extends TranslationReviewKey>(\n prev: readonly T[],\n cleared: readonly TranslationReviewKey[],\n): T[] {\n const gone = new Set(cleared.map((r) => `${r.locale}|${r.key}`));\n return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));\n}\n\n// ── Swipe bindings ──────────────────────────────────────────────────────────────\n\n/**\n * What a phone card's swipe can be bound to in `TranslationReviewPanel`'s `swipe`\n * (keksdose — Marcel's live #377 rework, 2026-10-03: *\"Add the swipe options to be shown\n * also to the settings /settings#interaction area where the other swipe options are\n * defined\"*). keksdose lets the user bind every list's swipes there — per side, a primary\n * action at the first threshold and a secondary one at a longer drag — and lists each\n * surface's ids in its `SWIPE_ACTIONS`; this is the translation list's, in the order a\n * settings page offers them.\n *\n * - `approve` — the string is right as it stands. With the panel's Undo toast.\n * - `edit` — open the editor with the cursor in the wording: \"Needs a change\" (or\n * \"Suggest translation\" on a missing string). Writes nothing; the editor does.\n * - `clear` — take the verdict back: unreviewed again. With the Undo toast, which stores\n * the verdict again.\n *\n * THERE IS NO SEPARATE \"needs a change\". The editor has no verdict to preselect: Approve\n * and \"Needs a change\" stand side by side in it, and \"Needs a change\" takes a note or a\n * different wording — a verdict without either tells whoever fixes the string nothing, so\n * a swipe never sends one blind (0.25). The one honest thing a \"needs a change\" swipe can\n * do is therefore open the editor with the cursor in the wording — which is `edit`, and\n * `edit` already says \"Needs a change\" on the card. A second id doing the same would arm\n * a second threshold that does what the first already did: the duplicate keksdose's\n * `resolveSwipePlan` drops for exactly that reason.\n */\nexport const TRANSLATION_REVIEW_SWIPE_ACTIONS = [\"approve\", \"edit\", \"clear\"] as const;\n\n/** One of {@link TRANSLATION_REVIEW_SWIPE_ACTIONS}. */\nexport type TranslationReviewSwipeAction = (typeof TRANSLATION_REVIEW_SWIPE_ACTIONS)[number];\n\n/**\n * Which swipe does what on a phone card — `TranslationReviewPanel`'s `swipe`. Each side is\n * an ordered ladder, exactly as `SwipeableRow` and DataTable's `mobileSwipeActions` model\n * one: index 0 commits at the first threshold, index 1 at the longer drag (and so on — the\n * thresholds spread evenly over the card's width). A side left out, or empty, does not\n * swipe.\n *\n * LOGICAL SIDES: `end` is a drag toward the reading end of the line — right in a\n * left-to-right page, left in a right-to-left one — and `start` the other way, so one\n * binding holds in both directions. An app whose settings store physical sides (keksdose's\n * `right`/`left`) hands `right` as `end` for a left-to-right page.\n */\nexport interface TranslationReviewSwipeBinding {\n /** A drag toward the reading START, nearest threshold first. */\n start?: readonly TranslationReviewSwipeAction[];\n /** A drag toward the reading END, nearest threshold first. */\n end?: readonly TranslationReviewSwipeAction[];\n}\n\n/** What `swipe={true}` means — 0.25's fixed mapping: approve toward the end, open the\n * editor (\"Needs a change\") toward the start. A settings page's \"reset\" goes back here. */\nexport const DEFAULT_TRANSLATION_REVIEW_SWIPE: TranslationReviewSwipeBinding = Object.freeze({\n start: Object.freeze([\"edit\"] as const),\n end: Object.freeze([\"approve\"] as const),\n});\n\n/** The ladders one row actually offers, from a {@link TranslationReviewSwipeBinding}. */\nexport interface TranslationReviewSwipePlan {\n start: TranslationReviewSwipeAction[];\n end: TranslationReviewSwipeAction[];\n}\n\n/**\n * The binding resolved against one row — keksdose's `resolveSwipePlan`, for this list.\n * An id the row cannot offer DROPS OUT and the ladder closes up behind it, so the second\n * stage becomes the first rather than the side going dead behind an unreachable first\n * stage (or arming a threshold that does nothing):\n *\n * - `approve`: not on a missing string (nothing to approve) or an approved one (it would\n * say it again) — the rows that have no approve button either;\n * - `clear`: not without `canClear` (the app passed no `onClear`), and not on a row with\n * no verdict to take back (unreviewed; missing with no suggestion);\n * - `edit`: on every row;\n * - an id named twice on one side: the second time (a longer drag to the same effect);\n * - an id this version does not know (a binding stored by another version, an app's own\n * \"none\" slot): dropped like one that does not apply.\n *\n * Only what the ROW decides. Whether the panel may write at all — `readOnly`, no\n * `onSave`, a write lock, the row's own write still out — is the panel's, and then no\n * side swipes.\n */\nexport function translationReviewSwipePlan(\n binding: TranslationReviewSwipeBinding,\n row: TranslationRow,\n { canClear = false }: { canClear?: boolean } = {},\n): TranslationReviewSwipePlan {\n const offers = (id: TranslationReviewSwipeAction): boolean => {\n switch (id) {\n case \"approve\":\n return row.text !== \"\" && row.status !== \"approved\";\n case \"edit\":\n return true;\n case \"clear\":\n return canClear && row.review !== null;\n default:\n return false;\n }\n };\n const side = (ids: readonly TranslationReviewSwipeAction[] | undefined): TranslationReviewSwipeAction[] => {\n const out: TranslationReviewSwipeAction[] = [];\n for (const id of ids ?? []) {\n if (!out.includes(id) && offers(id)) out.push(id);\n }\n return out;\n };\n return { start: side(binding.start), end: side(binding.end) };\n}\n\n// ── Export ──────────────────────────────────────────────────────────────────────\n\n/**\n * One entry of the \"Export corrections\" file — enough for a developer to apply the change\n * to the bundle without opening the page. snake_case `reviewed_at` on purpose: this is a\n * FILE, the one keksdose and kastlan already write, beside an API spelled that way.\n */\nexport interface TranslationCorrection {\n locale: string;\n key: string;\n status: ReviewStatus;\n current: string;\n reference: string;\n suggestion: string | null;\n note: string | null;\n reviewer: string | null;\n reviewed_at: string | null;\n}\n\n/** The rows sent back (verdict NEEDS_CHANGE — a suggested translation for a missing\n * string included), as the export file lists them. */\nexport function translationCorrections(rows: readonly TranslationRow[]): TranslationCorrection[] {\n return rows\n .filter((row) => row.review?.verdict === \"NEEDS_CHANGE\")\n .map((row) => ({\n locale: row.locale,\n key: row.key,\n status: row.status,\n current: row.text,\n reference: row.reference,\n suggestion: row.review?.suggestion ?? null,\n note: row.review?.note ?? null,\n reviewer: row.review?.reviewerName ?? null,\n reviewed_at: row.review?.reviewedAt ?? null,\n }));\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/lib/translation-review.ts"],"sourcesContent":["/**\n * The pure half of an app's translation review (H1 in docs/i18n-harmonization.md): from\n * the strings the app ships and the verdicts its server stores to one row per (locale,\n * key) with a status — keksdose's `features/translations/translation-catalogue.ts` and\n * kastlan's copy of it, which had grown apart by a fifth status and a second kind of key.\n *\n * WHY THE STRINGS ARE THE CATALOGUE. Nothing on the server lists which strings exist: a\n * verdict only says what somebody thought of one. So the rows are built from what the app\n * ships — its locale bundles, kastlan's document texts, the kit's own words\n * (`kitLabelStrings`) — and the review can never offer a string users do not see. A\n * verdict is stored WITH the wording it was given on, and a string that changed since is\n * noticed by comparing the two: `changed`, read it again, whatever the verdict was.\n *\n * WHAT STAYS IN THE APP. Which locale is read against which (keksdose: everything against\n * English, English against de-CH; kastlan: against German, German against English), where\n * the strings come from, who may review what (the locales and areas its server answers\n * with), and every request. The kit takes those as input and never derives them.\n *\n * camelCase, like the rest of the kit. The contract's wire shape is snake_case\n * (`reference_text`, `reviewer_name`, `reviewed_at`); {@link fromApiReview} and\n * {@link toApiWrite} are the two lines each app would otherwise write — both apps speak\n * the same contract, so the mapping is written once, here.\n */\n\nimport { matchesSearch, searchWords } from \"./text-match\";\n\n// ── Shapes ──────────────────────────────────────────────────────────────────────\n\nexport type TranslationVerdict = \"APPROVED\" | \"NEEDS_CHANGE\";\n\n/** One stored verdict: what a reviewer said about one string in one locale. */\nexport interface TranslationReview {\n locale: string;\n key: string;\n /** The wording that was judged — compared with today's to notice a change. Empty for\n * a string the locale lacked (a suggested translation for a `missing` row). */\n text: string;\n /** The reference wording at the time. `null` from a server that did not store it. */\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n /** The reviewer's wording — or, for a missing string, the translation to add. */\n suggestion: string | null;\n /** `null` once the reviewer's account has been erased. */\n reviewerName: string | null;\n /** ISO timestamp. */\n reviewedAt: string;\n}\n\n/** What a save sends: the verdict plus the wording it is given on. */\nexport interface TranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** What a reset sends: back to \"unreviewed\". */\nexport interface TranslationReviewKey {\n locale: string;\n key: string;\n}\n\n/**\n * Where a string stands, in the order a reviewer works through them: what is not there\n * at all, what nobody has read, what changed after it was read, what was sent back, and\n * what is done. `missing` never occurs for an app whose locales have every key\n * (keksdose); kastlan's French lacks 172 screen texts.\n */\nexport type ReviewStatus = \"missing\" | \"unreviewed\" | \"changed\" | \"needs_change\" | \"approved\";\n\nexport const REVIEW_STATUSES: readonly ReviewStatus[] = [\n \"missing\",\n \"unreviewed\",\n \"changed\",\n \"needs_change\",\n \"approved\",\n];\n\nexport interface TranslationRow {\n /** `${locale}|${key}` — unique across every locale on a page. A bar, not a colon:\n * kastlan's keys contain colons (`common:actions.save`). */\n id: string;\n key: string;\n /** The key's group: `budget` for `budget.rta`, `common` for `common:actions.save`,\n * `kit` for the kit's own words. See {@link keyNamespace}. */\n namespace: string;\n /** Where the string comes from, in the app's own words — kastlan's `screen` and\n * `documents`, keksdose's one source. `\"\"` when the app does not say. */\n source: string;\n locale: string;\n /** The wording users see today; empty when the locale lacks the string. */\n text: string;\n /** The same key in the reference locale. */\n reference: string;\n status: ReviewStatus;\n review: TranslationReview | null;\n /** The interpolations and tags differ from the reference's — `{{count}}` dropped, a\n * `<1>` link lost, a `{date}` renamed. A machine can check that much, so it does,\n * before a person reads. Never set on a missing string: it has nothing to lose. */\n placeholderMismatch: boolean;\n}\n\n// ── The wire contract ───────────────────────────────────────────────────────────\n\n/** A verdict as `GET /translations/reviews` returns it (H1's shared contract). Typed\n * structurally, so an app's generated OpenAPI type fits without a cast. */\nexport interface ApiTranslationReview {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n reviewer_name: string | null;\n /** server-kit 0.10: the name's two parts (`reviewer_name` is them in the reader's order). */\n reviewer_first_name?: string | null;\n reviewer_last_name?: string | null;\n reviewed_at: string;\n}\n\n/** `GET /translations/reviews` (server-kit `TranslationReviewsResponse`): the locales and\n * areas the caller may review (`areas` null: every area; `[\"kit\"]` for a review token)\n * and every stored verdict in them. Typed structurally, as {@link ApiTranslationReview}. */\nexport interface ApiTranslationReviewsResponse {\n locales: string[];\n areas?: string[] | null;\n reviews: ApiTranslationReview[];\n}\n\n/** One item of `PUT /translations/reviews`. */\nexport interface ApiTranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** A verdict from the server, in the kit's spelling. */\nexport function fromApiReview(review: ApiTranslationReview): TranslationReview {\n return {\n locale: review.locale,\n key: review.key,\n text: review.text,\n referenceText: review.reference_text,\n verdict: review.verdict,\n note: review.note,\n suggestion: review.suggestion,\n reviewerName: review.reviewer_name,\n reviewedAt: review.reviewed_at,\n };\n}\n\n/** A save, in the server's spelling. `TranslationReviewKey` needs no mapping — it is\n * `{ locale, key }` on both sides. */\nexport function toApiWrite(write: TranslationReviewWrite): ApiTranslationReviewWrite {\n return {\n locale: write.locale,\n key: write.key,\n text: write.text,\n reference_text: write.referenceText,\n verdict: write.verdict,\n note: write.note,\n suggestion: write.suggestion,\n };\n}\n\n// ── Strings ─────────────────────────────────────────────────────────────────────\n\n/**\n * A nested bundle as `key → string`, in the bundle's own order. Non-string leaves are\n * skipped rather than stringified into something nobody wrote.\n *\n * `prefix` is put in front of every key AS IS, so it names the separator too:\n * `flattenStrings(bundle)` is keksdose's `budget.rta`, `flattenStrings(tree, \"common:\")`\n * kastlan's `common:actions.save`, and `flattenStrings(kitLabelStrings(labels), \"kit.\")`\n * the kit's own words as `kit.combobox.resultCount(3)` — already flat, so only the prefix\n * is added.\n */\nexport function flattenStrings(tree: Readonly<Record<string, unknown>>, prefix = \"\"): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(tree)) {\n const path = `${prefix}${key}`;\n if (typeof value === \"string\") out[path] = value;\n else if (value && typeof value === \"object\" && !Array.isArray(value)) {\n Object.assign(out, flattenStrings(value as Record<string, unknown>, `${path}.`));\n }\n }\n return out;\n}\n\n/**\n * The key's group, for the namespace filter: the part before a colon when there is one\n * (kastlan's `common:actions.save` → `common`, `doc_text.invoice:due` →\n * `doc_text.invoice`), else the first dotted segment (keksdose's `budget.rta` →\n * `budget`, the kit's `kit.combobox.resultCount(3)` → `kit`). Only the part before a\n * sample's `(` is looked at, so a colon inside one cannot move the split.\n */\nexport function keyNamespace(key: string): string {\n const head = key.split(\"(\", 1)[0];\n const colon = head.indexOf(\":\");\n return colon >= 0 ? head.slice(0, colon) : head.split(\".\", 1)[0];\n}\n\n// i18next `{{x}}` (formatting options ignored: `{{x, number}}` is `{{x}}`); a Python\n// format field `{x}` / `{0}` / `{amount:.2f}` (kastlan's document texts), but not a\n// LaTeX argument (`\\textbf{x}` — a letter or backslash right before the brace); a\n// numbered or named tag (`<1>`, `<strong>`), not prose in angle brackets (\"<deleted\n// user>\"); and a nested `$t(key)`. `{{x}}` is tried first at its position and consumed\n// whole, so it is never counted a second time as `{x}`.\nconst PLACEHOLDER_RE =\n /\\{\\{\\s*([^}\\s,]+)[^}]*\\}\\}|(?<![\\\\A-Za-z])\\{([a-z_][a-z0-9_]*|\\d+)(?:[!:][^{}]*)?\\}|<\\/?(\\d+|[a-z][a-z0-9]*)\\s*\\/?>|\\$t\\(([^)]+)\\)/g;\n\n/** The placeholders in a string, as a sorted multiset: `{{count}}`, `{date}`, `<1>`,\n * `$t(common.save)`. */\nexport function placeholderTokens(text: string): string[] {\n const found: string[] = [];\n for (const m of text.matchAll(PLACEHOLDER_RE)) {\n if (m[1]) found.push(`{{${m[1]}}}`);\n // `{ref:retention}` (ui-kit 0.28's legal cross-reference) keeps its target: a\n // translation that points at another section changed what the sentence says. Every\n // other `{x:spec}` is a format spec, which a translation may change.\n else if (m[2]) found.push(m[2] === \"ref\" ? m[0] : `{${m[2]}}`);\n else if (m[3]) found.push(`<${m[3]}>`);\n else if (m[4]) found.push(`$t(${m[4]})`);\n }\n return found.sort();\n}\n\n/** Whether `text` has other placeholders than `reference` — dropped, renamed or added.\n * Moving one is fine: a translation reorders a sentence. */\nexport function placeholderMismatch(text: string, reference: string): boolean {\n const a = placeholderTokens(text);\n const b = placeholderTokens(reference);\n return a.length !== b.length || a.some((p, i) => p !== b[i]);\n}\n\n// ── Status ──────────────────────────────────────────────────────────────────────\n\n/**\n * A verdict counts only for the wording it was given on. If the string or its reference\n * changed since, it is `changed` — read it again — whatever the verdict was. A string the\n * locale lacks is `missing` until somebody suggests its translation (stored as\n * NEEDS_CHANGE on the empty text, so it reads `needs_change`, and `changed` once a\n * developer adds the string).\n */\nexport function reviewStatus(\n review: TranslationReview | null | undefined,\n text: string,\n reference: string,\n): ReviewStatus {\n if (!review) return text === \"\" && reference !== \"\" ? \"missing\" : \"unreviewed\";\n if (review.text !== text) return \"changed\";\n if (review.referenceText != null && review.referenceText !== reference) return \"changed\";\n return review.verdict === \"APPROVED\" ? \"approved\" : \"needs_change\";\n}\n\n/**\n * Whether `key` lies in `area`: the key IS the area, or sits under it — `legal.terms`\n * and kastlan's `legal:terms` are in `legal`, `legalese.title` is not.\n *\n * 0.28: the kit's own words of that area too — `kit.legal.sections.terms.law.body(…)`\n * is in `legal`. All three apps list the kit's labels under `kit.`\n * (`flattenStrings(kitLabelStrings(labels), \"kit.\")`), and since 0.28 the kit writes\n * the shared legal sections, so a lawyer with a `[\"legal\"]` grant who was not shown\n * `kit.legal.*` would not see half of the Privacy Policy — and the server would refuse\n * the verdicts (docs/legal-harmonization.md §7.5). server-kit's `in_areas` and the apps'\n * copies of it apply the same rule.\n */\nexport function keyInArea(key: string, area: string): boolean {\n return key === area || key.startsWith(`${area}.`) || key.startsWith(`${area}:`) || key.startsWith(`kit.${area}.`);\n}\n\n/**\n * The area of `areas` that `key` lies in ({@link keyInArea}), or `undefined` — the\n * translation page's grouping by the same rule the filter and the server use, so\n * `kit.legal.sections.privacy.rights.lead` is grouped under `legal` with the app's own\n * legal texts instead of under `kit.legal`:\n *\n * const namespaceOf = (key: string) => reviewAreaOf(key, [\"legal\"]) ?? ownNamespaceOf(key);\n *\n * Where two areas both hold the key (`legal` and `legal.privacy`), the longer — the more\n * specific — wins, whatever their order. `inArea` is the app's own rule, as for\n * {@link keyInAreas}.\n */\nexport function reviewAreaOf(\n key: string,\n areas: readonly string[],\n inArea: (key: string, area: string) => boolean = keyInArea,\n): string | undefined {\n let found: string | undefined;\n for (const area of areas) {\n if (inArea(key, area) && (found === undefined || area.length > found.length)) found = area;\n }\n return found;\n}\n\n/**\n * Whether `key` lies in one of `areas` — the scope a reviewer may be limited to\n * (keksdose: `[\"legal\"]`, a lawyer who judges the Imprint, the Privacy Policy and the\n * Terms and sees nothing else). `null`/`undefined` is every area. The server applies the\n * same rule and refuses verdicts outside; this decides only what is listed.\n */\nexport function keyInAreas(\n key: string,\n areas: readonly string[] | null | undefined,\n inArea: (key: string, area: string) => boolean = keyInArea,\n): boolean {\n return !areas || areas.some((area) => inArea(key, area));\n}\n\nexport interface TranslationRowsInput {\n locale: string;\n /** The locale's strings, `key → text` ({@link flattenStrings}). */\n strings: Readonly<Record<string, string>>;\n /** The reference locale's strings — whichever locale THIS app reads `locale` against. */\n reference: Readonly<Record<string, string>>;\n /** The verdicts: this locale's by key, or the whole list as the server sent it (other\n * locales' verdicts are skipped). */\n reviews?: ReadonlyMap<string, TranslationReview> | readonly TranslationReview[];\n /** Default {@link keyNamespace}. */\n namespaceOf?: (key: string) => string;\n /** The row's source, an app-defined word (kastlan: `doc_text.` keys are `documents`,\n * the rest `screen`). Default: none (`\"\"`). */\n sourceOf?: (key: string) => string;\n /** Keep only keys inside these areas ({@link keyInAreas}); `null` keeps every key. */\n areas?: readonly string[] | null;\n /** Default {@link keyInArea}. */\n inArea?: (key: string, area: string) => boolean;\n}\n\n/**\n * Every key of one locale, with its status: the reference's keys first, in its order, so\n * rows read the way the app's authors wrote them; a key only this locale has is appended\n * rather than dropped (a parity test should forbid it, but the page should not lie if one\n * slips through).\n */\nexport function translationRows({\n locale,\n strings,\n reference,\n reviews,\n namespaceOf: groupOf = keyNamespace,\n sourceOf,\n areas,\n inArea,\n}: TranslationRowsInput): TranslationRow[] {\n const byKey = new Map<string, TranslationReview>();\n if (reviews instanceof Map) {\n for (const [key, review] of reviews) byKey.set(key, review);\n } else if (reviews) {\n for (const review of reviews as readonly TranslationReview[]) {\n if (review.locale === locale) byKey.set(review.key, review);\n }\n }\n const keys = Object.keys(reference);\n for (const key of Object.keys(strings)) if (!(key in reference)) keys.push(key);\n return keys\n .filter((key) => keyInAreas(key, areas, inArea))\n .map((key) => {\n const text = strings[key] ?? \"\";\n const ref = reference[key] ?? \"\";\n const review = byKey.get(key) ?? null;\n return {\n id: `${locale}|${key}`,\n key,\n namespace: groupOf(key),\n source: sourceOf?.(key) ?? \"\",\n locale,\n text,\n reference: ref,\n status: reviewStatus(review, text, ref),\n review,\n placeholderMismatch: text !== \"\" && placeholderMismatch(text, ref),\n };\n });\n}\n\n/** How many rows stand where, and how many there are. */\nexport type TranslationSummary = Record<ReviewStatus, number> & { total: number };\n\nexport function summariseRows(rows: readonly TranslationRow[]): TranslationSummary {\n const summary: TranslationSummary = {\n missing: 0,\n unreviewed: 0,\n changed: 0,\n needs_change: 0,\n approved: 0,\n total: rows.length,\n };\n for (const row of rows) summary[row.status] += 1;\n return summary;\n}\n\n// ── Filtering ───────────────────────────────────────────────────────────────────\n\n/** The panel's filters. `\"all\"` switches one off. */\nexport interface TranslationReviewFilter {\n status: ReviewStatus | \"all\";\n source: string;\n namespace: string;\n /** Searched in the key, the text and the reference, folded as every kit list's search\n * is (`foldForSearch`): no case, no accents — \"uberweisung\" finds \"Überweisung\". Every\n * word on its own (0.52): \"budget assign\" finds `budget.rta`, \"Ready to Assign\". */\n query: string;\n placeholdersOnly: boolean;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_FILTER: TranslationReviewFilter = {\n status: \"all\",\n source: \"all\",\n namespace: \"all\",\n query: \"\",\n placeholdersOnly: false,\n};\n\n/**\n * The rows the filters leave. `locale` (0.52) folds the query and the texts as the kit's\n * other lists do — the panel passes the provider's; left out, the runtime's default.\n */\nexport function filterTranslationRows(\n rows: readonly TranslationRow[],\n filter: Partial<TranslationReviewFilter>,\n locale?: string,\n): TranslationRow[] {\n const f = { ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...filter };\n // Folded as every kit list's search is (DUP-5, refactor audit 2026-10), and since 0.52\n // word by word, each anywhere in the key, the text or the reference (0.51.4's rule;\n // refactor audit 2026-10-30, DUP-4: the whole query had to be one substring of one).\n const words = searchWords(f.query, locale);\n return rows.filter(\n (row) =>\n (f.status === \"all\" || row.status === f.status) &&\n (f.source === \"all\" || row.source === f.source) &&\n (f.namespace === \"all\" || row.namespace === f.namespace) &&\n (!f.placeholdersOnly || row.placeholderMismatch) &&\n matchesSearch(words, [row.key, row.text, row.reference], locale),\n );\n}\n\n// ── Grouping ────────────────────────────────────────────────────────────────────\n\n/** One group of rows — an area, a source — and how they stand. */\nexport interface TranslationRowGroup {\n key: string;\n rows: TranslationRow[];\n summary: TranslationSummary;\n}\n\n/**\n * Rows in groups, each group where its first row is — the reference's order, so the areas\n * read the way the app's authors wrote the bundle — and each group's rows in their own\n * order. keksdose live #377: a reviewer on a phone works area by area (\"the budget\n * strings, then the legal ones\"), and a flat list of 3859 rows is not a way to see\n * where an area stands.\n *\n * `groupOf` names a row's group: `(row) => row.namespace` for the areas,\n * `(row) => row.source` for kastlan's screen texts and documents.\n */\nexport function groupTranslationRows(\n rows: readonly TranslationRow[],\n groupOf: (row: TranslationRow) => string,\n): TranslationRowGroup[] {\n const byKey = new Map<string, TranslationRow[]>();\n for (const row of rows) {\n const key = groupOf(row);\n const list = byKey.get(key);\n if (list) list.push(row);\n else byKey.set(key, [row]);\n }\n return [...byKey].map(([key, list]) => ({ key, rows: list, summary: summariseRows(list) }));\n}\n\n/**\n * The rows a group's \"Approve the unreviewed\" takes: the ones nobody has given a verdict\n * and that have a text. Not `changed` — a string that moved since it was judged asks to\n * be READ again, which a bulk action is the opposite of — and not `missing`, which has\n * nothing to approve.\n */\nexport function unreviewedRows(rows: readonly TranslationRow[]): TranslationRow[] {\n return rows.filter((row) => row.status === \"unreviewed\" && row.text !== \"\");\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────────\n\n/** What gets stored for a verdict: the wording it was given on, so a later change of the\n * string (or of its reference) shows up as `changed`. */\nexport function reviewWrite(\n row: TranslationRow,\n verdict: TranslationVerdict,\n note: string | null = null,\n suggestion: string | null = null,\n): TranslationReviewWrite {\n return {\n locale: row.locale,\n key: row.key,\n text: row.text,\n referenceText: row.reference,\n verdict,\n note,\n suggestion,\n };\n}\n\n/** What takes a set of verdicts back: see {@link reviewUndo}. */\nexport interface TranslationReviewUndo {\n /** The verdicts the rows had before, to store again — each on the wording it was\n * given on, so a row that was `changed` reads `changed` again. */\n writes: TranslationReviewWrite[];\n /** The rows that had no verdict before: back to unreviewed. */\n clears: TranslationReviewKey[];\n}\n\n/**\n * How to take back what was just said about `rows` — the rows AS THEY WERE before the\n * write, with the verdicts they carried then. The panel's Undo toast (0.25, keksdose live\n * #377) runs it: a swipe on a phone approves in one movement, and a movement can be a\n * mistake.\n *\n * A row that had a verdict gets it stored again (its note, its suggestion, the wording it\n * was judged on); one that had none is cleared. The server stamps the restored verdict\n * with the person undoing and the time, as it stamps every write — the contract has no\n * way to write a verdict in somebody else's name, and should not.\n */\nexport function reviewUndo(rows: readonly TranslationRow[]): TranslationReviewUndo {\n const writes: TranslationReviewWrite[] = [];\n const clears: TranslationReviewKey[] = [];\n for (const row of rows) {\n const before = row.review;\n if (!before) {\n clears.push({ locale: row.locale, key: row.key });\n continue;\n }\n writes.push({\n locale: row.locale,\n key: row.key,\n text: before.text,\n referenceText: before.referenceText,\n verdict: before.verdict,\n note: before.note,\n suggestion: before.suggestion,\n });\n }\n return { writes, clears };\n}\n\n/** The app's cached verdict list with `written` in place of what was said before — a\n * save answers with the rows it wrote, so a page patches its copy instead of\n * downloading every verdict again after each click. Any spelling with `locale` and\n * `key` (the API's or the kit's). */\nexport function mergeReviews<T extends TranslationReviewKey>(prev: readonly T[], written: readonly T[]): T[] {\n const id = (r: TranslationReviewKey) => `${r.locale}|${r.key}`;\n const replaced = new Set(written.map(id));\n return [...prev.filter((r) => !replaced.has(id(r))), ...written];\n}\n\n/** The cached list without the verdicts on `cleared`. */\nexport function dropReviews<T extends TranslationReviewKey>(\n prev: readonly T[],\n cleared: readonly TranslationReviewKey[],\n): T[] {\n const gone = new Set(cleared.map((r) => `${r.locale}|${r.key}`));\n return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));\n}\n\n/**\n * How many verdicts one request carries. server-kit refuses more than 2000 in one\n * `PUT /translations/reviews` or `POST /translations/reviews/clear` (`BATCH_MAX`, its\n * translation_review/schemas.py); half of it, as each of the three apps sent. A whole area\n * is rarely more than a few hundred, so most saves are one request; \"Approve the\n * unreviewed\" over an app's every string is not.\n */\nconst REVIEW_CHUNK = 1000;\n\n/**\n * A save of any size under server-kit's cap: `writes` in chunks of 1000, one request\n * after the other, in their order, and every verdict the server answered with, in that\n * order — to patch the page's cached list with {@link mergeReviews} once. `save` is the\n * app's own request (`PUT /translations/reviews`), answering with the verdicts it wrote,\n * in either spelling. No writes, no request: the server refuses an empty batch.\n *\n * A chunk that fails rejects with its error, and the chunks after it are not sent. What\n * the chunks before it wrote stays written — the server has it — but is not handed back,\n * so the page leaves its cache as it was and the panel shows the error; a second try\n * writes those verdicts again, which changes nothing but the time on them.\n *\n * All three apps wrote this loop, with a `chunks` helper and `SAVE_CHUNK = 1000` of\n * their own (kastlan reuse audit 2026-11-02, proposal 5):\n *\n * const written = await saveReviewsInChunks(writes.map(toApiWrite), (part) => translationsApi.save(part));\n * patchCache((reviews) => mergeReviews(reviews, written));\n */\nexport async function saveReviewsInChunks<W extends TranslationReviewKey, R extends TranslationReviewKey>(\n writes: readonly W[],\n save: (chunk: W[]) => Promise<readonly R[]>,\n): Promise<R[]> {\n const written: R[] = [];\n for (let at = 0; at < writes.length; at += REVIEW_CHUNK) {\n written.push(...(await save(writes.slice(at, at + REVIEW_CHUNK))));\n }\n return written;\n}\n\n/**\n * {@link saveReviewsInChunks} for a reset (`POST /translations/reviews/clear`): `keys` in\n * chunks of 1000, one request after the other, and the keys themselves once every chunk\n * is through — to drop from the cached list with {@link dropReviews} once. A failing\n * chunk rejects as a save's does, and what the chunks before it cleared stays cleared.\n */\nexport async function clearReviewsInChunks<K extends TranslationReviewKey>(\n keys: readonly K[],\n clear: (chunk: K[]) => Promise<unknown>,\n): Promise<K[]> {\n for (let at = 0; at < keys.length; at += REVIEW_CHUNK) await clear(keys.slice(at, at + REVIEW_CHUNK));\n return [...keys];\n}\n\n// ── Swipe bindings ──────────────────────────────────────────────────────────────\n\n/**\n * What a phone card's swipe can be bound to in `TranslationReviewPanel`'s `swipe`\n * (keksdose — Marcel's live #377 rework, 2026-10-03: *\"Add the swipe options to be shown\n * also to the settings /settings#interaction area where the other swipe options are\n * defined\"*). keksdose lets the user bind every list's swipes there — per side, a primary\n * action at the first threshold and a secondary one at a longer drag — and lists each\n * surface's ids in its `SWIPE_ACTIONS`; this is the translation list's, in the order a\n * settings page offers them.\n *\n * - `approve` — the string is right as it stands. With the panel's Undo toast.\n * - `edit` — open the editor with the cursor in the wording: \"Needs a change\" (or\n * \"Suggest translation\" on a missing string). Writes nothing; the editor does.\n * - `clear` — take the verdict back: unreviewed again. With the Undo toast, which stores\n * the verdict again.\n *\n * THERE IS NO SEPARATE \"needs a change\". The editor has no verdict to preselect: Approve\n * and \"Needs a change\" stand side by side in it, and \"Needs a change\" takes a note or a\n * different wording — a verdict without either tells whoever fixes the string nothing, so\n * a swipe never sends one blind (0.25). The one honest thing a \"needs a change\" swipe can\n * do is therefore open the editor with the cursor in the wording — which is `edit`, and\n * `edit` already says \"Needs a change\" on the card. A second id doing the same would arm\n * a second threshold that does what the first already did: the duplicate keksdose's\n * `resolveSwipePlan` drops for exactly that reason.\n */\nexport const TRANSLATION_REVIEW_SWIPE_ACTIONS = [\"approve\", \"edit\", \"clear\"] as const;\n\n/** One of {@link TRANSLATION_REVIEW_SWIPE_ACTIONS}. */\nexport type TranslationReviewSwipeAction = (typeof TRANSLATION_REVIEW_SWIPE_ACTIONS)[number];\n\n/**\n * Which swipe does what on a phone card — `TranslationReviewPanel`'s `swipe`. Each side is\n * an ordered ladder, exactly as `SwipeableRow` and DataTable's `mobileSwipeActions` model\n * one: index 0 commits at the first threshold, index 1 at the longer drag (and so on — the\n * thresholds spread evenly over the card's width). A side left out, or empty, does not\n * swipe.\n *\n * LOGICAL SIDES: `end` is a drag toward the reading end of the line — right in a\n * left-to-right page, left in a right-to-left one — and `start` the other way, so one\n * binding holds in both directions. An app whose settings store physical sides (keksdose's\n * `right`/`left`) hands `right` as `end` for a left-to-right page.\n */\nexport interface TranslationReviewSwipeBinding {\n /** A drag toward the reading START, nearest threshold first. */\n start?: readonly TranslationReviewSwipeAction[];\n /** A drag toward the reading END, nearest threshold first. */\n end?: readonly TranslationReviewSwipeAction[];\n}\n\n/** What `swipe={true}` means — 0.25's fixed mapping: approve toward the end, open the\n * editor (\"Needs a change\") toward the start. A settings page's \"reset\" goes back here. */\nexport const DEFAULT_TRANSLATION_REVIEW_SWIPE: TranslationReviewSwipeBinding = Object.freeze({\n start: Object.freeze([\"edit\"] as const),\n end: Object.freeze([\"approve\"] as const),\n});\n\n/** The ladders one row actually offers, from a {@link TranslationReviewSwipeBinding}. */\nexport interface TranslationReviewSwipePlan {\n start: TranslationReviewSwipeAction[];\n end: TranslationReviewSwipeAction[];\n}\n\n/**\n * The binding resolved against one row — keksdose's `resolveSwipePlan`, for this list.\n * An id the row cannot offer DROPS OUT and the ladder closes up behind it, so the second\n * stage becomes the first rather than the side going dead behind an unreachable first\n * stage (or arming a threshold that does nothing):\n *\n * - `approve`: not on a missing string (nothing to approve) or an approved one (it would\n * say it again) — the rows that have no approve button either;\n * - `clear`: not without `canClear` (the app passed no `onClear`), and not on a row with\n * no verdict to take back (unreviewed; missing with no suggestion);\n * - `edit`: on every row;\n * - an id named twice on one side: the second time (a longer drag to the same effect);\n * - an id this version does not know (a binding stored by another version, an app's own\n * \"none\" slot): dropped like one that does not apply.\n *\n * Only what the ROW decides. Whether the panel may write at all — `readOnly`, no\n * `onSave`, a write lock, the row's own write still out — is the panel's, and then no\n * side swipes.\n */\nexport function translationReviewSwipePlan(\n binding: TranslationReviewSwipeBinding,\n row: TranslationRow,\n { canClear = false }: { canClear?: boolean } = {},\n): TranslationReviewSwipePlan {\n const offers = (id: TranslationReviewSwipeAction): boolean => {\n switch (id) {\n case \"approve\":\n return row.text !== \"\" && row.status !== \"approved\";\n case \"edit\":\n return true;\n case \"clear\":\n return canClear && row.review !== null;\n default:\n return false;\n }\n };\n const side = (ids: readonly TranslationReviewSwipeAction[] | undefined): TranslationReviewSwipeAction[] => {\n const out: TranslationReviewSwipeAction[] = [];\n for (const id of ids ?? []) {\n if (!out.includes(id) && offers(id)) out.push(id);\n }\n return out;\n };\n return { start: side(binding.start), end: side(binding.end) };\n}\n\n// ── Export ──────────────────────────────────────────────────────────────────────\n\n/**\n * One entry of the \"Export corrections\" file — enough for a developer to apply the change\n * to the bundle without opening the page. snake_case `reviewed_at` on purpose: this is a\n * FILE, the one keksdose and kastlan already write, beside an API spelled that way.\n */\nexport interface TranslationCorrection {\n locale: string;\n key: string;\n status: ReviewStatus;\n current: string;\n reference: string;\n suggestion: string | null;\n note: string | null;\n reviewer: string | null;\n reviewed_at: string | null;\n}\n\n/** The rows sent back (verdict NEEDS_CHANGE — a suggested translation for a missing\n * string included), as the export file lists them. */\nexport function translationCorrections(rows: readonly TranslationRow[]): TranslationCorrection[] {\n return rows\n .filter((row) => row.review?.verdict === \"NEEDS_CHANGE\")\n .map((row) => ({\n locale: row.locale,\n key: row.key,\n status: row.status,\n current: row.text,\n reference: row.reference,\n suggestion: row.review?.suggestion ?? null,\n note: row.review?.note ?? null,\n reviewer: row.review?.reviewerName ?? null,\n reviewed_at: row.review?.reviewedAt ?? null,\n }));\n}\n"],"mappings":"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","names":[]}
@@ -282,8 +282,8 @@ export type RhfDateRangePickerProps<TFieldValues extends FieldValues = FieldValu
282
282
  *
283
283
  * Without `apply` (the picker's default) the first calendar click is reported on its
284
284
  * own, so the form holds `(from, emptyValue)` until the second; with `apply` nothing
285
- * reaches the form until Apply. `apply` was `commit="apply"` until 0.52 (deprecated, gone
286
- * in 0.53): `commit` is now the write lock's, as on every other binding — with
285
+ * reaches the form until Apply. `apply` was `commit="apply"` until 0.52 (gone in 0.53):
286
+ * `commit` is now the write lock's, as on every other binding — with
287
287
  * `disabledReason`, the picker's own (refactor audit 2026-10-30, API-3).
288
288
  *
289
289
  * The common props (`label`, `hint`, `required`, `disabled`, `excludeWhenDisabled`,