"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(() => withoutReported(deck.id, deck.activities)); /** Ids reported during this run: skipped, so they are not "missed". */ const [skipped, setSkipped] = useState([]); const [index, setIndex] = useState(0); const [results, setResults] = useState>({}); 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(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(null); const scrollRef = useRef(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 = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]); const correct = answered.filter((activity) => results[activity.id]?.correct).length; const attempted = answered.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 — and // swiping through a deck without answering is a pass, not a zero. 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; /** Seconds left on the card in view, or null when no countdown runs. */ const timedRemaining = timed && remaining !== null && timedActivity && !timedAnswered ? remaining : null; const timedDanger = timedRemaining !== null && timedRemaining <= 10; 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]); /* Last ten seconds: one soft tick per card, like a phone timer. */ const warnedRef = useRef(null); useEffect(() => { if (!timed || timedRemaining === null || timedRemaining > 10 || !timedActivity) return; if (warnedRef.current === timedActivity.id) return; warnedRef.current = timedActivity.id; haptic("toggle"); }, [timed, timedRemaining, timedActivity]); /* 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]); /** Answered, but landed wrong. */ const missed = useMemo( () => queue.filter( (activity) => !skipped.includes(activity.id) && results[activity.id] && !results[activity.id]?.correct, ), [queue, results, skipped], ); /** Scrolled past without answering — waiting, not wrong. */ const unanswered = useMemo( () => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]), [queue, results, skipped], ); /** What the results screen counts: only cards that were answered (reported * cards were never attempted, skipped ones are not mistakes). */ const attemptedTotal = total - skipped.length - unanswered.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).
{/* 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. */}
{/* The countdown, drawn along the pill's lower edge: the chip alone was easy to miss mid-card. */} {timedRemaining !== null ? ( ) : null}
{currentDeck.title} {retryMode ? t("feed.retry") : null} {Math.min(index + 1, total)}/{total}
= 3 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]", )} title={t("feed.streak", { n: streak })} aria-label={t("feed.streak", { n: streak })} > {streak} {xp}
{/* 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. */}
{queue.map((activity, activityIndex) => (
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 ? (
{ setSkipped((current) => [...current, activity.id]); goTo(activityIndex + 1); }} />
) : ( )}
))}
restart(missed, true)} onStudyUnanswered={() => restart(unanswered, true)} onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)} onRegenerate={regenerate} regenerating={regenerating} regenerateError={regenerateError} />
{/* Swipe hint on the very first screen ----------------------- */} {index === 0 && Object.keys(results).length === 0 ? ( {t("feed.swipeUp")} ) : null} {/* Desktop next/prev buttons --------------------------------- */}
{index + 1} / {slideCount}
); }