"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityRenderer } from "@/components/activities"; import { AiDisclaimer } from "@/components/AiDisclaimer"; import { XIcon } from "@/components/icons"; import { ReportQuestionButton } from "@/components/ReportQuestionButton"; import { ProgressBar } from "@/components/ui"; import type { Activity, Deck } from "@/lib/schemas"; import { useI18n } from "@/lib/i18n"; import { useWakeLock } from "@/lib/use-wake-lock"; import { cn } from "@/lib/utils"; export type SessionItem = { deck: Deck; activity: Activity }; export type SessionResult = { correct: boolean; userAnswer?: string }; export type SessionRenderResults = (state: { results: Record; items: SessionItem[]; correct: number; total: number; score: number; }) => React.ReactNode; /** * The scroll-snap activity feed shared by review, mistakes and exam sessions. * Same mechanics as the study feed (remount per run, scroll tracking, keyboard * navigation) but with a pluggable results slide and an optional timer. */ export function SessionFeed({ title, items, timerSeconds, noHints = false, testId, onExit, onAnswered, renderResults, }: { title: string; items: SessionItem[]; timerSeconds?: number; noHints?: boolean; testId?: string; onExit: () => void; onAnswered?: (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => void; renderResults: SessionRenderResults; }) { const { t } = useI18n(); useWakeLock(true); const [index, setIndex] = useState(0); const [results, setResults] = useState>({}); /** Ids reported during this run: skipped, so they are not "missed". */ const [skipped, setSkipped] = useState([]); const [runId] = useState(0); const [remaining, setRemaining] = useState(timerSeconds ?? 0); const scrollRef = useRef(null); const total = items.length; const slideCount = total + 1; 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]); 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]); 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); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goTo, index, next]); // Countdown (exam mode); reaching zero jumps to the results slide. useEffect(() => { if (!timerSeconds) return; const interval = window.setInterval(() => { setRemaining((value) => { if (value <= 1) { window.clearInterval(interval); return 0; } return value - 1; }); }, 1000); return () => window.clearInterval(interval); }, [timerSeconds]); useEffect(() => { if (timerSeconds && remaining === 0) goTo(slideCount - 1); }, [remaining, timerSeconds, goTo, slideCount]); const handleAnswered = useCallback( (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => { setResults((current) => { if (current[item.activity.id]) return current; return { ...current, [item.activity.id]: { correct, userAnswer: meta?.userAnswer } }; }); onAnswered?.(item, correct, meta); }, [onAnswered], ); const finished = index >= total; const correct = items.filter((item) => results[item.activity.id]?.correct).length; /** What the results slide counts: reported cards were never attempted. */ const attemptedTotal = Math.max(0, total - skipped.length); const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0; return (
{timerSeconds ? ( {formatClock(remaining)} ) : null} {!finished && items[index] ? ( { setSkipped((current) => [...current, items[index].activity.id]); goTo(index + 1); }} /> ) : null}
{title} {Math.min(index + 1, total)}/{total}
{items.map((item, itemIndex) => (
handleAnswered(item, correct, meta)} onNext={next} isLast={itemIndex === total - 1} nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined} />
))}
{finished ? ( <> {renderResults({ results, items, correct, total: attemptedTotal, score })} ) : (

{t("review.results")}

{t("review.finishFirst")}

)}
); } function formatClock(seconds: number): string { const safe = Math.max(0, seconds); const minutes = Math.floor(safe / 60); return `${minutes}:${String(safe % 60).padStart(2, "0")}`; }