diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c400ff6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Stray frontend scaffold โ€” only contains node_modules, should never be tracked. +brain-trails-frontend/ + +# Safety nets (the app lives in brain-trails/ which has its own .gitignore) +**/node_modules/ +**/.next/ +**/.env.local diff --git a/brain-trails/app/codex/[id]/page.tsx b/brain-trails/app/codex/[id]/page.tsx new file mode 100644 index 0000000..64a03b8 --- /dev/null +++ b/brain-trails/app/codex/[id]/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { motion } from "framer-motion"; +import { useRouter, useParams } from "next/navigation"; +import { + ArrowLeft, NotebookPen, Layers, GraduationCap, CalendarClock, + Plus, Check, Sparkle, Loader2, ChevronRight, BookOpen, +} from "lucide-react"; +import { useAuth } from "@/context/AuthContext"; +import { useCardStyles } from "@/hooks/useCardStyles"; +import { useUIStore } from "@/stores"; +import { supabase } from "@/lib/supabase"; +import { friendlyAiError } from "@/lib/aiError"; +import BackgroundLayer from "@/components/layout/BackgroundLayer"; +import TravelerHotbar from "@/components/layout/TravelerHotbar"; + +const BACKEND_URL = process.env.NEXT_PUBLIC_AI_API_URL || process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:5000"; + +interface Subject { id: string; name: string; code: string; emoji: string; color: string; professor: string; target_grade: string; } +interface Topic { id: string; name: string; mastery_pct: number; is_completed: boolean; sort_order: number; } +interface Deck { id: string; name: string; emoji: string; color: string; cardCount: number; } +interface Note { id: string; title: string; updated_at: string; } +interface Exam { id: string; name: string; exam_date: string; } + +function daysUntil(iso: string): number { + return Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000); +} + +export default function SubjectDetail() { + const router = useRouter(); + const params = useParams(); + const subjectId = String(params.id); + const { user } = useAuth(); + const { isSun, muted } = useCardStyles(); + const addToast = useUIStore((s) => s.addToast); + + const [subject, setSubject] = useState(null); + const [topics, setTopics] = useState([]); + const [decks, setDecks] = useState([]); + const [notes, setNotes] = useState([]); + const [exam, setExam] = useState(null); + const [attempts, setAttempts] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + const load = useCallback(async () => { + if (!user) return; + + const { data: subj } = await supabase + .from("subjects").select("id, name, code, emoji, color, professor, target_grade") + .eq("id", subjectId).maybeSingle(); + if (subj) setSubject(subj as Subject); + + const [topicsRes, decksRes, notesRes, examRes] = await Promise.all([ + supabase.from("topics").select("id, name, mastery_pct, is_completed, sort_order").eq("subject_id", subjectId).order("sort_order"), + supabase.from("decks").select("id, name, emoji, color").eq("subject_id", subjectId).order("created_at"), + supabase.from("notes").select("id, title, updated_at").eq("subject_id", subjectId).order("updated_at", { ascending: false }), + supabase.from("exams").select("id, name, exam_date").eq("subject_id", subjectId) + .gte("exam_date", new Date().toISOString()).order("exam_date").limit(1).maybeSingle(), + ]); + + setTopics((topicsRes.data ?? []) as Topic[]); + const deckRows = (decksRes.data ?? []) as { id: string; name: string; emoji: string; color: string }[]; + setNotes((notesRes.data ?? []) as Note[]); + if (examRes.data) setExam(examRes.data as Exam); + + // Card counts per deck + if (deckRows.length) { + const { data: cardRows } = await supabase.from("cards").select("deck_id").in("deck_id", deckRows.map(d => d.id)); + const counts = (cardRows ?? []).reduce((m: Record, c: { deck_id: string }) => { + m[c.deck_id] = (m[c.deck_id] ?? 0) + 1; return m; + }, {}); + setDecks(deckRows.map(d => ({ ...d, cardCount: counts[d.id] ?? 0 }))); + } else { + setDecks([]); + } + + // Trial attempts on this subject's quizzes (loose: count user's recent attempts) + const { count } = await supabase.from("quiz_attempts").select("id", { count: "exact", head: true }).eq("user_id", user.id); + setAttempts(count ?? 0); + + setIsLoading(false); + }, [user, subjectId]); + + useEffect(() => { load(); }, [load]); + + const mastery = topics.length + ? Math.round(topics.reduce((a, t) => a + (t.mastery_pct ?? 0), 0) / topics.length) + : 0; + + const toggleTopic = async (t: Topic) => { + const completed = !t.is_completed; + const mastery_pct = completed ? 100 : t.mastery_pct; + setTopics(prev => prev.map(x => x.id === t.id ? { ...x, is_completed: completed, mastery_pct } : x)); + await supabase.from("topics").update({ is_completed: completed, mastery_pct }).eq("id", t.id); + }; + + const generateDeck = async (topicName?: string) => { + if (!user || !subject || generating) return; + setGenerating(true); + try { + const res = await fetch(`${BACKEND_URL}/api/ai/generate-quiz`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: subject.name, topic: topicName || "General", count: 8, type: "flashcard" }), + }); + const data = await res.json(); + if (!data.questions || data.questions.length === 0) throw new Error(data.error || "No cards generated"); + + const deckName = `${subject.emoji || "๐Ÿ“˜"} ${topicName || subject.name}`; + const { data: deckData, error: deckErr } = await supabase + .from("decks").insert({ user_id: user.id, name: deckName, emoji: subject.emoji || "๐Ÿ“˜", color: subject.color, subject_id: subject.id }) + .select().single(); + if (deckErr || !deckData) throw new Error("Failed to create deck"); + + await supabase.from("cards").insert( + data.questions.map((q: { question: string; answer: string }) => ({ + deck_id: deckData.id, front: q.question, back: q.answer, mastery: 0, review_count: 0, + })) + ); + addToast(`Generated "${deckName}" โ€” ${data.questions.length} cards`, "success"); + await load(); + } catch (err) { + addToast(friendlyAiError(err), "error"); + } finally { + setGenerating(false); + } + }; + + const newNote = async () => { + if (!user || !subject) return; + const { data } = await supabase.from("notes") + .insert({ user_id: user.id, title: `${subject.name} note`, subject_id: subject.id, content_html: "" }) + .select("id").single(); + if (data) router.push(`/notes?note=${data.id}`); + else router.push("/notes"); + }; + + const r = 34, c = 2 * Math.PI * r; + const card = isSun ? "bg-white border-slate-200" : "bg-slate-900/70 border-white/10"; + const ink = isSun ? "text-slate-800" : "text-white"; + + if (isLoading) { + return ( + <> + +
+ +
+ + + ); + } + + if (!subject) { + return ( + <> + +
+

Subject not found.

+ +
+ + + ); + } + + return ( + <> + +
+
+ {/* Header */} +
+ +
+ +
+
+ + + + +
+ {subject.emoji || "๐Ÿ“˜"} +
+
+
+

{subject.name}

+

+ {[subject.code, subject.professor, subject.target_grade && `Target ${subject.target_grade}`].filter(Boolean).join(" ยท ") || "Subject"} +

+
+ {mastery}% mastery + {exam && ( + + {exam.name} in {daysUntil(exam.exam_date)}d + + )} +
+
+
+ + {/* Topics */} +
} card={card} ink={ink} muted={muted}> + {topics.length === 0 ? ( +

No topics yet โ€” the AI adds these when it parses your syllabus.

+ ) : ( +
+ {topics.map(t => ( +
+ + {t.name} +
+
+
+
+ ))} +
+ )} +
+ +
+ {/* Decks */} +
} card={card} ink={ink} muted={muted} + action={ + + }> + {decks.length === 0 ? ( +

No decks yet. Generate one with AI from your topics.

+ ) : ( +
+ {decks.map(d => ( + + ))} +
+ )} +
+ + {/* Notes */} +
} card={card} ink={ink} muted={muted} + action={ + + }> + {notes.length === 0 ? ( +

No notes for this subject yet.

+ ) : ( +
+ {notes.slice(0, 6).map(n => ( + + ))} +
+ )} +
+
+ + {/* Trials */} +
+
+
+ +
+
+

