@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.18.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +860 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +504 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/debug.js +45 -0
  22. package/src/decisions.js +90 -0
  23. package/src/device-storage.js +213 -0
  24. package/src/experiments.js +220 -0
  25. package/src/ga4.js +124 -0
  26. package/src/hash.js +138 -0
  27. package/src/hp.js +135 -0
  28. package/src/index.js +840 -0
  29. package/src/preview.js +44 -0
  30. package/src/recommendation-context.js +101 -0
  31. package/src/sampling.js +19 -0
  32. package/src/segment.js +388 -0
  33. package/src/session/category.js +40 -0
  34. package/src/session/record.js +176 -0
  35. package/src/session/rules.js +195 -0
  36. package/src/session/signals.js +150 -0
  37. package/src/session-id.js +62 -0
  38. package/src/session.js +28 -0
  39. package/src/settings.js +230 -0
  40. package/src/shape.js +120 -0
  41. package/src/social-proof.js +139 -0
  42. package/src/spec-version.js +4 -0
  43. package/src/storage-scope.js +164 -0
  44. package/src/transport.js +451 -0
  45. package/src/version.js +2 -0
  46. package/types/auto.d.ts +51 -0
  47. package/types/bootstrap.d.ts +165 -0
  48. package/types/consent.d.ts +126 -0
  49. package/types/datalayer.d.ts +76 -0
  50. package/types/events.d.ts +713 -0
  51. package/types/hp.d.ts +64 -0
  52. package/types/index.d.ts +617 -0
  53. package/types/segment.d.ts +68 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * GA4 dataLayer -> CDP events.
