@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
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
2
  import { readStored, removeStored, writeStored } from "../lib/safe-storage";
3
+ import { httpStatusOf } from "../auth/auth-errors";
3
4
  import { isTourDone, type TourCompletions } from "./tour-info";
4
5
 
5
6
  /**
@@ -36,10 +37,80 @@ import { isTourDone, type TourCompletions } from "./tour-info";
36
37
  * and callbacks (`completed`, `TourCompletions`) give `null` for `""`. A `null` read from
37
38
  * storage (or returned by `migrate`) is accepted as the same thing and written back as
38
39
  * `""`. To write a completion from outside a page, use {@link completeTour}.
40
+ *
41
+ * **On the account** (0.45, §10.2–§10.3): with `account`, the device key becomes the
42
+ * account's CACHE — see {@link TourProgressAccount}. Without it, nothing changes from 0.44.
39
43
  */
40
44
  export interface UseTourProgressOptions {
41
45
  storageKey: string;
42
46
  migrate?: (raw: unknown) => Record<string, string | null> | undefined;
47
+ /**
48
+ * 0.45: the account's record behind the device key (§10.2.4). Remembered per key like
49
+ * `migrate` (the FIRST one any reader or the provider passes wins). With it,
50
+ * `storageKey` must be per account (a prefix plus the user id), so build the options per
51
+ * account and keep ONE stable object per key — memoise them in a `Map` by user id — and
52
+ * let `load`/`save` read the current token when they run, never capture one:
53
+ *
54
+ * const byUser = new Map<string, UseTourProgressOptions>();
55
+ * function tourProgressFor(user: { id: string; isDemo: boolean } | null) {
56
+ * if (!user || user.isDemo) return { storageKey: "app.tours.completions" }; // no account
57
+ * let options = byUser.get(user.id);
58
+ * if (!options) byUser.set(user.id, (options = { storageKey: `app.tours.completions:${user.id}`, account }));
59
+ * return options;
60
+ * }
61
+ *
62
+ * Signed out, and in a demo, pass none (and a fixed key): the device record works as in
63
+ * 0.44, and a demo neither PATCHes into a 403 nor leaves an orphan key per demo id.
64
+ */
65
+ account?: TourProgressAccount;
66
+ }
67
+
68
+ /** The record on the wire (§10.2.2): tour id → ISO instant, or `""` (done, no date). */
69
+ type TourProgressWire = Record<string, string>;
70
+
71
+ /** The `PATCH /tours/progress` body (§10.2.2): what to mark done, and what to forget.
72
+ * An id is never in both. Either may be left out. */
73
+ export interface TourProgressBody {
74
+ completed?: TourProgressWire;
75
+ forget?: string[];
76
+ }
77
+
78
+ /**
79
+ * Taking over a device-wide record from before the account (§10.2.5), on the FIRST load
80
+ * of the account key on this page:
81
+ * - `{storageKey, discard: true}` (kastlan): removes the old key and uploads nothing — a
82
+ * shared PC must not hand one person another's marks. Removing an absent key is a no-op.
83
+ * - `{storageKey, migrate?}`: reads the old key once (through `migrate` for an older
84
+ * shape), uploads it to the first account that signs in on this device, then removes it.
85
+ */
86
+ export type TourProgressAdopt =
87
+ | { storageKey: string; discard: true; migrate?: never }
88
+ | { storageKey: string; discard?: false; migrate?: UseTourProgressOptions["migrate"] };
89
+
90
+ /**
91
+ * The app's account record (§10.3): `load()` is its `GET /tours/progress`, `save(body)`
92
+ * its `PATCH`, each resolving to the parsed answer, `{completed}` — the whole record.
93
+ *
94
+ * - The page paints from the device key at once (and offline); the account is loaded on
95
+ * the first reader of the key on this page, on `online`, and after each save, and its
96
+ * answer replaces the cache.
97
+ * - A write (`complete`, the provider's finish, `reset`) goes to the cache at once and to
98
+ * a PENDING record beside it, `<storageKey>:pending` (`{synced, completed, forget}`),
99
+ * sent as one PATCH and cleared by its answer. A failure with no answer (offline, 5xx,
100
+ * 401) keeps it for the next load or `online`; a 402 (a lapsed plan) keeps it but sends it
101
+ * no more on this page (the next session or page load tries again); a 403 or 422 drops
102
+ * it and logs.
103
+ * - `awarded` stays the device's answer against the cache (§10.2.4).
104
+ * - The pending record is NOT the app's outbox: leave it out of any "N waiting" count
105
+ * and any sign-out guard (losing a tour mark is acceptable).
106
+ * - A device record never synced (no `:pending` with `synced: true`) is uploaded whole on
107
+ * its first load, by the server's merge rule (later completion wins).
108
+ * - On every end of a session call {@link signOutTourProgress}.
109
+ */
110
+ export interface TourProgressAccount {
111
+ load: () => Promise<{ completed: TourProgressWire }>;
112
+ save: (body: TourProgressBody) => Promise<{ completed: TourProgressWire }>;
113
+ adopt?: TourProgressAdopt;
43
114
  }
