// islands/BrainStudy.tsx // Brain study component with locked-set mastery behavior // Cards must be mastered through all 6 modes before new cards are introduced import { useEffect, useRef, useState } from "preact/hooks"; import { signal } from "@preact/signals"; import Card from "./Card.tsx"; import { BrainQueueItem } from "../utils/types.ts"; // Import mode utilities (these will be inlined for client-side use) const QUIZ_MODES = [ "photo-see-bisaya", "photo-see-english", "photo-hide-bisaya", "photo-hide-english", "blank-see-bisaya", "blank-see-english", ] as const; const MODE_LABELS: Record = { "photo-see-bisaya": "Photo, See Bisaya", "photo-see-english": "Photo, See English", "photo-hide-bisaya": "Blank, Hide Bisaya", "photo-hide-english": "Photo, Hide English", "blank-see-bisaya": "Blank, See Bisaya", "blank-see-english": "Blank, See English", }; function getModeDisplayInfo(mode: string) { const index = QUIZ_MODES.indexOf(mode as typeof QUIZ_MODES[number]); const safeIndex = index >= 0 ? index : 0; return { index: safeIndex + 1, total: QUIZ_MODES.length, label: MODE_LABELS[mode] || mode, isFinal: safeIndex === QUIZ_MODES.length - 1, }; } function getNextMode(currentMode: string): string | null { const index = QUIZ_MODES.indexOf(currentMode as typeof QUIZ_MODES[number]); if (index >= 0 && index < QUIZ_MODES.length - 1) { return QUIZ_MODES[index + 1]; } return null; // Already at final mode } interface BrainStudyProps { userId: string; role: string; initialMode: string; tab_index?: number; } interface QueueResponse { success: boolean; queue: BrainQueueItem[]; stats: { totalReviewed: number; totalCorrect: number; dueNow: number; accuracy: number; reviewOnlyCount?: number; }; mode: string; totalInQueue: number; dueCount: number; newCount: number; activeSetCount?: number; } interface SessionStats { reviewed: number; correctFirstTry: number; sessionStart: number; modeAdvances: number; // Track how many cards advanced to next mode cardsRetired: number; // Cards that completed all 6 modes this session } // Extended queue item for client-side tracking interface ActiveQueueItem extends BrainQueueItem { // Client-side tracking clientModeIndex: number; // Current mode being studied (client-side, may differ from server after correct answer) clientDisplayMode: string; // Current mode for display hasRetired: boolean; // Has completed all 6 modes } const useInputHint = signal(false); export default function BrainStudy( { userId, role, initialMode, tab_index }: BrainStudyProps, ) { const [loading, setLoading] = useState(true); const [hasStarted, setHasStarted] = useState(false); // Active set: the locked set of cards being mastered const [activeSet, setActiveSet] = useState([]); // Pool of available new cards (from server) that haven't been added to active set yet const [newCardPool, setNewCardPool] = useState([]); // Current position in active set rotation const [currentIndex, setCurrentIndex] = useState(0); const [sessionStats, setSessionStats] = useState({ reviewed: 0, correctFirstTry: 0, sessionStart: Date.now(), modeAdvances: 0, cardsRetired: 0, }); const [overallStats, setOverallStats] = useState({ totalReviewed: 0, totalCorrect: 0, dueNow: 0, accuracy: 0, reviewOnlyCount: 0, }); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); const [isFirstAttempt, setIsFirstAttempt] = useState(true); const [cardKey, setCardKey] = useState(0); // Card state const [cardInputParams, setCardInputParams] = useState({ setFullAuto: (_auto: boolean) => {}, runFullAuto: false, setTogglePause: (_fn: () => void) => {}, setIsPausedDisplay: (_paused: boolean) => {}, questionPhrase: "", answerPhrase: "", nextButton: () => {}, audio1: "", audio2: "", onHelpUsed: () => {}, flipped: false, }); const [backgroundUrl, setBackgroundUrl] = useState(""); // Track first attempt per question const firstAttemptRef = useRef(true); // Maximum active set size const MAX_ACTIVE_SET_SIZE = 10; // Fetch queue on mount useEffect(() => { fetchQueue(); }, []); // Convert server queue items to active queue items with client-side tracking const initializeActiveSet = (items: BrainQueueItem[]): ActiveQueueItem[] => { return items.map((item) => ({ ...item, clientModeIndex: item.mode_index, clientDisplayMode: item.display_mode, hasRetired: false, })); }; const fetchQueue = async () => { setLoading(true); setError(null); try { // Request more cards than we need for the active set so we have a pool to draw from // Include tab_index if provided to filter quiz configs by tab const tabParam = tab_index !== undefined ? `&tab_index=${tab_index}` : ""; const response = await fetch( `/api/brain/queue?limit=100&newLimit=100${tabParam}`, ); if (!response.ok) { throw new Error("Failed to fetch queue"); } const data: QueueResponse = await response.json(); if (data.success) { // Separate the queue into active set (up to MAX_ACTIVE_SET_SIZE) and pool const allCards = data.queue.filter((q) => !q.is_review_only); const initialActiveCards = allCards.slice(0, MAX_ACTIVE_SET_SIZE); const remainingPool = allCards.slice(MAX_ACTIVE_SET_SIZE); setActiveSet(initializeActiveSet(initialActiveCards)); setNewCardPool(remainingPool); setOverallStats({ ...data.stats, reviewOnlyCount: data.stats.reviewOnlyCount ?? 0, }); setCurrentIndex(0); setIsComplete(allCards.length === 0); } else { throw new Error("Failed to build queue"); } } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } }; /** * Configure card display based on mode * Each mode determines: * - What text is shown (questionPhrase) * - What the user types (answerPhrase) * - Whether to show the photo (backgroundUrl) * - Whether to hide certain text (done via questionPhrase) * * Both audio tracks always play for every mode. */ const loadCard = (item: ActiveQueueItem) => { const question = item.question_data; // Use client-side mode tracking for display const mode = item.clientDisplayMode; firstAttemptRef.current = true; setIsFirstAttempt(true); let questionPhrase = ""; let answerPhrase = ""; let showPhoto = true; let flipped = false; // Configure based on mode switch (mode) { case "photo-see-bisaya": // Show photo, display Bisaya text, user types English questionPhrase = question.text_1; // Bisaya answerPhrase = question.text_2; // English showPhoto = true; flipped = false; break; case "photo-see-english": // Show photo, display English text, user types Bisaya questionPhrase = question.text_2; // English answerPhrase = question.text_1; // Bisaya showPhoto = true; flipped = true; break; case "photo-hide-bisaya": // Show photo, hide Bisaya (rely on audio), user types Bisaya questionPhrase = ""; answerPhrase = question.text_2; // Bisaya showPhoto = false; flipped = false; break; case "photo-hide-english": // Show photo, hide English, user types English questionPhrase = ""; answerPhrase = question.text_1; // English showPhoto = true; flipped = true; break; case "blank-see-bisaya": // No photo, display Bisaya text, user types English questionPhrase = question.text_2; // Bisaya answerPhrase = question.text_1; // English showPhoto = false; flipped = true; break; case "blank-see-english": // No photo, display English text, user types Bisaya questionPhrase = question.text_1; // English answerPhrase = question.text_2; // Bisaya showPhoto = false; flipped = false; break; default: // Fallback to photo-see-english behavior questionPhrase = question.text_2; answerPhrase = question.text_1; showPhoto = true; } setCardInputParams({ setFullAuto: () => {}, runFullAuto: false, setTogglePause: () => {}, setIsPausedDisplay: () => {}, questionPhrase, answerPhrase, nextButton: () => handleNextCard(true), // Both audio tracks always provided for every mode audio1: question.audio_file_1, audio2: "", onHelpUsed: handleWrongAttempt, flipped, }); // Set background only if mode requires photo setBackgroundUrl(showPhoto ? (question.background_image_data || "") : ""); setCardKey((prev) => prev + 1); }; // Load the current card whenever the active set or index changes useEffect(() => { if (!hasStarted) return; if (!activeSet.length) return; if (isComplete) return; // Wrap around if we've gone past the end (for rotation) const safeIndex = currentIndex % activeSet.length; if (safeIndex !== currentIndex) { setCurrentIndex(safeIndex); return; } loadCard(activeSet[safeIndex]); }, [activeSet, currentIndex, isComplete, hasStarted]); const recordProgress = async ( wasCorrectFirstTry: boolean, ): Promise<{ modeAdvanced: boolean; isReviewOnly: boolean } | null> => { const safeIndex = currentIndex % activeSet.length; const currentItem = activeSet[safeIndex]; if (!currentItem) return null; try { const response = await fetch("/api/brain/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ questionId: currentItem.question_id, quizId: currentItem.quiz_id, wasCorrectFirstTry, }), }); if (response.ok) { const data = await response.json(); return { modeAdvanced: data.modeAdvanced ?? false, isReviewOnly: data.isReviewOnly ?? false, }; } } catch (err) { console.error("Failed to record progress:", err); } return null; }; // Pull cards from the pool to fill the active set const refillActiveSet = () => { if (newCardPool.length === 0) return; const currentActiveCount = activeSet.filter((item) => !item.hasRetired).length; const slotsAvailable = MAX_ACTIVE_SET_SIZE - currentActiveCount; if (slotsAvailable > 0 && newCardPool.length > 0) { const cardsToAdd = newCardPool.slice(0, slotsAvailable); const remainingPool = newCardPool.slice(slotsAvailable); setNewCardPool(remainingPool); setActiveSet((prev) => { // Filter out retired cards and add new ones const activeCards = prev.filter((item) => !item.hasRetired); const newActiveItems = initializeActiveSet(cardsToAdd); return [...activeCards, ...newActiveItems]; }); } }; const handleNextCard = async (wasCorrect: boolean) => { // Only count first attempt const wasFirstAttempt = firstAttemptRef.current; firstAttemptRef.current = false; const safeIndex = currentIndex % activeSet.length; const currentItem = activeSet[safeIndex]; if (!currentItem) { setIsComplete(true); return; } let cardRetired = false; if (wasFirstAttempt) { // Record progress to server await recordProgress(wasCorrect); // Update session stats setSessionStats((prev) => ({ ...prev, reviewed: prev.reviewed + 1, correctFirstTry: prev.correctFirstTry + (wasCorrect ? 1 : 0), })); if (wasCorrect) { // Correct on first try: advance mode const nextMode = getNextMode(currentItem.clientDisplayMode); if (nextMode !== null) { // Advance to next mode in the progression setSessionStats((prev) => ({ ...prev, modeAdvances: prev.modeAdvances + 1, })); // Update the card's client-side mode setActiveSet((prev) => { const updated = [...prev]; const idx = safeIndex; if (updated[idx]) { updated[idx] = { ...updated[idx], clientModeIndex: updated[idx].clientModeIndex + 1, clientDisplayMode: nextMode, }; } return updated; }); } else { // Already at final mode and correct on first try - card is RETIRED cardRetired = true; setSessionStats((prev) => ({ ...prev, cardsRetired: prev.cardsRetired + 1, })); // Mark the card as retired and remove from active set setActiveSet((prev) => { const updated = prev.filter((_, idx) => idx !== safeIndex); return updated; }); } } // If not correct on first try: card stays in rotation at same mode (no state change needed) } // Move to next card in the rotation setActiveSet((prevActiveSet) => { if (prevActiveSet.length === 0) { // All cards retired, try to refill if (newCardPool.length > 0) { // Will be handled by refillActiveSet return prevActiveSet; } setIsComplete(true); return prevActiveSet; } return prevActiveSet; }); // Adjust index for next card setCurrentIndex((prevIndex) => { // Get current active set length (accounting for possible removal) const currentSetLength = cardRetired ? activeSet.length - 1 : activeSet.length; if (currentSetLength === 0) { return 0; } // Move to next card, wrapping around const nextIndex = cardRetired ? prevIndex % currentSetLength // Stay at same index (next card shifted into position) : (prevIndex + 1) % currentSetLength; return nextIndex; }); // Check if we need to refill the active set if (cardRetired) { // Use setTimeout to ensure state has updated setTimeout(() => { refillActiveSet(); }, 0); } // Check if session is complete setTimeout(() => { setActiveSet((currentActiveSet) => { if (currentActiveSet.length === 0 && newCardPool.length === 0) { setIsComplete(true); } return currentActiveSet; }); }, 100); }; const handleWrongAttempt = () => { // Mark that this is no longer a first attempt if (firstAttemptRef.current) { firstAttemptRef.current = false; setIsFirstAttempt(false); } }; const handleReturnToDashboard = () => { const baseUrl = "/language/dashboard"; const returnUrl = tab_index !== undefined ? `${baseUrl}?tab_index=${tab_index}` : baseUrl; globalThis.location.href = returnUrl; }; const handleRestartSession = async () => { setLoading(true); setError(null); try { // Reset all quiz progress on server (back to first study mode) const response = await fetch("/api/brain/reset-session", { method: "DELETE", }); if (!response.ok) { throw new Error("Failed to reset session"); } // Reset local state setSessionStats({ reviewed: 0, correctFirstTry: 0, sessionStart: Date.now(), modeAdvances: 0, cardsRetired: 0, }); setHasStarted(false); setActiveSet([]); setNewCardPool([]); setCurrentIndex(0); setIsComplete(false); await fetchQueue(); } catch (err) { setError( err instanceof Error ? err.message : "Failed to reset session. Please try again.", ); } finally { setLoading(false); } }; // Loading state if (loading) { return (

