@eifi1/ui-kit 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/README.md +29 -14
  2. package/dist/admin/admin-action-log-labels.js +9 -0
  3. package/dist/admin/admin-action-log-labels.js.map +1 -1
  4. package/dist/admin/admin-action-log.d.ts +18 -1
  5. package/dist/admin/admin-action-log.js +25 -16
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/admin-area-labels.d.ts +15 -0
  8. package/dist/admin/admin-area-labels.js +6 -1
  9. package/dist/admin/admin-area-labels.js.map +1 -1
  10. package/dist/admin/admin-overview.d.ts +16 -1
  11. package/dist/admin/admin-overview.js +12 -1
  12. package/dist/admin/admin-overview.js.map +1 -1
  13. package/dist/admin/mail-test-card-labels.d.ts +3 -0
  14. package/dist/admin/mail-test-card-labels.js +17 -0
  15. package/dist/admin/mail-test-card-labels.js.map +1 -0
  16. package/dist/admin/mail-test-card.d.ts +75 -0
  17. package/dist/admin/mail-test-card.js +82 -0
  18. package/dist/admin/mail-test-card.js.map +1 -0
  19. package/dist/components/appearance-labels.d.ts +10 -0
  20. package/dist/components/appearance-labels.js +6 -1
  21. package/dist/components/appearance-labels.js.map +1 -1
  22. package/dist/components/data-table-column-settings.js +2 -2
  23. package/dist/components/data-table-column-settings.js.map +1 -1
  24. package/dist/components/data-table-head.js +2 -2
  25. package/dist/components/data-table-head.js.map +1 -1
  26. package/dist/components/data-table-mobile.js +17 -2
  27. package/dist/components/data-table-mobile.js.map +1 -1
  28. package/dist/components/data-table-row-actions.d.ts +18 -2
  29. package/dist/components/data-table-row-actions.js +86 -2
  30. package/dist/components/data-table-row-actions.js.map +1 -1
  31. package/dist/components/data-table-shared.d.ts +2 -0
  32. package/dist/components/data-table-shared.js +17 -2
  33. package/dist/components/data-table-shared.js.map +1 -1
  34. package/dist/components/data-table.d.ts +43 -2
  35. package/dist/components/data-table.js +10 -10
  36. package/dist/components/data-table.js.map +1 -1
  37. package/dist/components/date-format-setting.d.ts +29 -0
  38. package/dist/components/date-format-setting.js +36 -0
  39. package/dist/components/date-format-setting.js.map +1 -0
  40. package/dist/components/swipeable-row.d.ts +8 -0
  41. package/dist/components/swipeable-row.js.map +1 -1
  42. package/dist/components/tabs.d.ts +3 -2
  43. package/dist/components/tabs.js +1 -1
  44. package/dist/components/tabs.js.map +1 -1
  45. package/dist/components/translation-review-editor.js +1 -0
  46. package/dist/components/translation-review-editor.js.map +1 -1
  47. package/dist/components/translation-review-labels.d.ts +5 -0
  48. package/dist/components/translation-review-labels.js +2 -0
  49. package/dist/components/translation-review-labels.js.map +1 -1
  50. package/dist/components/translation-review.d.ts +18 -8
  51. package/dist/components/translation-review.js +64 -7
  52. package/dist/components/translation-review.js.map +1 -1
  53. package/dist/components/write-lock-labels.js +2 -1
  54. package/dist/components/write-lock-labels.js.map +1 -1
  55. package/dist/components/write-lock.d.ts +33 -3
  56. package/dist/components/write-lock.js +9 -0
  57. package/dist/components/write-lock.js.map +1 -1
  58. package/dist/data-table.d.ts +1 -1
  59. package/dist/data-table.js.map +1 -1
  60. package/dist/eslint/index.js +1 -1
  61. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  62. package/dist/feedback/feedback-row-detail.js +17 -26
  63. package/dist/feedback/feedback-row-detail.js.map +1 -1
  64. package/dist/feedback/feedback-swipe.d.ts +18 -0
  65. package/dist/feedback/feedback-swipe.js +10 -1
  66. package/dist/feedback/feedback-swipe.js.map +1 -1
  67. package/dist/i18n/defaults.js +9 -1
  68. package/dist/i18n/defaults.js.map +1 -1
  69. package/dist/i18n/german.js +81 -5
  70. package/dist/i18n/german.js.map +1 -1
  71. package/dist/i18n/kit-labels.d.ts +25 -1
  72. package/dist/i18n/kit-labels.js +8 -2
  73. package/dist/i18n/kit-labels.js.map +1 -1
  74. package/dist/i18n/locales/en.js +97 -7
  75. package/dist/i18n/locales/en.js.map +3 -3
  76. package/dist/i18n/locales/es.js +81 -5
  77. package/dist/i18n/locales/es.js.map +1 -1
  78. package/dist/i18n/locales/fr.js +81 -5
  79. package/dist/i18n/locales/fr.js.map +1 -1
  80. package/dist/i18n/locales/hu.js +81 -5
  81. package/dist/i18n/locales/hu.js.map +1 -1
  82. package/dist/i18n/locales/it.js +81 -5
  83. package/dist/i18n/locales/it.js.map +1 -1
  84. package/dist/i18n/locales/zh.js +81 -5
  85. package/dist/i18n/locales/zh.js.map +1 -1
  86. package/dist/i18n/review.js +16 -2
  87. package/dist/i18n/review.js.map +1 -1
  88. package/dist/index.d.ts +17 -2
  89. package/dist/index.js +40 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/date-format.d.ts +81 -0
  92. package/dist/lib/date-format.js +56 -0
  93. package/dist/lib/date-format.js.map +1 -0
  94. package/dist/lib/format.d.ts +5 -1
  95. package/dist/lib/format.js +11 -5
  96. package/dist/lib/format.js.map +1 -1
  97. package/dist/notifications/device-zone-report.d.ts +27 -0
  98. package/dist/notifications/device-zone-report.js +24 -0
  99. package/dist/notifications/device-zone-report.js.map +1 -0
  100. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  101. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  102. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  103. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  104. package/dist/notifications/feedback-notification-types.js +2 -1
  105. package/dist/notifications/feedback-notification-types.js.map +1 -1
  106. package/dist/notifications/notification-bell.d.ts +33 -0
  107. package/dist/notifications/notification-bell.js +74 -0
  108. package/dist/notifications/notification-bell.js.map +1 -0
  109. package/dist/notifications/notification-inbox.d.ts +77 -0
  110. package/dist/notifications/notification-inbox.js +206 -0
  111. package/dist/notifications/notification-inbox.js.map +1 -0
  112. package/dist/notifications/notification-types-setting.d.ts +6 -4
  113. package/dist/notifications/notification-types-setting.js +103 -45
  114. package/dist/notifications/notification-types-setting.js.map +1 -1
  115. package/dist/notifications/notification-types.d.ts +19 -7
  116. package/dist/notifications/notifications-labels.d.ts +41 -7
  117. package/dist/notifications/notifications-labels.js +18 -1
  118. package/dist/notifications/notifications-labels.js.map +1 -1
  119. package/dist/notifications/push-devices-setting.d.ts +20 -3
  120. package/dist/notifications/push-devices-setting.js +93 -33
  121. package/dist/notifications/push-devices-setting.js.map +1 -1
  122. package/dist/notifications/push-worker.d.ts +108 -0
  123. package/dist/notifications/push-worker.js +62 -0
  124. package/dist/notifications/push-worker.js.map +1 -0
  125. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  126. package/dist/notifications/quiet-hours-setting.js +9 -41
  127. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  128. package/dist/notifications/time-zone-select.d.ts +5 -2
  129. package/dist/notifications/time-zone-select.js.map +1 -1
  130. package/dist/notifications/use-push-device.d.ts +4 -17
  131. package/dist/notifications/use-push-device.js +7 -15
  132. package/dist/notifications/use-push-device.js.map +1 -1
  133. package/dist/offline/local-first.d.ts +33 -0
  134. package/dist/offline/local-first.js +39 -0
  135. package/dist/offline/local-first.js.map +1 -0
  136. package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
  137. package/dist/offline/pwa-update-prompt-labels.js +9 -0
  138. package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
  139. package/dist/offline/pwa-update-prompt.d.ts +32 -0
  140. package/dist/offline/pwa-update-prompt.js +36 -0
  141. package/dist/offline/pwa-update-prompt.js.map +1 -0
  142. package/dist/offline/query-persistence.d.ts +125 -0
  143. package/dist/offline/query-persistence.js +183 -0
  144. package/dist/offline/query-persistence.js.map +1 -0
  145. package/dist/offline/service-worker-updates.d.ts +46 -0
  146. package/dist/offline/service-worker-updates.js +39 -0
  147. package/dist/offline/service-worker-updates.js.map +1 -0
  148. package/dist/offline/shared-computer-labels.d.ts +2 -0
  149. package/dist/offline/shared-computer-labels.js +9 -0
  150. package/dist/offline/shared-computer-labels.js.map +1 -0
  151. package/dist/offline/shared-computer.d.ts +44 -0
  152. package/dist/offline/shared-computer.js +51 -0
  153. package/dist/offline/shared-computer.js.map +1 -0
  154. package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
  155. package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
  156. package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
  157. package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
  158. package/dist/offline/sign-out-pending-dialog.js +66 -0
  159. package/dist/offline/sign-out-pending-dialog.js.map +1 -0
  160. package/dist/offline.d.ts +11 -0
  161. package/dist/offline.js +33 -0
  162. package/dist/offline.js.map +1 -0
  163. package/dist/push-worker.d.ts +2 -0
  164. package/dist/push-worker.js +14 -0
  165. package/dist/push-worker.js.map +1 -0
  166. package/dist/shell/account-menu-text-size.d.ts +33 -0
  167. package/dist/shell/account-menu-text-size.js +51 -0
  168. package/dist/shell/account-menu-text-size.js.map +1 -0
  169. package/dist/shell/admin-menu.d.ts +24 -0
  170. package/dist/shell/admin-menu.js +12 -3
  171. package/dist/shell/admin-menu.js.map +1 -1
  172. package/dist/shell/top-bar-actions.d.ts +6 -2
  173. package/dist/shell/top-bar-actions.js +1 -0
  174. package/dist/shell/top-bar-actions.js.map +1 -1
  175. package/dist/shell.d.ts +4 -0
  176. package/dist/shell.js +4 -0
  177. package/dist/shell.js.map +1 -1
  178. package/dist/sync/db.d.ts +76 -0
  179. package/dist/sync/db.js +24 -0
  180. package/dist/sync/db.js.map +1 -0
  181. package/dist/sync/engine.d.ts +124 -0
  182. package/dist/sync/engine.js +474 -0
  183. package/dist/sync/engine.js.map +1 -0
  184. package/dist/sync/hooks.d.ts +30 -0
  185. package/dist/sync/hooks.js +67 -0
  186. package/dist/sync/hooks.js.map +1 -0
  187. package/dist/sync/testing.d.ts +28 -0
  188. package/dist/sync/testing.js +41 -0
  189. package/dist/sync/testing.js.map +1 -0
  190. package/dist/sync/wire.d.ts +50 -0
  191. package/dist/sync/wire.js +2 -0
  192. package/dist/sync/wire.js.map +1 -0
  193. package/dist/sync.d.ts +7 -0
  194. package/dist/sync.js +22 -0
  195. package/dist/sync.js.map +1 -0
  196. package/dist/tour/tour-catalogue.d.ts +7 -1
  197. package/dist/tour/tour-catalogue.js.map +1 -1
  198. package/dist/tour/tour-labels.js +1 -0
  199. package/dist/tour/tour-labels.js.map +1 -1
  200. package/dist/tour/tour.d.ts +30 -0
  201. package/dist/tour/tour.js +54 -8
  202. package/dist/tour/tour.js.map +1 -1
  203. package/dist/tour/use-tour-progress.d.ts +99 -1
  204. package/dist/tour/use-tour-progress.js +228 -12
  205. package/dist/tour/use-tour-progress.js.map +1 -1
  206. package/dist/tour.d.ts +2 -0
  207. package/dist/tour.js +2 -0
  208. package/dist/tour.js.map +1 -1
  209. package/package.json +43 -1
  210. package/src/admin/admin-action-log-labels.ts +9 -0
  211. package/src/admin/admin-action-log.tsx +41 -5
  212. package/src/admin/admin-area-labels.ts +20 -0
  213. package/src/admin/admin-overview.tsx +37 -2
  214. package/src/admin/mail-test-card-labels.ts +19 -0
  215. package/src/admin/mail-test-card.tsx +190 -0
  216. package/src/components/appearance-labels.ts +16 -0
  217. package/src/components/data-table-column-settings.tsx +2 -2
  218. package/src/components/data-table-head.tsx +2 -2
  219. package/src/components/data-table-mobile.tsx +26 -3
  220. package/src/components/data-table-row-actions.tsx +118 -2
  221. package/src/components/data-table-shared.tsx +39 -3
  222. package/src/components/data-table.tsx +52 -21
  223. package/src/components/date-format-setting.tsx +64 -0
  224. package/src/components/swipeable-row.tsx +8 -0
  225. package/src/components/tabs.tsx +4 -3
  226. package/src/components/translation-review-editor.tsx +2 -0
  227. package/src/components/translation-review-labels.ts +7 -0
  228. package/src/components/translation-review.tsx +102 -14
  229. package/src/components/write-lock-labels.ts +1 -0
  230. package/src/components/write-lock.tsx +36 -3
  231. package/src/data-table.ts +1 -0
  232. package/src/feedback/feedback-row-detail.tsx +18 -23
  233. package/src/feedback/feedback-swipe.tsx +28 -1
  234. package/src/i18n/defaults.ts +8 -0
  235. package/src/i18n/german.ts +90 -4
  236. package/src/i18n/kit-labels.tsx +31 -1
  237. package/src/i18n/locales/en.ts +13 -0
  238. package/src/i18n/locales/es.ts +90 -4
  239. package/src/i18n/locales/fr.ts +90 -4
  240. package/src/i18n/locales/hu.ts +86 -4
  241. package/src/i18n/locales/it.ts +90 -4
  242. package/src/i18n/locales/zh.ts +84 -2
  243. package/src/i18n/review.ts +15 -1
  244. package/src/index.ts +45 -0
  245. package/src/lib/date-format.ts +151 -0
  246. package/src/lib/format.ts +23 -6
  247. package/src/notifications/device-zone-report.ts +54 -0
  248. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  249. package/src/notifications/feedback-notification-types.ts +12 -5
  250. package/src/notifications/notification-bell.tsx +116 -0
  251. package/src/notifications/notification-inbox.tsx +304 -0
  252. package/src/notifications/notification-types-setting.tsx +151 -55
  253. package/src/notifications/notification-types.ts +20 -8
  254. package/src/notifications/notifications-labels.ts +69 -11
  255. package/src/notifications/push-devices-setting.tsx +123 -34
  256. package/src/notifications/push-worker.ts +160 -0
  257. package/src/notifications/quiet-hours-setting.tsx +41 -58
  258. package/src/notifications/time-zone-select.tsx +5 -2
  259. package/src/notifications/use-push-device.ts +20 -33
  260. package/src/offline/local-first.ts +76 -0
  261. package/src/offline/pwa-update-prompt-labels.ts +9 -0
  262. package/src/offline/pwa-update-prompt.tsx +72 -0
  263. package/src/offline/query-persistence.ts +356 -0
  264. package/src/offline/service-worker-updates.ts +81 -0
  265. package/src/offline/shared-computer-labels.ts +8 -0
  266. package/src/offline/shared-computer.tsx +87 -0
  267. package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
  268. package/src/offline/sign-out-pending-dialog.tsx +135 -0
  269. package/src/offline.ts +32 -0
  270. package/src/push-worker.ts +25 -0
  271. package/src/shell/account-menu-text-size.tsx +77 -0
  272. package/src/shell/admin-menu.tsx +40 -3
  273. package/src/shell/top-bar-actions.tsx +6 -1
  274. package/src/shell.ts +7 -0
  275. package/src/sync/db.ts +103 -0
  276. package/src/sync/engine.ts +699 -0
  277. package/src/sync/hooks.ts +93 -0
  278. package/src/sync/testing.ts +73 -0
  279. package/src/sync/wire.ts +61 -0
  280. package/src/sync.ts +29 -0
  281. package/src/tour/tour-catalogue.tsx +7 -1
  282. package/src/tour/tour-labels.ts +1 -0
  283. package/src/tour/tour.tsx +105 -9
  284. package/src/tour/use-tour-progress.ts +378 -12
  285. package/src/tour.ts +3 -0
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { useMediaQuery } from "../hooks/use-media-query";
4
4
  import { httpStatusOf } from "../auth/auth-errors";
