@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.
- package/README.md +29 -14
- package/dist/admin/admin-action-log-labels.js +9 -0
- package/dist/admin/admin-action-log-labels.js.map +1 -1
- package/dist/admin/admin-action-log.d.ts +18 -1
- package/dist/admin/admin-action-log.js +25 -16
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.d.ts +15 -0
- package/dist/admin/admin-area-labels.js +6 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card-labels.d.ts +3 -0
- package/dist/admin/mail-test-card-labels.js +17 -0
- package/dist/admin/mail-test-card-labels.js.map +1 -0
- package/dist/admin/mail-test-card.d.ts +75 -0
- package/dist/admin/mail-test-card.js +82 -0
- package/dist/admin/mail-test-card.js.map +1 -0
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-mobile.js +17 -2
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +18 -2
- package/dist/components/data-table-row-actions.js +86 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +2 -0
- package/dist/components/data-table-shared.js +17 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +43 -2
- package/dist/components/data-table.js +10 -10
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +64 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +81 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +25 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +97 -7
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +81 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +81 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +81 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +81 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +81 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +16 -2
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/format.js +11 -5
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +27 -0
- package/dist/notifications/device-zone-report.js +24 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +11 -5
- package/dist/notifications/feedback-notification-types.js +2 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +77 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +41 -7
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -2
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +33 -0
- package/dist/offline/local-first.js +39 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
- package/dist/offline/pwa-update-prompt-labels.js +9 -0
- package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
- package/dist/offline/pwa-update-prompt.d.ts +32 -0
- package/dist/offline/pwa-update-prompt.js +36 -0
- package/dist/offline/pwa-update-prompt.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +125 -0
- package/dist/offline/query-persistence.js +183 -0
- package/dist/offline/query-persistence.js.map +1 -0
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline/shared-computer-labels.d.ts +2 -0
- package/dist/offline/shared-computer-labels.js +9 -0
- package/dist/offline/shared-computer-labels.js.map +1 -0
- package/dist/offline/shared-computer.d.ts +44 -0
- package/dist/offline/shared-computer.js +51 -0
- package/dist/offline/shared-computer.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
- package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
- package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
- package/dist/offline/sign-out-pending-dialog.js +66 -0
- package/dist/offline/sign-out-pending-dialog.js.map +1 -0
- package/dist/offline.d.ts +11 -0
- package/dist/offline.js +33 -0
- package/dist/offline.js.map +1 -0
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +24 -0
- package/dist/shell/admin-menu.js +12 -3
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell.d.ts +4 -0
- package/dist/shell.js +4 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.d.ts +76 -0
- package/dist/sync/db.js +24 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +124 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +30 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +22 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +7 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +54 -8
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +99 -1
- package/dist/tour/use-tour-progress.js +228 -12
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +2 -0
- package/dist/tour.js.map +1 -1
- package/package.json +43 -1
- package/src/admin/admin-action-log-labels.ts +9 -0
- package/src/admin/admin-action-log.tsx +41 -5
- package/src/admin/admin-area-labels.ts +20 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card-labels.ts +19 -0
- package/src/admin/mail-test-card.tsx +190 -0
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-mobile.tsx +26 -3
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +39 -3
- package/src/components/data-table.tsx +52 -21
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +102 -14
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/german.ts +90 -4
- package/src/i18n/kit-labels.tsx +31 -1
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +90 -4
- package/src/i18n/locales/fr.ts +90 -4
- package/src/i18n/locales/hu.ts +86 -4
- package/src/i18n/locales/it.ts +90 -4
- package/src/i18n/locales/zh.ts +84 -2
- package/src/i18n/review.ts +15 -1
- package/src/index.ts +45 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +23 -6
- package/src/notifications/device-zone-report.ts +54 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +12 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +304 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +69 -11
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +5 -2
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +76 -0
- package/src/offline/pwa-update-prompt-labels.ts +9 -0
- package/src/offline/pwa-update-prompt.tsx +72 -0
- package/src/offline/query-persistence.ts +356 -0
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline/shared-computer-labels.ts +8 -0
- package/src/offline/shared-computer.tsx +87 -0
- package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
- package/src/offline/sign-out-pending-dialog.tsx +135 -0
- package/src/offline.ts +32 -0
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/admin-menu.tsx +40 -3
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +7 -0
- package/src/sync/db.ts +103 -0
- package/src/sync/engine.ts +699 -0
- package/src/sync/hooks.ts +93 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-catalogue.tsx +7 -1
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +105 -9
- package/src/tour/use-tour-progress.ts +378 -12
- 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
|
-
|
|
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";
|