Brain Study

Loading study queue...

); } // Error state if (error) { return (

Error

{error}

); } // Complete state if (isComplete) { const accuracy = sessionStats.reviewed > 0 ? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100) : 0; return (

Session Complete!

{sessionStats.reviewed}

Cards Reviewed

{accuracy}%

First-Try Accuracy

{sessionStats.correctFirstTry} / {sessionStats.reviewed}

Correct on First Try

{sessionStats.modeAdvances > 0 && (

{sessionStats.modeAdvances}

Mode Advances

)} {sessionStats.cardsRetired > 0 && (

{sessionStats.cardsRetired}

Cards Fully Mastered (All 6 Modes)

)}
); } if (!hasStarted) { return (

Brain Study

Press Start to begin. This enables audio playback for the first card.

); } // Study state const safeIndex = activeSet.length > 0 ? currentIndex % activeSet.length : 0; const currentItem = activeSet[safeIndex]; // Show progress as "X cards remaining in active set" const progress = `${safeIndex + 1} / ${activeSet.length}`; const modeInfo = getModeDisplayInfo( currentItem?.clientDisplayMode || QUIZ_MODES[0], ); return ( <>
{/* Header with progress and mode indicator */}
{/* Mode indicator row */} {/* Progress row */}
Step {modeInfo.index}/{modeInfo.total}
{currentItem?.is_review_only && ( Review )}
{progress} ({currentItem?.is_new ? "New" : "Old"})
{/* Card */} {/* Skip button */}
); }