freezmo/components/activities/FillBlank.tsx
Martin Dvorak 796e2e9fe9 Study cards: from-your-notes, read aloud, timed runs
From your notes: the generator now stores a verbatim source quote with every
activity (schema + prompt + a server-side clamp so a chatty model cannot drop a
card), and the feedback panel shows the sentence that carries it with the quote
highlighted green. Decks made before the change, and quotes the model trimmed,
fall back to a local IDF-weighted match over the saved notes text
(lib/notes-source.ts): nothing is shown unless at least two distinct terms
land. The passage hides in exam mode with the other reveals.

Read aloud: a Web Speech API speaker control on the text a learner reads
(flashcard faces, questions, statements, sentences, instructions) via
lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice
matched to the deck's language, and no button at all where the browser has no
speech synthesis. It used to sit inside the flashcard's flip button (nested
buttons, tapping it flipped the card); it now lives next to the hint row.

Timed runs: the feed chrome carries a timer chip (off by default) that gives
each card 30 seconds; a card that runs out is recorded as a miss through the
same path as a wrong answer (history + SRS agree), shows a toast, and the feed
moves straight on with a fresh budget. handleAnswered ignores a second result
per card, so a timeout plus a manual answer cannot record twice.

The generator's type spec now names what each type suits (language decks lean
on flashcards/matching/fill blank, ordering needs a real sequence, short answer
reasoning), so the deck-aware guidance covers all seven types.

New activities-test suite (24 checks: quote path, local path, the green mark,
the speech stub, the timed run on Playwright's fake clock), registered in
run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs
keys for every new string; no new dependencies. Version 0.6.1, SW v83.
2026-10-15 05:53:47 +00:00

210 lines
7.3 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
import { SpeakButton } from "@/components/SpeakButton";
import {
ActivityHeader,
FeedbackPanel,
Hint,
OptionTile,
PromptCard,
type ActivityComponentProps,
} from "./shared";
type FillBlankActivity = Extract<Activity, { type: "fill_blank" }>;
const BLANK = /_{2,}/;
function normalise(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a.length || !b.length) return Math.max(a.length, b.length);
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const current = [i];
for (let j = 1; j <= b.length; j++) {
current[j] = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
previous = current;
}
return previous[b.length];
}
/** Lenient comparison: case/punctuation-insensitive, one typo allowed, "/" alternatives. */
export function isFillBlankCorrect(userValue: string, answer: string): boolean {
const user = normalise(userValue);
if (!user) return false;
const candidates = answer
.split("/")
.map((part) => normalise(part.replace(/\(.*?\)/g, "")))
.filter(Boolean);
return candidates.some((candidate) => {
if (candidate === user) return true;
if (candidate.length >= 5 && user.length >= 4 && levenshtein(candidate, user) <= 1) return true;
// accept the answer with/without a trailing unit or article
const candidateWords = candidate.split(" ");
const userWords = user.split(" ");
if (candidateWords.length > 1 && userWords.length === 1) return candidateWords.includes(user);
return false;
});
}
export function FillBlank({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [chosen, setChosen] = useState<string | null>(null);
const [result, setResult] = useState<boolean | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const hasBlank = BLANK.test(activity.sentence);
const [before, after = ""] = hasBlank ? activity.sentence.split(BLANK) : [activity.sentence, ""];
useEffect(() => {
if (active && !hasBlank && !activity.options) inputRef.current?.focus();
}, [active, hasBlank, activity.options]);
const submit = (answerValue: string) => {
if (result !== null) return;
const correct = isFillBlankCorrect(answerValue, activity.answer);
setChosen(answerValue);
setResult(correct);
onAnswered(correct, { userAnswer: answerValue });
};
const displayAnswer = chosen ?? value;
return (
<>
<ActivityHeader type="fill_blank" topic={activity.topic} />
<PromptCard>
<p className="text-lg font-semibold leading-loose text-frost-950 sm:text-xl dark:text-frost-50">
{before}
{activity.options ? (
<span
className={cn(
"mx-1 inline-block min-w-24 rounded-xl border-b-4 px-3 py-0.5 text-center align-baseline",
result === null
? "border-frost-300 bg-frost-50/80 text-frost-500 dark:bg-white/10 dark:text-frost-200"
: result
? "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-400/15 dark:text-rose-100",
)}
>
{displayAnswer || "?"}
</span>
) : (
<input
ref={inputRef}
value={displayAnswer}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !result) {
event.preventDefault();
submit(value);
}
event.stopPropagation();
}}
disabled={result !== null}
aria-label={t("fill.typeMissing")}
placeholder={t("fill.placeholder")}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
className={cn(
"mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none backdrop-blur-lg placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30",
result === null
? "border-frost-300 focus:border-frost-500"
: result
? "border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100",
)}
style={{ width: `${Math.max(5, (displayAnswer.length || 6) + 2)}ch` }}
/>
)}
{after}
</p>
<div className="mt-3 flex justify-end">
<SpeakButton text={activity.sentence} language={language} />
</div>
</PromptCard>
{activity.options ? (
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label={t("activity.wordBank")}>
{activity.options.map((option) => {
const state =
result === null
? "idle"
: normalise(option) === normalise(activity.answer)
? "correct"
: chosen && normalise(chosen) === normalise(option) && result === false
? "wrong"
: "dim";
return (
<OptionTile
key={option}
state={state}
disabled={result !== null}
onClick={() => submit(option)}
className="justify-center text-center"
>
{option}
</OptionTile>
);
})}
</div>
) : result === null ? (
<div className="flex gap-2">
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
{t("fill.check")}
</Button>
</div>
) : null}
{result === null && !activity.options ? (
<Hint>{t("fill.hint")}</Hint>
) : null}
{result !== null ? (
<FeedbackPanel
activity={activity}
correct={result}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
nextLabel={nextLabel}
onNext={onNext}
detail={
<span>
{t("fill.expected")} <span className="font-bold">{activity.answer}</span>
{!result && chosen ? (
<>
{" "}
{t("fill.youWrote")} <span className="italic">{chosen}</span>
</>
) : null}
</span>
}
/>
) : null}
</>
);
}