harden database interactions and improve error handling

This commit is contained in:
ImBenji
2026-04-28 14:36:13 +01:00
parent 5a4caaf1d0
commit e1f168a302
29 changed files with 869 additions and 241 deletions
+142 -52
View File
@@ -14,6 +14,9 @@ const lessonPending = ref(false);
const lessonError = ref<any>(null);
const genState = ref<GenState | null>(null);
const abortController = ref<AbortController | null>(null);
const generationFired = ref(false);
const GEN_TIPS = [
"Reading through your past papers...",
"Crafting analogies just for this topic...",
@@ -36,6 +39,7 @@ let genTipTimer: ReturnType<typeof setInterval> | null = null;
let genStageTimer: ReturnType<typeof setInterval> | null = null;
function startGenAnimations() {
if (genTipTimer) return;
genTipTimer = setInterval(() => {
genTipVisible.value = false;
setTimeout(() => {
@@ -55,7 +59,12 @@ function stopGenAnimations() {
}
async function loadLesson() {
lessonPending.value = true;
lesson.value = null;
const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100);
abortController.value = new AbortController();
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
@@ -68,11 +77,15 @@ async function loadLesson() {
lessonError.value = err;
}
} finally {
clearTimeout(pendingTimer);
lessonPending.value = false;
}
}
async function triggerGeneration() {
if (generationFired.value) return;
generationFired.value = true;
genState.value = "loading";
startGenAnimations();
try {
@@ -95,6 +108,7 @@ onMounted(loadLesson);
onUnmounted(() => {
stopGenAnimations();
abortController.value?.abort();
});
// ── branch loading overlay ─────────────────────────────────────────────────
@@ -103,8 +117,13 @@ const branchOverlayVisible = ref(false);
const branchOverlayError = ref(false);
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
const branchPollStart = ref(0);
function startBranchPoll() {
if (branchPollTimer) return;
branchPollStart.value = Date.now();
branchPollTimer = setInterval(async () => {
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
@@ -117,6 +136,11 @@ function startBranchPoll() {
branchOverlayError.value = true;
}
} catch { /* ignore poll errors */ }
if (Date.now() - branchPollStart.value > 60_000) {
stopBranchPoll();
branchOverlayError.value = true;
}
}, 2000);
}
@@ -295,10 +319,12 @@ const showMainExplanation = computed(() => {
const shuffledOptions = ref<string[]>([]);
function fisherYates(arr: string[]): string[] {
function seededShuffle(arr: string[], seed: number): string[] {
const a = [...arr];
let s = seed;
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
s = (s * 1664525 + 1013904223) >>> 0;
const j = s % (i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
@@ -307,7 +333,8 @@ function fisherYates(arr: string[]): string[] {
watch([() => stepKey.value, steps], () => {
const s = displayStep.value;
if (s?.type === "question" && s.options) {
shuffledOptions.value = fisherYates(s.options);
const seed = lessonState.stepIndex * 31 + (lessonState.branchOption ? lessonState.branchOption.charCodeAt(0) : 0);
shuffledOptions.value = seededShuffle(s.options, seed);
} else {
shuffledOptions.value = [];
}
@@ -478,19 +505,24 @@ function advance() {
}
const lessonScore = ref(0);
watch(selectedAnswers, (opts, prev) => {
watch(() => ({ ...selectedAnswers.value }), (opts, prev) => {
const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev));
for (const k of newKeys) {
const s = steps.value[k];
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
}
}, { deep: true });
});
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
});
const celebrationFired = ref(false);
function fireCelebration() {
if (celebrationFired.value) return;
celebrationFired.value = true;
const count = 160;
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
const interval = setInterval(() => {
@@ -502,13 +534,20 @@ function fireCelebration() {
const lessonDone = ref(false);
const completing = ref(false);
const completeError = ref(false);
async function completeLesson() {
if (completing.value || lessonDone.value) return;
completing.value = true;
completeError.value = false;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10000);
try {
await $fetch(`/api/topics/${topicId}/progress`, {
method: "POST",
signal: ctrl.signal,
body: {
lessonComplete: true,
tookBranches: branchesEntered.value > 0,
@@ -516,12 +555,38 @@ async function completeLesson() {
},
});
lessonDone.value = true;
} catch {
completing.value = false;
completeError.value = true;
return;
} finally {
clearTimeout(timer);
completing.value = false;
}
}
function goBack() { history.back(); }
const router = useRouter();
function goBack() {
if (history.length <= 1) { router.push("/"); return; }
history.back();
}
function stepBack() {
stopAllAudio();
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
// exit branch back to main question
lessonState.mode = "main";
lessonState.branchOption = "";
lessonState.branchStepIndex = 0;
confirmSelectedAnswer.value = null;
return;
}
if (lessonState.stepIndex > 0) {
lessonState.stepIndex--;
// clear the answer for this step so they can re-answer
delete selectedAnswers.value[lessonState.stepIndex];
}
}
// ── FOCUS MODE ────────────────────────────────────────────────────────────
@@ -529,6 +594,7 @@ const focusMode = ref(false);
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
const questionPlaying = ref(false);
const narratingOptionIndex = ref<number | null>(null);;
const focusEverActivated = ref(false);
if (import.meta.client) {
@@ -550,6 +616,7 @@ const showKaraoke = computed(() => {
return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0);
});
// kept for compat — no longer drives a full overlay, just used to gate the continue button
const showQuestionOverlay = computed(() =>
focusMode.value &&
(lessonState.mode === "main" || lessonState.mode === "confirm") &&
@@ -656,13 +723,8 @@ async function playSequence(urls: string[]): Promise<void> {
});
}
const questionVariants = ref<Record<number, number>>({});
function getVariant(stepIndex: number): number {
if (!questionVariants.value[stepIndex]) {
questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1;
}
return questionVariants.value[stepIndex];
return (Math.abs(stepIndex) % 4) + 1;
}
async function playQuestionLike(
@@ -672,26 +734,31 @@ async function playQuestionLike(
) {
stopQueue();
questionPlaying.value = true;
narratingOptionIndex.value = null;
const variant = getVariant(variantKey);
const playlist: string[] = [];
if (questionStep.questionAudioPath) playlist.push(questionStep.questionAudioPath);
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
const label = LABELS[oi];
playlist.push(`/audio/labels/${label}_${variant}.mp3`);
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
const optAudio = questionStep.optionAudioPaths?.[origIdx];
if (optAudio) playlist.push(optAudio);
}
try {
await playSequence(playlist);
// play question stem first
if (questionStep.questionAudioPath) {
await playSequence([questionStep.questionAudioPath]);
}
// play each option in order, highlighting it while it plays
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
narratingOptionIndex.value = oi;
const label = LABELS[oi];
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
const optAudio = questionStep.optionAudioPaths?.[origIdx];
const clips: string[] = [`/audio/labels/${label}_${variant}.mp3`];
if (optAudio) clips.push(optAudio);
await playSequence(clips);
}
} catch {
// aborted
}
narratingOptionIndex.value = null;
questionPlaying.value = false;
}
@@ -704,10 +771,9 @@ function startStepAudio() {
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) {
// no usable audio for this step — skip it automatically so focus mode stays alive
// no usable audio — advance state only, the watcher fires startStepAudio for the new step
if (!isLastMainStep.value) {
lessonState.stepIndex++;
nextTick(() => startStepAudio());
} else {
completeLesson();
}
@@ -720,7 +786,7 @@ function startStepAudio() {
startKaraoke(audio, s.audioChunks, () => {
if (!isLastMainStep.value) {
lessonState.stepIndex++;
nextTick(() => startStepAudio());
// watcher handles startStepAudio for the new step
} else {
completeLesson();
}
@@ -862,7 +928,7 @@ onBeforeUnmount(() => {
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
<header class="topbar">
<button class="topbar-back" @click="goBack" aria-label="Back">
<button class="topbar-back" @click="stepBack" aria-label="Previous step" :disabled="lessonState.stepIndex === 0 && lessonState.mode === 'main'">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
@@ -892,29 +958,33 @@ onBeforeUnmount(() => {
</svg>
<span class="focus-label">Focus Mode</span>
</button>
<button class="topbar-exit" @click="goBack" aria-label="Exit lesson" title="Back to course">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</header>
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
<Transition name="karaoke-fade">
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
<p class="karaoke-text">{{ currentChunk?.text ?? '' }}</p>
<button class="karaoke-skip" @click="skipStep" title="Skip">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</Transition>
<!-- ── QUESTION AUDIO OVERLAY ─────────────────────────────────────────── -->
<Transition name="karaoke-fade">
<div v-if="showQuestionOverlay" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
<p class="karaoke-text karaoke-text--question">Listen carefully…</p>
<button class="karaoke-skip" @click="stopQueue" title="Skip to question">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<template v-if="currentChunk">
<p class="karaoke-text">{{ currentChunk.text }}</p>
<button class="karaoke-skip" @click="skipStep" title="Skip">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</template>
<template v-else>
<button class="karaoke-start" @click="startStepAudio">
<svg width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Start
</button>
</template>
</div>
</Transition>
@@ -956,7 +1026,7 @@ onBeforeUnmount(() => {
<!-- MAIN -->
<main
class="lesson-main"
v-show="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
v-show="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
>
<div v-if="lessonPending" class="state-center">
@@ -1002,7 +1072,8 @@ onBeforeUnmount(() => {
:key="opt"
class="option"
:class="{
'option--hover-ready': !hasAnswered,
'option--hover-ready': !hasAnswered && narratingOptionIndex === null,
'option--narrating': !hasAnswered && narratingOptionIndex === i,
'option--correct': hasAnswered && opt === displayStep.answer,
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
@@ -1077,13 +1148,15 @@ onBeforeUnmount(() => {
</div>
<button
v-if="!lessonDone"
v-if="!lessonDone && !completeError"
class="summary-cta"
:disabled="completing"
@click="completeLesson"
>
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
</button>
<button v-if="completeError" class="summary-cta" @click="completeLesson">Retry ✓</button>
</template>
</div>
@@ -1092,7 +1165,7 @@ onBeforeUnmount(() => {
</main>
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
<template v-if="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
<template v-if="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
<!-- non-question, non-summary steps -->
<div
@@ -1201,7 +1274,23 @@ onBeforeUnmount(() => {
opacity: 0.5;
transition: opacity 0.15s;
}
.topbar-back:hover { opacity: 1; }
.topbar-back:hover:not(:disabled) { opacity: 1; }
.topbar-back:disabled { opacity: 0.2; cursor: default; }
.topbar-exit {
color: oklch(20% 0.020 55);
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
margin-left: 4px;
}
.topbar-exit:hover { opacity: 0.85; }
.progress-segments {
flex: 1;
@@ -1485,6 +1574,7 @@ onBeforeUnmount(() => {
font-size: 1rem;
}
.option--hover-ready:hover { border-color: #6366F1; background: #F5F3FF; }
.option--narrating { border-color: #6366F1; background: #EEF2FF; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
.option--correct { border-color: #10B981; background: #D1FAE5; animation: spring-correct 0.3s ease-out; }
.option--wrong { border-color: #EF4444; background: #FEE2E2; }
.option--dim { opacity: 0.38; }