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.
This commit is contained in:
Martin Dvořák 2026-10-07 10:19:44 +00:00
commit 1bacf43ed2

View file

@ -24,6 +24,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
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);
@ -134,12 +136,19 @@ export function StudyFeed({ deck }: { deck: Deck }) {
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);
requestAnimationFrame(() => goTo(0));
},
[goTo],
[],
);
// 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;
@ -205,7 +214,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
>
{queue.map((activity, activityIndex) => (
<section
key={activity.id}
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"
>