freezmo/components/StudyFeed.tsx
Martin Dvorak 1bacf43ed2 Fix 'Retry missed ones' showing locked, already-answered items
The retry reused the same React keys for the same activity ids, so every
activity component kept its internal state (selected option, feedback,
flip, matched pairs) from the first run — the retried items appeared
pre-answered with the old wrong answer and could not be answered again.

- StudyFeed now keeps a runId that increments on every restart/retry and
  includes it in the slide keys, so each run gets fresh component state.
- Scroll snaps back to the first slide instantly on a new run (the old
  requestAnimationFrame + smooth goTo could race the queue change).

New retry suite (14 checks): answer wrong → results → retry → items are
unanswered and enabled → answer correctly → 2/2 on the retry run, plus the
full 'study again' reset. Deck-chrome 15/15, pwa 16/16, final-ux 12/12,
security 8/8, contrast 20/20.
2026-10-07 10:19:44 +00:00

287 lines
11 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 } from "@/components/activities";
import { celebrate } from "@/lib/confetti";
import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
export function StudyFeed({ deck }: { deck: Deck }) {
const [queue, setQueue] = useState<Activity[]>(deck.activities);
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);
/** Bumped on every restart so the activity components remount fresh. */
const [runId, setRunId] = useState(0);
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).
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);
};
}, []);
// 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 = answered.filter((entry) => entry.correct).length;
const score = total > 0 ? (correct / total) * 100 : 0;
recordAttempt(deck.id, { scorePercent: score, correct, total });
void celebrate("big");
}
}, [finished, results, total, deck.id, retryMode]);
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
setResults((current) => {
if (current[activity.id]) return current; // first answer counts
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
});
if (correct) {
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 {
setStreak(0);
}
},
[streak],
);
const missed = useMemo(
() => queue.filter((activity) => !results[activity.id]?.correct),
[queue, results],
);
const correctCount = useMemo(
() => queue.filter((activity) => results[activity.id]?.correct).length,
[queue, results],
);
const restart = useCallback(
(activities: Activity[], retry: boolean) => {
setQueue(activities);
setResults({});
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.
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]);
const progressValue = Math.min(index + 1, slideCount);
const progressMax = slideCount;
return (
<div className="frost-bg fixed inset-0 flex flex-col">
{/* Chrome ---------------------------------------------------- */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-2xl px-3 py-2">
<div className="flex items-center gap-3">
<Link
href="/"
aria-label="Back to your library"
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</Link>
<div className="min-w-0 flex-1">
<ProgressBar
value={progressValue}
max={progressMax}
label={`Activity ${progressValue} of ${progressMax}`}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-extrabold transition",
streak >= 3
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={`Answer streak: ${streak}`}
aria-label={`Answer streak: ${streak}`}
>
<FlameIcon className="size-3.5" />
{streak}
</span>
<span
className="inline-flex items-center gap-1 rounded-xl bg-[var(--surface-sunken)] px-2 py-1 text-xs font-extrabold text-[var(--text-muted)]"
title={`XP earned: ${xp}`}
aria-label={`XP earned: ${xp}`}
>
<BoltIcon className="size-3.5" />
{xp}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{deck.title}</span>
<span className="shrink-0">
{retryMode ? "Retry · " : ""}
{Math.min(index + 1, total)}/{total}
</span>
</div>
</div>
</header>
{/* Feed ------------------------------------------------------ */}
<div
ref={scrollRef}
data-testid="study-feed"
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain"
>
{queue.map((activity, activityIndex) => (
<section
key={`${activity.id}-${runId}`}
aria-label={`Activity ${activityIndex + 1} of ${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]">
<ActivityRenderer
activity={activity}
active={index === activityIndex}
language={deck.language}
isLast={activityIndex === total - 1}
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)}
/>
</div>
</section>
))}
<section
aria-label="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]">
<ResultsScreen
deck={deck}
xp={xp}
bestStreak={bestStreak}
total={total}
correct={correctCount}
missed={missed}
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(deck.activities, false)}
/>
</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">
Swipe up for the next activity ↑
</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-2xl px-2 py-1.5">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
↑
</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="Next activity">
↓
</Button>
</div>
</div>
</div>
);
}