436 lines
16 KiB
Svelte
436 lines
16 KiB
Svelte
<script lang="ts">
|
|
import * as Select from '../../base/select';
|
|
import {
|
|
getExerciseQuestionContractKey,
|
|
isAutoGradableQuestionTypeId,
|
|
QUESTION_TYPE_KEY,
|
|
type AnswerData,
|
|
type ExerciseQuestionLabels,
|
|
type ExerciseQuestionModel
|
|
} from '@cio/question-types';
|
|
import { untrack } from 'svelte';
|
|
import { Button } from '../../base/button';
|
|
import { QuestionList } from '../exercise-question';
|
|
import { cn } from '../../tools';
|
|
import Callout from './callout.svelte';
|
|
import { SafeHtmlContent } from '../safe-html-content';
|
|
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
|
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
|
import {
|
|
cloneAnswersByKey,
|
|
readPublicExerciseAttempts,
|
|
writePublicExerciseAttempts
|
|
} from './public-exercise-attempts-storage';
|
|
|
|
interface Props {
|
|
exercise: PublicExerciseViewData;
|
|
callout?: PublicCourseCalloutData | null;
|
|
calloutAnimation?: PublicCourseCalloutAnimation;
|
|
labels?: ExerciseQuestionLabels;
|
|
/** When set, completed attempts persist and hydrate from `localStorage`. */
|
|
attemptsPersistenceKey?: string | null;
|
|
formatAttemptOption?: (args: { attemptNumber: number; correct: number; total: number }) => string;
|
|
newAttemptOptionLabel?: string;
|
|
attemptsSelectAriaLabel?: string;
|
|
submitLabel?: string;
|
|
tryAgainLabel?: string;
|
|
privacyHint?: string;
|
|
summaryTemplate?: string;
|
|
class?: string;
|
|
}
|
|
|
|
let {
|
|
exercise,
|
|
callout = null,
|
|
calloutAnimation,
|
|
labels,
|
|
attemptsPersistenceKey = null,
|
|
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
|
|
newAttemptOptionLabel = 'Practice again',
|
|
attemptsSelectAriaLabel = 'Your attempts',
|
|
submitLabel = 'Check my answers',
|
|
tryAgainLabel = 'Practice again',
|
|
privacyHint = 'Graded instantly in your browser — nothing is submitted.',
|
|
summaryTemplate = 'You got [[correct]] / [[total]] correct.',
|
|
class: className
|
|
}: Props = $props();
|
|
|
|
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
|
|
calloutAnimation ?? callout?.animation ?? 'waves'
|
|
);
|
|
|
|
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
|
|
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
|
|
let selectValue = $state<string>('live');
|
|
let answers = $state<Record<string, AnswerData>>({});
|
|
let submitted = $state(false);
|
|
|
|
const mode = $derived<'take' | 'review'>(submitted ? 'review' : 'take');
|
|
|
|
$effect(() => {
|
|
attemptsPersistenceKey;
|
|
untrack(() => {
|
|
persistedAttempts = readPublicExerciseAttempts(attemptsPersistenceKey);
|
|
const rows = persistedAttempts;
|
|
if (rows.length === 0) {
|
|
selectValue = 'live';
|
|
answers = {};
|
|
submitted = false;
|
|
return;
|
|
}
|
|
|
|
const defaultIndex = rows.length - 1;
|
|
selectValue = String(defaultIndex);
|
|
const row = rows[defaultIndex];
|
|
answers = cloneAnswersByKey(row.answersByKey);
|
|
submitted = true;
|
|
});
|
|
});
|
|
|
|
function handleAttemptSelect(selected: string) {
|
|
if (selected === 'live') {
|
|
selectValue = 'live';
|
|
answers = {};
|
|
submitted = false;
|
|
return;
|
|
}
|
|
|
|
const idx = Number.parseInt(selected, 10);
|
|
if (!Number.isInteger(idx) || idx < 0 || idx >= persistedAttempts.length) return;
|
|
|
|
selectValue = selected;
|
|
submitted = true;
|
|
answers = cloneAnswersByKey(persistedAttempts[idx].answersByKey);
|
|
}
|
|
|
|
function handleAnswerChange(question: ExerciseQuestionModel, answer: AnswerData | null) {
|
|
if (submitted) return;
|
|
|
|
const key = getExerciseQuestionContractKey(question);
|
|
if (answer === null) {
|
|
const next = { ...answers };
|
|
delete next[key];
|
|
answers = next;
|
|
return;
|
|
}
|
|
|
|
answers = { ...answers, [key]: answer };
|
|
}
|
|
|
|
function hasProvidedAnswer(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean {
|
|
if (!answer) return false;
|
|
|
|
switch (question.questionType) {
|
|
case QUESTION_TYPE_KEY.RADIO:
|
|
return answer.type === 'RADIO' && answer.optionId != null;
|
|
case QUESTION_TYPE_KEY.CHECKBOX:
|
|
return answer.type === 'CHECKBOX' && answer.optionIds.length > 0;
|
|
case QUESTION_TYPE_KEY.TRUE_FALSE:
|
|
return answer.type === 'TRUE_FALSE';
|
|
case QUESTION_TYPE_KEY.TEXTAREA:
|
|
return answer.type === 'TEXTAREA' && answer.text.trim().length > 0;
|
|
case QUESTION_TYPE_KEY.SHORT_ANSWER:
|
|
return answer.type === 'SHORT_ANSWER' && answer.text.trim().length > 0;
|
|
case QUESTION_TYPE_KEY.NUMERIC:
|
|
return answer.type === 'NUMERIC' && Number.isFinite(answer.value);
|
|
case QUESTION_TYPE_KEY.FILL_BLANK: {
|
|
if (answer.type !== 'FILL_BLANK') return false;
|
|
const blankSlots = (question.options ?? []).filter((option) => option.isCorrect !== false).length;
|
|
if (blankSlots === 0) return false;
|
|
|
|
return (
|
|
answer.values.length >= blankSlots &&
|
|
answer.values.slice(0, blankSlots).every((value) => typeof value === 'string' && value.trim().length > 0)
|
|
);
|
|
}
|
|
case QUESTION_TYPE_KEY.ORDERING: {
|
|
if (answer.type !== 'ORDERING') return false;
|
|
const expectedLength = (question.options ?? [])
|
|
.map((option) => String(option.value ?? option.label ?? ''))
|
|
.filter(Boolean).length;
|
|
|
|
return (
|
|
expectedLength > 0 &&
|
|
answer.orderedValues.length === expectedLength &&
|
|
answer.orderedValues.every((value) => typeof value === 'string' && value.length > 0)
|
|
);
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const canSubmitAnswers = $derived(
|
|
exercise.questions.length > 0 &&
|
|
exercise.questions.every((question, index) =>
|
|
hasProvidedAnswer(question, answers[getExerciseQuestionContractKey(question, index)])
|
|
)
|
|
);
|
|
|
|
function isAnswerCorrect(question: ExerciseQuestionModel, answer: AnswerData | undefined): boolean | null {
|
|
if (!answer) return false;
|
|
|
|
switch (question.questionType) {
|
|
case QUESTION_TYPE_KEY.RADIO: {
|
|
if (answer.type !== 'RADIO') return false;
|
|
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
|
if (!correctOption?.id) return false;
|
|
return String(correctOption.id) === String(answer.optionId);
|
|
}
|
|
case QUESTION_TYPE_KEY.CHECKBOX: {
|
|
if (answer.type !== 'CHECKBOX') return false;
|
|
const correctIds = new Set(
|
|
(question.options ?? [])
|
|
.filter((option) => option.isCorrect && option.id !== undefined)
|
|
.map((option) => String(option.id))
|
|
);
|
|
const selected = new Set(answer.optionIds.map(String));
|
|
if (selected.size !== correctIds.size) return false;
|
|
for (const id of correctIds) {
|
|
if (!selected.has(id)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
case QUESTION_TYPE_KEY.TRUE_FALSE: {
|
|
if (answer.type !== 'TRUE_FALSE') return false;
|
|
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
|
const correctValue = /true/i.test(String(correctOption?.label ?? correctOption?.value ?? ''));
|
|
return answer.value === correctValue;
|
|
}
|
|
case QUESTION_TYPE_KEY.SHORT_ANSWER: {
|
|
if (answer.type !== 'SHORT_ANSWER') return false;
|
|
const caseSensitive = Boolean((question.settings as { caseSensitive?: boolean } | undefined)?.caseSensitive);
|
|
const accepted = (question.options ?? [])
|
|
.filter((option) => option.isCorrect !== false)
|
|
.map((option) => String(option.label ?? option.value ?? '').trim());
|
|
const submittedValue = answer.text.trim();
|
|
if (!submittedValue || accepted.length === 0) return false;
|
|
return accepted.some((expected) => {
|
|
if (caseSensitive) return expected === submittedValue;
|
|
return expected.toLowerCase() === submittedValue.toLowerCase();
|
|
});
|
|
}
|
|
case QUESTION_TYPE_KEY.NUMERIC: {
|
|
if (answer.type !== 'NUMERIC') return false;
|
|
const settings = (question.settings ?? {}) as { correctValue?: unknown; tolerance?: unknown };
|
|
const correctOption = (question.options ?? []).find((option) => option.isCorrect);
|
|
const correctValue = Number(correctOption?.label ?? correctOption?.value ?? settings.correctValue ?? NaN);
|
|
const tolerance = Number(settings.tolerance ?? 0);
|
|
if (!Number.isFinite(correctValue)) return null;
|
|
return Math.abs(answer.value - correctValue) <= (Number.isFinite(tolerance) ? tolerance : 0);
|
|
}
|
|
case QUESTION_TYPE_KEY.FILL_BLANK: {
|
|
if (answer.type !== 'FILL_BLANK') return false;
|
|
const accepted = (question.options ?? [])
|
|
.filter((option) => option.isCorrect !== false)
|
|
.map((option) =>
|
|
String(option.label ?? option.value ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
if (accepted.length === 0) return null;
|
|
return answer.values.every((value, index) => {
|
|
const expected = accepted[index];
|
|
if (expected === undefined) return false;
|
|
return value.trim().toLowerCase() === expected;
|
|
});
|
|
}
|
|
case QUESTION_TYPE_KEY.ORDERING: {
|
|
if (answer.type !== 'ORDERING') return false;
|
|
const expected = (question.options ?? [])
|
|
.map((option) => String(option.value ?? option.label ?? ''))
|
|
.filter(Boolean);
|
|
if (expected.length === 0) return null;
|
|
return (
|
|
answer.orderedValues.length === expected.length &&
|
|
answer.orderedValues.every((value, index) => value === expected[index])
|
|
);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const totalGradable = $derived(
|
|
exercise.questions.reduce((count, question) => {
|
|
const typeId = (question as ExerciseQuestionModel & { questionTypeId?: number }).questionTypeId;
|
|
const isAutoGradable = typeof typeId === 'number' ? isAutoGradableQuestionTypeId(typeId) : true;
|
|
return isAutoGradable ? count + 1 : count;
|
|
}, 0)
|
|
);
|
|
|
|
const correctCountComputed = $derived(
|
|
submitted
|
|
? exercise.questions.reduce((count, question) => {
|
|
const key = getExerciseQuestionContractKey(question);
|
|
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
|
|
}, 0)
|
|
: 0
|
|
);
|
|
|
|
/** When reviewing a stored snapshot, scores are pinned to saved numbers (handles changed rubrics later). */
|
|
const displayCorrectCount = $derived.by(() => {
|
|
if (!submitted) return correctCountComputed;
|
|
const idx =
|
|
selectValue !== 'live'
|
|
? Number.parseInt(selectValue, 10)
|
|
: persistedAttempts.length > 0
|
|
? persistedAttempts.length - 1
|
|
: -1;
|
|
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
|
|
return persistedAttempts[idx].correctCount;
|
|
}
|
|
return correctCountComputed;
|
|
});
|
|
|
|
const displayTotalGradable = $derived.by(() => {
|
|
if (!submitted) return totalGradable;
|
|
const idx =
|
|
selectValue !== 'live'
|
|
? Number.parseInt(selectValue, 10)
|
|
: persistedAttempts.length > 0
|
|
? persistedAttempts.length - 1
|
|
: -1;
|
|
if (Number.isInteger(idx) && idx >= 0 && idx < persistedAttempts.length) {
|
|
return persistedAttempts[idx].totalGradable;
|
|
}
|
|
return totalGradable;
|
|
});
|
|
|
|
const summaryText = $derived(
|
|
summaryTemplate
|
|
.replace('[[correct]]', String(displayCorrectCount))
|
|
.replace('[[total]]', String(displayTotalGradable))
|
|
);
|
|
|
|
const showAttemptsPicker = $derived(
|
|
!!attemptsPersistenceKey && persistedAttempts.length > 0 && exercise.questions.length > 0 && exercise.isUnlocked
|
|
);
|
|
|
|
function handleSubmit() {
|
|
if (!canSubmitAnswers) return;
|
|
|
|
submitted = true;
|
|
|
|
if (!attemptsPersistenceKey) return;
|
|
|
|
const correct = exercise.questions.reduce((count, question) => {
|
|
const key = getExerciseQuestionContractKey(question);
|
|
return isAnswerCorrect(question, answers[key]) === true ? count + 1 : count;
|
|
}, 0);
|
|
const tot = totalGradable;
|
|
|
|
const snapshot: PublicExerciseStoredAttempt = {
|
|
savedAt: new Date().toISOString(),
|
|
answersByKey: cloneAnswersByKey(answers),
|
|
correctCount: correct,
|
|
totalGradable: tot
|
|
};
|
|
|
|
persistedAttempts = [...persistedAttempts, snapshot];
|
|
writePublicExerciseAttempts(attemptsPersistenceKey, persistedAttempts);
|
|
selectValue = String(persistedAttempts.length - 1);
|
|
}
|
|
|
|
function handleReset() {
|
|
selectValue = 'live';
|
|
answers = {};
|
|
submitted = false;
|
|
}
|
|
|
|
/** Trigger text when user is reviewing a frozen attempt vs starting fresh. */
|
|
const tryAgainButtonLabel = $derived(
|
|
showAttemptsPicker && selectValue !== 'live' ? newAttemptOptionLabel : tryAgainLabel
|
|
);
|
|
</script>
|
|
|
|
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
|
{#if !exercise.isUnlocked}
|
|
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
|
|
{:else}
|
|
{#if exercise.sectionTitle}
|
|
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
|
{exercise.sectionTitle}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showAttemptsPicker}
|
|
<div class="ui:mt-4 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
|
<span class="ui:sr-only" id="public-exercise-attempts-select-label">{attemptsSelectAriaLabel}</span>
|
|
<Select.Root
|
|
type="single"
|
|
value={selectValue}
|
|
onValueChange={(nextValue) => nextValue && handleAttemptSelect(nextValue)}
|
|
>
|
|
<Select.Trigger
|
|
class="ui:min-w-[min(24rem,calc(100vw-5rem))] ui:max-w-full"
|
|
aria-labelledby="public-exercise-attempts-select-label"
|
|
>
|
|
<span class="ui:truncate">
|
|
{selectValue === 'live'
|
|
? newAttemptOptionLabel
|
|
: formatAttemptOption({
|
|
attemptNumber: Number(selectValue) + 1,
|
|
correct: persistedAttempts[Number(selectValue)]?.correctCount ?? 0,
|
|
total: persistedAttempts[Number(selectValue)]?.totalGradable ?? totalGradable
|
|
})}
|
|
</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each persistedAttempts as _, attemptIdx (attemptIdx)}
|
|
{@const stored = persistedAttempts[attemptIdx]}
|
|
{@const attemptLabel = formatAttemptOption({
|
|
attemptNumber: attemptIdx + 1,
|
|
correct: stored.correctCount,
|
|
total: stored.totalGradable
|
|
})}
|
|
<Select.Item value={String(attemptIdx)} label={attemptLabel}>{attemptLabel}</Select.Item>
|
|
{/each}
|
|
<Select.Item value="live" label={newAttemptOptionLabel}>{newAttemptOptionLabel}</Select.Item>
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
{/if}
|
|
|
|
<h1
|
|
class={cn(
|
|
'ui:text-2xl ui:tracking-tight ui:text-foreground ui:sm:text-3xl',
|
|
showAttemptsPicker ? 'ui:mt-4' : 'ui:mt-2'
|
|
)}
|
|
>
|
|
{exercise.title}
|
|
</h1>
|
|
|
|
{#if exercise.description}
|
|
<div class="prose ui:sm:prose-sm ui:mt-8 ui:max-w-none ui:dark:text-white">
|
|
<SafeHtmlContent content={exercise.description} />
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="ui:mt-8 ui:space-y-6">
|
|
<QuestionList
|
|
contract={{
|
|
mode,
|
|
questions: exercise.questions,
|
|
answersByKey: answers,
|
|
disabled: submitted,
|
|
labels
|
|
}}
|
|
onAnswerChange={handleAnswerChange}
|
|
/>
|
|
</div>
|
|
|
|
<div class="ui:mt-10 ui:flex ui:items-center ui:justify-between ui:gap-4">
|
|
{#if submitted}
|
|
<div class="ui:text-sm ui:text-muted-foreground">{summaryText}</div>
|
|
<Button variant="outline" onclick={handleReset}>{tryAgainButtonLabel}</Button>
|
|
{:else}
|
|
<span class="ui:text-sm ui:text-muted-foreground">{privacyHint}</span>
|
|
<Button disabled={!canSubmitAnswers} onclick={handleSubmit}>{submitLabel}</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
|
|
{/if}
|
|
</article>
|