@lessonkit/core 1.3.1 → 1.5.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.
@@ -0,0 +1,813 @@
1
+ // src/identityTypes.ts
2
+ var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
3
+ var ID_MAX_LENGTH = 64;
4
+
5
+ // src/validateId.ts
6
+ function validateId(input, path = "id") {
7
+ if (typeof input !== "string") {
8
+ return { ok: false, issues: [{ path, message: "id must be a string" }] };
9
+ }
10
+ const id = input.trim();
11
+ if (!id.length) {
12
+ return { ok: false, issues: [{ path, message: "id must not be empty" }] };
13
+ }
14
+ if (id.length > ID_MAX_LENGTH) {
15
+ return {
16
+ ok: false,
17
+ issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
18
+ };
19
+ }
20
+ if (!ID_PATTERN.test(id)) {
21
+ return {
22
+ ok: false,
23
+ issues: [
24
+ {
25
+ path,
26
+ message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
27
+ }
28
+ ]
29
+ };
30
+ }
31
+ return { ok: true, id };
32
+ }
33
+ function parseCourseId(input) {
34
+ const result = validateId(input, "courseId");
35
+ return result.ok ? result.id : null;
36
+ }
37
+ function parseLessonId(input) {
38
+ const result = validateId(input, "lessonId");
39
+ return result.ok ? result.id : null;
40
+ }
41
+ function parseCheckId(input) {
42
+ const result = validateId(input, "checkId");
43
+ return result.ok ? result.id : null;
44
+ }
45
+ function parseBlockId(input) {
46
+ const result = validateId(input, "blockId");
47
+ return result.ok ? result.id : null;
48
+ }
49
+ function assertValidId(input, path = "id") {
50
+ const result = validateId(input, path);
51
+ if (!result.ok) {
52
+ throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
53
+ }
54
+ return result.id;
55
+ }
56
+
57
+ // src/ids.ts
58
+ function randomSessionIdFallback() {
59
+ const g = globalThis;
60
+ if (g.crypto?.getRandomValues) {
61
+ const bytes = new Uint8Array(16);
62
+ g.crypto.getRandomValues(bytes);
63
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ }
65
+ throw new Error(
66
+ "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
67
+ );
68
+ }
69
+ function createSessionId() {
70
+ const g = globalThis;
71
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
72
+ return randomSessionIdFallback();
73
+ }
74
+
75
+ // src/time.ts
76
+ function nowIso() {
77
+ return (/* @__PURE__ */ new Date()).toISOString();
78
+ }
79
+
80
+ // src/internal/env.ts
81
+ function isDevEnvironment() {
82
+ const g = globalThis;
83
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
84
+ }
85
+ function warnDev(message, err) {
86
+ if (!isDevEnvironment()) return;
87
+ console.warn(message, err instanceof Error ? err.message : err);
88
+ }
89
+
90
+ // src/telemetry/eventRegistry.ts
91
+ function resolveLessonId(opts, eventName) {
92
+ const lessonId = opts.lessonId ?? opts.data?.lessonId;
93
+ if (!lessonId) throw new Error(`${eventName} requires lessonId`);
94
+ return lessonId;
95
+ }
96
+ function withLessonScopedData(name, base, lessonId, data) {
97
+ return { name, ...base, lessonId, data: { ...data, lessonId } };
98
+ }
99
+ var TELEMETRY_EVENT_REGISTRY = {
100
+ course_started: {
101
+ build: (_opts, base) => ({ name: "course_started", ...base })
102
+ },
103
+ course_completed: {
104
+ build: (_opts, base) => ({ name: "course_completed", ...base })
105
+ },
106
+ lesson_started: {
107
+ requiresLessonId: true,
108
+ build: (opts, base) => {
109
+ if (opts.name !== "lesson_started") throw new Error("unexpected event");
110
+ const lessonId = resolveLessonId(opts, "lesson_started");
111
+ return withLessonScopedData("lesson_started", base, lessonId, opts.data);
112
+ }
113
+ },
114
+ lesson_completed: {
115
+ requiresLessonId: true,
116
+ build: (opts, base) => {
117
+ if (opts.name !== "lesson_completed") throw new Error("unexpected event");
118
+ const lessonId = resolveLessonId(opts, opts.name);
119
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
120
+ }
121
+ },
122
+ lesson_time_on_task: {
123
+ requiresLessonId: true,
124
+ build: (opts, base) => {
125
+ if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
126
+ const lessonId = resolveLessonId(opts, opts.name);
127
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
128
+ }
129
+ },
130
+ quiz_answered: {
131
+ requiresLessonId: true,
132
+ tryBuildMissingLessonWarning: "quiz",
133
+ build: (opts, base) => {
134
+ if (opts.name !== "quiz_answered") throw new Error("unexpected event");
135
+ const lessonId = opts.lessonId;
136
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
137
+ return {
138
+ name: "quiz_answered",
139
+ ...base,
140
+ lessonId,
141
+ data: opts.data
142
+ };
143
+ }
144
+ },
145
+ quiz_completed: {
146
+ requiresLessonId: true,
147
+ tryBuildMissingLessonWarning: "quiz",
148
+ build: (opts, base) => {
149
+ if (opts.name !== "quiz_completed") throw new Error("unexpected event");
150
+ const lessonId = opts.lessonId;
151
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
152
+ return {
153
+ name: "quiz_completed",
154
+ ...base,
155
+ lessonId,
156
+ data: opts.data
157
+ };
158
+ }
159
+ },
160
+ assessment_answered: {
161
+ requiresLessonId: true,
162
+ tryBuildMissingLessonWarning: "assessment",
163
+ build: (opts, base) => {
164
+ if (opts.name !== "assessment_answered") throw new Error("unexpected event");
165
+ const lessonId = opts.lessonId;
166
+ if (!lessonId) throw new Error("assessment_answered requires active lessonId");
167
+ return {
168
+ name: "assessment_answered",
169
+ ...base,
170
+ lessonId,
171
+ data: opts.data
172
+ };
173
+ }
174
+ },
175
+ assessment_completed: {
176
+ requiresLessonId: true,
177
+ tryBuildMissingLessonWarning: "assessment",
178
+ build: (opts, base) => {
179
+ if (opts.name !== "assessment_completed") throw new Error("unexpected event");
180
+ const lessonId = opts.lessonId;
181
+ if (!lessonId) throw new Error("assessment_completed requires active lessonId");
182
+ return {
183
+ name: "assessment_completed",
184
+ ...base,
185
+ lessonId,
186
+ data: opts.data
187
+ };
188
+ }
189
+ },
190
+ interaction: {
191
+ build: (opts, base) => {
192
+ if (opts.name !== "interaction") throw new Error("unexpected event");
193
+ return {
194
+ name: "interaction",
195
+ ...base,
196
+ lessonId: opts.lessonId,
197
+ data: opts.data
198
+ };
199
+ }
200
+ },
201
+ book_page_viewed: {
202
+ requiresLessonId: true,
203
+ build: (opts, base) => {
204
+ if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
205
+ const lessonId = opts.lessonId;
206
+ if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
207
+ return {
208
+ name: "book_page_viewed",
209
+ ...base,
210
+ lessonId,
211
+ data: opts.data
212
+ };
213
+ }
214
+ },
215
+ slide_viewed: {
216
+ requiresLessonId: true,
217
+ build: (opts, base) => {
218
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
219
+ const lessonId = opts.lessonId;
220
+ if (!lessonId) throw new Error("slide_viewed requires active lessonId");
221
+ return {
222
+ name: "slide_viewed",
223
+ ...base,
224
+ lessonId,
225
+ data: opts.data
226
+ };
227
+ }
228
+ },
229
+ compound_page_viewed: {
230
+ requiresLessonId: true,
231
+ build: (opts, base) => {
232
+ if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
233
+ const lessonId = opts.lessonId;
234
+ if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
235
+ return {
236
+ name: "compound_page_viewed",
237
+ ...base,
238
+ lessonId,
239
+ data: opts.data
240
+ };
241
+ }
242
+ },
243
+ hotspot_opened: {
244
+ build: (opts, base) => {
245
+ if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
246
+ return {
247
+ name: "hotspot_opened",
248
+ ...base,
249
+ lessonId: opts.lessonId,
250
+ data: opts.data
251
+ };
252
+ }
253
+ },
254
+ accordion_section_toggled: {
255
+ build: (opts, base) => {
256
+ if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
257
+ return {
258
+ name: "accordion_section_toggled",
259
+ ...base,
260
+ lessonId: opts.lessonId,
261
+ data: opts.data
262
+ };
263
+ }
264
+ },
265
+ flashcard_flipped: {
266
+ build: (opts, base) => {
267
+ if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
268
+ return {
269
+ name: "flashcard_flipped",
270
+ ...base,
271
+ lessonId: opts.lessonId,
272
+ data: opts.data
273
+ };
274
+ }
275
+ },
276
+ image_slider_changed: {
277
+ build: (opts, base) => {
278
+ if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
279
+ return {
280
+ name: "image_slider_changed",
281
+ ...base,
282
+ lessonId: opts.lessonId,
283
+ data: opts.data
284
+ };
285
+ }
286
+ },
287
+ video_cue_reached: {
288
+ requiresLessonId: true,
289
+ build: (opts, base) => {
290
+ if (opts.name !== "video_cue_reached") throw new Error("unexpected event");
291
+ const lessonId = opts.lessonId;
292
+ if (!lessonId) throw new Error("video_cue_reached requires active lessonId");
293
+ return {
294
+ name: "video_cue_reached",
295
+ ...base,
296
+ lessonId,
297
+ data: opts.data
298
+ };
299
+ }
300
+ },
301
+ video_segment_completed: {
302
+ requiresLessonId: true,
303
+ build: (opts, base) => {
304
+ if (opts.name !== "video_segment_completed") throw new Error("unexpected event");
305
+ const lessonId = opts.lessonId;
306
+ if (!lessonId) throw new Error("video_segment_completed requires active lessonId");
307
+ return {
308
+ name: "video_segment_completed",
309
+ ...base,
310
+ lessonId,
311
+ data: opts.data
312
+ };
313
+ }
314
+ },
315
+ memory_card_flipped: {
316
+ build: (opts, base) => {
317
+ if (opts.name !== "memory_card_flipped") throw new Error("unexpected event");
318
+ return {
319
+ name: "memory_card_flipped",
320
+ ...base,
321
+ lessonId: opts.lessonId,
322
+ data: opts.data
323
+ };
324
+ }
325
+ },
326
+ information_wall_search: {
327
+ build: (opts, base) => {
328
+ if (opts.name !== "information_wall_search") throw new Error("unexpected event");
329
+ return {
330
+ name: "information_wall_search",
331
+ ...base,
332
+ lessonId: opts.lessonId,
333
+ data: opts.data
334
+ };
335
+ }
336
+ },
337
+ parallax_slide_viewed: {
338
+ build: (opts, base) => {
339
+ if (opts.name !== "parallax_slide_viewed") throw new Error("unexpected event");
340
+ return {
341
+ name: "parallax_slide_viewed",
342
+ ...base,
343
+ lessonId: opts.lessonId,
344
+ data: opts.data
345
+ };
346
+ }
347
+ },
348
+ questionnaire_submitted: {
349
+ requiresLessonId: true,
350
+ build: (opts, base) => {
351
+ if (opts.name !== "questionnaire_submitted") throw new Error("unexpected event");
352
+ const lessonId = opts.lessonId;
353
+ if (!lessonId) throw new Error("questionnaire_submitted requires active lessonId");
354
+ return {
355
+ name: "questionnaire_submitted",
356
+ ...base,
357
+ lessonId,
358
+ data: opts.data
359
+ };
360
+ }
361
+ },
362
+ branch_node_viewed: {
363
+ requiresLessonId: true,
364
+ build: (opts, base) => {
365
+ if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
366
+ const lessonId = opts.lessonId;
367
+ if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
368
+ return {
369
+ name: "branch_node_viewed",
370
+ ...base,
371
+ lessonId,
372
+ data: opts.data
373
+ };
374
+ }
375
+ },
376
+ branch_selected: {
377
+ requiresLessonId: true,
378
+ build: (opts, base) => {
379
+ if (opts.name !== "branch_selected") throw new Error("unexpected event");
380
+ const lessonId = opts.lessonId;
381
+ if (!lessonId) throw new Error("branch_selected requires active lessonId");
382
+ return {
383
+ name: "branch_selected",
384
+ ...base,
385
+ lessonId,
386
+ data: opts.data
387
+ };
388
+ }
389
+ }
390
+ };
391
+ function buildTelemetryEventFromRegistry(opts) {
392
+ const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
393
+ if (!entry) {
394
+ throw new Error("Unexpected value");
395
+ }
396
+ const base = {
397
+ timestamp: opts.timestamp ?? nowIso(),
398
+ courseId: opts.courseId,
399
+ sessionId: opts.sessionId,
400
+ attemptId: opts.attemptId,
401
+ user: opts.user
402
+ };
403
+ return entry.build(opts, base);
404
+ }
405
+ function getTelemetryEventRegistryEntry(name) {
406
+ return TELEMETRY_EVENT_REGISTRY[name];
407
+ }
408
+
409
+ // src/telemetryBuilder.ts
410
+ var warnedMissingQuizLesson = false;
411
+ var warnedMissingAssessmentLesson = false;
412
+ function resetTelemetryBuilderWarningsForTests() {
413
+ warnedMissingQuizLesson = false;
414
+ warnedMissingAssessmentLesson = false;
415
+ }
416
+ function buildTelemetryEvent(opts) {
417
+ return buildTelemetryEventFromRegistry(opts);
418
+ }
419
+ function tryBuildTelemetryEvent(opts) {
420
+ const entry = getTelemetryEventRegistryEntry(opts.name);
421
+ if (entry.requiresLessonId && !opts.lessonId) {
422
+ if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
423
+ if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
424
+ warnedMissingQuizLesson = true;
425
+ console.warn(
426
+ `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
427
+ );
428
+ }
429
+ if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
430
+ warnedMissingAssessmentLesson = true;
431
+ console.warn(
432
+ `[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
433
+ );
434
+ }
435
+ }
436
+ return null;
437
+ }
438
+ return buildTelemetryEvent(opts);
439
+ }
440
+
441
+ // src/ports.ts
442
+ function createDefaultClock() {
443
+ return {
444
+ nowMs: () => Date.now(),
445
+ nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
446
+ };
447
+ }
448
+ function createNoopStorage() {
449
+ const memory = /* @__PURE__ */ new Map();
450
+ return {
451
+ getItem: (key) => memory.get(key) ?? null,
452
+ setItem: (key, value) => {
453
+ memory.set(key, value);
454
+ return true;
455
+ },
456
+ removeItem: (key) => {
457
+ memory.delete(key);
458
+ },
459
+ resetForTests: () => {
460
+ memory.clear();
461
+ }
462
+ };
463
+ }
464
+ function createMemoryBackedSessionStorage(session) {
465
+ const memory = /* @__PURE__ */ new Map();
466
+ const tombstones = /* @__PURE__ */ new Set();
467
+ let warnedPersistFailure = false;
468
+ const syncFromStorageEvent = (key, newValue) => {
469
+ if (key === null) {
470
+ memory.clear();
471
+ tombstones.clear();
472
+ return;
473
+ }
474
+ tombstones.delete(key);
475
+ if (newValue === null) {
476
+ memory.delete(key);
477
+ } else {
478
+ memory.set(key, newValue);
479
+ }
480
+ };
481
+ if (typeof window !== "undefined") {
482
+ window.addEventListener("storage", (event) => {
483
+ if (event.storageArea !== sessionStorage) return;
484
+ syncFromStorageEvent(event.key, event.newValue);
485
+ });
486
+ }
487
+ const warnPersistFailure = () => {
488
+ if (warnedPersistFailure) return;
489
+ warnedPersistFailure = true;
490
+ const g = globalThis;
491
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "development") {
492
+ console.warn(
493
+ "[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
494
+ );
495
+ }
496
+ };
497
+ return {
498
+ getItem: (key) => {
499
+ if (tombstones.has(key)) return null;
500
+ if (memory.has(key)) return memory.get(key);
501
+ try {
502
+ const value = session.getItem(key);
503
+ if (value !== null) memory.set(key, value);
504
+ return value;
505
+ } catch {
506
+ return memory.get(key) ?? null;
507
+ }
508
+ },
509
+ setItem: (key, value) => {
510
+ tombstones.delete(key);
511
+ memory.set(key, value);
512
+ try {
513
+ session.setItem(key, value);
514
+ return true;
515
+ } catch {
516
+ warnPersistFailure();
517
+ return false;
518
+ }
519
+ },
520
+ removeItem: (key) => {
521
+ memory.delete(key);
522
+ try {
523
+ session.removeItem(key);
524
+ tombstones.delete(key);
525
+ } catch {
526
+ warnPersistFailure();
527
+ tombstones.add(key);
528
+ }
529
+ },
530
+ resetForTests: () => {
531
+ memory.clear();
532
+ tombstones.clear();
533
+ }
534
+ };
535
+ }
536
+ function resetStoragePortForTests(storage) {
537
+ storage.resetForTests?.();
538
+ }
539
+ function createInMemorySessionStoragePort() {
540
+ const memory = /* @__PURE__ */ new Map();
541
+ return {
542
+ getItem: (key) => memory.get(key) ?? null,
543
+ setItem: (key, value) => {
544
+ memory.set(key, value);
545
+ return true;
546
+ },
547
+ removeItem: (key) => {
548
+ memory.delete(key);
549
+ },
550
+ resetForTests: () => {
551
+ memory.clear();
552
+ }
553
+ };
554
+ }
555
+ function resolveBrowserSessionStorage() {
556
+ try {
557
+ if (typeof sessionStorage === "undefined" || sessionStorage == null) {
558
+ return null;
559
+ }
560
+ return sessionStorage;
561
+ } catch {
562
+ return null;
563
+ }
564
+ }
565
+ function createSessionStoragePort() {
566
+ const session = resolveBrowserSessionStorage();
567
+ if (!session) {
568
+ return createInMemorySessionStoragePort();
569
+ }
570
+ return createMemoryBackedSessionStorage(session);
571
+ }
572
+ function createGlobalTimer() {
573
+ return {
574
+ setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
575
+ clearInterval: (id) => globalThis.clearInterval(id)
576
+ };
577
+ }
578
+
579
+ // src/session.ts
580
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
581
+ var volatileSessionIds = /* @__PURE__ */ new WeakMap();
582
+ function isDevEnvironment2() {
583
+ const g = globalThis;
584
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
585
+ }
586
+ function getTabSessionId(storage) {
587
+ return storage.getItem(SESSION_STORAGE_KEY);
588
+ }
589
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
590
+ var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
591
+ var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
592
+ var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
593
+ function sessionKeySegment(sessionId) {
594
+ const validated = validateId(sessionId);
595
+ return validated.ok ? validated.id : encodeURIComponent(sessionId);
596
+ }
597
+ function resolveSessionId(storage, provided) {
598
+ if (provided !== void 0) {
599
+ const trimmed = provided.trim();
600
+ if (trimmed.length > 0) {
601
+ const validated = validateId(trimmed);
602
+ if (validated.ok) return validated.id;
603
+ if (isDevEnvironment2()) {
604
+ console.warn(
605
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
606
+ );
607
+ }
608
+ }
609
+ }
610
+ const existing = storage.getItem(SESSION_STORAGE_KEY);
611
+ if (existing) return existing;
612
+ const volatile = volatileSessionIds.get(storage);
613
+ if (volatile) return volatile;
614
+ const id = createSessionId();
615
+ const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
616
+ if (!persisted) {
617
+ volatileSessionIds.set(storage, id);
618
+ if (isDevEnvironment2()) {
619
+ console.warn(
620
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
621
+ );
622
+ }
623
+ return id;
624
+ }
625
+ return id;
626
+ }
627
+ function courseStartedStorageKey(sessionId, courseId) {
628
+ return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
629
+ }
630
+ function courseStartedTrackingStorageKey(sessionId, courseId) {
631
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
632
+ }
633
+ function courseStartedPipelineStorageKey(sessionId, courseId) {
634
+ return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
635
+ }
636
+ function courseStartedXapiStorageKey(sessionId, courseId) {
637
+ return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
638
+ }
639
+ function hasCourseStarted(storage, sessionId, courseId) {
640
+ if (!courseId) return false;
641
+ return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
642
+ }
643
+ function markCourseStarted(storage, sessionId, courseId) {
644
+ if (!courseId) return false;
645
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
646
+ }
647
+ function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
648
+ if (!courseId) return false;
649
+ return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
650
+ }
651
+ function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
652
+ if (!courseId) return false;
653
+ return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
654
+ }
655
+ function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
656
+ if (!courseId) return false;
657
+ return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
658
+ }
659
+ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
660
+ if (!courseId) return false;
661
+ return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
662
+ }
663
+ function hasCourseStartedXapiSent(storage, sessionId, courseId) {
664
+ if (!courseId) return false;
665
+ return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
666
+ }
667
+ function markCourseStartedXapiSent(storage, sessionId, courseId) {
668
+ if (!courseId) return false;
669
+ return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
670
+ }
671
+ function resetSharedVolatileSessionIdForTests() {
672
+ }
673
+ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
674
+ if (!hasMark) return;
675
+ if (storage.setItem(toKey, "1")) {
676
+ storage.removeItem?.(fromKey);
677
+ }
678
+ }
679
+ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
680
+ if (!courseId || fromSessionId === toSessionId) return;
681
+ migrateStorageMark(
682
+ storage,
683
+ courseStartedStorageKey(fromSessionId, courseId),
684
+ courseStartedStorageKey(toSessionId, courseId),
685
+ hasCourseStarted(storage, fromSessionId, courseId)
686
+ );
687
+ migrateStorageMark(
688
+ storage,
689
+ courseStartedTrackingStorageKey(fromSessionId, courseId),
690
+ courseStartedTrackingStorageKey(toSessionId, courseId),
691
+ hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
692
+ );
693
+ migrateStorageMark(
694
+ storage,
695
+ courseStartedPipelineStorageKey(fromSessionId, courseId),
696
+ courseStartedPipelineStorageKey(toSessionId, courseId),
697
+ hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
698
+ );
699
+ migrateStorageMark(
700
+ storage,
701
+ courseStartedXapiStorageKey(fromSessionId, courseId),
702
+ courseStartedXapiStorageKey(toSessionId, courseId),
703
+ hasCourseStartedXapiSent(storage, fromSessionId, courseId)
704
+ );
705
+ }
706
+
707
+ // src/runtime/courseLifecycle.ts
708
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
709
+ function resetCourseStartedEmitFlightForTests() {
710
+ courseStartedEmitFlights.clear();
711
+ }
712
+ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
713
+ const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
714
+ const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
715
+ if (alreadyEmittedToSink) {
716
+ return Promise.resolve({ emitted: true, marked });
717
+ }
718
+ const existing = courseStartedEmitFlights.get(flightKey);
719
+ if (existing) {
720
+ return existing;
721
+ }
722
+ const flight = Promise.resolve().then(() => {
723
+ try {
724
+ const emitted = deps.emitCourseStartedEvent(ctx);
725
+ if (emitted && !marked) {
726
+ markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
727
+ }
728
+ return {
729
+ emitted,
730
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
731
+ };
732
+ } catch {
733
+ return { emitted: false, marked };
734
+ } finally {
735
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
736
+ courseStartedEmitFlights.delete(flightKey);
737
+ }
738
+ }
739
+ });
740
+ courseStartedEmitFlights.set(flightKey, flight);
741
+ return flight;
742
+ }
743
+ function buildCourseStartedTelemetryEvent(ctx) {
744
+ return buildTelemetryEvent({
745
+ name: "course_started",
746
+ courseId: ctx.courseId,
747
+ sessionId: ctx.sessionId,
748
+ attemptId: ctx.attemptId,
749
+ user: ctx.user
750
+ });
751
+ }
752
+ function completeLessonWithTelemetry(opts) {
753
+ const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
754
+ if (!result.didComplete) return false;
755
+ opts.emitLessonCompleted(opts.lessonId, result.durationMs);
756
+ return true;
757
+ }
758
+ function completeCourseWithTelemetry(opts) {
759
+ const current = opts.progress.getState();
760
+ if (current.activeLessonId) {
761
+ completeLessonWithTelemetry({
762
+ progress: opts.progress,
763
+ lessonId: current.activeLessonId,
764
+ nowMs: opts.nowMs,
765
+ emitLessonCompleted: opts.emitLessonCompleted
766
+ });
767
+ }
768
+ const result = opts.progress.completeCourse();
769
+ if (!result.didComplete) return false;
770
+ opts.emitCourseCompleted();
771
+ return true;
772
+ }
773
+
774
+ export {
775
+ ID_PATTERN,
776
+ ID_MAX_LENGTH,
777
+ validateId,
778
+ parseCourseId,
779
+ parseLessonId,
780
+ parseCheckId,
781
+ parseBlockId,
782
+ assertValidId,
783
+ isDevEnvironment,
784
+ warnDev,
785
+ createSessionId,
786
+ nowIso,
787
+ resetTelemetryBuilderWarningsForTests,
788
+ buildTelemetryEvent,
789
+ tryBuildTelemetryEvent,
790
+ createDefaultClock,
791
+ createNoopStorage,
792
+ resetStoragePortForTests,
793
+ createSessionStoragePort,
794
+ createGlobalTimer,
795
+ SESSION_STORAGE_KEY,
796
+ getTabSessionId,
797
+ resolveSessionId,
798
+ hasCourseStarted,
799
+ markCourseStarted,
800
+ hasCourseStartedEmittedToTracking,
801
+ markCourseStartedEmittedToTracking,
802
+ hasCourseStartedPipelineDelivered,
803
+ markCourseStartedPipelineDelivered,
804
+ hasCourseStartedXapiSent,
805
+ markCourseStartedXapiSent,
806
+ resetSharedVolatileSessionIdForTests,
807
+ migrateCourseStartedMark,
808
+ resetCourseStartedEmitFlightForTests,
809
+ tryEmitCourseStarted,
810
+ buildCourseStartedTelemetryEvent,
811
+ completeLessonWithTelemetry,
812
+ completeCourseWithTelemetry
813
+ };