44
115
 
45
116
  /** What `complete` takes: a tour id, or the tour (`{id, updated}`, a `TourInfo` fits). */
@@ -185,6 +256,275 @@ function write(key: string, record: TourCompletions) {
185
256
  emit(key);
186
257
  }
187
258
 
259
+ /* ── The account behind the device key (0.45, §10.2.4–§10.2.8) ─────────────── */
260
+
261
+ /** The key's account: the first one any reader or the provider passes (as `migrate`). */
262
+ const accounts = new Map<string, TourProgressAccount>();
263
+ /** Keys whose account was loaded (or is loading) on this page: the first reader loads. */
264
+ const loaded = new Set<string>();
265
+ /** Keys with a sync running, and those asked for another while it ran. */
266
+ const running = new Set<string>();
267
+ const rerun = new Set<string>();
268
+ /** Keys with a sync queued for the end of this task: two writes in one handler, one PATCH. */
269
+ const queued = new Set<string>();
270
+ /** Bumped by a sign-out: an answer that comes back after it never refills the cache. */
271
+ const epochs = new Map<string, number>();
272
+ /** Keys whose PATCH got a 402 (a lapsed plan): the pending record is kept but not sent
273
+ * again on this page; the next session (or page load) tries once more. */
274
+ const parked = new Set<string>();
275
+ /** The pending record where storage refused to keep it. */
276
+ const pendingMemory = new Map<string, string>();
277
+ let onlineListening = false;
278
+
279
+ /** The pending record (§10.2.4): what waits to be sent, and whether the device key was
280
+ * ever synced. */
281
+ interface PendingRecord {
282
+ synced: boolean;
283
+ completed: TourProgressWire;
284
+ forget: string[];
285
+ }
286
+
287
+ const pendingKey = (key: string) => `${key}:pending`;
288
+
289
+ function devWarn(message: string) {
290
+ if (import.meta.env?.DEV) console.warn(message);
291
+ }
292
+
293
+ /**
294
+ * Remember `account` for `key` if it is the first one, and return the key's account.
295
+ * @internal Not part of the barrel; `TourProvider` registers its `progress` with it.
296
+ */
297
+ export function rememberAccount(key: string, account: TourProgressAccount | undefined): TourProgressAccount | undefined {
298
+ if (account && !accounts.has(key)) {
299
+ accounts.set(key, account);
300
+ if (account.adopt && account.adopt.storageKey === key) {
301
+ devWarn(
302
+ `[ui-kit] tour progress: the account key "${key}" is the key \`adopt\` takes over. ` +
303
+ "With an account the key must be per account (a prefix plus the user id), apart from the old device-wide key.",
304
+ );
305
+ }
306
+ if (!onlineListening && typeof window !== "undefined") {
307
+ onlineListening = true;
308
+ window.addEventListener("online", () => {
309
+ for (const known of loaded) void sync(known);
310
+ });
311
+ }
312
+ }
313
+ return accounts.get(key);
314
+ }
315
+
316
+ function readPending(key: string): PendingRecord | null {
317
+ const raw = pendingMemory.get(key) ?? readStored(pendingKey(key));
318
+ if (raw === null) return null;
319
+ try {
320
+ const value = JSON.parse(raw) as Partial<PendingRecord> | null;
321
+ if (!value || typeof value !== "object") return null;
322
+ const completed: TourProgressWire = {};
323
+ for (const [id, at] of Object.entries(value.completed ?? {})) {
324
+ if (typeof at === "string") completed[id] = at;
325
+ else if (at === null) completed[id] = "";
326
+ }
327
+ const forget = Array.isArray(value.forget) ? value.forget.filter((id): id is string => typeof id === "string") : [];
328
+ return { synced: value.synced === true, completed, forget };
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
334
+ function writePending(key: string, record: PendingRecord) {
335
+ const raw = JSON.stringify(record);
336
+ writeStored(pendingKey(key), raw);
337
+ if (readStored(pendingKey(key)) === raw) pendingMemory.delete(key);
338
+ else pendingMemory.set(key, raw);
339
+ }
340
+
341
+ const hasWork = (record: PendingRecord) => Object.keys(record.completed).length > 0 || record.forget.length > 0;
342
+
343
+ /** "Later completion wins" (§10.2.3), as the server merges: an instant beats `""`, `""`
344
+ * beats nothing, and between two instants the later is kept. */
345
+ function later(a: string | undefined, b: string): string {
346
+ if (a === undefined || a === "") return b;
347
+ if (b === "") return a;
348
+ return Date.parse(b) > Date.parse(a) ? b : a;
349
+ }
350
+
351
+ /** Note a completion (or, with `forget`, its removal) in the key's pending record. */
352
+ function markPending(key: string, id: string, at: string | null | undefined, forget = false) {
353
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
354
+ if (forget) {
355
+ delete record.completed[id];
356
+ if (!record.forget.includes(id)) record.forget.push(id);
357
+ } else {
358
+ record.completed[id] = at ?? "";
359
+ record.forget = record.forget.filter((other) => other !== id);
360
+ }
361
+ writePending(key, record);
362
+ }
363
+
364
+ /** After an answer (or a 403/422): take what was sent out of the pending record, keeping
365
+ * what was written while the request ran. */
366
+ function clearSent(key: string, sent: TourProgressBody, synced: boolean) {
367
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
368
+ for (const [id, at] of Object.entries(sent.completed ?? {})) {
369
+ if (record.completed[id] === at) delete record.completed[id];
370
+ }
371
+ const forgotten = new Set(sent.forget ?? []);
372
+ record.forget = record.forget.filter((id) => !forgotten.has(id));
373
+ record.synced = record.synced || synced;
374
+ writePending(key, record);
375
+ }
376
+
377
+ /** The account's answer as the cache, with what still waits to be sent laid over it. */
378
+ function refill(key: string, answer: { completed: TourProgressWire } | null | undefined) {
379
+ const next: Record<string, string> = {};
380
+ for (const [id, at] of Object.entries(fromRecord(answer?.completed) ?? {})) next[id] = at ?? "";
381
+ const waiting = readPending(key);
382
+ if (waiting) {
383
+ for (const [id, at] of Object.entries(waiting.completed)) next[id] = later(next[id], at);
384
+ for (const id of waiting.forget) delete next[id];
385
+ }
386
+ const record: Record<string, string | null> = {};
387
+ for (const [id, at] of Object.entries(next)) record[id] = at === "" ? null : at;
388
+ write(key, record);
389
+ }
390
+
391
+ /** The first load of an account key on this page takes over the old device-wide key
392
+ * (§10.2.5). */
393
+ function adoptOldRecord(key: string, account: TourProgressAccount) {
394
+ const adopt = account.adopt;
395
+ if (!adopt || adopt.storageKey === key) return;
396
+ const old = adopt.storageKey;
397
+ if (!adopt.discard) {
398
+ const { value } = parse(rawOf(old), adopt.migrate ?? migrates.get(old));
399
+ const entries = Object.entries(value);
400
+ if (entries.length > 0) {
401
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
402
+ for (const [id, at] of entries) record.completed[id] = later(record.completed[id], at ?? "");
403
+ writePending(key, record);
404
+ }
405
+ }
406
+ // Removed either way, at once: no second account on this device gets it.
407
+ removeStored(old);
408
+ memory.delete(old);
409
+ cache.delete(old);
410
+ emit(old);
411
+ }
412
+
413
+ /** A device record never synced is uploaded whole on its first load (§10.2.5). */
414
+ function uploadUnsynced(key: string) {
415
+ const record = readPending(key);
416
+ if (record?.synced) return;
417
+ const device = snapshot(key, migrates.get(key));
418
+ const entries = Object.entries(device);
419
+ const next = record ?? { synced: false, completed: {}, forget: [] };
420
+ if (entries.length === 0 && record) return;
421
+ for (const [id, at] of entries) {
422
+ if (!next.forget.includes(id)) next.completed[id] = later(next.completed[id], at ?? "");
423
+ }
424
+ writePending(key, next);
425
+ }
426
+
427
+ /** The key's first sync on this page: take over the old record, upload a never-synced one. */
428
+ function prepare(key: string) {
429
+ loaded.add(key);
430
+ adoptOldRecord(key, accounts.get(key)!);
431
+ uploadUnsynced(key);
432
+ }
433
+
434
+ /** Queue a sync for the end of this task, so a handler's writes go as one PATCH. */
435
+ function queueSync(key: string) {
436
+ if (!loaded.has(key)) prepare(key);
437
+ if (queued.has(key)) return;
438
+ queued.add(key);
439
+ queueMicrotask(() => {
440
+ queued.delete(key);
441
+ void sync(key);
442
+ });
443
+ }
444
+
445
+ /** The first reader of an account key on this page loads it. */
446
+ function ensureLoaded(key: string) {
447
+ if (!accounts.has(key) || loaded.has(key)) return;
448
+ prepare(key);
449
+ void sync(key);
450
+ }
451
+
452
+ /**
453
+ * Send what waits (one PATCH) or, with nothing waiting, load; the answer replaces the
454
+ * cache. One at a time per key; a request asked for while one runs follows it.
455
+ */
456
+ async function sync(key: string): Promise<void> {
457
+ const account = accounts.get(key);
458
+ if (!account || !loaded.has(key)) return;
459
+ if (running.has(key)) {
460
+ rerun.add(key);
461
+ return;
462
+ }
463
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
464
+ running.add(key);
465
+ const epoch = epochs.get(key) ?? 0;
466
+ try {
467
+ const waiting = readPending(key);
468
+ if (waiting && hasWork(waiting)) {
469
+ if (parked.has(key)) return;
470
+ const body: TourProgressBody = {};
471
+ if (Object.keys(waiting.completed).length > 0) body.completed = { ...waiting.completed };
472
+ if (waiting.forget.length > 0) body.forget = [...waiting.forget];
473
+ let answer: { completed: TourProgressWire };
474
+ try {
475
+ answer = await account.save(body);
476
+ } catch (error) {
477
+ const status = httpStatusOf(error);
478
+ if (status === 403 || status === 422) {
479
+ // Refused for good (a demo, a malformed or full record): dropped, not retried.
480
+ if ((epochs.get(key) ?? 0) === epoch) clearSent(key, body, false);
481
+ console.warn(`[ui-kit] tour progress: the account refused the pending record (${status}); dropped.`, error);
482
+ } else if (status === 402) {
483
+ // A lapsed plan refuses every write: kept, but not re-sent after each finish
484
+ // and each `online` — the next session or page load tries again.
485
+ parked.add(key);
486
+ }
487
+ // No answer (offline, 5xx, 401): it stays for the next load or `online`.
488
+ return;
489
+ }
490
+ if ((epochs.get(key) ?? 0) !== epoch) return;
491
+ clearSent(key, body, true);
492
+ refill(key, answer);
493
+ } else {
494
+ const answer = await account.load();
495
+ if ((epochs.get(key) ?? 0) !== epoch) return;
496
+ if (waiting && !waiting.synced) writePending(key, { ...waiting, synced: true });
497
+ else if (!waiting) writePending(key, { synced: true, completed: {}, forget: [] });
498
+ refill(key, answer);
499
+ }
500
+ } catch {
501
+ // A load that failed keeps the cache as it is.
502
+ } finally {
503
+ running.delete(key);
504
+ if (rerun.delete(key)) void sync(key);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * The end of a session (§10.2.6) — sign-out, a 401 nothing could renew, the account's
510
+ * deletion, every path: removes the CACHE only (the device key and the page's snapshot;
511
+ * every reader in this tab moves), synchronously and with no request, and KEEPS
512
+ * `<storageKey>:pending`. That record sits under this person's own key, holds tour ids and
513
+ * dates only, and goes out on their next sign-in. An answer still on its way is ignored.
514
+ *
515
+ * function logout() { …; signOutTourProgress(`app.tours:${user.id}`); }
516
+ */
517
+ export function signOutTourProgress(storageKey: string): void {
518
+ epochs.set(storageKey, (epochs.get(storageKey) ?? 0) + 1);
519
+ loaded.delete(storageKey);
520
+ rerun.delete(storageKey);
521
+ parked.delete(storageKey);
522
+ removeStored(storageKey);
523
+ memory.delete(storageKey);
524
+ cache.delete(storageKey);
525
+ emit(storageKey);
526
+ }
527
+
188
528
  function subscribe(key: string, listener: () => void): () => void {
189
529
  let set = listeners.get(key);
190
530
  if (!set) listeners.set(key, (set = new Set()));
@@ -208,8 +548,13 @@ function subscribe(key: string, listener: () => void): () => void {
208
548
  * once. For "Reset this device's settings" (settings §11):
209
549
  *
210
550
  * registerDeviceSetting({ key: TOUR_KEY, kind: "view", reset: () => resetTourProgress(TOUR_KEY) });
551
+ *
552
+ * With an account (0.45) it clears the cache only: the account is never written, the
553
+ * pending record stays ("anything waiting to be sent", settings §11.2.2) and the next load
554
+ * refills the cache. Tour progress is never registered with the device reset (§10.2.8).
211
555
  */
212
556
  export function resetTourProgress(storageKey: string): void {
557
+ if (accounts.has(storageKey)) loaded.delete(storageKey);
213
558
  removeStored(storageKey);
214
559
  memory.delete(storageKey);
215
560
  cache.delete(storageKey);
@@ -231,10 +576,28 @@ export function completeStored(
231
576
  // Read now rather than from a render: two completions in one handler both count.
232
577
  const current = readForWrite(storageKey, migrate);
233
578
  if (isTourDone(subject, current[subject.id])) return false;
234
- write(storageKey, { ...current, [subject.id]: new Date().toISOString() });
579
+ const at = new Date().toISOString();
580
+ write(storageKey, { ...current, [subject.id]: at });
581
+ if (accounts.has(storageKey)) {
582
+ markPending(storageKey, subject.id, at);
583
+ queueSync(storageKey);
584
+ }
235
585
  return true;
236
586
  }
237
587
 
588
+ /** "Mark as not done" (§10.2.8): off the cache, and `forget` to the account. @internal */
589
+ function resetStored(storageKey: string, migrate: UseTourProgressOptions["migrate"], id: string) {
590
+ const current = snapshot(storageKey, migrate);
591
+ if (current[id] === undefined) return;
592
+ const next = { ...current };
593
+ delete next[id];
594
+ write(storageKey, next);
595
+ if (accounts.has(storageKey)) {
596
+ markPending(storageKey, id, undefined, true);
597
+ queueSync(storageKey);
598
+ }
599
+ }
600
+
238
601
  /**
239
602
  * Write a completion from outside a page (§9.2): for an app's tests, seeds and scripts,
240
603
  * beside {@link resetTourProgress}. It writes the kit's stored shape (`{[id]: ISO | ""}`)
@@ -253,6 +616,9 @@ export function completeStored(
253
616
  * nor the kit's shape and no migrate reads it, this THROWS and writes nothing (B-1):
254
617
  *
255
618
  * completeTour(TOUR_KEY, "budget", undefined, { migrate: fromZustand });
619
+ *
620
+ * With an account on the key (0.45) it stays a device write — no request — and is marked
621
+ * pending, so the next load sends it.
256
622
  */
257
623
  export function completeTour(
258
624
  storageKey: string,
@@ -263,16 +629,25 @@ export function completeTour(
263
629
  const id = typeof tour === "string" ? tour : tour.id;
264
630
  const when = at === null ? null : at instanceof Date ? at.toISOString() : (at ?? new Date().toISOString());
265
631
  write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });
632
+ if (accounts.has(storageKey)) markPending(storageKey, id, when);
266
633
  }
267
634
 
268
635
  /** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */
269
- export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {
636
+ export function useTourProgress({ storageKey, migrate, account }: UseTourProgressOptions): TourProgress {
270
637
  // Held, not depended on: an inline arrow is a new function every render, and neither the
271
638
  // snapshot nor the callbacks may change with it.
272
639
  const migrateRef = useRef(migrate);
273
640
  useEffect(() => {
274
641
  migrateRef.current = migrate;
275
642
  });
643
+ // The first account for the key wins (as `migrate`); its first reader on the page loads.
644
+ const accountRef = useRef(account);
645
+ useEffect(() => {
646
+ accountRef.current = account;
647
+ });
648
+ useEffect(() => {
649
+ if (rememberAccount(storageKey, accountRef.current)) ensureLoaded(storageKey);
650
+ }, [storageKey]);
276
651
  const completed = useSyncExternalStore(
277
652
  useCallback((listener: () => void) => subscribe(storageKey, listener), [storageKey]),
278
653
  () => snapshot(storageKey, migrateRef.current),
@@ -284,16 +659,7 @@ export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions)
284
659
  [storageKey],
285
660
  );
286
661
 
287
- const reset = useCallback(
288
- (id: string) => {
289
- const current = snapshot(storageKey, migrateRef.current);
290
- if (current[id] === undefined) return;
291
- const next = { ...current };
292
- delete next[id];
293
- write(storageKey, next);
294
- },
295
- [storageKey],
296
- );
662
+ const reset = useCallback((id: string) => resetStored(storageKey, migrateRef.current, id), [storageKey]);
297
663
 
298
664
  return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
299
665
  }
package/src/tour.ts CHANGED
@@ -11,5 +11,8 @@ export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/to
11
11
  // 0.44.0: the writer outside a page (§9.2).
12
12
  export { useTourProgress, resetTourProgress, completeTour } from "./tour/use-tour-progress";
13
13
  export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress";
14
+ // 0.45.0: progress on the account (§10.2–§10.3) and the end of a session.
15
+ export { signOutTourProgress } from "./tour/use-tour-progress";
16
+ export type { TourProgressAccount, TourProgressAdopt, TourProgressBody } from "./tour/use-tour-progress";
14
17
  export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync";
15
18
  export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync";