freezmo/components/StudyFeed.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

472 lines
20 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer, NotesTextProvider } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops";
import { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { withoutReported } from "@/lib/question-reports";
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 { showToast } from "@/lib/toast";
import { BoltIcon, ClockIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui";
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). */
useWakeLock(true);
const [currentDeck, setCurrentDeck] = useState(deck);
// Reported questions never enter a session; a fully reported deck stays
// studiable (withoutReported falls back to the full list).
const [queue, setQueue] = useState<Activity[]>(() => withoutReported(deck.id, deck.activities));
/** Ids reported during this run: skipped, so they are not "missed". */
const [skipped, setSkipped] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({});
const [xp, setXp] = useState(0);
const [streak, setStreak] = useState(0);
const [bestStreak, setBestStreak] = useState(0);
const [retryMode, setRetryMode] = useState(false);
const [regenerating, setRegenerating] = useState(false);
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);
const total = queue.length;
const slideCount = total + 1; // + results slide
const goTo = useCallback((target: number) => {
const container = scrollRef.current;
if (!container) return;
const clamped = Math.max(0, Math.min(target, slideCount - 1));
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
}, [slideCount]);
const next = useCallback(() => goTo(index + 1), [goTo, index]);
// Track which slide is visible (works for wheel, touch and programmatic
// scroll). Re-attached whenever the scroller remounts on a new run (runId).
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
let frame = 0;
const onScroll = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const height = container.clientHeight || 1;
const current = Math.round(container.scrollTop / height);
setIndex((previous) => (previous === current ? previous : current));
});
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => {
container.removeEventListener("scroll", onScroll);
cancelAnimationFrame(frame);
};
}, [runId]);
// Keyboard navigation.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
event.preventDefault();
next();
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
event.preventDefault();
goTo(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
goTo(0);
} else if (event.key === "End") {
event.preventDefault();
goTo(slideCount - 1);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goTo, index, next, slideCount]);
const finished = index >= total;
useEffect(() => {
if (!finished) return;
if (!retryMode && !recordedRef.current) {
recordedRef.current = true;
const answered = Object.values(results);
const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length;
const attempted = Math.max(0, total - skipped.length);
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
// Only a run with at least one answer counts. Without this, the scroll
// anchoring shift when a regenerated deck swaps in could fire a bogus
// 0% attempt before the feed had snapped back to the first slide.
if (answered.length > 0) {
recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted });
setCurrentDeck((prev) => ({
...prev,
attempts: prev.attempts + 1,
bestScore: Math.max(prev.bestScore ?? 0, Math.round(score)),
updatedAt: Date.now(),
}));
haptic("celebrate");
void celebrate("big");
}
}
}, [finished, queue, results, total, skipped, currentDeck.id, retryMode]);
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;
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
});
// Every answer feeds the history, the SRS schedule and the mistakes set.
recordAnswer({
deckId: currentDeck.id,
deckTitle: currentDeck.title,
activityId: activity.id,
type: activity.type,
topic: activity.topic,
correct,
});
if (correct) {
haptic("success");
const nextStreak = streak + 1;
setStreak(nextStreak);
setBestStreak((best) => Math.max(best, nextStreak));
setXp((value) => value + activity.xp);
if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal");
} else {
haptic("error");
setStreak(0);
}
},
[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],
);
/** What the results screen counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
const correctCount = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
[queue, results, skipped],
);
const restart = useCallback(
(activities: Activity[], retry: boolean) => {
setQueue(activities);
setResults({});
setSkipped([]);
setStreak(0);
setRetryMode(retry);
recordedRef.current = retry; // don't overwrite the best score with a retry run
// A new run: remount every activity (fresh state) and jump to the top
// *before* the new slides render, so scroll anchoring can't hold the
// old results position.
const container = scrollRef.current;
if (container) container.scrollTop = 0;
setRunId((value) => value + 1);
setIndex(0);
},
[],
);
// Whenever a run starts (including the first render), snap back to slide 1.
useEffect(() => {
const container = scrollRef.current;
if (container) container.scrollTop = 0;
}, [runId]);
/** Rebuild the deck from its stored source notes and start it over. */
const regenerate = useCallback(async () => {
if (regenerating) return;
const source = currentDeck.sourceText?.trim();
if (!source) {
setRegenerateError(t("deck.noSourceRegenerate"));
return;
}
setRegenerating(true);
setRegenerateError(null);
try {
const { deck: generated } = await generateDeck({
text: source,
difficulty: currentDeck.difficulty,
language: currentDeck.language,
count: Math.max(8, currentDeck.activities.length),
});
const activities = materialiseActivities(generated.activities, currentDeck.difficulty);
const updated: Deck = {
...currentDeck,
title: generated.title || currentDeck.title,
subject: generated.subject ?? currentDeck.subject,
summary: generated.summary ?? currentDeck.summary,
language: generated.language ?? currentDeck.language,
activities,
attempts: 0,
bestScore: undefined,
updatedAt: Date.now(),
};
upsertDeck(updated);
setCurrentDeck(updated);
restart(activities, false);
} catch (error) {
setRegenerateError(error instanceof ApiError ? error.message : t("deck.couldNotRegenerate"));
} finally {
setRegenerating(false);
}
}, [currentDeck, regenerating, restart, t]);
const progressValue = Math.min(index + 1, slideCount);
const progressMax = slideCount;
return (
// 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
centre line with even padding, so nothing crowds the rounded ends. */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
<Link
href="/"
aria-label={t("feed.back")}
title={t("feed.back")}
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
{currentDeck.title}
</span>
<span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
{retryMode ? t("feed.retry") : null}
{Math.min(index + 1, total)}/{total}
</span>
</div>
<ProgressBar
className="mt-1.5"
value={progressValue}
max={progressMax}
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
/>
</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",
streak >= 3
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={t("feed.streak", { n: streak })}
aria-label={t("feed.streak", { n: streak })}
>
<FlameIcon className="size-3.5" />
{streak}
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-xs font-extrabold tabular-nums text-[var(--text-muted)]"
title={t("feed.xp", { n: xp })}
aria-label={t("feed.xp", { n: xp })}
>
<BoltIcon className="size-3.5" />
{xp}
</span>
</div>
</div>
</header>
{/* Feed ------------------------------------------------------ */}
{/* The scroller remounts on every run (key={runId}) so it always
starts on the first slide with fresh scroll/snap state: a shrinking
queue or an interrupted smooth scroll can never leave the feed on a
stale, half-scrolled or blank position. */}
<div
key={runId}
ref={scrollRef}
data-testid="study-feed"
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
>
{queue.map((activity, activityIndex) => (
<section
key={`${activity.id}-${runId}`}
aria-label={t("feed.activityOf", { n: activityIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<ActivityRenderer
activity={activity}
active={index === activityIndex}
language={currentDeck.language}
isLast={activityIndex === total - 1}
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)}
/>
{/* Under the card: disclaimer centred, report control in the
bottom-right corner (it used to sit in the top chrome). */}
{!finished && index === activityIndex ? (
<div className="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3">
<span aria-hidden />
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deckId={currentDeck.id}
deckTitle={currentDeck.title}
activity={activity}
onSkip={() => {
setSkipped((current) => [...current, activity.id]);
goTo(activityIndex + 1);
}}
/>
</div>
</div>
) : (
<AiDisclaimer />
)}
</div>
</section>
))}
<section
aria-label={t("feed.results")}
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<ResultsScreen
deck={currentDeck}
xp={xp}
bestStreak={bestStreak}
total={attemptedTotal}
correct={correctCount}
missed={missed}
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
onRegenerate={regenerate}
regenerating={regenerating}
regenerateError={regenerateError}
/>
<AiDisclaimer />
</div>
</section>
</div>
{/* Swipe hint on the very first screen ----------------------- */}
<AnimatePresence>
{index === 0 && Object.keys(results).length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center sm:hidden"
>
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
{t("feed.swipeUp")}
</span>
</motion.div>
) : null}
</AnimatePresence>
{/* Desktop next/prev buttons --------------------------------- */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label={t("feed.prev")}>
↑
</Button>
<span className="px-2 text-xs font-semibold text-[var(--text)]">
{index + 1} / {slideCount}
</span>
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label={t("feed.next")}>
↓
</Button>
</div>
</div>
</div>
</NotesTextProvider>
);
}