integratedhub/islands/BrainStudy.tsx
2026-01-20 09:02:26 -06:00

771 lines
24 KiB
TypeScript

// 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<string, string> = {
"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<ActiveQueueItem[]>([]);
// Pool of available new cards (from server) that haven't been added to active set yet
const [newCardPool, setNewCardPool] = useState<BrainQueueItem[]>([]);
// Current position in active set rotation
const [currentIndex, setCurrentIndex] = useState(0);
const [sessionStats, setSessionStats] = useState<SessionStats>({
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<string | null>(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 (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto">
</div>
<p className="mt-4 theme-text">Loading study queue...</p>
</div>
);
}
// Error state
if (error) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Error</h2>
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={fetchQueue}
className="bg-blue-500 text-white px-6 py-2 rounded-full hover:bg-blue-600"
>
Try Again
</button>
</div>
);
}
// Complete state
if (isComplete) {
const accuracy = sessionStats.reviewed > 0
? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100)
: 0;
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">
Session Complete!
</h2>
<div className="space-y-4 mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
{sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Cards Reviewed</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{accuracy}%
</p>
<p className="text-sm theme-text">First-Try Accuracy</p>
</div>
<div className="bg-purple-100 dark:bg-purple-900 p-4 rounded-lg">
<p className="text-xl font-bold text-purple-600 dark:text-purple-400">
{sessionStats.correctFirstTry} / {sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Correct on First Try</p>
</div>
{sessionStats.modeAdvances > 0 && (
<div className="bg-yellow-100 dark:bg-yellow-900 p-4 rounded-lg">
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
{sessionStats.modeAdvances}
</p>
<p className="text-sm theme-text">Mode Advances</p>
</div>
)}
{sessionStats.cardsRetired > 0 && (
<div className="bg-emerald-100 dark:bg-emerald-900 p-4 rounded-lg">
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
{sessionStats.cardsRetired}
</p>
<p className="text-sm theme-text">
Cards Fully Mastered (All 6 Modes)
</p>
</div>
)}
</div>
<div className="space-y-3">
<button
onClick={handleRestartSession}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start New Session
</button>
<button
onClick={handleReturnToDashboard}
className="w-full bg-gray-500 text-white px-6 py-3 rounded-full hover:bg-gray-600 font-semibold"
>
Return to Dashboard
</button>
</div>
</div>
);
}
if (!hasStarted) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<p className="theme-text mb-6">
Press Start to begin. This enables audio playback for the first card.
</p>
<button
type="button"
onClick={() => {
try {
const Webkit = globalThis as unknown as {
webkitAudioContext?: typeof AudioContext;
};
const Ctx = globalThis.AudioContext || Webkit.webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
ctx.resume?.();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.01);
setTimeout(() => ctx.close?.(), 50);
}
} catch (_err) {
void _err;
}
setHasStarted(true);
}}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start
</button>
</div>
);
}
// 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 (
<>
<div className="theme-container p-2 sm:p-3 md:p-2 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip">
{/* Header with progress and mode indicator */}
<div className="flex flex-col gap-2 mb-4">
{/* Mode indicator row */}
{/* Progress row */}
<div className="flex items-center gap-4">
<div className="flex-[0.7]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
Step {modeInfo.index}/{modeInfo.total}
</span>
</div>
{currentItem?.is_review_only && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
Review
</span>
)}
</div>
</div>
<div className="flex-[0.7]">
<div className="text-sm theme-text">
<span className="font-semibold">{progress}</span>
<span className="ml-2 text-gray-500">
({currentItem?.is_new ? "New" : "Old"})
</span>
</div>
</div>
<div className="flex-[0.6]">
<button
type="button"
onClick={handleReturnToDashboard}
className="px-4 py-2 rounded-full theme-button font-semibold shadow-lg"
>
Dashboard
</button>
</div>
</div>
</div>
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
>
{/* Card */}
<Card
key={cardKey}
cardInputParams={cardInputParams}
backgroundUrl={backgroundUrl}
hideText={false}
useInputHint={useInputHint}
/>
{/* Skip button */}
<div className="mt-4 flex justify-center">
<button
onClick={() => handleNextCard(false)}
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label="Next question"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
</div>
</>
);
}