Trial by Fire

+

Get tested on {subject.name}{attempts ? ` ยท ${attempts} attempts` : ""}

+
+
+ +
+
+
+ + + ); +} + +function Section({ + title, icon, action, children, card, ink, muted, +}: { + title: string; icon: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; + card: string; ink: string; muted: string; +}) { + return ( +
+
+

+ {icon} {title} +

+ {action} +
+ {children} +
+ ); +} diff --git a/brain-trails/app/codex/page.tsx b/brain-trails/app/codex/page.tsx new file mode 100644 index 0000000..8dee7c3 --- /dev/null +++ b/brain-trails/app/codex/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Plus, NotebookPen, Layers, GraduationCap, CalendarClock, ChevronRight } from "lucide-react"; +import { useAuth } from "@/context/AuthContext"; +import { useCardStyles } from "@/hooks/useCardStyles"; +import { supabase } from "@/lib/supabase"; +import BackgroundLayer from "@/components/layout/BackgroundLayer"; +import TravelerHotbar from "@/components/layout/TravelerHotbar"; + +interface SubjectCard { + id: string; + name: string; + code: string; + emoji: string; + color: string; + mastery: number; // avg of topic mastery + topicCount: number; + deckCount: number; + noteCount: number; + nextExam: { name: string; date: string } | null; +} + +function daysUntil(iso: string): number { + return Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000); +} + +export default function CodexHub() { + const router = useRouter(); + const { user } = useAuth(); + const { isSun, muted } = useCardStyles(); + const [subjects, setSubjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!user) return; + let cancelled = false; + + (async () => { + // Active semester + const { data: sem } = await supabase + .from("semesters").select("id").eq("user_id", user.id).eq("is_active", true).limit(1).maybeSingle(); + + const { data: subs } = await supabase + .from("subjects") + .select("id, name, code, emoji, color") + .eq("user_id", user.id) + .eq("is_archived", false) + .order("name"); + + if (cancelled) return; + const subjectRows = subs ?? []; + if (subjectRows.length === 0) { setSubjects([]); setIsLoading(false); return; } + + const ids = subjectRows.map((s: { id: string }) => s.id); + + // Bulk-fetch related rows, then group client-side. + const [topicsRes, decksRes, notesRes, examsRes] = await Promise.all([ + supabase.from("topics").select("subject_id, mastery_pct").in("subject_id", ids), + supabase.from("decks").select("id, subject_id").in("subject_id", ids), + supabase.from("notes").select("id, subject_id").in("subject_id", ids), + supabase.from("exams").select("subject_id, name, exam_date").in("subject_id", ids) + .gte("exam_date", new Date().toISOString()).order("exam_date"), + ]); + + const topics = topicsRes.data ?? []; + const decks = decksRes.data ?? []; + const notes = notesRes.data ?? []; + const exams = examsRes.data ?? []; + + const cards: SubjectCard[] = subjectRows.map((s: { id: string; name: string; code: string; emoji: string; color: string }) => { + const myTopics = topics.filter((t: { subject_id: string }) => t.subject_id === s.id); + const mastery = myTopics.length + ? Math.round(myTopics.reduce((a: number, t: { mastery_pct: number }) => a + (t.mastery_pct ?? 0), 0) / myTopics.length) + : 0; + const nextExam = exams.find((e: { subject_id: string }) => e.subject_id === s.id) as { name: string; exam_date: string } | undefined; + return { + id: s.id, name: s.name, code: s.code, emoji: s.emoji || "๐Ÿ“˜", + color: s.color || "from-violet-500 to-purple-600", + mastery, + topicCount: myTopics.length, + deckCount: decks.filter((d: { subject_id: string }) => d.subject_id === s.id).length, + noteCount: notes.filter((n: { subject_id: string }) => n.subject_id === s.id).length, + nextExam: nextExam ? { name: nextExam.name, date: nextExam.exam_date } : null, + }; + }); + + if (!cancelled) { setSubjects(cards); setIsLoading(false); } + })(); + + return () => { cancelled = true; }; + }, [user]); + + return ( + <> + +
+
+ {/* Header */} +
+ +
+

+ Codex +

+

Every subject, its notes, decks & trials in one place

+
+
+ + {isLoading ? ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) : subjects.length === 0 ? ( +
+
+ +
+

No subjects yet

+

Let the AI parse your syllabus into a study plan โ€” subjects, topics and exams.

+ +
+ ) : ( +
+ {subjects.map((s, i) => ( + router.push(`/codex/${s.id}`)} + className={`group text-left rounded-3xl border p-5 transition-colors ${ + isSun ? "bg-white border-slate-200 hover:border-slate-300" : "bg-slate-900/70 border-white/10 hover:border-white/20" + }`} + > +
+
+
+ {s.emoji} +
+
+

{s.name}

+ {s.code &&

{s.code}

} +
+
+ +
+ + {/* Mastery */} +
+
+ Mastery + {s.mastery}% +
+
+
+
+
+ + {/* Meta */} +
+ {s.topicCount} topics + {s.deckCount} decks + {s.noteCount} notes +
+ + {s.nextExam && ( +
+ + {s.nextExam.name} in {daysUntil(s.nextExam.date)}d +
+ )} + + ))} + + {/* Add subject */} + +
+ )} +
+
+ + + ); +} diff --git a/brain-trails/app/flashcards/page.tsx b/brain-trails/app/flashcards/page.tsx index 1f5e5c3..5b2d665 100644 --- a/brain-trails/app/flashcards/page.tsx +++ b/brain-trails/app/flashcards/page.tsx @@ -6,17 +6,60 @@ import { RotateCcw, ChevronLeft, ChevronRight, Plus, Shuffle, Brain, BrainCircui import TravelerHotbar from "@/components/layout/TravelerHotbar"; import { useTheme } from "@/context/ThemeContext"; import { useAuth } from "@/context/AuthContext"; -import { useGameStore } from "@/stores"; +import { useGameStore, useUIStore } from "@/stores"; import { useSoundEffects } from "@/hooks/useSoundEffects"; import { supabase } from "@/lib/supabase"; +import { friendlyAiError } from "@/lib/aiError"; import { gameText } from "@/constants/gameText"; interface Flashcard { id: string; front: string; back: string; - mastery: number; // 0-100 + mastery: number; // 0-100 (visual progress only) review_count: number; + // SM-2 scheduling + ease_factor: number; + srs_interval: number; // days until due + repetitions: number; + next_review: string; // ISO timestamp +} + +/** + * SM-2 spaced-repetition update. Maps the 4 grade buttons + * (0 Again, 1 Hard, 2 Good, 3 Easy) to SM-2 quality scores and returns the + * new scheduling fields. See https://super-memory.com/english/ol/sm2.htm + */ +function applySM2(card: Flashcard, button: number) { + const quality = [1, 3, 4, 5][button] ?? 4; // Again, Hard, Good, Easy + let ef = card.ease_factor ?? 2.5; + let interval = card.srs_interval ?? 0; + let reps = card.repetitions ?? 0; + + if (quality < 3) { + // Lapse โ€” relearn from the start, see it again tomorrow. + reps = 0; + interval = 1; + } else { + if (reps === 0) interval = 1; + else if (reps === 1) interval = 6; + else interval = Math.round(interval * ef); + reps += 1; + } + + ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); + if (ef < 1.3) ef = 1.3; + + const next = new Date(); + next.setDate(next.getDate() + interval); + + return { ease_factor: ef, srs_interval: interval, repetitions: reps, next_review: next.toISOString() }; +} + +/** True if a card is due for review now (or has never been scheduled). */ +function isDue(card: Flashcard): boolean { + if (!card.next_review) return true; + return new Date(card.next_review).getTime() <= Date.now(); } interface Deck { @@ -25,8 +68,6 @@ interface Deck { emoji: string; color: string; cards: Flashcard[]; - subject_id?: string | null; - subject?: { name: string; emoji: string } | null; } function MasteryDots({ mastery }: { mastery: number }) { @@ -57,7 +98,8 @@ const COLORS = [ export default function FlashcardsPage() { const { theme } = useTheme(); const { user, profile, refreshProfile } = useAuth(); - const { awardXp, logActivity } = useGameStore(); + const { awardXp, logActivity, reportQuestProgress } = useGameStore(); + const addToast = useUIStore((s) => s.addToast); const playSound = useSoundEffects(); const isSun = theme === "sun"; @@ -83,7 +125,8 @@ export default function FlashcardsPage() { useEffect(() => { if (!user) return; const fetchSyllabusSubjects = async () => { - const { data: semData } = await (supabase.from("semesters") as any) + const { data: semData } = await supabase + .from("semesters") .select("id") .eq("user_id", user.id) .eq("is_active", true) @@ -92,7 +135,8 @@ export default function FlashcardsPage() { if (!semData) return; - const { data: subs } = await (supabase.from("subjects") as any) + const { data: subs } = await supabase + .from("subjects") .select("id, name, emoji") .eq("semester_id", semData.id) .order("name"); @@ -100,7 +144,8 @@ export default function FlashcardsPage() { if (!subs || subs.length === 0) return; const subjectIds = subs.map((s: { id: string }) => s.id); - const { data: topics } = await (supabase.from("topics") as any) + const { data: topics } = await supabase + .from("topics") .select("id, name, subject_id") .in("subject_id", subjectIds) .order("sort_order"); @@ -143,7 +188,7 @@ export default function FlashcardsPage() { const data = await res.json(); if (!data.questions || data.questions.length === 0) { - throw new Error("No flashcards generated"); + throw new Error(data.error || "No flashcards generated"); } // Create the deck @@ -151,7 +196,8 @@ export default function FlashcardsPage() { const emoji = subject.emoji || EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; const color = COLORS[Math.floor(Math.random() * COLORS.length)]; - const { data: deckData, error: deckErr } = await (supabase.from("decks") as any) + const { data: deckData, error: deckErr } = await supabase + .from("decks") .insert({ user_id: user.id, name: deckName, emoji, color, subject_id: subject.id }) .select() .single(); @@ -167,7 +213,7 @@ export default function FlashcardsPage() { review_count: 0, })); - const { data: cardData } = await (supabase.from("cards") as any).insert(cardInserts).select(); + const { data: cardData } = await supabase.from("cards").insert(cardInserts).select(); const newDeck: Deck = { id: deckData.id, @@ -193,6 +239,7 @@ export default function FlashcardsPage() { playSound("success"); } catch (err) { console.error("AI generation failed:", err); + addToast(friendlyAiError(err), "error"); } finally { setIsGenerating(false); } @@ -202,59 +249,31 @@ export default function FlashcardsPage() { if (!user) return; const fetchDecks = async () => { - // Try to fetch with subject relation, fallback if column doesn't exist - let decksData: Deck[] = []; - - try { - const { data, error } = await (supabase.from('decks') as any) - .select(` - id, name, emoji, color, subject_id, - cards ( id, front, back, mastery, review_count ), - subjects:subject_id ( name, emoji ) - `) - .eq('user_id', user.id) - .order('created_at', { ascending: true }); - - if (error?.code === "42703" || error?.message?.includes("subject_id")) { - // Column doesn't exist, fetch without subject - const { data: fallbackData } = await (supabase.from('decks') as any) - .select(` - id, name, emoji, color, - cards ( id, front, back, mastery, review_count ) - `) - .eq('user_id', user.id) - .order('created_at', { ascending: true }); - - decksData = (fallbackData ?? []).map((d: Deck) => ({ - ...d, - cards: (d.cards || []).sort((a: Flashcard, b: Flashcard) => a.id.localeCompare(b.id)), - subject: null, - })); - } else { - decksData = (data ?? []).map((d: any) => ({ - ...d, - cards: (d.cards || []).sort((a: Flashcard, b: Flashcard) => a.id.localeCompare(b.id)), - subject: d.subjects || null, - })); - } - } catch { - // Fallback - const { data: fallbackData } = await (supabase.from('decks') as any) - .select(` - id, name, emoji, color, - cards ( id, front, back, mastery, review_count ) - `) - .eq('user_id', user.id) - .order('created_at', { ascending: true }); - - decksData = (fallbackData ?? []).map((d: Deck) => ({ + const { data, error } = await supabase + .from('decks') + .select(` + id, name, emoji, color, + cards ( id, front, back, mastery, review_count, ease_factor, srs_interval, repetitions, next_review ) + `) + .eq('user_id', user.id) + .order('created_at', { ascending: true }); + + if (error) { + console.error("Error fetching decks:", error); + } else { + // Order cards "due first" (most overdue โ†’ soonest) so studying surfaces + // what SM-2 says needs review; tie-break on id for stability. + const raw = (data ?? []) as unknown as Deck[]; + const formattedDecks = raw.map(d => ({ ...d, - cards: (d.cards || []).sort((a: Flashcard, b: Flashcard) => a.id.localeCompare(b.id)), - subject: null, + cards: (d.cards || []).sort((a: Flashcard, b: Flashcard) => { + const ta = a.next_review ? new Date(a.next_review).getTime() : 0; + const tb = b.next_review ? new Date(b.next_review).getTime() : 0; + return ta !== tb ? ta - tb : a.id.localeCompare(b.id); + }) })); + setDecks(formattedDecks); } - - setDecks(decksData); setIsLoading(false); }; @@ -298,12 +317,14 @@ export default function FlashcardsPage() { color: COLORS[Math.floor(Math.random() * COLORS.length)], }; - const { data, error } = await (supabase.from('decks') as any) + const { data, error } = await supabase + .from('decks') .insert(newDeck) .select() .single(); if (!error && data) { + const isFirstDeck = decks.length === 0; const created: Deck = { id: data.id, name: data.name, @@ -314,6 +335,15 @@ export default function FlashcardsPage() { setDecks([...decks, created]); setNewDeckName(""); setShowNewDeck(false); + + // Deliver the "create your first deck and earn 50 XP" promise. + if (isFirstDeck) { + await awardXp(user.id, 50); + await logActivity(user.id, "flashcard", 50, { type: "first_deck_created", deck_name: created.name }); + refreshProfile(); + playSound("success"); + addToast("First deck created! +50 XP ๐ŸŽด", "success"); + } } }; @@ -321,7 +351,7 @@ export default function FlashcardsPage() { e.stopPropagation(); if (!confirm("Are you sure you want to delete this deck? All cards inside will be lost!")) return; - const { error } = await (supabase.from('decks') as any).delete().eq('id', deckId); + const { error } = await supabase.from('decks').delete().eq('id', deckId); if (!error) { setDecks(decks.filter(d => d.id !== deckId)); } @@ -330,7 +360,8 @@ export default function FlashcardsPage() { const handleAddCard = async () => { if (!selectedDeck || !newFront.trim() || !newBack.trim() || !user) return; - const { data, error } = await (supabase.from('cards') as any) + const { data, error } = await supabase + .from('cards') .insert({ deck_id: selectedDeck.id, front: newFront.trim(), @@ -370,23 +401,35 @@ export default function FlashcardsPage() { else if (quality === 2) newMastery = Math.min(100, newMastery + 20); else if (quality === 3) newMastery = Math.min(100, newMastery + 40); - const updatedCard = { - ...currentCard, + // Reschedule the card with SM-2 based on the grade. + const sm2 = applySM2(currentCard, quality); + + const updatedCard = { + ...currentCard, mastery: newMastery, - review_count: currentCard.review_count + 1 + review_count: currentCard.review_count + 1, + ...sm2, }; // Update locally immediately for responsiveness const updatedCards = [...selectedDeck.cards]; updatedCards[currentIndex] = updatedCard; const updatedDeck = { ...selectedDeck, cards: updatedCards }; - + setSelectedDeck(updatedDeck); setDecks(prev => prev.map(d => d.id === updatedDeck.id ? updatedDeck : d)); // Update in background - await (supabase.from('cards') as any) - .update({ mastery: newMastery, review_count: updatedCard.review_count }) + await supabase + .from('cards') + .update({ + mastery: newMastery, + review_count: updatedCard.review_count, + ease_factor: sm2.ease_factor, + srs_interval: sm2.srs_interval, + repetitions: sm2.repetitions, + next_review: sm2.next_review, + }) .eq('id', currentCard.id); // Also award some DB XP for studying @@ -400,6 +443,9 @@ export default function FlashcardsPage() { card_id: currentCard.id, }); + // Advance flashcard quests (counted per card reviewed) + await reportQuestProgress(user.id, "flashcard", 1); + refreshProfile(); } @@ -596,14 +642,14 @@ export default function FlashcardsPage() {

{deck.name}

- {deck.subject && ( -

- {deck.subject.emoji} - {deck.subject.name} -

- )}

{deck.cards.length} cards + {(() => { + const due = deck.cards.filter(isDue).length; + return due > 0 ? ( + ยท {due} due + ) : null; + })()}

{/* Mastery bar */} {deck.cards.length > 0 && ( diff --git a/brain-trails/app/onboarding/page.tsx b/brain-trails/app/onboarding/page.tsx index 12b095d..0db6a8e 100644 --- a/brain-trails/app/onboarding/page.tsx +++ b/brain-trails/app/onboarding/page.tsx @@ -25,6 +25,7 @@ import { useAuth } from "@/context/AuthContext"; import { useCardStyles } from "@/hooks/useCardStyles"; import { supabase } from "@/lib/supabase"; import { useGameStore } from "@/stores"; +import { friendlyAiError } from "@/lib/aiError"; import TravelerHotbar from "@/components/layout/TravelerHotbar"; // ============================================ @@ -51,7 +52,7 @@ interface OnboardingData { subjects: OnboardingSubject[]; } -type Step = "welcome" | "syllabus" | "manual" | "review" | "done"; +type Step = "quickadd" | "syllabus" | "manual" | "review" | "done"; // ============================================ // Constants (outside component โ€” no render-time randomness) @@ -107,7 +108,10 @@ export default function OnboardingPage() { const { isSun, card, title, subtitle, muted, accent } = useCardStyles(); // Step state - const [step, setStep] = useState("welcome"); + const [step, setStep] = useState("quickadd"); + + // Quick-add: the fast path โ€” just type subject names, AI scaffolds the rest. + const [quickNames, setQuickNames] = useState(""); // Shared onboarding data (used by both syllabus & manual paths) const [data, setData] = useState({ @@ -160,6 +164,68 @@ export default function OnboardingPage() { if (file) setSyllabusFile(file); }, []); + // Map the AI parser's output into our onboarding shape, then go to review. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const applyParsed = (parsed: any) => { + const subjects: OnboardingSubject[] = (parsed?.subjects || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s: any, i: number) => ({ + name: s.name || "", + code: s.code || "", + emoji: s.emoji || emojiByIndex(i), + color: colorByIndex(i), + topics: (s.topics || []).length > 0 + ? s.topics.map((t: unknown) => (typeof t === "string" ? t : (t as { name?: string }).name || "")) + : [""], + // The parser returns exam_date (ISO) + exam_type; keep dates (the old code dropped them). + exams: (s.exams || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ex: any) => ({ + name: ex.name || "", + date: ex.exam_date ? String(ex.exam_date).slice(0, 10) : (ex.date || ""), + type: ex.exam_type || ex.type || "exam", + }) + ), + }) + ); + setData({ semesterName: parsed?.semester || "", subjects }); + setStep("review"); + }; + + const subjectsFromNames = (names: string[]): OnboardingSubject[] => + names.map((n, i) => ({ name: n, code: "", emoji: emojiByIndex(i), color: colorByIndex(i), topics: [""], exams: [] })); + + // Fast path: turn typed names into fully-scaffolded subjects via AI. Falls back + // to bare subjects if the AI is unreachable, so the user is never blocked. + const handleScaffold = async () => { + const names = quickNames.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean); + if (names.length === 0) { setParseError("Type at least one subject to continue."); return; } + setIsParsing(true); + setParseError(""); + try { + const content = + "Course list. For each course infer 6-8 key study topics and a fitting emoji.\n" + + names.map((n) => `- ${n}`).join("\n"); + const res = await fetch(`${BACKEND_URL}/api/ai/parse-syllabus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file_type: "text", content }), + }); + const result = res.ok ? await res.json() : null; + if (result?.data?.subjects?.length) { + applyParsed(result.data); + } else { + setData({ semesterName: "", subjects: subjectsFromNames(names) }); + setStep("review"); + } + } catch { + setData({ semesterName: "", subjects: subjectsFromNames(names) }); + setStep("review"); + } finally { + setIsParsing(false); + } + }; + const handleParseSyllabus = async () => { setIsParsing(true); setParseError(""); @@ -201,42 +267,10 @@ export default function OnboardingPage() { } const result = await res.json(); - const parsed = result.data; - - // Map parsed data into our onboarding format - const subjects: OnboardingSubject[] = (parsed.subjects || []).map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s: any, i: number) => ({ - name: s.name || "", - code: s.code || "", - emoji: s.emoji || emojiByIndex(i), - color: colorByIndex(i), - topics: (s.topics || []).length > 0 - ? s.topics.map((t: unknown) => (typeof t === "string" ? t : (t as { name?: string }).name || "")) - : [""], - exams: (s.exams || []).map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ex: any) => ({ - name: ex.name || "", - date: ex.date || "", - type: ex.type || "exam", - }) - ), - }) - ); - - setData({ - semesterName: parsed.semester || "", - subjects, - }); - setStep("review"); + applyParsed(result.data); } catch (err) { console.error("Parse syllabus error:", err); - setParseError( - err instanceof Error - ? err.message - : "Failed to parse syllabus. Make sure the Flask backend is running." - ); + setParseError(`${friendlyAiError(err)} You can also just type your subject names on the previous screen.`); } finally { setIsParsing(false); } @@ -453,103 +487,82 @@ export default function OnboardingPage() { // Render helpers for each step // ============================================ - const renderWelcome = () => ( - - {/* Hero */} + const renderQuickAdd = () => { + const names = quickNames.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean); + return ( -
- +
+
-

- Welcome, Traveler! +

+ What are you studying?

-

- Let's set up your study journey. Tell us about your courses so we can - build your adventure map. +

+ Just type your subjects โ€” I'll build the topics, decks and trials around them.

- - {/* Two path cards */} -
- {/* Upload Syllabus */} - setStep("syllabus")} - className={`p-8 rounded-[24px] text-left transition-all border-[3px] ${ +