@eifi1/ui-kit 0.43.0 → 0.44.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.
@@ -18,21 +18,33 @@ import { isTourDone, type TourCompletions } from "./tour-info";
18
18
  * a shape it recognises, `undefined` for any other — the kit's own shape included,
19
19
  * which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;
20
20
  * kastlan: an array of ids, completed without a date (`null`). It runs only on a stored
21
- * text that is not the kit's shape already, once per text and key, whichever reader
22
- * reads it first; its identity is ignored. **Define `migrate` once, at module level**,
23
- * and hand every reader of the key the same one: readers of one key share one snapshot.
21
+ * text that is not the kit's shape already, once per text and key. The FIRST `migrate`
22
+ * any reader, `TourProvider` or {@link completeTour} passes for a key is remembered for
23
+ * that key (later identities are ignored), so a reader without one, mounted first, no
24
+ * longer hides the old marks from the rest (keksdose B-2). **Still, define the options
25
+ * once, at module level, and hand the provider and every reader the same object.**
26
+ * - **Never overwritten unread** (keksdose B-1): a write (`complete`, the provider's,
27
+ * `completeTour`) on a stored text that is neither empty nor the kit's shape, and that no
28
+ * known `migrate` reads, THROWS instead of replacing the old marks.
24
29
  * - **Resetting the device** (settings §11): register the key with `reset: () =>
25
30
  * resetTourProgress(storageKey)`, which clears storage and the page's memory and tells
26
31
  * every reader in this tab.
27
32
  *
