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.
This commit is contained in:
Martin Dvořák 2026-10-15 05:53:47 +00:00
commit 796e2e9fe9
26 changed files with 1068 additions and 51 deletions

View file

@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
import { ActivityRenderer, NotesTextProvider } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
@ -17,7 +17,8 @@ import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { showToast } from "@/lib/toast";
import { BoltIcon, ClockIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui";
@ -25,6 +26,9 @@ import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
/** Seconds a card is given in a timed run (the chrome shows the countdown). */
export const TIMED_CARD_SECONDS = 30;
export function StudyFeed({ deck }: { deck: Deck }) {
const { t } = useI18n();
/* Keep the screen awake for the whole study session (feature-detected). */
@ -45,6 +49,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const [regenerateError, setRegenerateError] = useState<string | null>(null);
/** Bumped on every restart so the activity components remount fresh. */
const [runId, setRunId] = useState(0);
/** Timed run: each card gets TIMED_CARD_SECONDS, a card that runs out counts
* as a miss. Off by default — the toggle sits in the chrome. */
const [timed, setTimed] = useState(false);
const [remaining, setRemaining] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const recordedRef = useRef(false);
@ -135,8 +143,9 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
if (results[activity.id]) return; // first answer counts, recorded once
setResults((current) => {
if (current[activity.id]) return current; // first answer counts
if (current[activity.id]) return current;
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
});
// Every answer feeds the history, the SRS schedule and the mistakes set.
@ -160,9 +169,37 @@ export function StudyFeed({ deck }: { deck: Deck }) {
setStreak(0);
}
},
[currentDeck.id, currentDeck.title, streak],
[currentDeck.id, currentDeck.title, results, streak],
);
/* Timed runs ---------------------------------------------------------- */
const timedActivity = index < total ? queue[index] : null;
const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true;
useEffect(() => {
if (!timed || !timedActivity || timedAnswered) {
setRemaining(null);
return;
}
setRemaining(TIMED_CARD_SECONDS);
const timer = window.setInterval(() => {
setRemaining((value) => (value === null ? null : Math.max(0, value - 1)));
}, 1000);
return () => window.clearInterval(timer);
}, [timed, timedActivity, timedAnswered, runId]);
/* Ran out: the card counts as a miss (the same path as a wrong answer, so
history and the SRS schedule see it), the learner is told, and the feed
moves on. */
useEffect(() => {
if (!timed || remaining !== 0 || !timedActivity || timedAnswered) return;
handleAnswered(timedActivity, false, { userAnswer: t("feed.timedOut") });
showToast({ message: t("feed.timedOut") });
// Straight away, not on a timer: recording the miss re-renders (and
// re-runs this effect), and a deferred scroll would be cleaned up with it.
goTo(index + 1);
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
const missed = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
[queue, results, skipped],
@ -244,7 +281,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const progressMax = slideCount;
return (
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
// The deck's own notes travel with the feed: after an answer the feedback
// panel points back into them (NotesSource).
<NotesTextProvider value={currentDeck.sourceText}>
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
{/* Chrome ---------------------------------------------------- */}
{/* One pill: round close button, title + position above the bar, and
the streak/xp chips at the other end. Everything sits on the pill's
@ -279,6 +319,22 @@ export function StudyFeed({ deck }: { deck: Deck }) {
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
data-timed-toggle
data-timed-seconds={TIMED_CARD_SECONDS}
aria-pressed={timed}
onClick={() => setTimed((value) => !value)}
aria-label={timed ? t("feed.timedOn") : t("feed.timed")}
title={timed ? t("feed.timedOn") : t("feed.timed")}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
timed ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
)}
>
<ClockIcon className="size-3.5" />
{timed && remaining !== null ? `${remaining}s` : `${TIMED_CARD_SECONDS}s`}
</button>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
@ -410,6 +466,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
</Button>
</div>
</div>
</div>
</div>
</NotesTextProvider>
);
}