3
+ *
4
+ * const disconnect = connectDataLayer(analytics);
5
+ *
6
+ * The core knows the GA4 standard only: `{ event: 'view_item', ecommerce: { items, currency,
7
+ * value } }` as GTM documents it, parameters at the top level for non-ecommerce events
8
+ * (`{ event: 'login', method }`), and gtag's `['event', name, params]`. A platform with its own
9
+ * conventions is taught them by a preset, which the host passes in: `eventName`, `transform` and
10
+ * `productId` normalise its pushes into that standard, and `map` adds the events GA4 has no name
11
+ * for.
12
+ *
13
+ * Works with this package's client and with an analytics-next instance: the only call it makes is
14
+ * `analytics.track(event, properties, { context })`, plus `analytics.signedOut()` when a mapping
15
+ * emits Signed Out and the client has one (so identity is cleared after the event, not before).
16
+ *
17
+ * `purchase` and `refund` are never mapped: orders are reported by the service that took them,
18
+ * which sees every order exactly once. A browser copy would be a second system of record.
19
+ */
20
+
21
+ import { currencyCode } from './context.js';
22
+ import { defaultProductId, toProducts } from './ga4.js';
23
+
24
+ /** Re-exported for presets: the first usable id among the candidates, as a string, or null. */
25
+ export { firstId } from './ga4.js';
26
+
27
+ const DEDUPE_MS = 1000;
28
+ const DEFAULT_STEPS = { shipping: 2, payment: 3 };
29
+ const SIGN_IN_METHODS = new Set([
30
+ 'password',
31
+ 'google',
32
+ 'apple',
33
+ 'facebook',
34
+ 'magic_link',
35
+ 'sso',
36
+ 'passkey',
37
+ 'other',
38
+ ]);
39
+
40
+ const text = (v) => {
41
+ if (v == null || typeof v === 'object') return null;
42
+ const s = String(v).trim();
43
+ return s === '' ? null : s;
44
+ };
45
+
46
+ const amount = (v) => {
47
+ if (v == null || v === '' || typeof v === 'object') return null;
48
+ const n = Number(v);
49
+ return Number.isFinite(n) && n >= 0 ? n : null;
50
+ };
51
+
52
+ const isObject = (v) => v !== null && typeof v === 'object';
53
+
54
+ /** Null when the push carried no item list at all, which is not the same as an empty one. */
55
+ function readItems(ecommerce) {
56
+ if (!Array.isArray(ecommerce.items)) return null;
57
+ return ecommerce.items.filter(isObject);
58
+ }
59
+
60
+ function signInMethod(value) {
61
+ const method = text(value)
62
+ ?.toLowerCase()
63
+ .replace(/[\s-]+/g, '_');
64
+ if (!method) return null;
65
+ if (SIGN_IN_METHODS.has(method)) return method;
66
+ if (method === 'email' || method === 'email_password') return 'password';
67
+ return 'other';
68
+ }
69
+
70
+ /** Promotion fields live on ecommerce, or on each item (GA4 allows both). One per promotion id. */
71
+ function promotionsOf({ ecommerce, items }) {
72
+ const read = (source) => ({
73
+ promotion_id: text(source.promotion_id),
74
+ name: text(source.promotion_name),
75
+ creative: text(source.creative_name),
76
+ // GA4's creative_slot is Segment's `position`, which spec 1.2 restored.
77
+ position: text(source.creative_slot),
78
+ });
79
+ const seen = new Set();
80
+ const out = [];
81
+ for (const promotion of [read(ecommerce), ...items.map(read)]) {
82
+ // Never invent identity: a name is not an id, and an impression nobody can join is noise.
83
+ if (!promotion.promotion_id || seen.has(promotion.promotion_id)) continue;
84
+ seen.add(promotion.promotion_id);
85
+ out.push(promotion);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const listId = ({ ecommerce, items }) =>
91
+ text(ecommerce.item_list_id) ??
92
+ text(ecommerce.item_list_name) ??
93
+ text(items[0]?.item_list_id) ??
94
+ text(items[0]?.item_list_name);
95
+
96
+ const needProducts = (event, properties) =>
97
+ properties.products?.length ? { event, properties } : null;
98
+
99
+ /** GA4 `share` names what was shared by content_type; only a product maps onto Product Shared. */
100
+ const SHARED_PRODUCT_TYPES = new Set(['product', 'products', 'item', 'items']);
101
+
102
+ /**
103
+ * GA4's recommended `share` event ({ method, content_type, item_id }) -> Product Shared.
104
+ *
105
+ * `item_id` is the shared content's id, not a GA4 item, so it is taken as the product id as it
106
+ * stands. A share of anything other than a product (an article, a video) is not an ecommerce
107
+ * event, and GA4 has no basket share, so there is nothing here for Cart Shared.
108
+ */
109
+ function productShared({ ecommerce }) {
110
+ const productId = text(ecommerce.item_id);
111
+ const contentType = text(ecommerce.content_type)?.toLowerCase();
112
+ if (!productId || (contentType && !SHARED_PRODUCT_TYPES.has(contentType)))
113
+ return null;
114
+ return {
115
+ event: 'Product Shared',
116
+ properties: {
117
+ share_via: text(ecommerce.method),
118
+ products: [{ product_id: productId }],
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * GA4's recommended ecommerce and account events -> spec events. Each builder returns only fields
125
+ * the target schema allows, or null when the push cannot make a valid event.
126
+ *
127
+ * GA4 has no coupon events (`coupon` is a parameter on the commerce events, carried through
128
+ * above), no wishlist removal or wishlist-to-basket event, and no basket share, so Coupon Entered,
129
+ * Applied, Denied and Removed, Product Removed from Wishlist, Wishlist Product Added to Cart and
130
+ * Cart Shared have no default mapping. A preset whose platform pushes them adds one through `map`.
131
+ */
132
+ function defaultMappings(steps) {
133
+ const listViewed = (p) =>
134
+ needProducts('Product List Viewed', {
135
+ list_id: listId(p),
136
+ products: p.products,
137
+ });
138
+ const searched = (p) => {
139
+ const query = text(p.ecommerce.search_term);
140
+ if (!query) return null;
141
+ return {
142
+ event: 'Products Searched',
143
+ properties: {
144
+ query,
145
+ ...(p.products.length ? { products: p.products } : {}),
146
+ },
147
+ };
148
+ };
149
+ const promotion = (event) => (p) =>
150
+ p.promotions.map((fields) => ({ event, properties: fields }));
151
+
152
+ return {
153
+ view_item: (p) =>
154
+ needProducts('Product Viewed', {
155
+ currency: p.currency,
156
+ value: p.value,
157
+ products: p.products.slice(0, 1),
158
+ }),
159
+ view_item_list: listViewed,
160
+ view_search_results: (p) => searched(p) ?? listViewed(p),
161
+ search: searched,
162
+ select_item: (p) =>
163
+ needProducts('Product Clicked', {
164
+ list_id: listId(p),
165
+ products: p.products.slice(0, 1),
166
+ }),
167
+ add_to_cart: (p) =>
168
+ needProducts('Product Added', {
169
+ cart_id: text(p.ecommerce.cart_id),
170
+ products: p.products,
171
+ }),
172
+ remove_from_cart: (p) =>
173
+ needProducts('Product Removed', {
174
+ cart_id: text(p.ecommerce.cart_id),
175
+ products: p.products,
176
+ }),
177
+ view_cart: (p) => ({
178
+ event: 'Cart Viewed',
179
+ properties: {
180
+ cart_id: text(p.ecommerce.cart_id),
181
+ value: p.value,
182
+ currency: p.currency,
183
+ coupon: text(p.ecommerce.coupon),
184
+ // A view_cart without items says nothing about the basket; [] would claim it was empty.
185
+ ...(p.itemsListed ? { products: p.products } : {}),
186
+ },
187
+ }),
188
+ begin_checkout: (p) =>
189
+ needProducts('Checkout Started', {
190
+ value: p.value,
191
+ currency: p.currency,
192
+ coupon: text(p.ecommerce.coupon),
193
+ products: p.products,
194
+ }),
195
+ add_shipping_info: (p) => ({
196
+ event: 'Checkout Step Completed',
197
+ properties: {
198
+ step: steps.shipping,
199
+ step_name: 'shipping',
200
+ shipping_method: text(p.ecommerce.shipping_tier),
201
+ },
202
+ }),
203
+ add_payment_info: (p) => ({
204
+ event: 'Payment Info Entered',
205
+ properties: {
206
+ step: steps.payment,
207
+ step_name: 'payment',
208
+ payment_method: text(p.ecommerce.payment_type),
209
+ },
210
+ }),
211
+ view_promotion: promotion('Promotion Viewed'),
212
+ select_promotion: promotion('Promotion Clicked'),
213
+ add_to_wishlist: (p) =>
214
+ needProducts('Product Added to Wishlist', { products: p.products }),
215
+ share: productShared,
216
+ login: (p) => ({
217
+ event: 'Signed In',
218
+ properties: { method: signInMethod(p.ecommerce.method) },
219
+ }),
220
+ sign_up: (p) => ({
221
+ event: 'Signed Up',
222
+ properties: { method: signInMethod(p.ecommerce.method) },
223
+ }),
224
+ };
225
+ }
226
+
227
+ const defaultEventName = (entry) => entry.event;
228
+
229
+ /**
230
+ * An entry as { name, entry, ecommerce }: gtag's `['event', name, params]`, or a dataLayer object
231
+ * whose ecommerce block, when it has none, is its own top level (GTM puts event parameters there).
232
+ */
233
+ function readEntry(entry, eventName) {
234
+ if (!isObject(entry)) return null;
235
+ if (
236
+ typeof entry.length === 'number' &&
237
+ entry[0] === 'event' &&
238
+ typeof entry[1] === 'string'
239
+ ) {
240
+ const params = isObject(entry[2]) ? entry[2] : {};
241
+ return { name: entry[1], entry: params, ecommerce: params };
242
+ }
243
+ const name = text(eventName(entry));
244
+ if (!name) return null;
245
+ return {
246
+ name,
247
+ entry,
248
+ ecommerce: isObject(entry.ecommerce) ? entry.ecommerce : entry,
249
+ };
250
+ }
251
+
252
+ /** JSON with object keys sorted, so two equal payloads always serialise identically. */
253
+ function stableJson(value) {
254
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
255
+ if (value && typeof value === 'object') {
256
+ return `{${Object.keys(value)
257
+ .sort()
258
+ .map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`)
259
+ .join(',')}}`;
260
+ }
261
+ return JSON.stringify(value ?? null);
262
+ }
263
+
264
+ /**
265
+ * @param {{ track: Function, signedOut?: Function }} analytics
266
+ * @param {object} [options]
267
+ * @param {unknown[]} [options.dataLayer] Defaults to `window.dataLayer`, created if absent.
268
+ * @param {(entry: object) => string|null|undefined} [options.eventName] The GA4 event name of a
269
+ * push. Default `entry.event`.
270
+ * @param {(entry: unknown) => unknown} [options.transform] Rewrites a push before anything else
271
+ * reads it; return null to skip it.
272
+ * @param {(item: object) => unknown} [options.productId] Parent product id of a GA4 item.
273
+ * Default `item.item_group_id ?? item.item_id`.
274
+ * @param {Record<string, (payload: object, helpers: object) => object | object[] | null>} [options.map]
275
+ * Add or override mappings, keyed by GA4 event name. `payload` is the (transformed) entry.
276
+ * @param {string[]} [options.ignore] GA4 event names to skip.
277
+ * @param {{ shipping?: number, payment?: number }} [options.steps] Checkout step numbers.
278
+ * @param {number} [options.dedupeMs] Window in which an identical push is emitted once.
279
+ * @param {(error: unknown, entry: unknown) => void} [options.onError]
280
+ * @returns {() => void} disconnect
281
+ */
282
+ export function connectDataLayer(analytics, options = {}) {
283
+ if (!analytics || typeof analytics.track !== 'function') {
284
+ throw new Error(
285
+ 'connectDataLayer: analytics must have a track(event, properties, options) method',
286
+ );
287
+ }
288
+ const dataLayer =
289
+ options.dataLayer ??
290
+ (typeof window !== 'undefined'
291
+ ? (window.dataLayer = window.dataLayer || [])
292
+ : null);
293
+ if (!Array.isArray(dataLayer)) return () => {};
294
+
295
+ const defaults = defaultMappings({ ...DEFAULT_STEPS, ...options.steps });
296
+ const custom = options.map ?? {};
297
+ const eventName = options.eventName ?? defaultEventName;
298
+ const productId = options.productId ?? defaultProductId;
299
+ const ignore = new Set(['purchase', 'refund', ...(options.ignore ?? [])]);
300
+ const dedupeMs = options.dedupeMs ?? DEDUPE_MS;
301
+ const recent = new Map();
302
+ let connected = true;
303
+
304
+ // Duplicate means the same event with the same properties, in the same currency: legacy trackers
305
+ // push some events twice, and item-less events (two filter changes) must stay distinct.
306
+ function isDuplicate(event, properties, currency) {
307
+ if (dedupeMs <= 0) return false;
308
+ const key = `${event}|${currency ?? ''}|${stableJson(properties)}`;
309
+ const now = Date.now();
310
+ for (const [k, at] of recent) if (now - at > dedupeMs) recent.delete(k);
311
+ if (recent.has(key)) return true;
312
+ recent.set(key, now);
313
+ return false;
314
+ }
315
+
316
+ // A product's details are viewed once per page. GA4 storefronts commonly push view_item again
317
+ // when a size or colour is picked, which is a variant change, not another view. The page is the
318
+ // path: a variant is often carried in the query string.
319
+ let viewedPage = null;
320
+ const viewedProducts = new Set();
321
+ function isRepeatView(event, properties) {
322
+ if (event !== 'Product Viewed') return false;
323
+ const page = typeof location === 'undefined' ? '' : location.pathname;
324
+ if (page !== viewedPage) {
325
+ viewedPage = page;
326
+ viewedProducts.clear();
327
+ }
328
+ const productId = properties.products?.[0]?.product_id;
329
+ if (!productId) return false;
330
+ if (viewedProducts.has(productId)) return true;
331
+ viewedProducts.add(productId);
332
+ return false;
333
+ }
334
+
335
+ // A push's prices are in the push's currency. Events whose schema has `currency` carry it as a
336
+ // property; every event also gets it as a context override, so the site's current currency can
337
+ // never be stamped onto a price that was pushed in another.
338
+ function send({ event, properties = {} }, currency) {
339
+ if (
340
+ !event ||
341
+ isDuplicate(event, properties, currency) ||
342
+ isRepeatView(event, properties)
343
+ ) {
344
+ return;
345
+ }
346
+ const callOptions = currency ? { context: { currency } } : undefined;
347
+ if (event === 'Signed Out' && typeof analytics.signedOut === 'function') {
348
+ analytics.signedOut(callOptions);
349
+ return;
350
+ }
351
+ analytics.track(event, properties, callOptions);
352
+ }
353
+
354
+ function handle(raw) {
355
+ const entry = options.transform ? options.transform(raw) : raw;
356
+ const read = readEntry(entry, eventName);
357
+ if (!read || ignore.has(read.name)) return;
358
+ const isCustom = Object.hasOwn(custom, read.name);
359
+ const mapping = isCustom ? custom[read.name] : defaults[read.name];
360
+ if (typeof mapping !== 'function') return;
361
+
362
+ const listed = readItems(read.ecommerce);
363
+ const items = listed ?? [];
364
+ const payload = {
365
+ ...read,
366
+ items,
367
+ itemsListed: listed !== null,
368
+ products: toProducts(items, productId),
369
+ currency: currencyCode(read.ecommerce.currency),
370
+ value: amount(read.ecommerce.value),
371
+ };
372
+ payload.promotions = promotionsOf(payload);
373
+
374
+ const result = isCustom
375
+ ? mapping(entry, {
376
+ ...payload,
377
+ toProducts: (list) => toProducts(list, productId),
378
+ })
379
+ : mapping(payload);
380
+ for (const out of [result].flat()) if (out) send(out, payload.currency);
381
+ }
382
+
383
+ function safely(entry) {
384
+ if (!connected) return;
385
+ try {
386
+ handle(entry);
387
+ } catch (err) {
388
+ try {
389
+ options.onError?.(err, entry);
390
+ } catch {
391
+ /* never throw into the page */
392
+ }
393
+ }
394
+ }
395
+
396
+ for (const entry of dataLayer.slice()) safely(entry);
397
+
398
+ // Wrap, never replace: GTM wraps push too, and whichever of us loads second must call through.
399
+ const previousPush = dataLayer.push;
400
+ function push(...entries) {
401
+ const result = previousPush.apply(this, entries);
402
+ for (const entry of entries) safely(entry);
403
+ return result;
404
+ }
405
+ dataLayer.push = push;
406
+
407
+ return () => {
408
+ connected = false;
409
+ if (dataLayer.push === push) dataLayer.push = previousPush;
410
+ };
411
+ }
package/src/debug.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Session debug (3.18): `?hp_debug=<token>` turns console logging on for the rest of the browser
3
+ * session, but only when the token hashes to the one the source's settings name, so a shopper who
4
+ * guesses the parameter turns nothing on. `?hp_debug=` with no value ends it. The token is kept in
5
+ * sessionStorage and checked on every page against the settings that page starts from, so a token
6
+ * cleared in the console stops working once the browser has read the new settings.
7
+ */
8
+
9
+ import { sha256Hex } from './hash.js';
10
+
11
+ export const DEBUG_PARAM = 'hp_debug';
12
+
13
+ const MAX_TOKEN_LENGTH = 256;
14
+
15
+ function readParam() {
16
+ try {
17
+ return new URLSearchParams(location.search).get(DEBUG_PARAM);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Whether this session has debug on: apply the page URL's `hp_debug` to sessionStorage under
25
+ * `name`, then check what is kept against `tokenSha256`.
26
+ *
27
+ * @param {string|null} tokenSha256 The settings' `hp.debug.tokenSha256`.
28
+ * @param {string} name The sessionStorage key, storageName('debug').
29
+ */
30
+ export function sessionDebug(tokenSha256, name) {
31
+ if (typeof location === 'undefined') return false;
32
+ try {
33
+ const given = readParam();
34
+ if (given !== null) {
35
+ const token = given.trim();
36
+ if (token === '' || token.length > MAX_TOKEN_LENGTH)
37
+ sessionStorage.removeItem(name);
38
+ else sessionStorage.setItem(name, token);
39
+ }
40
+ const kept = sessionStorage.getItem(name);
41
+ return Boolean(tokenSha256 && kept && sha256Hex(kept) === tokenSha256);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The decisions in force for a visitor: what a decisions response served and the page rendered,
3
+ * latest per slot. Every later event carries them as `context.decisions`, which is how a basket is
4
+ * credited to the slot shown before it (decision_rewards bounds that to the decision's session and
5
+ * attribution tail). They survive a navigation in sessionStorage, which is written only under a grant.
6
+ */
7
+
8
+ /**
9
+ * `context.decisions[].kind`, as spec core/context 1-0-0 closes it. Any other value is dropped
10
+ * rather than sent: one would quarantine the event carrying it, basket and all.
11
+ */
12
+ export const DECISION_KINDS = [
13
+ 'recommendation',
14
+ 'search_rank',
15
+ 'banner',
16
+ 'offer',
17
+ 'layout',
18
+ 'copy',
19
+ 'price',
20
+ 'other',
21
+ ];
22
+
23
+ const named = (value) => typeof value === 'string' && value !== '';
24
+
25
+ /**
26
+ * What a decisions response served, as `context.decisions[]` entries. Only logged decisions: a
27
+ * held-out shopper, or a visitor without analytics consent, gets no `decision_id`, and has nothing
28
+ * a reward could join to.
29
+ */
30
+ export function servedDecisions(body) {
31
+ return (Array.isArray(body?.decisions) ? body.decisions : [])
32
+ .filter((d) => named(d?.slot) && named(d.decision_id) && d.holdout !== true)
33
+ .map(({ decision_id, slot, arm, kind }) => ({
34
+ decision_id,
35
+ slot,
36
+ ...(named(arm) && { arm }),
37
+ ...(DECISION_KINDS.includes(kind) && { kind }),
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * @param {object} options
43
+ * @param {string} options.key The sessionStorage key, scoped to the property.
44
+ * @param {() => boolean} options.mayStore Whether a device write is allowed now.
45
+ */
46
+ export function createDecisionMemory({ key, mayStore }) {
47
+ // Memory is authoritative within a document; storage only carries the decisions across a navigation.
48
+ let inForce;
49
+ const load = () => {
50
+ if (inForce) return inForce;
51
+ try {
52
+ const stored = JSON.parse(sessionStorage.getItem(key));
53
+ inForce = Array.isArray(stored)
54
+ ? stored.filter((d) => named(d?.slot))
55
+ : [];
56
+ } catch {
57
+ inForce = [];
58
+ }
59
+ return inForce;
60
+ };
61
+ const persist = () => {
62
+ try {
63
+ if (inForce?.length && mayStore())
64
+ sessionStorage.setItem(key, JSON.stringify(inForce));
65
+ } catch {
66
+ /* private browsing: the decisions still hold within this document */
67
+ }
68
+ };
69
+
70
+ return {
71
+ persist,
72
+ /** A copy, so a beforeSend that edits one event cannot edit the next. */
73
+ current: () => (load().length ? inForce.map((d) => ({ ...d })) : null),
74
+ /** Put these in force, replacing whatever each slot decided before. */
75
+ remember(served) {
76
+ if (!served.length) return;
77
+ const slots = new Set(served.map((d) => d.slot));
78
+ inForce = [...load().filter((d) => !slots.has(d.slot)), ...served];
79
+ persist();
80
+ },
81
+ forget() {
82
+ inForce = [];
83
+ try {
84
+ sessionStorage.removeItem(key);
85
+ } catch {
86
+ /* nothing was stored */
87
+ }
88
+ },
89
+ };
90
+ }