28
- * The kit's own shape is the record itself, `{ [id]: ISO | null }`; an empty string reads
29
- * as `null` (kastlan's record of 0.42).
33
+ * **The stored shape** (§9.3, §9.6.6), for an app's tests and seeds: the JSON record
34
+ * `{ [id]: ISO instant | "" }` under `storageKey`, where `""` is a completion without a
35
+ * date (a migrated record: kastlan's id list). That is the one written form; the hooks
36
+ * and callbacks (`completed`, `TourCompletions`) give `null` for `""`. A `null` read from
37
+ * storage (or returned by `migrate`) is accepted as the same thing and written back as
38
+ * `""`. To write a completion from outside a page, use {@link completeTour}.
30
39
  */
31
40
  export interface UseTourProgressOptions {
32
41
  storageKey: string;
33
42
  migrate?: (raw: unknown) => Record<string, string | null> | undefined;
34
43
  }
35
44
 
45
+ /** What `complete` takes: a tour id, or the tour (`{id, updated}`, a `TourInfo` fits). */
46
+ type CompletedTour = string | { id: string; updated?: { date: string } | null };
47
+
36
48
  export interface TourProgress {
37
49
  /** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */
38
50
  completed: TourCompletions;
@@ -43,8 +55,12 @@ export interface TourProgress {
43
55
  *
44
56
  * Returns `true` when this counts as a new completion — the moment to award the tour's
45
57
  * points and show the app's toast (§6.1) — and `false` for a replay.
58
+ *
59
+ * A tour started with `start(steps, {tour})` under a `TourProvider` that has `progress`
60
+ * is recorded by the provider (§9.6.2): do not call this from that start's `onFinish`
61
+ * too, or the second write answers `false`.
46
62
  */
47
- complete: (tour: string | { id: string; updated?: { date: string } | null }) => boolean;
63
+ complete: (tour: CompletedTour) => boolean;
48
64
  /** "Mark as not done": forget the completion. */
49
65
  reset: (id: string) => void;
50
66
  }
@@ -62,7 +78,24 @@ const memory = new Map<string, string>();
62
78
  * holds. Several readers of one key with inline `migrate` arrows used to evict each other
63
79
  * here and loop (kastlan B1, 0.43).
64
80
  */
65
- const cache = new Map<string, { raw: string | null; value: TourCompletions }>();
81
+ const cache = new Map<string, { raw: string | null; value: TourCompletions; readable: boolean }>();
82
+ type Migrate = NonNullable<UseTourProgressOptions["migrate"]>;
83
+ /** Each key's migrate: the first one any reader, the provider or `completeTour` passes (B-2). */
84
+ const migrates = new Map<string, Migrate>();
85
+
86
+ /**
87
+ * Remember `migrate` for `key` if it is the first one, and return the key's migrate. A
88
+ * snapshot parsed before the key had one is dropped and re-parsed, and the key's readers
89
+ * hear of it after this render (never from inside one).
90
+ * @internal Not part of the barrel; `TourProvider` registers its `progress` with it.
91
+ */
92
+ export function rememberMigrate(key: string, migrate: UseTourProgressOptions["migrate"]): Migrate | undefined {
93
+ if (migrate && !migrates.has(key)) {
94
+ migrates.set(key, migrate);
95
+ if (cache.delete(key)) queueMicrotask(() => emit(key));
96
+ }
97
+ return migrates.get(key);
98
+ }
66
99
 
67
100
  function emit(key: string) {
68
101
  listeners.get(key)?.forEach((listener) => listener());
@@ -89,8 +122,12 @@ function fromRecord(value: unknown): TourCompletions | undefined {
89
122
  return out;
90
123
  }
91
124
 
92
- function parse(raw: string | null, migrate: UseTourProgressOptions["migrate"]): TourCompletions {
93
- if (raw === null) return EMPTY;
125
+ /**
126
+ * The record, and whether the text was READ (empty, the kit's shape, or a shape `migrate`
127
+ * knows) rather than dropped: a write over an unread text would lose it (B-1).
128
+ */
129
+ function parse(raw: string | null, migrate: UseTourProgressOptions["migrate"]): { value: TourCompletions; readable: boolean } {
130
+ if (raw === null || raw.trim() === "") return { value: EMPTY, readable: true };
94
131
  let value: unknown = raw;
95
132
  try {
96
133
  value = JSON.parse(raw);
@@ -99,26 +136,48 @@ function parse(raw: string | null, migrate: UseTourProgressOptions["migrate"]):
99
136
  }
100
137
  if (migrate && !isKitShape(value)) {
101
138
  try {
102
- const migrated = migrate(value);
103
- if (migrated !== undefined) return fromRecord(migrated) ?? EMPTY;
139
+ const migrated = fromRecord(migrate(value));
140
+ if (migrated !== undefined) return { value: migrated, readable: true };
104
141
  } catch {
105
142
  // A migrate that throws reads as "not a shape it knows".
106
143
  }
107
144
  }
108
- return fromRecord(value) ?? EMPTY;
145
+ return { value: fromRecord(value) ?? EMPTY, readable: isKitShape(value) };
109
146
  }
110
147
 
111
148
  function snapshot(key: string, migrate: UseTourProgressOptions["migrate"]): TourCompletions {
149
+ const known = rememberMigrate(key, migrate);
112
150
  const raw = rawOf(key);
113
151
  const hit = cache.get(key);
114
152
  if (hit && hit.raw === raw) return hit.value;
115
- const value = parse(raw, migrate);
116
- cache.set(key, { raw, value });
153
+ const parsed = parse(raw, known);
154
+ cache.set(key, { raw, ...parsed });
155
+ return parsed.value;
156
+ }
157
+
158
+ /** The record a write builds on; throws rather than overwrite a text nobody could read (B-1). */
159
+ function readForWrite(key: string, migrate: UseTourProgressOptions["migrate"], own = false): TourCompletions {
160
+ // `own`: this migrate, even where the key registered another (completeTour's `{migrate}`).
161
+ const known = rememberMigrate(key, migrate);
162
+ const { value, readable } =
163
+ own && migrate && known !== migrate
164
+ ? parse(rawOf(key), migrate)
165
+ : { value: snapshot(key, migrate), readable: cache.get(key)?.readable };
166
+ if (!readable) {
167
+ throw new Error(
168
+ `[ui-kit] tour progress: the text stored under "${key}" is not the kit's shape and no migrate reads it; ` +
169
+ "refusing to overwrite it. Pass the key's migrate (the shared useTourProgress / TourProvider options, " +
170
+ "or completeTour's {migrate}).",
171
+ );
172
+ }
117
173
  return value;
118
174
  }
119
175
 
176
+ /** The one written form (§9.6.6): a completion without a date is stored as `""`, never `null`. */
120
177
  function write(key: string, record: TourCompletions) {
121
- const raw = JSON.stringify(record);
178
+ const stored: Record<string, string> = {};
179
+ for (const [id, at] of Object.entries(record)) stored[id] = at ?? "";
180
+ const raw = JSON.stringify(stored);
122
181
  writeStored(key, raw);
123
182
  // Storage refused it (blocked, full): keep it for this page's life instead.
124
183
  if (readStored(key) === raw) memory.delete(key);
@@ -157,6 +216,55 @@ export function resetTourProgress(storageKey: string): void {
157
216
  emit(storageKey);
158
217
  }
159
218
 
219
+ /**
220
+ * Record a completion under `storageKey`, as {@link TourProgress.complete} does: through
221
+ * the one shared store and snapshot (never a copy), so every reader of the key moves.
222
+ * Returns `true` when it counts anew. `TourProvider` calls this on a tour's last step.
223
+ * @internal Not part of the barrel.
224
+ */
225
+ export function completeStored(
226
+ storageKey: string,
227
+ migrate: UseTourProgressOptions["migrate"],
228
+ tour: CompletedTour,
229
+ ): boolean {
230
+ const subject = typeof tour === "string" ? { id: tour } : tour;
231
+ // Read now rather than from a render: two completions in one handler both count.
232
+ const current = readForWrite(storageKey, migrate);
233
+ if (isTourDone(subject, current[subject.id])) return false;
234
+ write(storageKey, { ...current, [subject.id]: new Date().toISOString() });
235
+ return true;
236
+ }
237
+
238
+ /**
239
+ * Write a completion from outside a page (§9.2): for an app's tests, seeds and scripts,
240
+ * beside {@link resetTourProgress}. It writes the kit's stored shape (`{[id]: ISO | ""}`)
241
+ * under `storageKey`, keeps the other tours' marks, and notifies every reader of the key
242
+ * in this tab directly (the `storage` event reaches only OTHER tabs), so a mounted
243
+ * `useTourProgress` or `TourCatalogue` moves at once.
244
+ *
245
+ * Unlike `complete`, it always writes (over a readable text; see below): `at` is the
246
+ * completion's instant (an ISO string or a `Date`, default now), `null` a completion
247
+ * without a date (stored as `""`, read back as `null`).
248
+ *
249
+ * completeTour(TOUR_KEY, "budget", "2026-09-01T10:00:00Z");
250
+ *
251
+ * A stored text in an app's older shape is read through `opts.migrate`, or else the
252
+ * migrate a reader or the provider registered for the key. If the text is neither empty
253
+ * nor the kit's shape and no migrate reads it, this THROWS and writes nothing (B-1):
254
+ *
255
+ * completeTour(TOUR_KEY, "budget", undefined, { migrate: fromZustand });
256
+ */
257
+ export function completeTour(
258
+ storageKey: string,
259
+ tour: string | { id: string },
260
+ at?: string | Date | null,
261
+ opts?: { migrate?: UseTourProgressOptions["migrate"] },
262
+ ): void {
263
+ const id = typeof tour === "string" ? tour : tour.id;
264
+ const when = at === null ? null : at instanceof Date ? at.toISOString() : (at ?? new Date().toISOString());
265
+ write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });
266
+ }
267
+
160
268
  /** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */
161
269
  export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {
162
270
  // Held, not depended on: an inline arrow is a new function every render, and neither the
@@ -172,14 +280,7 @@ export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions)
172
280
  );
173
281
 
174
282
  const complete = useCallback(
175
- (tour: string | { id: string; updated?: { date: string } | null }) => {
176
- const subject = typeof tour === "string" ? { id: tour } : tour;
177
- // Read now rather than from the render: two completions in one handler both count.
178
- const current = snapshot(storageKey, migrateRef.current);
179
- if (isTourDone(subject, current[subject.id])) return false;
180
- write(storageKey, { ...current, [subject.id]: new Date().toISOString() });
181
- return true;
182
- },
283
+ (tour: CompletedTour) => completeStored(storageKey, migrateRef.current, tour),
183
284
  [storageKey],
184
285
  );
185
286
 
package/src/tour.ts CHANGED
@@ -8,7 +8,8 @@ export { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from
8
8
  export type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from "./tour/tour-catalogue";
9
9
  export { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info";
10
10
  export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/tour-info";
11
- export { useTourProgress, resetTourProgress } from "./tour/use-tour-progress";
11
+ // 0.44.0: the writer outside a page (§9.2).
12
+ export { useTourProgress, resetTourProgress, completeTour } from "./tour/use-tour-progress";
12
13
  export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress";
13
14
  export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync";
14
15
  export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync";