@hkdigital/lib-core 0.5.57 → 0.5.59

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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Base class for page state machines with URL route mapping
3
+ *
4
+ * Simple state tracker that maps states to URL routes.
5
+ * Does NOT enforce FSM transitions - allows free navigation
6
+ * (because users can navigate to any URL via browser).
7
+ *
8
+ * Features:
9
+ * - State-to-route mapping and sync
10
+ * - Start path management
11
+ * - Data properties for business/domain state
12
+ * - Visited states tracking
13
+ * - onEnter hooks with abort/complete handlers for animations
14
+ *
15
+ * Basic usage:
16
+ * ```javascript
17
+ * const machine = new PageMachine({
18
+ * startPath: '/intro/start',
19
+ * routeMap: {
20
+ * [STATE_START]: '/intro/start',
21
+ * [STATE_PROFILE]: '/intro/profile'
22
+ * }
23
+ * });
24
+ *
25
+ * // Sync machine state with URL changes
26
+ * $effect(() => {
27
+ * machine.syncFromPath($page.url.pathname);
28
+ * });
29
+ * ```
30
+ *
31
+ * With onEnter hooks (for animations):
32
+ * ```javascript
33
+ * const machine = new PageMachine({
34
+ * startPath: '/game/animate',
35
+ * routeMap: {
36
+ * [STATE_ANIMATE]: '/game/animate',
37
+ * [STATE_PLAY]: '/game/play'
38
+ * },
39
+ * onEnterHooks: {
40
+ * [STATE_ANIMATE]: (done) => {
41
+ * const animation = playAnimation(1000);
42
+ * animation.finished.then(() => done(STATE_PLAY));
43
+ *
44
+ * return {
45
+ * abort: () => animation.cancel(),
46
+ * complete: () => animation.finish()
47
+ * };
48
+ * }
49
+ * }
50
+ * });
51
+ *
52
+ * // Fast-forward animation
53
+ * machine.completeTransitions();
54
+ *
55
+ * // Cancel animation
56
+ * machine.abortTransitions();
57
+ * ```
58
+ */
59
+ import { switchToPage } from '$lib/util/sveltekit.js';
60
+
61
+ export default class PageMachine {
62
+ /**
63
+ * Logger instance for state machine
64
+ * @type {import('$lib/logging/client.js').Logger}
65
+ */
66
+ logger;
67
+ /**
68
+ * Current state
69
+ * @type {string}
70
+ */
71
+ // @ts-ignore
72
+ #current = $state();
73
+
74
+ /**
75
+ * Start path for this page machine
76
+ * @type {string}
77
+ */
78
+ #startPath = '';
79
+
80
+ /**
81
+ * Initial/start state (derived from startPath)
82
+ * @type {string}
83
+ */
84
+ #startState = '';
85
+
86
+ /**
87
+ * Map of states to route paths
88
+ * @type {Record<string, string>}
89
+ */
90
+ #routeMap = {};
91
+
92
+ /**
93
+ * Reverse map of route paths to states
94
+ * @type {Record<string, string>}
95
+ */
96
+ #pathToStateMap = {};
97
+
98
+ /**
99
+ * Data properties for business/domain state
100
+ * Can be initialized from server and synced back
101
+ * @type {Record<string, any>}
102
+ */
103
+ #data = $state({});
104
+
105
+ /**
106
+ * Track which states have been visited during this session
107
+ * Useful for showing first-time hints/tips
108
+ * @type {Set<string>}
109
+ */
110
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
111
+ #visitedStates = new Set();
112
+
113
+ /**
114
+ * Revision counter for triggering reactivity
115
+ * @type {number}
116
+ */
117
+ #revision = $state(0);
118
+
119
+ /**
120
+ * Map of state names to onEnter hook configurations
121
+ * @type {Record<string, {onEnter: Function}>}
122
+ */
123
+ #onEnterHooks = {};
124
+
125
+ /**
126
+ * Current state's onEnter handler (abort/complete functions)
127
+ * @type {{abort?: Function, complete?: Function} | null}
128
+ */
129
+ #currentOnEnterHandler = null;
130
+
131
+ /**
132
+ * Current state's done callback
133
+ * @type {Function | null}
134
+ */
135
+ #currentOnEnterDone = null;
136
+
137
+ /**
138
+ * Flag to prevent concurrent state transitions
139
+ * @type {boolean}
140
+ */
141
+ #isTransitioning = false;
142
+
143
+ /**
144
+ * Constructor
145
+ *
146
+ * @param {Object} config - Configuration object
147
+ * @param {string} config.startPath
148
+ * Start path for this route group (e.g., '/game/play')
149
+ * @param {Record<string, string>} [config.routeMap={}]
150
+ * Map of states to route paths
151
+ * @param {Record<string, any>} [config.initialData={}]
152
+ * Initial data properties (from server)
153
+ * @param {Record<string, Function>} [config.onEnterHooks={}]
154
+ * Map of states to onEnter hook functions
155
+ * @param {import('$lib/logging/client.js').Logger} [config.logger]
156
+ * Logger instance (optional, if not provided no logging occurs)
157
+ *
158
+ * @example
159
+ * ```javascript
160
+ * const machine = new PageMachine({
161
+ * startPath: '/intro/start',
162
+ * routeMap: {
163
+ * [STATE_START]: '/intro/start',
164
+ * [STATE_ANIMATE]: '/intro/animate'
165
+ * },
166
+ * initialData: {
167
+ * INTRO_COMPLETED: false
168
+ * },
169
+ * onEnterHooks: {
170
+ * [STATE_ANIMATE]: (done) => {
171
+ * setTimeout(() => done(STATE_START), 1000);
172
+ * return {
173
+ * abort: () => clearTimeout(...),
174
+ * complete: () => done(STATE_START)
175
+ * };
176
+ * }
177
+ * }
178
+ * });
179
+ * ```
180
+ */
181
+ constructor({
182
+ startPath,
183
+ routeMap = {},
184
+ initialData = {},
185
+ onEnterHooks = {},
186
+ logger = null
187
+ }) {
188
+ if (!startPath) {
189
+ throw new Error('PageMachine requires startPath parameter');
190
+ }
191
+
192
+ this.logger = logger;
193
+ this.#startPath = startPath;
194
+ this.#routeMap = routeMap;
195
+ this.#data = initialData;
196
+ this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
197
+
198
+ // Build reverse map (path -> state) and validate no duplicates
199
+ for (const [state, path] of Object.entries(routeMap)) {
200
+ // Check if this path is already mapped to a different state
201
+ const existingState = this.#pathToStateMap[path];
202
+ if (existingState && existingState !== state) {
203
+ throw new Error(
204
+ `PageMachine: Duplicate route mapping detected. ` +
205
+ `Path "${path}" is mapped to both "${existingState}" and "${state}". ` +
206
+ `Each route path must map to exactly one state.`
207
+ );
208
+ }
209
+ this.#pathToStateMap[path] = state;
210
+ }
211
+
212
+ // Derive initial state from startPath
213
+ const initialState = this.#pathToStateMap[startPath];
214
+ if (!initialState) {
215
+ throw new Error(
216
+ `PageMachine: startPath "${startPath}" not found in routeMap`
217
+ );
218
+ }
219
+
220
+ this.#startState = initialState;
221
+ this.#current = initialState;
222
+
223
+ // Mark initial state as visited
224
+ this.#visitedStates.add(initialState);
225
+ }
226
+
227
+ /**
228
+ * Normalize onEnterHooks to ensure consistent format
229
+ * Converts function to {onEnter: function} object
230
+ *
231
+ * @param {Record<string, Function|{onEnter: Function}>} hooks - Raw hooks configuration
232
+ * @returns {Record<string, {onEnter: Function}>} Normalized hooks
233
+ */
234
+ #normalizeOnEnterHooks(hooks) {
235
+ /** @type {Record<string, {onEnter: Function} */
236
+ const normalized = {};
237
+
238
+ for (const [state, hook] of Object.entries(hooks)) {
239
+ if (typeof hook === 'function') {
240
+ // Simple function -> wrap in object
241
+ normalized[state] = { onEnter: hook };
242
+ } else if (hook?.onEnter) {
243
+ // Already an object with onEnter
244
+ normalized[state] = hook;
245
+ }
246
+ }
247
+
248
+ return normalized;
249
+ }
250
+
251
+ /**
252
+ * Synchronize machine state with URL path
253
+ *
254
+ * Call this in a $effect that watches $page.url.pathname
255
+ * Automatically tracks visited states and triggers onEnter hooks
256
+ *
257
+ * @param {string} currentPath - Current URL pathname
258
+ *
259
+ * @returns {boolean} True if state was changed
260
+ */
261
+ syncFromPath(currentPath) {
262
+ const targetState = this.#getStateFromPath(currentPath);
263
+
264
+ if (targetState && targetState !== this.#current) {
265
+ // Log state transition from URL sync
266
+ this.logger?.debug(
267
+ `syncFromPath: ${currentPath} → targetState: ${targetState}`
268
+ );
269
+
270
+ // Use #setState to handle onEnter hooks
271
+ this.#setState(targetState);
272
+
273
+ return true;
274
+ }
275
+
276
+ return false;
277
+ }
278
+
279
+ /**
280
+ * Set the current state directly (internal use only)
281
+ * Handles onEnter hooks and auto-transitions
282
+ *
283
+ * Note: This is private to enforce URL-first navigation.
284
+ * To change state, navigate to the URL using switchToPage()
285
+ * and let syncFromPath() update the state.
286
+ *
287
+ * @param {string} newState - Target state
288
+ */
289
+ async #setState(newState) {
290
+ if (newState === this.#current || this.#isTransitioning) {
291
+ return;
292
+ }
293
+
294
+ // Abort previous state's onEnter handler
295
+ if (this.#currentOnEnterHandler?.abort) {
296
+ this.#currentOnEnterHandler.abort();
297
+ }
298
+ this.#currentOnEnterHandler = null;
299
+ this.#currentOnEnterDone = null;
300
+
301
+ const oldState = this.#current;
302
+ this.#isTransitioning = true;
303
+ this.#current = newState;
304
+ this.#visitedStates.add(newState);
305
+
306
+ // Log state transition
307
+ this.logger?.debug(`setState: ${oldState} → ${newState}`);
308
+
309
+ // Check if this state has an onEnter hook
310
+ const hookConfig = this.#onEnterHooks[newState];
311
+ if (hookConfig?.onEnter) {
312
+ // Create done callback for auto-transition
313
+ let doneCalled = false;
314
+
315
+ const done = (/** @type {string} */ nextState) => {
316
+ if (!doneCalled && nextState && nextState !== newState) {
317
+ doneCalled = true;
318
+ this.#isTransitioning = false;
319
+ this.#setState(nextState);
320
+ }
321
+ };
322
+
323
+ this.#currentOnEnterDone = done;
324
+
325
+ // Call the onEnter hook
326
+ try {
327
+ const handler = hookConfig.onEnter(done);
328
+
329
+ // Store abort/complete handlers if provided
330
+ if (handler && typeof handler === 'object') {
331
+ if (handler.abort || handler.complete) {
332
+ this.#currentOnEnterHandler = {
333
+ abort: handler.abort,
334
+ complete: handler.complete
335
+ };
336
+ }
337
+ }
338
+
339
+ // If hook returned a promise, await it
340
+ if (handler?.then) {
341
+ await handler;
342
+ }
343
+ } catch (error) {
344
+ const logger = this.logger ?? console;
345
+ logger.error(`Error in onEnter hook for state ${newState}:`, error);
346
+ }
347
+ }
348
+
349
+ this.#isTransitioning = false;
350
+ this.#revision++;
351
+ }
352
+
353
+ /**
354
+ * Get state name from URL path
355
+ *
356
+ * @param {string} path - URL pathname
357
+ *
358
+ * @returns {string|null} State name or null
359
+ */
360
+ #getStateFromPath(path) {
361
+ // Try exact match first
362
+ if (this.#pathToStateMap[path]) {
363
+ return this.#pathToStateMap[path];
364
+ }
365
+
366
+ // Try partial match (path includes route)
367
+ for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
368
+ if (path.includes(routePath)) {
369
+ return state;
370
+ }
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Get route path for a given state
378
+ *
379
+ * @param {string} state - State name
380
+ *
381
+ * @returns {string} Route path or null if no mapping
382
+ */
383
+ getPathForState(state) {
384
+ const path = this.#routeMap[state];
385
+
386
+ if (!path) {
387
+ throw new Error(`No path found for state [${state}]`);
388
+ }
389
+
390
+ return path;
391
+ }
392
+
393
+ /**
394
+ * Navigate to the route path for a given state
395
+ *
396
+ * @param {string} state - State name
397
+ */
398
+ navigateToState(state) {
399
+ const path = this.getPathForState(state);
400
+ switchToPage(path);
401
+ }
402
+
403
+ /**
404
+ * Get route path for current state
405
+ *
406
+ * @returns {string|null} Route path or null if no mapping
407
+ */
408
+ getCurrentPath() {
409
+ return this.getPathForState(this.#current);
410
+ }
411
+
412
+ /**
413
+ * Get current state
414
+ *
415
+ * @returns {string} Current state name
416
+ */
417
+ get current() {
418
+ return this.#current;
419
+ }
420
+
421
+ /**
422
+ * Get the route map
423
+ *
424
+ * @returns {Record<string, string>} Copy of route map
425
+ */
426
+ get routeMap() {
427
+ return { ...this.#routeMap };
428
+ }
429
+
430
+ /* ===== Data Properties (Business/Domain State) ===== */
431
+
432
+ /**
433
+ * Set a data property value
434
+ *
435
+ * @param {string} key - Property key
436
+ * @param {any} value - Property value
437
+ *
438
+ * @example
439
+ * ```javascript
440
+ * machine.setData('HAS_STRONG_PROFILE', true);
441
+ * machine.setData('PROFILE_SCORE', 85);
442
+ * ```
443
+ */
444
+ setData(key, value) {
445
+ this.#data[key] = value;
446
+ this.#revision++;
447
+ }
448
+
449
+ /**
450
+ * Get a data property value
451
+ *
452
+ * @param {string} key - Property key
453
+ *
454
+ * @returns {any} Property value or undefined
455
+ *
456
+ * @example
457
+ * ```javascript
458
+ * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
459
+ * const score = machine.getData('PROFILE_SCORE');
460
+ * ```
461
+ */
462
+ getData(key) {
463
+ // Access revision to ensure reactivity
464
+ this.#revision;
465
+ return this.#data[key];
466
+ }
467
+
468
+ /**
469
+ * Get all data properties
470
+ *
471
+ * @returns {Record<string, any>} Copy of all data
472
+ *
473
+ * @example
474
+ * ```javascript
475
+ * const allData = machine.getAllData();
476
+ * await playerService.saveData(allData);
477
+ * ```
478
+ */
479
+ getAllData() {
480
+ // Access revision to ensure reactivity
481
+ this.#revision;
482
+ return { ...this.#data };
483
+ }
484
+
485
+ /**
486
+ * Update multiple data properties at once
487
+ *
488
+ * @param {Record<string, any>} dataUpdates - Object with key-value pairs
489
+ *
490
+ * @example
491
+ * ```javascript
492
+ * machine.updateData({
493
+ * HAS_STRONG_PROFILE: true,
494
+ * PROFILE_SCORE: 85,
495
+ * MATCHED_SECTOR: 'technology'
496
+ * });
497
+ * ```
498
+ */
499
+ updateData(dataUpdates) {
500
+ for (const [key, value] of Object.entries(dataUpdates)) {
501
+ this.#data[key] = value;
502
+ }
503
+ this.#revision++;
504
+ }
505
+
506
+ /* ===== Visited States Tracking ===== */
507
+
508
+ /**
509
+ * Check if a state has been visited
510
+ *
511
+ * @param {string} state - State name to check
512
+ *
513
+ * @returns {boolean} True if the state has been visited
514
+ *
515
+ * @example
516
+ * ```javascript
517
+ * if (machine.hasVisited(STATE_PROFILE)) {
518
+ * // User has seen profile page, skip intro
519
+ * }
520
+ * ```
521
+ */
522
+ hasVisited(state) {
523
+ // Access revision to ensure reactivity
524
+ this.#revision;
525
+ return this.#visitedStates.has(state);
526
+ }
527
+
528
+ /**
529
+ * Check if the start state has been visited
530
+ *
531
+ * @returns {boolean} True if the start state has been visited
532
+ *
533
+ * @example
534
+ * ```javascript
535
+ * if (machine.hasVisitedStart) {
536
+ * // User has been to the start page
537
+ * }
538
+ * ```
539
+ */
540
+ get hasVisitedStart() {
541
+ return this.hasVisited(this.#startState);
542
+ }
543
+
544
+ /**
545
+ * Get all visited states
546
+ *
547
+ * @returns {string[]} Array of visited state names
548
+ */
549
+ getVisitedStates() {
550
+ // Access revision to ensure reactivity
551
+ this.#revision;
552
+ return Array.from(this.#visitedStates);
553
+ }
554
+
555
+ /**
556
+ * Reset visited states tracking
557
+ * Useful for testing or resetting experience
558
+ */
559
+ resetVisitedStates() {
560
+ this.#visitedStates.clear();
561
+ this.#visitedStates.add(this.#current);
562
+ this.#revision++;
563
+ }
564
+
565
+ /* ===== Start Path Methods ===== */
566
+
567
+ /**
568
+ * Get the start path
569
+ *
570
+ * @returns {string} Start path
571
+ */
572
+ get startPath() {
573
+ return this.#startPath;
574
+ }
575
+
576
+ /**
577
+ * Get the start state
578
+ *
579
+ * @returns {string} Start state name
580
+ */
581
+ get startState() {
582
+ return this.#startState;
583
+ }
584
+
585
+ /**
586
+ * Check if the supplied path matches the start path
587
+ *
588
+ * @param {string} path - Path to check
589
+ *
590
+ * @returns {boolean} True if path matches start path
591
+ *
592
+ * @example
593
+ * ```javascript
594
+ * if (machine.isStartPath('/game/play')) {
595
+ * // User is on the start page
596
+ * }
597
+ * ```
598
+ */
599
+ isStartPath(path) {
600
+ return path === this.#startPath;
601
+ }
602
+
603
+ /**
604
+ * Check if currently on the start state
605
+ *
606
+ * @returns {boolean} True if current state is the start state
607
+ *
608
+ * @example
609
+ * ```javascript
610
+ * if (machine.isOnStartState) {
611
+ * // Show onboarding
612
+ * }
613
+ * ```
614
+ */
615
+ get isOnStartState() {
616
+ return this.#current === this.#startState;
617
+ }
618
+
619
+ /**
620
+ * Navigate to the start path
621
+ *
622
+ * @example
623
+ * ```javascript
624
+ * // Redirect user to start
625
+ * machine.redirectToStartPath();
626
+ * ```
627
+ */
628
+ redirectToStartPath() {
629
+ // Import dynamically to avoid circular dependencies
630
+ import('$src/lib/util/sveltekit.js').then(({ switchToPage }) => {
631
+ switchToPage(this.#startPath);
632
+ });
633
+ }
634
+
635
+ /* ===== Transition Control Methods ===== */
636
+
637
+ /**
638
+ * Abort current state's transitions
639
+ * Cancels animations/operations immediately (incomplete state)
640
+ *
641
+ * @example
642
+ * ```javascript
643
+ * // User clicks "Cancel" button
644
+ * machine.abortTransitions();
645
+ * ```
646
+ */
647
+ abortTransitions() {
648
+ if (this.#currentOnEnterHandler?.abort) {
649
+ this.#currentOnEnterHandler.abort();
650
+ this.#currentOnEnterHandler = null;
651
+ this.#currentOnEnterDone = null;
652
+ this.#revision++;
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Complete current state's transitions immediately
658
+ * Fast-forwards animations/operations to completion (complete state)
659
+ *
660
+ * @example
661
+ * ```javascript
662
+ * // User clicks "Skip" or "Next" button
663
+ * machine.completeTransitions();
664
+ * ```
665
+ */
666
+ completeTransitions() {
667
+ if (this.#currentOnEnterHandler?.complete) {
668
+ this.#currentOnEnterHandler.complete();
669
+ this.#currentOnEnterHandler = null;
670
+ this.#currentOnEnterDone = null;
671
+ this.#revision++;
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Check if current state has transitions that can be completed
677
+ *
678
+ * @returns {boolean} True if completeTransitions() can be called
679
+ *
680
+ * @example
681
+ * ```svelte
682
+ * {#if machine.canCompleteTransitions}
683
+ * <button onclick={() => machine.completeTransitions()}>Skip</button>
684
+ * {/if}
685
+ * ```
686
+ */
687
+ get canCompleteTransitions() {
688
+ this.#revision; // Ensure reactivity
689
+ return !!this.#currentOnEnterHandler?.complete;
690
+ }
691
+
692
+ /**
693
+ * Check if current state has transitions that can be aborted
694
+ *
695
+ * @returns {boolean} True if abortTransitions() can be called
696
+ *
697
+ * @example
698
+ * ```svelte
699
+ * {#if machine.canAbortTransitions}
700
+ * <button onclick={() => machine.abortTransitions()}>Cancel</button>
701
+ * {/if}
702
+ * ```
703
+ */
704
+ get canAbortTransitions() {
705
+ this.#revision; // Ensure reactivity
706
+ return !!this.#currentOnEnterHandler?.abort;
707
+ }
708
+ }