tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Format `Interaction` payloads for SCORM 2004 / xAPI `cmi.interactions.n.*`
3
+ * writes. Delimiters follow SCORM 2004 4th Edition RTE §4.2.7 — `cmi5` (xAPI)
4
+ * reuses the same encoding for `cmi.interaction` activity statements.
5
+ *
6
+ * ITEM delimiter [,]
7
+ * PAIR delimiter [.]
8
+ * RANGE delimiter [:]
9
+ */
10
+
11
+ import type { Interaction } from './interaction.js';
12
+
13
+ /**
14
+ * Serialize the learner response to the `learner_response` / `student_response`
15
+ * field format expected by SCORM 2004 and mirrored by xAPI.
16
+ */
17
+ export function formatResponse(i: Interaction): string {
18
+ switch (i.type) {
19
+ case 'choice':
20
+ case 'sequencing':
21
+ return i.response.join('[,]');
22
+ case 'true-false':
23
+ return i.response ? 'true' : 'false';
24
+ case 'fill-in':
25
+ case 'long-fill-in':
26
+ case 'likert':
27
+ case 'other':
28
+ return i.response;
29
+ case 'matching':
30
+ return i.response.map(([l, r]) => `${l}[.]${r}`).join('[,]');
31
+ case 'numeric':
32
+ return String(i.response);
33
+ case 'performance':
34
+ return i.response.map(([s, v]) => `${s}[.]${v}`).join('[,]');
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Serialize the `correct_responses.0.pattern` for this interaction. Returns
40
+ * `null` if no correct pattern was provided.
41
+ */
42
+ export function formatCorrectPattern(i: Interaction): string | null {
43
+ if (i.correct === undefined) return null;
44
+ switch (i.type) {
45
+ case 'choice':
46
+ case 'sequencing':
47
+ return (i.correct as string[]).join('[,]');
48
+ case 'true-false':
49
+ return (i.correct as boolean) ? 'true' : 'false';
50
+ case 'fill-in':
51
+ case 'long-fill-in':
52
+ // SCORM 2004 accepts multiple acceptable patterns joined with `[,]`.
53
+ return (i.correct as string[]).join('[,]');
54
+ case 'matching':
55
+ return (i.correct as Array<[string, string]>).map(([l, r]) => `${l}[.]${r}`).join('[,]');
56
+ case 'numeric': {
57
+ const c = i.correct as { min?: number; max?: number };
58
+ const min = c.min ?? '';
59
+ const max = c.max ?? '';
60
+ return `${min}[:]${max}`;
61
+ }
62
+ case 'likert':
63
+ case 'other':
64
+ return i.correct as string;
65
+ case 'performance':
66
+ return (i.correct as Array<[string, string | number]>)
67
+ .map(([s, v]) => `${s}[.]${v}`)
68
+ .join('[,]');
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Map Tessera interaction types to SCORM 1.2's narrower vocabulary. SCORM 1.2
74
+ * does not define `long-fill-in`; fall back to `fill-in`. `other` is not in
75
+ * the spec either — fall back to `fill-in` (free text).
76
+ */
77
+ export function scorm12Type(type: Interaction['type']): string {
78
+ switch (type) {
79
+ case 'long-fill-in':
80
+ return 'fill-in';
81
+ case 'other':
82
+ return 'fill-in';
83
+ default:
84
+ return type;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Per-standard differences in how `cmi.interactions.n.*` is written. The
90
+ * SCORM 1.2 vs 2004 deltas are: response field name, result vocabulary,
91
+ * timestamp field name+format, and the type vocabulary mapping.
92
+ */
93
+ export interface ScormInteractionSpec {
94
+ responseField: 'student_response' | 'learner_response';
95
+ timestampField: 'time' | 'timestamp';
96
+ /** Wall-clock value formatted to whichever style the standard expects. */
97
+ timestamp: string;
98
+ /** Mapped interaction type — already narrowed for SCORM 1.2 callers. */
99
+ typeValue: string;
100
+ resultLabels: { correct: string; incorrect: string };
101
+ }
102
+
103
+ /**
104
+ * Build the ordered list of `cmi.interactions.n.*` writes that SCORM 1.2 and
105
+ * SCORM 2004 adapters share. Caller wires each pair through its own LMS
106
+ * SetValue queue (the queueing semantics differ between adapters).
107
+ */
108
+ export function buildScormInteractionFields(
109
+ prefix: string,
110
+ questionId: string,
111
+ interaction: Interaction,
112
+ correct: boolean | null,
113
+ spec: ScormInteractionSpec
114
+ ): Array<[string, string]> {
115
+ const fields: Array<[string, string]> = [
116
+ [`${prefix}.id`, questionId],
117
+ [`${prefix}.type`, spec.typeValue],
118
+ [`${prefix}.${spec.responseField}`, formatResponse(interaction)],
119
+ ];
120
+ const pattern = formatCorrectPattern(interaction);
121
+ if (pattern !== null) {
122
+ fields.push([`${prefix}.correct_responses.0.pattern`, pattern]);
123
+ }
124
+ if (correct !== null) {
125
+ fields.push([
126
+ `${prefix}.result`,
127
+ correct ? spec.resultLabels.correct : spec.resultLabels.incorrect,
128
+ ]);
129
+ }
130
+ fields.push([`${prefix}.${spec.timestampField}`, spec.timestamp]);
131
+ return fields;
132
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Learner interaction data — the payload `useQuestion` returns to the runtime
3
+ * and that adapters translate into SCORM `cmi.interactions.n.*` or xAPI
4
+ * `cmi.interaction` activity statements.
5
+ *
6
+ * Variants follow the SCORM 2004 4th Edition vocabulary (RTE §4.2.7) verbatim
7
+ * so there is no impedance mismatch when writing to an LMS.
8
+ */
9
+ export type Interaction =
10
+ | { type: 'choice'; response: string[]; correct?: string[] }
11
+ | { type: 'true-false'; response: boolean; correct?: boolean }
12
+ | { type: 'fill-in'; response: string; correct?: string[]; caseMatters?: boolean }
13
+ | { type: 'long-fill-in'; response: string; correct?: string[]; caseMatters?: boolean }
14
+ | { type: 'matching'; response: Array<[string, string]>; correct?: Array<[string, string]> }
15
+ | { type: 'sequencing'; response: string[]; correct?: string[] }
16
+ | { type: 'numeric'; response: number; correct?: { min?: number; max?: number } }
17
+ | { type: 'likert'; response: string; correct?: string }
18
+ | { type: 'performance'; response: Array<[string, string | number]>; correct?: Array<[string, string | number]> }
19
+ | { type: 'other'; response: string; correct?: string };
20
+
21
+ /**
22
+ * Decide whether a learner response is correct. Returns:
23
+ * - `true` — response matches the correct pattern
24
+ * - `false` — response does not match
25
+ * - `null` — `correct` was not provided; the author will decide externally
26
+ */
27
+ export function isCorrect(i: Interaction): boolean | null {
28
+ switch (i.type) {
29
+ case 'choice':
30
+ if (i.correct === undefined) return null;
31
+ return setEqual(i.response, i.correct);
32
+ case 'true-false':
33
+ if (i.correct === undefined) return null;
34
+ return i.response === i.correct;
35
+ case 'fill-in':
36
+ case 'long-fill-in': {
37
+ if (i.correct === undefined) return null;
38
+ const matters = !!i.caseMatters;
39
+ const actual = matters ? i.response : i.response.toLowerCase();
40
+ return i.correct.some((c) => (matters ? c : c.toLowerCase()) === actual);
41
+ }
42
+ case 'matching': {
43
+ if (i.correct === undefined) return null;
44
+ return pairSetEqual(i.response, i.correct);
45
+ }
46
+ case 'sequencing': {
47
+ if (i.correct === undefined) return null;
48
+ if (i.response.length !== i.correct.length) return false;
49
+ for (let k = 0; k < i.response.length; k++) {
50
+ if (i.response[k] !== i.correct[k]) return false;
51
+ }
52
+ return true;
53
+ }
54
+ case 'numeric': {
55
+ if (i.correct === undefined) return null;
56
+ const { min, max } = i.correct;
57
+ if (min !== undefined && i.response < min) return false;
58
+ if (max !== undefined && i.response > max) return false;
59
+ return true;
60
+ }
61
+ case 'likert':
62
+ if (i.correct === undefined) return null;
63
+ return i.response === i.correct;
64
+ case 'performance': {
65
+ if (i.correct === undefined) return null;
66
+ if (i.response.length !== i.correct.length) return false;
67
+ const cmap = new Map<string, string | number>(i.correct);
68
+ for (const [stepId, value] of i.response) {
69
+ if (!cmap.has(stepId)) return false;
70
+ if (cmap.get(stepId) !== value) return false;
71
+ }
72
+ return true;
73
+ }
74
+ case 'other':
75
+ if (i.correct === undefined) return null;
76
+ return i.response === i.correct;
77
+ }
78
+ }
79
+
80
+ function setEqual(a: string[], b: string[]): boolean {
81
+ if (a.length !== b.length) return false;
82
+ const seen = new Set(b);
83
+ for (const x of a) if (!seen.has(x)) return false;
84
+ return true;
85
+ }
86
+
87
+ function pairSetEqual(
88
+ a: Array<[string, string]>,
89
+ b: Array<[string, string]>
90
+ ): boolean {
91
+ if (a.length !== b.length) return false;
92
+ const key = ([l, r]: [string, string]) => `${l}\u241F${r}`;
93
+ const bset = new Set(b.map(key));
94
+ for (const p of a) if (!bset.has(key(p))) return false;
95
+ return true;
96
+ }
@@ -0,0 +1,117 @@
1
+ import type { Manifest } from '../plugin/manifest.js';
2
+ import type { CourseConfig } from './types.js';
3
+ import { ProgressState } from './progress.svelte.js';
4
+
5
+ export function isPageComplete(
6
+ index: number,
7
+ manifest: Manifest,
8
+ progress: ProgressState,
9
+ config: CourseConfig
10
+ ): boolean {
11
+ const page = manifest.pages[index];
12
+ if (!page) return false;
13
+
14
+ if (!page.quiz) {
15
+ return progress.visitedPages.has(index);
16
+ }
17
+
18
+ if (!page.quiz.gatesProgress) {
19
+ return progress.quizScores.has(index);
20
+ }
21
+
22
+ return (progress.quizScores.get(index) ?? 0) >= config.scoring.passingScore;
23
+ }
24
+
25
+ export class NavigationState {
26
+ manifest = $state<Manifest>(null!);
27
+ #progress: ProgressState;
28
+ #config: CourseConfig;
29
+ currentPageIndex = $state(0);
30
+
31
+ canGoPrev = $derived(this.currentPageIndex > 0);
32
+
33
+ canGoNext = $derived.by(() => {
34
+ const next = this.currentPageIndex + 1;
35
+ if (next >= this.manifest.totalPages) return false;
36
+ return !this.isPageLocked(next);
37
+ });
38
+
39
+ // Cache locked-page lookup as a single derived Set so the sidebar's
40
+ // per-page `isPageLocked` calls stay O(1). Without this, sequential mode
41
+ // is O(n²) per render (each `isPageLocked` walks all earlier pages).
42
+ // Recomputed once per relevant state change.
43
+ #lockedSet = $derived.by<Set<number>>(() => this.#computeLockedSet());
44
+
45
+ constructor(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
46
+ this.manifest = manifest;
47
+ this.#progress = progress;
48
+ this.#config = config;
49
+ }
50
+
51
+ goToPage(index: number) {
52
+ if (index < 0 || index >= this.manifest.totalPages) return;
53
+ if (this.isPageLocked(index)) return;
54
+ this.currentPageIndex = index;
55
+ }
56
+
57
+ goNext() {
58
+ if (this.canGoNext) this.goToPage(this.currentPageIndex + 1);
59
+ }
60
+
61
+ goPrev() {
62
+ if (this.canGoPrev) this.goToPage(this.currentPageIndex - 1);
63
+ }
64
+
65
+ isPageLocked(index: number): boolean {
66
+ return this.#lockedSet.has(index);
67
+ }
68
+
69
+ // Compute the locked set in a single forward pass. The built-in modes are
70
+ // expressed inline (rather than calling resolveAccess/freeAccess/sequential
71
+ // per page) so the whole walk is O(n). Custom predicates fall back to a
72
+ // per-page evaluation since their semantics are arbitrary — but it still
73
+ // runs once per state change rather than once per page per render.
74
+ #computeLockedSet(): Set<number> {
75
+ const total = this.manifest.totalPages;
76
+ const locked = new Set<number>();
77
+
78
+ if (this.#config.navigation.canAccess) {
79
+ const fn = this.#config.navigation.canAccess;
80
+ for (let i = 0; i < total; i++) {
81
+ if (!fn({
82
+ pageIndex: i,
83
+ page: this.manifest.pages[i],
84
+ manifest: this.manifest,
85
+ progress: this.#progress,
86
+ config: this.#config,
87
+ })) {
88
+ locked.add(i);
89
+ }
90
+ }
91
+ return locked;
92
+ }
93
+
94
+ if (this.#config.navigation.mode === 'sequential') {
95
+ // Once any page is incomplete, every later page is locked.
96
+ for (let i = 1; i < total; i++) {
97
+ if (!isPageComplete(i - 1, this.manifest, this.#progress, this.#config)) {
98
+ for (let k = i; k < total; k++) locked.add(k);
99
+ return locked;
100
+ }
101
+ }
102
+ return locked;
103
+ }
104
+
105
+ // Free mode: a page is locked iff its most-recent gating quiz is unmet.
106
+ let lastGatingUnmet = false;
107
+ for (let i = 0; i < total; i++) {
108
+ if (lastGatingUnmet) locked.add(i);
109
+ const page = this.manifest.pages[i];
110
+ if (page.quiz?.gatesProgress) {
111
+ const score = this.#progress.quizScores.get(i) ?? 0;
112
+ lastGatingUnmet = score < this.#config.scoring.passingScore;
113
+ }
114
+ }
115
+ return locked;
116
+ }
117
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Persistence API — interface for saving/restoring course state.
3
+ */
4
+
5
+ import type { Interaction } from './interaction.js';
6
+
7
+ export interface PersistenceAdapter {
8
+ init(): Promise<void>;
9
+ getState(): SavedState | null;
10
+ saveState(state: SavedState): void;
11
+ setScore(score: number): void;
12
+ setCompletionStatus(status: 'incomplete' | 'complete'): void;
13
+ setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void;
14
+ setDuration(seconds: number): void;
15
+ /**
16
+ * Tell the LMS how the learner is leaving the SCO. SCORM 1.2 maps
17
+ * `'suspend'` → `cmi.core.exit = 'suspend'`, `'normal'` → empty (the
18
+ * vocabulary has no explicit normal value). SCORM 2004 maps directly
19
+ * onto `cmi.exit`. cmi5 / web adapters no-op.
20
+ */
21
+ setExit(mode: 'suspend' | 'normal'): void;
22
+ /**
23
+ * Report a single learner interaction (answered question) to the LMS.
24
+ * Called once per question on quiz submit or standalone useQuestion submit.
25
+ */
26
+ reportInteraction(
27
+ questionId: string,
28
+ interaction: Interaction,
29
+ correct: boolean | null
30
+ ): void;
31
+ commit(): void;
32
+ terminate(): void;
33
+ }
34
+
35
+ /**
36
+ * Compact serialization format for course state.
37
+ * Single-letter keys to minimize storage footprint (SCORM 1.2 suspend_data is 4KB).
38
+ */
39
+ export interface SavedState {
40
+ /** Bookmark — current page index */
41
+ b: number;
42
+ /** Visited — array of page indices */
43
+ v: number[];
44
+ /** Quiz scores — pageIndex (as string key) to score */
45
+ q: Record<string, number>;
46
+ /** Duration — accumulated seconds */
47
+ d: number;
48
+ /** Chunk progress — pageIndex (as string key) to highest revealed chunk index */
49
+ c?: Record<string, number>;
50
+ /** User-scoped state written via `usePersistence(key)`, keyed by caller. */
51
+ u?: Record<string, unknown>;
52
+ /** Standalone question scores — pageIndex → (questionId → score 0-100) */
53
+ s?: Record<string, Record<string, number>>;
54
+ /** Graded standalone page indices — pages with at least one graded standalone question */
55
+ gs?: number[];
56
+ }
@@ -0,0 +1,168 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
+ import type { Manifest } from '../plugin/manifest.js';
3
+ import type { CourseConfig } from './types.js';
4
+
5
+ export class ProgressState {
6
+ visitedPages = $state(new SvelteSet<number>());
7
+ quizScores = $state(new SvelteMap<number, number>());
8
+ /**
9
+ * Chunk progress — for pages that reveal content in stages (Continue buttons).
10
+ * Maps pageIndex → highest revealed chunk index (0-based).
11
+ */
12
+ chunkProgress = $state(new SvelteMap<number, number>());
13
+ /**
14
+ * Per-page standalone question scores from `useQuestion`. pageIndex → (questionId → score 0-100).
15
+ * Tracked separately from `quizScores` because <Quiz> blocks score as a unit
16
+ * while standalone questions score individually and average per page.
17
+ */
18
+ standaloneQuestionScores = $state(new SvelteMap<number, Map<string, number>>());
19
+ /**
20
+ * Set of page indices that have at least one graded standalone question.
21
+ * Pages in this set contribute to course success status via their standalone average.
22
+ */
23
+ gradedStandalonePages = $state(new SvelteSet<number>());
24
+ completionStatus = $state<'incomplete' | 'complete'>('incomplete');
25
+ successStatus = $state<'unknown' | 'passed' | 'failed'>('unknown');
26
+
27
+ /**
28
+ * Monotonic counter incremented on every persistable state mutation
29
+ * (visited/scores/chunks/standalone). Callers that need to react to *any*
30
+ * progress change can subscribe to this single signal instead of iterating
31
+ * each Map/Set themselves.
32
+ */
33
+ version = $state(0);
34
+
35
+ /**
36
+ * Mark a page as visited. Callers must call recalculateCompletion()
37
+ * afterward to update completionStatus.
38
+ */
39
+ markVisited(pageIndex: number) {
40
+ if (this.visitedPages.has(pageIndex)) return;
41
+ this.visitedPages.add(pageIndex);
42
+ this.version++;
43
+ }
44
+
45
+ /**
46
+ * Record a quiz score. Callers must call recalculateCompletion()
47
+ * and recalculateSuccess() afterward to update status fields.
48
+ */
49
+ quizCompleted(pageIndex: number, score: number) {
50
+ this.quizScores.set(pageIndex, score);
51
+ this.version++;
52
+ }
53
+
54
+ /**
55
+ * Record the highest chunk index revealed on a page. Idempotent — only
56
+ * advances forward, never backward.
57
+ */
58
+ markChunk(pageIndex: number, chunkIndex: number) {
59
+ const current = this.chunkProgress.get(pageIndex) ?? -1;
60
+ if (chunkIndex <= current) return;
61
+ this.chunkProgress.set(pageIndex, chunkIndex);
62
+ this.version++;
63
+ }
64
+
65
+ /** Highest chunk revealed on a page, or -1 if none. */
66
+ getChunk(pageIndex: number): number {
67
+ return this.chunkProgress.get(pageIndex) ?? -1;
68
+ }
69
+
70
+ /**
71
+ * Record the score for a single standalone question (one created via
72
+ * `useQuestion` outside a `<Quiz>`). When `graded`, the page is added to
73
+ * `gradedStandalonePages` so it contributes to course success.
74
+ */
75
+ markStandaloneQuestion(
76
+ pageIndex: number,
77
+ questionId: string,
78
+ score: number,
79
+ graded: boolean
80
+ ) {
81
+ let pageMap = this.standaloneQuestionScores.get(pageIndex);
82
+ if (!pageMap) {
83
+ pageMap = new Map<string, number>();
84
+ this.standaloneQuestionScores.set(pageIndex, pageMap);
85
+ }
86
+ pageMap.set(questionId, score);
87
+ if (graded) {
88
+ this.gradedStandalonePages.add(pageIndex);
89
+ }
90
+ this.version++;
91
+ }
92
+
93
+ /** Average of standalone question scores on a page, or 0 if none. */
94
+ getPageStandaloneAverage(pageIndex: number): number {
95
+ const pageMap = this.standaloneQuestionScores.get(pageIndex);
96
+ if (!pageMap || pageMap.size === 0) return 0;
97
+ let sum = 0;
98
+ for (const s of pageMap.values()) sum += s;
99
+ return sum / pageMap.size;
100
+ }
101
+
102
+ recalculateCompletion(manifest: Manifest, config: CourseConfig) {
103
+ if (config.completion.mode === 'percentage') {
104
+ const threshold = config.completion.percentageThreshold ?? 100;
105
+ const percent = manifest.totalPages > 0
106
+ ? (this.visitedPages.size / manifest.totalPages) * 100
107
+ : 0;
108
+ this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
109
+ } else if (config.completion.mode === 'quiz') {
110
+ const { indices } = this.#gradedPages(manifest);
111
+ if (indices.length === 0) {
112
+ this.completionStatus = 'incomplete';
113
+ return;
114
+ }
115
+ const average = this.#gradedAverage(indices);
116
+ this.completionStatus = average >= config.scoring.passingScore ? 'complete' : 'incomplete';
117
+ }
118
+ }
119
+
120
+ recalculateSuccess(manifest: Manifest, config: CourseConfig) {
121
+ const { indices, attempted } = this.#gradedPages(manifest);
122
+
123
+ if (indices.length === 0) {
124
+ this.successStatus = 'unknown';
125
+ return;
126
+ }
127
+ // Stay unknown until at least one graded score has been recorded
128
+ if (!attempted) {
129
+ this.successStatus = 'unknown';
130
+ return;
131
+ }
132
+ const average = this.#gradedAverage(indices);
133
+ this.successStatus = average >= config.scoring.passingScore ? 'passed' : 'failed';
134
+ }
135
+
136
+ /**
137
+ * Union of pages that contribute to graded scoring: pageConfig graded quizzes
138
+ * plus pages with at least one graded standalone question (deduped).
139
+ * `attempted` is true if any of those pages has a recorded score.
140
+ */
141
+ #gradedPages(manifest: Manifest): { indices: number[]; attempted: boolean } {
142
+ const quizPages = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
143
+ const indices = [...new Set([...quizPages, ...this.gradedStandalonePages])];
144
+ const attempted = indices.some(i => this.#hasScore(i));
145
+ return { indices, attempted };
146
+ }
147
+
148
+ /** Whether a page has any recorded graded score (quiz or standalone). */
149
+ #hasScore(pageIndex: number): boolean {
150
+ if (this.quizScores.has(pageIndex)) return true;
151
+ const pageMap = this.standaloneQuestionScores.get(pageIndex);
152
+ return !!pageMap && pageMap.size > 0;
153
+ }
154
+
155
+ /**
156
+ * Average across the given page indices. Each page contributes its quiz score
157
+ * if present, otherwise its standalone average. Pages with no recorded score
158
+ * contribute 0 (matching the existing "unattempted graded quiz = 0" rule).
159
+ */
160
+ #gradedAverage(indices: number[]): number {
161
+ if (indices.length === 0) return 0;
162
+ let sum = 0;
163
+ for (const i of indices) {
164
+ sum += this.quizScores.get(i) ?? this.getPageStandaloneAverage(i);
165
+ }
166
+ return sum / indices.length;
167
+ }
168
+ }