@anantagnanam/lms 0.1.0 → 0.3.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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +40 -2
  3. package/astro/assignment-player.ts +231 -0
  4. package/astro/client.ts +97 -152
  5. package/astro/components/AssignmentFeedback.astro +32 -0
  6. package/astro/components/AssignmentRubric.astro +27 -0
  7. package/astro/components/CourseOverview.astro +25 -3
  8. package/astro/components/LearnerDashboard.astro +9 -0
  9. package/astro/components/LearningShell.astro +16 -1
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +67 -0
  12. package/astro/components/LessonPlayer.astro +166 -99
  13. package/astro/components/MediaPlayer.astro +55 -4
  14. package/astro/draft-queue.ts +95 -0
  15. package/astro/media-player.ts +124 -0
  16. package/astro/pages/author-preview.astro +73 -0
  17. package/astro/pages/course.astro +10 -0
  18. package/astro/pages/lesson.astro +3 -0
  19. package/astro/pages/library.astro +173 -0
  20. package/astro/pages/paths.astro +73 -0
  21. package/astro/pages/study.astro +180 -0
  22. package/astro/quiz-drafts.ts +23 -0
  23. package/astro/quiz-player.ts +362 -0
  24. package/astro/study-tools.ts +386 -0
  25. package/astro/styles.css +30 -0
  26. package/astro/transcript-search.ts +56 -0
  27. package/dist/admin.d.ts +3 -1
  28. package/dist/admin.js +756 -127
  29. package/dist/admin.js.map +1 -1
  30. package/dist/assignments-CsuAjxGO.js +75 -0
  31. package/dist/assignments-CsuAjxGO.js.map +1 -0
  32. package/dist/astro.js +4 -0
  33. package/dist/astro.js.map +1 -1
  34. package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
  35. package/dist/constants-DyQbwNbP.js.map +1 -0
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/runtime.d.ts +129 -16
  39. package/dist/runtime.js +1201 -130
  40. package/dist/runtime.js.map +1 -1
  41. package/dist/schemas.d.ts +136 -3
  42. package/dist/schemas.js +80 -7
  43. package/dist/schemas.js.map +1 -1
  44. package/dist/server.d.ts +18 -1
  45. package/dist/server.js +17 -3
  46. package/dist/server.js.map +1 -1
  47. package/dist/types.d.ts +172 -4
  48. package/package.json +1 -1
  49. package/dist/constants-C0kT94tc.js.map +0 -1
  50. package/dist/errors-ChzJS9al.js +0 -16
  51. package/dist/errors-ChzJS9al.js.map +0 -1