5
+ import { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from "./push-worker";
6
+ import type { PushSubscriptionInput } from "./push-worker";
7
+
8
+ export { PUSH_SUBSCRIPTION_CHANGED };
9
+ export type { PushSubscriptionInput };
5
10
 
6
11
  /**
7
12
  * This browser's Web Push subscription for the signed-in account
@@ -34,13 +39,6 @@ export class PushDeviceError extends Error {
34
39
  }
35
40
  }
36
41
 
37
- /** What `register` sends: `POST /notifications/devices` (§3.5). */
38
- export interface PushSubscriptionInput {
39
- endpoint: string;
40
- keys: { p256dh: string; auth: string };
41
- user_agent: string | null;
42
- }
43
-
44
42
  export interface UsePushDeviceOptions {
45
43
  /** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
46
44
  pushKey: () => Promise<string>;
@@ -92,14 +90,15 @@ export interface PushDevice {
92
90
  sendTest: () => Promise<{ sent: number }>;
93
91
  }
94
92
 
95
- /**
96
- * The window event every mounted {@link usePushDevice} re-reads its state on. Fired by
97
- * every enable and disable, and by `PushDevicesSetting` when it removes this device — so
98
- * an app's own card on the same hook (keksdose's check-up) stays in step. The hook that
99
- * fires it does not re-read: it knows what it changed. Namespaced for the kit;
100
- * keksdose's was `"keksdose:push-subscription-changed"`.
93
+ /*
94
+ * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted
95
+ * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by
96
+ * `PushDevicesSetting` when it removes this device — so an app's own card on the same
97
+ * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it
98
+ * knows what it changed. Since 0.46 also the message a service worker posts after a
99
+ * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced
100
+ * for the kit; keksdose's was `"keksdose:push-subscription-changed"`.
101
101
  */
102
- export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
103
102
 
104
103
  /** Tell every {@link usePushDevice} that this browser's subscription changed. */
105
104
  export function notifyPushSubscriptionChanged(): void {
@@ -151,25 +150,6 @@ async function activeRegistration(): Promise<ServiceWorkerRegistration> {
151
150
  return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
152
151
  }
153
152
 
154
- /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
155
- function keyBytes(key: string): Uint8Array<ArrayBuffer> {
156
- const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
157
- const raw = atob(base64);
158
- const bytes = new Uint8Array(new ArrayBuffer(raw.length));
159
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
160
- return bytes;
161
- }
162
-
163
- /** A subscription's register body, or `null` when the browser handed back an incomplete
164
- * one. */
165
- function inputOf(subscription: PushSubscription): PushSubscriptionInput | null {
166
- const json = subscription.toJSON();
167
- const p256dh = json.keys?.p256dh;
168
- const auth = json.keys?.auth;
169
- if (!json.endpoint || !p256dh || !auth) return null;
170
- return { endpoint: json.endpoint, keys: { p256dh, auth }, user_agent: navigator.userAgent || null };
171
- }
172
-
173
153
  const defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;
174
154
 
175
155
  /**
@@ -289,11 +269,18 @@ export function usePushDevice(options: UsePushDeviceOptions): PushDevice {
289
269
  const heard = () => {
290
270
  if (!announcing.current) void sync();
291
271
  };
272
+ // The worker's word after it re-registered a rotated subscription (0.46).
273
+ const fromWorker = (event: MessageEvent) => {
274
+ if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();
275
+ };
276
+ const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;
292
277
  void sync();
293
278
  window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
279
+ worker?.addEventListener?.("message", fromWorker);
294
280
  return () => {
295
281
  cancelled = true;
296
282
  window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
283
+ worker?.removeEventListener?.("message", fromWorker);
297
284
  };
298
285
  }, [supported]);
299
286
 
@@ -0,0 +1,76 @@
1
+ import { isTransientRefreshFailure } from "../lib/session-refresh";
2
+
3
+ /*
4
+ * The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a
5
+ * page and a sync engine both need, with no store behind them. One module that both
6
+ * `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads
7
+ * `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports
8
+ * no package at all (packaging-contract.test.ts holds that).
9
+ */
10
+
11
+ /**
12
+ * An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past
13
+ * `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).
14
+ *
15
+ * `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock
16
+ * with the server's. A row the server just wrote carries the server's time, so an edit
17
+ * stamped by a clock a second slow would lose and come straight back. The millisecond is
18
+ * enough although the server keeps microseconds: `Date.parse` truncates them.
19
+ */
20
+ export function stampAfter(previous: unknown): string {
21
+ const now = Date.now();
22
+ const prior = typeof previous === "string" ? Date.parse(previous) : Number.NaN;
23
+ return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
24
+ }
25
+
26
+ /**
27
+ * The connection, not the server, failed: the request had no answer (an axios-like error
28
+ * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as
29
+ * passing because the server was out of reach ({@link isTransientRefreshFailure}). A real
30
+ * HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.
31
+ */
32
+ export function isOfflineError(err: unknown): boolean {
33
+ if (isTransientRefreshFailure(err)) return true;
34
+ if (typeof err !== "object" || err === null) return false;
35
+ const bag = err as { isAxiosError?: unknown; response?: unknown };
36
+ if (bag.isAxiosError === true || "response" in bag) return bag.response == null;
37
+ return err instanceof TypeError;
38
+ }
39
+
40
+ /**
41
+ * Read from the server, and from the device's copy only when the connection failed
42
+ * ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer
43
+ * before it is returned (to refresh the copy, say); a throw from it counts as a failure of
44
+ * the online path. The plain form of keksdose's net.ts:58, without its sealed-household
45
+ * and cold-start branches (those stay keksdose's).
46
+ */
47
+ export async function readLocalFirst<T>(
48
+ online: () => Promise<T>,
49
+ offline: () => Promise<T>,
50
+ onOnline?: (result: T) => void | Promise<void>,
51
+ ): Promise<T> {
52
+ try {
53
+ const result = await online();
54
+ await onOnline?.(result);
55
+ return result;
56
+ } catch (err) {
57
+ if (!isOfflineError(err)) throw err;
58
+ return offline();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Write through the server first (`rest`), and queue the change on the device (`queue`)
64
+ * only when the connection failed; any other failure throws, nothing queued. Online writes
65
+ * stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both
66
+ * paths, so a REST create that timed out after the server applied it is not created twice
67
+ * (D16).
68
+ */
69
+ export async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {
70
+ try {
71
+ return await rest();
72
+ } catch (err) {
73
+ if (!isOfflineError(err)) throw err;
74
+ return queue();
75
+ }
76
+ }
@@ -0,0 +1,9 @@
1
+ // The English words of ./pwa-update-prompt, in a module with no component code (0.34, KS U8).
2
+ // keksdose's reviewed words (pwa.updateAvailable, pwa.reload) are the canon.
3
+
4
+ import type { PwaUpdatePromptLabels } from "./pwa-update-prompt";
5
+
6
+ export const DEFAULT_PWA_UPDATE_PROMPT_LABELS: PwaUpdatePromptLabels = {
7
+ updateAvailable: "A new version is available.",
8
+ reload: "Reload",
9
+ };
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
3
+ import { toast } from "../components/toast";
4
+ import { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./pwa-update-prompt-labels";
5
+
6
+ /**
7
+ * "A new version is available. — Reload": keksdose's service-worker toast
8
+ * (app/pwa-update-prompt.tsx) as a kit component (docs/local-first-harmonization.md §1.5
9
+ * D8). The app keeps `useRegisterSW` (vite-plugin-pwa, `registerType: "prompt"`) and hands
10
+ * its state in: `needRefresh` from it, `onReload` = `updateServiceWorker(true)`,
11
+ * `onDismiss` = `setNeedRefresh(false)`.
12
+ *
13
+ * `hold` defers the prompt while a reload would hurt — a handover being signed, a tour
14
+ * running. Nothing shows while it holds; the toast appears once it drops. A hold that
15
+ * starts while the toast is up takes it down again until it drops.
16
+ *
17
+ * Renders nothing itself: the toast needs a mounted `<Toaster>`.
18
+ */
19
+ export interface PwaUpdatePromptLabels {
20
+ updateAvailable: string;
21
+ reload: string;
22
+ }
23
+
24
+ // Defined in ./pwa-update-prompt-labels, which holds no component code (0.34, KS U8).
25
+ export { DEFAULT_PWA_UPDATE_PROMPT_LABELS };
26
+
27
+ export interface PwaUpdatePromptProps {
28
+ /** A new service worker is waiting. */
29
+ needRefresh: boolean;
30
+ /** Activates it and reloads — `updateServiceWorker(true)`. */
31
+ onReload: () => void;
32
+ /** The toast was closed without reloading. */
33
+ onDismiss?: () => void;
34
+ /** Wait: a handover is open, a tour runs. */
35
+ hold?: boolean;
36
+ labels?: LabelOverride<PwaUpdatePromptLabels>;
37
+ }
38
+
39
+ /** The toast's id: one prompt at a time, replaced in place. */
40
+ const PWA_UPDATE_TOAST_ID = "pwa-update";
41
+
42
+ export function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold = false, labels: labelsProp }: PwaUpdatePromptProps) {
43
+ const labels = useKitLabels("pwaUpdate", DEFAULT_PWA_UPDATE_PROMPT_LABELS, labelsProp);
44
+ // The callbacks are read at click time, so a new function each render does not
45
+ // re-raise the toast.
46
+ const handlers = useRef({ onReload, onDismiss });
47
+ useEffect(() => {
48
+ handlers.current = { onReload, onDismiss };
49
+ });
50
+
51
+ const show = needRefresh && !hold;
52
+ useEffect(() => {
53
+ if (!show) return;
54
+ // sonner calls `onDismiss` for a programmatic dismiss too: the one this effect makes
55
+ // when a hold starts must not tell the app the person waved the update away.
56
+ let ours = false;
57
+ toast(labels.updateAvailable, {
58
+ id: PWA_UPDATE_TOAST_ID,
59
+ duration: Infinity,
60
+ action: { label: labels.reload, onClick: () => handlers.current.onReload() },
61
+ onDismiss: () => {
62
+ if (!ours) handlers.current.onDismiss?.();
63
+ },
64
+ });
65
+ return () => {
66
+ ours = true;
67
+ toast.dismiss(PWA_UPDATE_TOAST_ID);
68
+ };
69
+ }, [show, labels.updateAvailable, labels.reload]);
70
+
71
+ return null;
72
+ }
@@ -0,0 +1,356 @@
1
+ import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
2
+ import type { QueryClient } from "@tanstack/react-query";
3
+ import type { PersistedClient, PersistQueryClientOptions } from "@tanstack/react-query-persist-client";
4
+ import { del, get, keys, set } from "idb-keyval";
5
+
6
+ /**
7
+ * The persisted TanStack Query cache, keksdose's policy as a kit helper
8
+ * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's
9
+ * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the
10
+ * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps
11
+ * paused mutations. On top: a key per account (and company), a scrub list that removes
12
+ * secret fields from payloads that are otherwise wanted offline, and `removeStale` for
13
+ * another account's leftovers.
14
+ *
15
+ * The only module of the kit that imports TanStack and idb-keyval: both are optional peers
16
+ * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).
17
+ */
18
+
19
+ /** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */
20
+ export type ScrubField = string | RegExp;
21
+
22
+ /**
23
+ * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank
24
+ * account numbers and the handover protocol's signatures. A page restored offline shows
25
+ * such a field as missing ("Needs a connection").
26
+ */
27
+ export const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([
28
+ "iban",
29
+ "qr_iban",
30
+ "default_qr_iban",
31
+ /^signature_/,
32
+ ]);
33
+
34
+ /** The storage the persister writes to. The default is IndexedDB through idb-keyval. */
35
+ export interface QueryPersistenceStorage {
36
+ getItem: (key: string) => Promise<string | null | undefined>;
37
+ setItem: (key: string, value: string) => Promise<unknown>;
38
+ removeItem: (key: string) => Promise<unknown>;
39
+ /** Every key in the store — what `removeStale` walks. */
40
+ keys: () => Promise<readonly unknown[]>;
41
+ }
42
+
43
+ export interface QueryPersistenceOptions {
44
+ /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */
45
+ app: string;
46
+ /** The signed-in account. One cache per account (D5). */
47
+ user: string | number;
48
+ /** The acting company, where the app has several (kastlan): the switch changes the key,
49
+ * so no wipe and no cross-company leak — provided the app switches as
50
+ * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */
51
+ company?: string | number | null;
52
+ /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key
53
+ * whose second segment is `"admin"`. `false` keeps the query out. */
54
+ shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;
55
+ /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */
56
+ denyRoots?: readonly string[];
57
+ /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.
58
+ * A paused mutation whose variables carry one of these fields is never persisted (a
59
+ * scrubbed mutation would send something else), and one an older build wrote is dropped
60
+ * on restore. */
61
+ scrubFields?: readonly ScrubField[];
62
+ /** Told when a query's data is not plain JSON and is therefore not persisted. Default:
63
+ * `console.error`, as keksdose's `cacheSafeOrWarn`. */
64
+ onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;
65
+ /** Changes with each deploy (the app version), so a new build discards the old cache. */
66
+ buster: string;
67
+ /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's
68
+ * `gcTime` must be at least this, or restore drops every query. */
69
+ maxAge: number;
70
+ /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */
71
+ storage?: QueryPersistenceStorage;
72
+ /** Default 1000 ms, as keksdose. */
73
+ throttleTime?: number;
74
+ /**
75
+ * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`
76
+ * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).
77
+ * `false` keeps every mutation off the disk, and drops any an older build wrote on
78
+ * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie
79
+ * outbox), instead of overriding `dehydrateOptions` by hand.
80
+ */
81
+ persistMutations?: boolean;
82
+ }
83
+
84
+ /**
85
+ * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and
86
+ * ignores a changed `persistOptions`, so give it the key as its React key —
87
+ *
88
+ * ```tsx
89
+ * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>
90
+ * ```
91
+ *
92
+ * — and create the persistence once the account (and company) is known (`useMemo` on
93
+ * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call
94
+ * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare
95
+ * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next
96
+ * company's) cache under the old key.
97
+ */
98
+ export interface QueryPersistence {
99
+ /** `<app>-query-cache-<user>[-<company>]`. */
100
+ key: string;
101
+ /** Everything `<PersistQueryClientProvider persistOptions>` needs. */
102
+ persistOptions: Omit<PersistQueryClientOptions, "queryClient">;
103
+ /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this
104
+ * key's persisted copy. Best-effort; never throws. */
105
+ clear: (queryClient: QueryClient) => Promise<void>;
106
+ /** The company switch (or any change of key without a sign-out): stops writing under this
107
+ * key — a save already scheduled is dropped too — and empties the in-memory cache, so
108
+ * the next key's provider starts clean. The persisted copy stays for the way back. A
109
+ * provider that mounts this persistence again re-arms it with its restore. Never throws. */
110
+ leave: (queryClient: QueryClient) => Promise<void>;
111
+ /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another
112
+ * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */
113
+ removeStale: () => Promise<void>;
114
+ }
115
+
116
+ const idbStorage: QueryPersistenceStorage = {
117
+ getItem: (key) => get<string>(key),
118
+ setItem: (key, value) => set(key, value),
119
+ removeItem: (key) => del(key),
120
+ keys: () => keys(),
121
+ };
122
+
123
+ export interface RemoveStaleQueryCopiesOptions {
124
+ /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */
125
+ storage?: QueryPersistenceStorage;
126
+ /** One key to spare whatever its age — the signed-in account's own
127
+ * (`QueryPersistence.removeStale` passes its key). */
128
+ keep?: string;
129
+ }
130
+
131
+ /**
132
+ * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)
133
+ * older than `maxAge`, with no persistence at hand — for the sign-in page, where no
134
+ * account is known. A session that ended with no user known (a refresh token refused at
135
+ * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without
136
+ * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh
137
+ * caches stay; an unreadable one is stale. Never throws.
138
+ * {@link QueryPersistence.removeStale} is this with `keep` = its own key.
139
+ */
140
+ export async function removeStaleQueryCopies(
141
+ app: string,
142
+ maxAge: number,
143
+ { storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},
144
+ ): Promise<void> {
145
+ const prefix = `${app}-query-cache-`;
146
+ try {
147
+ const now = Date.now();
148
+ for (const k of await storage.keys()) {
149
+ if (typeof k !== "string" || !k.startsWith(prefix) || k === keep) continue;
150
+ let timestamp = 0;
151
+ try {
152
+ const raw = await storage.getItem(k);
153
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
154
+ if (parsed && typeof parsed === "object" && typeof (parsed as PersistedClient).timestamp === "number") {
155
+ timestamp = (parsed as PersistedClient).timestamp;
156
+ }
157
+ } catch {
158
+ /* unreadable: stale */
159
+ }
160
+ if (now - timestamp > maxAge) await storage.removeItem(k);
161
+ }
162
+ } catch {
163
+ /* best-effort */
164
+ }
165
+ }
166
+
167
+ /**
168
+ * True when `data` still means the same thing after JSON.stringify and JSON.parse —
169
+ * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a
170
+ * non-finite number, a function: all refused. An `undefined` property survives.
171
+ */
172
+ export function isCacheSafe(data: unknown, depth = 0): boolean {
173
+ if (depth > 12) return false;
174
+ if (data === null) return true;
175
+ const t = typeof data;
176
+ if (t === "string" || t === "boolean") return true;
177
+ if (t === "number") return Number.isFinite(data as number);
178
+ if (t !== "object") return false;
179
+ if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));
180
+ const proto: unknown = Object.getPrototypeOf(data);
181
+ if (proto !== Object.prototype && proto !== null) return false;
182
+ return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));
183
+ }
184
+
185
+ /** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */
186
+ function scrub(data: unknown, fields: readonly ScrubField[]): unknown {
187
+ if (fields.length === 0 || data === null || typeof data !== "object") return data;
188
+ if (Array.isArray(data)) return data.map((v) => scrub(v, fields));
189
+ const out: Record<string, unknown> = {};
190
+ for (const [name, value] of Object.entries(data as Record<string, unknown>)) {
191
+ if (fields.some((f) => (typeof f === "string" ? f === name : f.test(name)))) continue;
192
+ out[name] = scrub(value, fields);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ const defaultOnUnsafe = (queryKey: readonly unknown[]) => {
198
+ console.error(
199
+ `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +
200
+ `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +
201
+ `the rich object at the call site.`,
202
+ );
203
+ };
204
+
205
+ export function createQueryPersistence({
206
+ app,
207
+ user,
208
+ company,
209
+ shouldPersistKey,
210
+ denyRoots = [],
211
+ scrubFields = DEFAULT_SCRUB_FIELDS,
212
+ onUnsafe = defaultOnUnsafe,
213
+ buster,
214
+ maxAge,
215
+ storage = idbStorage,
216
+ throttleTime = 1000,
217
+ persistMutations = true,
218
+ }: QueryPersistenceOptions): QueryPersistence {
219
+ const prefix = `${app}-query-cache-`;
220
+ const key = `${prefix}${user}${company === undefined || company === null || company === "" ? "" : `-${company}`}`;
221
+ const denied = new Set(denyRoots);
222
+ // Set by `leave` and `clear`: a write under this key after the person left it (a
223
+ // throttled save of the emptied cache) is dropped. A restore re-arms it.
224
+ let retired = false;
225
+
226
+ const keyAllowed = (queryKey: readonly unknown[]) =>
227
+ !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);
228
+
229
+ /** The write side: a success, an allowed key, plain JSON (or the warning). */
230
+ const shouldDehydrateQuery = (query: {
231
+ queryKey: readonly unknown[];
232
+ state: { status: string; data?: unknown };
233
+ }): boolean => {
234
+ if (query.state.status !== "success" || !keyAllowed(query.queryKey)) return false;
235
+ if (isCacheSafe(query.state.data)) return true;
236
+ onUnsafe(query.queryKey, query.state.data);
237
+ return false;
238
+ };
239
+
240
+ /** One persisted query entry, scrubbed; `null` when it may not be restored. */
241
+ const restorable = (entry: unknown): unknown => {
242
+ if (!entry || typeof entry !== "object") return null;
243
+ const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };
244
+ if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;
245
+ if (!isCacheSafe(state?.data)) return null;
246
+ return withScrubbedData(entry);
247
+ };
248
+
249
+ /** Whether `value` has a scrubbed field's name anywhere in it. */
250
+ const carriesScrubbed = (value: unknown, depth = 0): boolean => {
251
+ if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== "object") return false;
252
+ if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));
253
+ return Object.entries(value as Record<string, unknown>).some(
254
+ ([name, v]) =>
255
+ scrubFields.some((f) => (typeof f === "string" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),
256
+ );
257
+ };
258
+
259
+ /** The write side for mutations: TanStack's default (paused ones), minus any whose
260
+ * variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */
261
+ const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>
262
+ persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
263
+
264
+ const withScrubbedData = (entry: unknown): unknown => {
265
+ const query = entry as { state?: { data?: unknown } };
266
+ if (!query.state || !("data" in query.state)) return entry;
267
+ return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };
268
+ };
269
+
270
+ // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open
271
+ // shows it), the disk never sees it. Mutations are a paused write queue and are kept
272
+ // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is
273
+ // not written at all (`shouldDehydrateMutation`).
274
+ const serialize = (client: PersistedClient): string =>
275
+ JSON.stringify({
276
+ ...client,
277
+ clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },
278
+ });
279
+
280
+ // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build
281
+ // wrote what this one may deny. Never throws; an unreadable blob comes back with
282
+ // `timestamp: 0`, which the restore treats as unusable and removes.
283
+ const deserialize = (raw: string): PersistedClient => {
284
+ const empty: PersistedClient = { timestamp: 0, buster: "", clientState: { mutations: [], queries: [] } };
285
+ try {
286
+ const parsed: unknown = JSON.parse(raw);
287
+ if (!parsed || typeof parsed !== "object") return empty;
288
+ const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;
289
+ if (typeof timestamp !== "number" || typeof stored !== "string") return empty;
290
+ if (!clientState || typeof clientState !== "object" || !Array.isArray(clientState.queries)) return empty;
291
+ return {
292
+ timestamp,
293
+ buster: stored,
294
+ clientState: {
295
+ mutations: persistMutations && Array.isArray(clientState.mutations)
296
+ ? clientState.mutations.filter(
297
+ (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),
298
+ )
299
+ : [],
300
+ queries: clientState.queries.map(restorable).filter((q) => q !== null) as PersistedClient["clientState"]["queries"],
301
+ },
302
+ };
303
+ } catch {
304
+ return empty;
305
+ }
306
+ };
307
+
308
+ const persister = createAsyncStoragePersister({
309
+ storage: {
310
+ // Only the provider's restore reads: mounting this persistence again re-arms it.
311
+ getItem: async (k) => {
312
+ retired = false;
313
+ return (await storage.getItem(k)) ?? null;
314
+ },
315
+ setItem: async (k, v) => {
316
+ if (!retired) await storage.setItem(k, v);
317
+ },
318
+ removeItem: async (k) => {
319
+ await storage.removeItem(k);
320
+ },
321
+ },
322
+ key,
323
+ throttleTime,
324
+ serialize,
325
+ deserialize,
326
+ });
327
+
328
+ const leave = async (queryClient: QueryClient) => {
329
+ retired = true;
330
+ try {
331
+ queryClient.clear();
332
+ } catch {
333
+ /* best-effort */
334
+ }
335
+ };
336
+
337
+ const clear = async (queryClient: QueryClient) => {
338
+ retired = true;
339
+ try {
340
+ queryClient.clear();
341
+ await persister.removeClient();
342
+ } catch {
343
+ /* best-effort: private mode / IndexedDB unavailable */
344
+ }
345
+ };
346
+
347
+ const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });
348
+
349
+ return {
350
+ key,
351
+ persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },
352
+ clear,
353
+ leave,
354
+ removeStale,
355
+ };
356
+ }