@@ -0,0 +1,180 @@
1
+ ---
2
+ import Layout from "virtual:anantagnanam-lms/layout";
3
+ import LearningShell from "../components/LearningShell.astro";
4
+ import { lmsLearner, loginUrl, lessonUrl, LmsError } from "@anantagnanam/lms/server";
5
+ import { basePath, loginPath } from "virtual:anantagnanam-lms/config";
6
+ import type { Notebook, CourseAgenda, Page } from "@anantagnanam/lms/types";
7
+ export const prerender = false;
8
+ Astro.response.headers.set("Cache-Control", "private, no-store");
9
+ Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
10
+ if (!Astro.locals.user)
11
+ return Astro.redirect(loginUrl(loginPath, Astro.url.pathname + Astro.url.search));
12
+ let courses: Page<{ courseId: string; title: string; slug: string | null }> = {
13
+ items: [],
14
+ hasMore: false,
15
+ };
16
+ let notebook: Notebook | undefined;
17
+ let agenda: CourseAgenda | undefined;
18
+ let error = "";
19
+ let agendaError = "";
20
+ const courseId = Astro.url.searchParams.get("courseId");
21
+ try {
22
+ courses = await lmsLearner(
23
+ Astro.locals,
24
+ "learner/study/courses",
25
+ { cursor: Astro.url.searchParams.get("cursor") ?? undefined, limit: 24 },
26
+ Astro.url.origin,
27
+ );
28
+ if (courseId) {
29
+ notebook = await lmsLearner(Astro.locals, "learner/notebook", { courseId }, Astro.url.origin);
30
+ try {
31
+ agenda = await lmsLearner(Astro.locals, "learner/agenda", { courseId }, Astro.url.origin);
32
+ } catch (cause) {
33
+ agendaError = cause instanceof Error ? cause.message : "Course agenda is unavailable.";
34
+ }
35
+ }
36
+ } catch (cause) {
37
+ Astro.response.status = cause instanceof LmsError ? cause.status : 503;
38
+ error = cause instanceof Error ? cause.message : "Study tools are unavailable.";
39
+ }
40
+ ---
41
+
42
+ <Layout title="My notebook and agenda" robots="noindex, nofollow">
43
+ <LearningShell>
44
+ <h1>My notebook &amp; agenda</h1>
45
+ <p>Revisit important ideas and plan your next course activity.</p>
46
+ {error && <p role="alert">{error}</p>}
47
+ <details open={!courseId}>
48
+ <summary>Choose an enrolled course</summary>
49
+ <ul>
50
+ {courses.items.map((course) => (
51
+ <li>
52
+ <a
53
+ href={`?courseId=${encodeURIComponent(course.courseId)}`}
54
+ aria-current={course.courseId === courseId ? "page" : undefined}
55
+ >
56
+ {course.title}
57
+ </a>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ {!courses.items.length && !error && <p>No enrolled courses on this page.</p>}
62
+ {courses.hasMore && courses.cursor && (
63
+ <a href={`?cursor=${encodeURIComponent(courses.cursor)}`}>More courses →</a>
64
+ )}
65
+ </details>
66
+ {notebook && (
67
+ <>
68
+ <h2>{notebook.course.title}</h2>
69
+ <section
70
+ class="lms-learning-card"
71
+ data-lms-notebook
72
+ data-notebook={JSON.stringify(notebook)}
73
+ data-base-path={basePath}
74
+ >
75
+ <h3>Private notebook</h3>
76
+ <p>
77
+ Add notes from a lesson. Search this course’s notes, star important ideas, or archive
78
+ and restore entries. Saved lesson summaries are preserved below.
79
+ </p>
80
+ <div class="lms-actions">
81
+ <label>
82
+ Search notes <input type="search" maxlength={200} data-lms-note-search />
83
+ </label>
84
+ <label>
85
+ Show{" "}
86
+ <select data-lms-note-filter>
87
+ <option value="active">Active notes</option>
88
+ <option value="starred">Starred notes</option>
89
+ <option value="archived">Archived notes</option>
90
+ </select>
91
+ </label>
92
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-notebook-refresh>
93
+ Reload saved notes
94
+ </button>
95
+ </div>
96
+ <p data-lms-feedback aria-live="polite"></p>
97
+ <div data-lms-note-list></div>
98
+ <noscript>
99
+ Enable JavaScript to search and edit notebook entries. Your learner data export also
100
+ includes these notes.
101
+ </noscript>
102
+ </section>
103
+ <section class="lms-learning-card" data-lms-agenda data-course-id={courseId}>
104
+ <h3>Course agenda</h3>
105
+ {agendaError && <p>{agendaError} Your private notebook remains available.</p>}
106
+ {agenda && (
107
+ <>
108
+ <p>
109
+ Assignment deadlines, live sessions and upcoming lesson releases from your enrolled
110
+ course edition. Lesson access and prerequisites still apply.
111
+ </p>
112
+ <div class="lms-actions">
113
+ <label>
114
+ Display timezone{" "}
115
+ <select data-lms-timezone>
116
+ <option value="UTC">UTC</option>
117
+ </select>
118
+ </label>
119
+ <label>
120
+ <input type="checkbox" data-lms-agenda-upcoming /> Upcoming only
121
+ </label>
122
+ <button type="button" class="lms-btn lms-btn-outline" data-lms-calendar-download>
123
+ Download upcoming calendar (.ics)
124
+ </button>
125
+ </div>
126
+ <p>
127
+ The download is a snapshot for your calendar app. It does not update automatically;
128
+ revisit the course for changes.
129
+ </p>
130
+ <p data-lms-feedback aria-live="polite"></p>
131
+ <ul class="lms-agenda-list">
132
+ {agenda.events.map((event) => (
133
+ <li data-lms-agenda-event data-status={event.status}>
134
+ <strong>
135
+ {
136
+ {
137
+ assignment: "Assignment due",
138
+ live: "Live session",
139
+ release: "Lesson release",
140
+ }[event.kind]
141
+ }
142
+ : {event.title}
143
+ </strong>
144
+ <p>
145
+ <time datetime={event.startsAt} data-lms-date>
146
+ {new Date(event.startsAt).toLocaleString("en-GB", { timeZone: "UTC" })} UTC
147
+ </time>
148
+ {event.endsAt && (
149
+ <>
150
+ {" "}
151
+ –{" "}
152
+ <time datetime={event.endsAt} data-lms-date>
153
+ {new Date(event.endsAt).toLocaleString("en-GB", { timeZone: "UTC" })}{" "}
154
+ UTC
155
+ </time>
156
+ </>
157
+ )}
158
+ </p>
159
+ <p class="lms-badge">{event.status}</p>
160
+ {event.available ? (
161
+ <a href={lessonUrl(basePath, agenda.course.slug, event.lessonId)}>
162
+ Open lesson →
163
+ </a>
164
+ ) : (
165
+ <p>{event.reason}</p>
166
+ )}
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ {!agenda.events.length && <p>This course has no dated activities.</p>}
171
+ <p data-lms-agenda-empty hidden>
172
+ No upcoming activities in this view.
173
+ </p>
174
+ </>
175
+ )}
176
+ </section>
177
+ </>
178
+ )}
179
+ </LearningShell>
180
+ </Layout>
@@ -0,0 +1,23 @@
1
+ import type { Answers } from "@anantagnanam/lms/types";
2
+ import { DraftQueue, type DraftState } from "./draft-queue.js";
3
+ export type { DraftState } from "./draft-queue.js";
4
+ export interface DraftWrite {
5
+ answers: Answers;
6
+ expectedDraftRevision: number;
7
+ }
8
+ /** Retain the quiz interface while sharing the reliable writer with assignments. */
9
+ export class QuizDraftQueue extends DraftQueue<Answers> {
10
+ constructor(
11
+ answers: Answers,
12
+ revision: number,
13
+ send: (write: DraftWrite) => Promise<{ draftRevision: number }>,
14
+ changed: (state: DraftState) => void,
15
+ ) {
16
+ super(
17
+ answers,
18
+ revision,
19
+ (write) => send({ answers: write.value, expectedDraftRevision: write.expectedDraftRevision }),
20
+ changed,
21
+ );
22
+ }
23
+ }
@@ -0,0 +1,362 @@
1
+ import type {
2
+ Answers,
3
+ ProgressSummary,
4
+ PublicEnrollment,
5
+ PublicQuizAttempt,
6
+ } from "@anantagnanam/lms/types";
7
+ import { QuizDraftQueue, type DraftState } from "./quiz-drafts.js";
8
+
9
+ type Call = <T>(route: string, input: Record<string, unknown>, method?: string) => Promise<T>;
10
+ type LessonState = { enrollment: PublicEnrollment; progress: ProgressSummary };
11
+ const mounted = new WeakMap<HTMLElement, () => void>();
12
+ function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string) {
13
+ const element = document.createElement(tag);
14
+ if (text !== undefined) element.textContent = text;
15
+ if (className) element.className = className;
16
+ return element;
17
+ }
18
+ function readAnswers(form: HTMLFormElement, attempt: PublicQuizAttempt): Answers {
19
+ const answers: Answers = {};
20
+ const values = new FormData(form);
21
+ for (const question of attempt.questions) {
22
+ const picked = values.getAll(question.id).map(String);
23
+ if (question.type === "multiple") answers[question.id] = picked;
24
+ else if (picked[0]) answers[question.id] = picked[0];
25
+ }
26
+ return answers;
27
+ }
28
+ function writeAnswers(form: HTMLFormElement, answers: Answers) {
29
+ for (const input of form.querySelectorAll<HTMLInputElement>("input[name]")) {
30
+ const value = answers[input.name];
31
+ if (input.type === "text") input.value = typeof value === "string" ? value : "";
32
+ else input.checked = Array.isArray(value) ? value.includes(input.value) : value === input.value;
33
+ }
34
+ }
35
+ function errorMessage(error: unknown) {
36
+ return error instanceof Error ? error.message : "Please try again.";
37
+ }
38
+
39
+ export function renderQuiz(root: HTMLElement, attempt: PublicQuizAttempt, call: Call) {
40
+ mounted.get(root)?.();
41
+ const content = root.querySelector<HTMLElement>("[data-lms-quiz-content]")!;
42
+ const feedback = (message: string, error = false) => {
43
+ if (!root.isConnected) return;
44
+ const target = root.querySelector<HTMLElement>("[data-lms-feedback]")!;
45
+ target.textContent = message;
46
+ target.dataset.tone = error ? "error" : "success";
47
+ target.setAttribute("role", error ? "alert" : "status");
48
+ };
49
+ const ids = {
50
+ courseId: root.dataset.courseId!,
51
+ lessonId: root.dataset.lessonId!,
52
+ attemptId: attempt.id,
53
+ };
54
+ const form = node("form");
55
+ const timer = node("p", "", "lms-quiz-timer");
56
+ timer.setAttribute("aria-live", "off");
57
+ form.append(timer);
58
+ for (const question of attempt.questions) {
59
+ const fieldset = node("fieldset", undefined, "lms-quiz-question");
60
+ fieldset.append(node("legend", question.prompt));
61
+ if (question.type === "short") {
62
+ const input = node("input");
63
+ input.name = question.id;
64
+ input.type = "text";
65
+ input.maxLength = 2000;
66
+ input.setAttribute("aria-label", "Your answer");
67
+ fieldset.append(input);
68
+ } else
69
+ for (const option of question.options) {
70
+ const label = node("label", undefined, "lms-quiz-option");
71
+ const input = node("input");
72
+ input.name = question.id;
73
+ input.type = question.type === "multiple" ? "checkbox" : "radio";
74
+ input.value = option.id;
75
+ label.append(input, node("span", option.text));
76
+ fieldset.append(label);
77
+ }
78
+ form.append(fieldset);
79
+ }
80
+ writeAnswers(form, attempt.answers);
81
+ const button = node("button", "Submit answers", "lms-btn");
82
+ button.type = "submit";
83
+ const status = node("p", "Saved answers loaded.", "lms-draft-status");
84
+ status.setAttribute("role", "status");
85
+ status.setAttribute("aria-live", "polite");
86
+ const retry = node("button", "Retry saving", "lms-btn lms-btn-outline");
87
+ retry.type = "button";
88
+ retry.hidden = true;
89
+ const review = node("button", "Review saved answers", "lms-btn lms-btn-outline");
90
+ review.type = "button";
91
+ review.hidden = true;
92
+ const recovery = node("div", undefined, "lms-notice");
93
+ recovery.hidden = true;
94
+ form.append(status, retry, review, recovery, button);
95
+ content.replaceChildren(form);
96
+ let interval: ReturnType<typeof setInterval> | undefined;
97
+ let debounce: ReturnType<typeof setTimeout> | undefined;
98
+ let submitting = false;
99
+ let uncertainSubmission = false;
100
+ let ended = false;
101
+ let timeoutSent = false;
102
+ const labels: Record<DraftState, string> = {
103
+ pending: "Unsaved answers · waiting to save…",
104
+ saving: "Saving answers…",
105
+ saved: "All answers saved.",
106
+ failed: "Answers could not be saved. Keep this page open and retry.",
107
+ conflict: "Saved answers changed in another tab. Review them before continuing.",
108
+ };
109
+ const queue = new QuizDraftQueue(
110
+ attempt.answers,
111
+ attempt.draftRevision,
112
+ (write) => call("learner/quiz/draft", { ...ids, ...write }),
113
+ (state) => {
114
+ status.textContent = labels[state];
115
+ status.dataset.tone = state === "failed" || state === "conflict" ? "error" : "success";
116
+ retry.hidden = state !== "failed";
117
+ review.hidden = state !== "conflict";
118
+ },
119
+ );
120
+ const atRisk = () =>
121
+ !ended && (queue.dirty || queue.state === "conflict" || submitting || uncertainSubmission);
122
+ const beforeUnload = (event: BeforeUnloadEvent) => {
123
+ if (atRisk()) event.preventDefault();
124
+ };
125
+ const beforeNavigate = (event: Event) => {
126
+ if (atRisk() && !window.confirm("Some quiz answers may not be saved. Leave this page?"))
127
+ event.preventDefault();
128
+ };
129
+ const cleanup = () => {
130
+ ended = true;
131
+ clearInterval(interval);
132
+ clearTimeout(debounce);
133
+ queue.dispose();
134
+ window.removeEventListener("beforeunload", beforeUnload);
135
+ document.removeEventListener("astro:before-preparation", beforeNavigate);
136
+ document.removeEventListener("astro:before-swap", cleanup);
137
+ observer.disconnect();
138
+ mounted.delete(root);
139
+ };
140
+ const observer = new MutationObserver(() => {
141
+ if (!form.isConnected) cleanup();
142
+ });
143
+ observer.observe(document.body, { childList: true, subtree: true });
144
+ window.addEventListener("beforeunload", beforeUnload);
145
+ document.addEventListener("astro:before-preparation", beforeNavigate);
146
+ document.addEventListener("astro:before-swap", cleanup, { once: true });
147
+ mounted.set(root, cleanup);
148
+
149
+ const finish = (final: PublicQuizAttempt, progress: ProgressSummary) => {
150
+ if (ended) return;
151
+ cleanup();
152
+ feedback(
153
+ final.status === "expired"
154
+ ? "The quiz time limit expired. Your saved attempt is retained."
155
+ : `${final.passed ? "Passed" : "Not passed yet"} · ${final.percent}%`,
156
+ );
157
+ const outcome = node("div");
158
+ for (const item of final.feedback) {
159
+ const section = node("div", undefined, "lms-notice");
160
+ const question = attempt.questions.find((entry) => entry.id === item.questionId);
161
+ section.append(
162
+ node("strong", `${item.correct ? "✓" : "○"} ${question?.prompt ?? "Question"}`),
163
+ );
164
+ if (item.explanation) section.append(node("p", item.explanation));
165
+ outcome.append(section);
166
+ }
167
+ content.replaceChildren(outcome);
168
+ const start = root.querySelector<HTMLButtonElement>("[data-lms-quiz-start]");
169
+ if (start) {
170
+ start.hidden = final.passed;
171
+ start.disabled = false;
172
+ start.textContent = "Try another attempt";
173
+ }
174
+ if (final.passed) {
175
+ const courseUrl = root.dataset.courseUrl!;
176
+ const link = node(
177
+ "a",
178
+ progress.nextLessonId ? "Continue learning →" : "Course overview →",
179
+ "lms-btn",
180
+ );
181
+ link.href = progress.nextLessonId
182
+ ? `${courseUrl}/lessons/${encodeURIComponent(progress.nextLessonId)}`
183
+ : courseUrl;
184
+ if (progress.blockedLessonId) {
185
+ const blocked = progress.availability[progress.blockedLessonId];
186
+ content.append(
187
+ node(
188
+ "p",
189
+ `${blocked?.reason ?? "The next lesson is unavailable."}${blocked?.releaseAt ? ` Available from ${new Date(blocked.releaseAt).toLocaleString(undefined, { timeZoneName: "short" })}.` : ""}`,
190
+ "lms-notice",
191
+ ),
192
+ );
193
+ }
194
+ content.append(link);
195
+ }
196
+ };
197
+ const fetchAttempt = async (expiredByServer = false) => {
198
+ const state = await call<LessonState>("learner/lesson", ids, "GET");
199
+ const remote = state.enrollment.progress[ids.lessonId]?.quizAttempts.find(
200
+ (item) => item.id === attempt.id,
201
+ );
202
+ if (!remote)
203
+ throw new Error(
204
+ "This attempt could not be found. Keep this page open and contact an instructor.",
205
+ );
206
+ if (remote.status !== "in_progress" || expiredByServer) {
207
+ finish(
208
+ remote.status === "in_progress" ? { ...remote, status: "expired" } : remote,
209
+ state.progress,
210
+ );
211
+ return null;
212
+ }
213
+ return remote;
214
+ };
215
+ const save = async () => {
216
+ try {
217
+ await queue.flush();
218
+ if (!ended) feedback("Answers saved.");
219
+ } catch (error) {
220
+ if (ended) return;
221
+ feedback(errorMessage(error), true);
222
+ if (["QUIZ_EXPIRED", "ATTEMPT_CLOSED"].includes((error as { code?: string })?.code ?? "")) {
223
+ try {
224
+ await fetchAttempt((error as { code?: string }).code === "QUIZ_EXPIRED");
225
+ } catch {
226
+ /* The visible save error and retry remain. */
227
+ }
228
+ }
229
+ }
230
+ };
231
+ const showConflict = (remote: PublicQuizAttempt) => {
232
+ if (ended) return;
233
+ queue.conflict();
234
+ clearTimeout(debounce);
235
+ recovery.hidden = false;
236
+ recovery.replaceChildren(
237
+ node("h3", "Review the saved draft"),
238
+ node(
239
+ "p",
240
+ "Compare the server answers below with your answers in the form. Choose which set to keep. A newer change in another tab will require another review.",
241
+ ),
242
+ );
243
+ for (const question of attempt.questions) {
244
+ const value = remote.answers[question.id];
245
+ const values = Array.isArray(value) ? value : value ? [value] : [];
246
+ recovery.append(
247
+ node(
248
+ "p",
249
+ `${question.prompt}: ${values.map((item) => question.options.find((option) => option.id === item)?.text ?? item).join(", ") || "Unanswered"}`,
250
+ ),
251
+ );
252
+ }
253
+ const useSaved = node("button", "Use saved answers", "lms-btn lms-btn-outline");
254
+ useSaved.type = "button";
255
+ const keepLocal = node("button", "Keep my answers and save", "lms-btn");
256
+ keepLocal.type = "button";
257
+ const choose = (keep: boolean) => {
258
+ queue.update(readAnswers(form, attempt));
259
+ queue.reconcile(remote.answers, remote.draftRevision, keep);
260
+ if (!keep) writeAnswers(form, remote.answers);
261
+ recovery.hidden = true;
262
+ feedback(keep ? "Saving your chosen answers…" : "Saved answers loaded.");
263
+ if (keep) void save();
264
+ };
265
+ useSaved.addEventListener("click", () => choose(false));
266
+ keepLocal.addEventListener("click", () => choose(true));
267
+ recovery.append(useSaved, keepLocal);
268
+ };
269
+ review.addEventListener("click", async () => {
270
+ review.disabled = true;
271
+ feedback("Loading saved answers for review…");
272
+ try {
273
+ const remote = await fetchAttempt();
274
+ if (remote) showConflict(remote);
275
+ } catch (error) {
276
+ feedback(errorMessage(error), true);
277
+ } finally {
278
+ review.disabled = false;
279
+ }
280
+ });
281
+ retry.addEventListener("click", () => void save());
282
+ form.addEventListener("input", () => {
283
+ queue.update(readAnswers(form, attempt));
284
+ clearTimeout(debounce);
285
+ if (!submitting && !["failed", "conflict"].includes(queue.state))
286
+ debounce = setTimeout(() => {
287
+ if (!ended) void save();
288
+ }, 700);
289
+ });
290
+ form.addEventListener("submit", async (event) => {
291
+ event.preventDefault();
292
+ if (submitting || ended) return;
293
+ clearTimeout(debounce);
294
+ const answers = readAnswers(form, attempt);
295
+ queue.update(answers);
296
+ submitting = true;
297
+ button.disabled = true;
298
+ retry.disabled = true;
299
+ review.disabled = true;
300
+ recovery.hidden = true;
301
+ form.querySelectorAll("fieldset").forEach((fieldset) => {
302
+ fieldset.disabled = true;
303
+ });
304
+ feedback("Checking your answers…");
305
+ try {
306
+ await queue.flush();
307
+ if (ended) return;
308
+ uncertainSubmission = true;
309
+ const result = await call<{ attempt: PublicQuizAttempt; progress: ProgressSummary }>(
310
+ "learner/quiz/submit",
311
+ { ...ids, answers, expectedDraftRevision: queue.revision },
312
+ );
313
+ finish(result.attempt, result.progress);
314
+ } catch (error) {
315
+ if (ended) return;
316
+ feedback(`${errorMessage(error)} Checking the original attempt…`, true);
317
+ try {
318
+ const remote = await fetchAttempt((error as { code?: string })?.code === "QUIZ_EXPIRED");
319
+ if (remote && !ended) {
320
+ uncertainSubmission = false;
321
+ if (queue.state === "conflict" || remote.draftRevision !== queue.revision)
322
+ showConflict(remote);
323
+ feedback(
324
+ `${errorMessage(error)} Your answers remain in this form. Retry this attempt when ready.`,
325
+ true,
326
+ );
327
+ }
328
+ } catch {
329
+ feedback(
330
+ `${errorMessage(error)} Could not confirm the attempt status. Keep this page open and retry this same attempt.`,
331
+ true,
332
+ );
333
+ }
334
+ } finally {
335
+ submitting = false;
336
+ button.disabled = false;
337
+ retry.disabled = false;
338
+ review.disabled = false;
339
+ form.querySelectorAll("fieldset").forEach((fieldset) => {
340
+ fieldset.disabled = false;
341
+ });
342
+ }
343
+ });
344
+ if (attempt.expiresAt) {
345
+ const tick = () => {
346
+ if (ended) return;
347
+ const remaining = Math.max(
348
+ 0,
349
+ Math.ceil((Date.parse(attempt.expiresAt!) - Date.now()) / 1000),
350
+ );
351
+ timer.textContent = remaining
352
+ ? `Time remaining ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}`
353
+ : "Time limit reached. Check the attempt result or retry to confirm its status.";
354
+ if (!remaining && !timeoutSent) {
355
+ timeoutSent = true;
356
+ form.requestSubmit();
357
+ }
358
+ };
359
+ interval = setInterval(tick, 1000);
360
+ tick();
361
+ } else timer.textContent = "Take your time. This quiz is untimed.";
362
+ }