From 088c555515fd86fdc67475baac3229658d8ea4ae Mon Sep 17 00:00:00 2001 From: renolation Date: Wed, 15 Apr 2026 00:41:02 +0700 Subject: [PATCH] update flash card, test --- src/components/layout/AppHeader.tsx | 2 +- src/components/layout/Sidebar.tsx | 2 +- src/features/flash-card/api/flashcard-api.ts | 102 ++++++ .../components/FlashCard.tsx | 0 .../components/FlashCardLearnPage.tsx | 301 ++++++++++++++++ .../components/FlashCardListPage.tsx | 152 +++++++++ .../components/FlashCardTermsPage.tsx | 187 ++++++++++ .../components/Vocabulary.tsx | 0 src/features/home/components/Home.tsx | 2 +- src/features/toeic/api/test-list-api.ts | 53 +++ src/features/toeic/components/TestResult.tsx | 230 +++++-------- src/features/toeic/components/TestSession.tsx | 322 ++++++++---------- .../toeic/components/TestSessionFooter.tsx | 36 ++ .../toeic/components/TestSessionHeader.tsx | 43 +++ .../toeic/components/TestSessionSidebar.tsx | 86 +++++ .../toeic/components/ToeicTestDetail.tsx | 151 ++++++++ .../toeic/components/ToeicTestList.tsx | 82 +++++ src/hooks/use-questions.ts | 113 ++++-- src/lib/progress-service.ts | 48 ++- src/routes/flash-card.$listId.learn.tsx | 11 + src/routes/flash-card.$listId.tsx | 11 + src/routes/flash-card.index.tsx | 6 + src/routes/flash-card.tsx | 5 + src/routes/toeic.$testId.tsx | 11 + src/routes/toeic.index.tsx | 4 +- src/routes/vocab.tsx | 6 - src/store/test-store.ts | 76 +++-- src/temp/local-data.ts | 7 +- src/types/index.ts | 42 ++- supabase/create/update.sql | 7 + supabase/migrations/004_full_schema_reset.sql | 302 ++++++++++++++++ supabase/migrations/005_nullable_test_id.sql | 3 + 32 files changed, 1988 insertions(+), 415 deletions(-) create mode 100644 src/features/flash-card/api/flashcard-api.ts rename src/features/{vocab => flash-card}/components/FlashCard.tsx (100%) create mode 100644 src/features/flash-card/components/FlashCardLearnPage.tsx create mode 100644 src/features/flash-card/components/FlashCardListPage.tsx create mode 100644 src/features/flash-card/components/FlashCardTermsPage.tsx rename src/features/{vocab => flash-card}/components/Vocabulary.tsx (100%) create mode 100644 src/features/toeic/api/test-list-api.ts create mode 100644 src/features/toeic/components/TestSessionFooter.tsx create mode 100644 src/features/toeic/components/TestSessionHeader.tsx create mode 100644 src/features/toeic/components/TestSessionSidebar.tsx create mode 100644 src/features/toeic/components/ToeicTestDetail.tsx create mode 100644 src/features/toeic/components/ToeicTestList.tsx create mode 100644 src/routes/flash-card.$listId.learn.tsx create mode 100644 src/routes/flash-card.$listId.tsx create mode 100644 src/routes/flash-card.index.tsx create mode 100644 src/routes/flash-card.tsx create mode 100644 src/routes/toeic.$testId.tsx delete mode 100644 src/routes/vocab.tsx create mode 100644 supabase/migrations/004_full_schema_reset.sql create mode 100644 supabase/migrations/005_nullable_test_id.sql diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index 8087cf6..3833725 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -5,7 +5,7 @@ import { UserMenu } from '@/components/UserMenu' const ROUTE_TITLES: Record = { '/': 'Trang chủ', '/writing': 'AI Chấm Writing', - '/vocab': 'Từ vựng TOEIC', + '/flash-card': 'Flash Card', '/toeic': 'Luyện đề TOEIC', '/toeic/session': '', // dynamic — filled below '/toeic/result': 'Kết quả bài thi', diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c2e83a5..bb59b86 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,7 +8,7 @@ const NAV_ITEMS = [ { to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false }, { to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false }, { to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false }, - { to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false }, + { to: '/flash-card', label: 'Flash Card', icon: 'menu_book', matchPrefix: '/flash-card', exact: false }, { to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false }, ] diff --git a/src/features/flash-card/api/flashcard-api.ts b/src/features/flash-card/api/flashcard-api.ts new file mode 100644 index 0000000..99d5b3a --- /dev/null +++ b/src/features/flash-card/api/flashcard-api.ts @@ -0,0 +1,102 @@ +import { supabase } from '@/lib/supabase' + +export interface FlashcardList { + id: number + title: string + description: string | null + total_words: number + is_public: boolean + created_by: string | null + created_at: string + // aggregated from user progress + count_new?: number + count_learning?: number + count_known?: number + progress_pct?: number +} + +export interface FlashcardTerm { + id: number + list_id: number + word: string + part_of_speech: string | null + phonetic: string | null + definition: string | null + example: string | null + image_url: string | null + display_order: number +} + +export interface UserProgress { + id: number + user_id: string + term_id: number + list_id: number + status: 'new' | 'learning' | 'known' | 'ignored' + ease_factor: number + review_count: number + last_reviewed_at: string | null + next_review_at: string | null +} + +/** Fetch all public flashcard lists with term counts */ +export async function fetchFlashcardLists(): Promise { + const { data, error } = await supabase + .from('flashcard_list') + .select('id, title, description, total_words, is_public, created_by, created_at') + .order('created_at', { ascending: false }) + if (error) throw error + return data ?? [] +} + +/** Fetch all terms for a flashcard list */ +export async function fetchFlashcardTerms(listId: number): Promise { + const { data, error } = await supabase + .from('flashcard_term') + .select('id, list_id, word, part_of_speech, phonetic, definition, example, image_url, display_order') + .eq('list_id', listId) + .order('display_order', { ascending: true }) + if (error) throw error + return data ?? [] +} + +/** Fetch user progress for all terms in a list */ +export async function fetchUserProgress(userId: string, listId: number): Promise { + const { data, error } = await supabase + .from('user_flashcard_progress') + .select('id, user_id, term_id, list_id, status, ease_factor, review_count, last_reviewed_at, next_review_at') + .eq('user_id', userId) + .eq('list_id', listId) + if (error) throw error + return data ?? [] +} + +/** Upsert user progress for a term (SRS update) */ +export async function upsertTermProgress( + userId: string, + termId: number, + listId: number, + status: UserProgress['status'], + easeFactor: number, +): Promise { + const now = new Date().toISOString() + // Compute next review date based on ease_factor + const intervalDays = easeFactor <= 0 ? 0 : easeFactor >= 1 ? 7 : easeFactor >= 0.65 ? 3 : 1 + const nextReview = new Date(Date.now() + intervalDays * 24 * 60 * 60 * 1000).toISOString() + + const { error } = await supabase + .from('user_flashcard_progress') + .upsert( + { + user_id: userId, + term_id: termId, + list_id: listId, + status, + ease_factor: easeFactor, + last_reviewed_at: now, + next_review_at: nextReview, + }, + { onConflict: 'user_id,term_id,list_id' }, + ) + if (error) console.error('Failed to upsert term progress:', error.message) +} diff --git a/src/features/vocab/components/FlashCard.tsx b/src/features/flash-card/components/FlashCard.tsx similarity index 100% rename from src/features/vocab/components/FlashCard.tsx rename to src/features/flash-card/components/FlashCard.tsx diff --git a/src/features/flash-card/components/FlashCardLearnPage.tsx b/src/features/flash-card/components/FlashCardLearnPage.tsx new file mode 100644 index 0000000..1053289 --- /dev/null +++ b/src/features/flash-card/components/FlashCardLearnPage.tsx @@ -0,0 +1,301 @@ +import { useState, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' +import { useAuthStore } from '@/store/auth-store' +import { fetchFlashcardTerms, fetchUserProgress, upsertTermProgress } from '../api/flashcard-api' +import type { FlashcardTerm, UserProgress } from '../api/flashcard-api' + +const EASE = { + ignored: -1, + hard: 0.1, + easy: 0.65, + known: 1.0, +} as const + +type EaseKey = keyof typeof EASE + +interface Props { + listId: number +} + +export function FlashCardLearnPage({ listId }: Props) { + const navigate = useNavigate() + const user = useAuthStore(s => s.user) + const queryClient = useQueryClient() + + const [isFlipped, setIsFlipped] = useState(false) + const [currentIdx, setCurrentIdx] = useState(0) + const [sessionStats, setSessionStats] = useState({ known: 0, learning: 0, ignored: 0 }) + const [isDone, setIsDone] = useState(false) + + const { data: terms = [], isLoading: loadingTerms } = useQuery({ + queryKey: ['flashcard-terms', listId], + queryFn: () => fetchFlashcardTerms(listId), + }) + + const { data: progress = [] } = useQuery({ + queryKey: ['flashcard-progress', user?.id, listId], + queryFn: () => fetchUserProgress(user!.id, listId), + enabled: !!user, + }) + + const progressMap: Record = {} + progress.forEach(p => { progressMap[p.term_id] = p }) + + // Prioritize: new + learning terms first, then known + const sessionTerms: FlashcardTerm[] = [ + ...terms.filter(t => { + const s = progressMap[t.id]?.status ?? 'new' + return s === 'new' || s === 'learning' + }), + ...terms.filter(t => progressMap[t.id]?.status === 'known'), + ] + + const { mutate: saveProgress } = useMutation({ + mutationFn: ({ termId, status, easeFactor }: { termId: number; status: UserProgress['status']; easeFactor: number }) => + upsertTermProgress(user!.id, termId, listId, status, easeFactor), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] }) + }, + }) + + const handleAnswer = useCallback((key: EaseKey) => { + const term = sessionTerms[currentIdx] + if (!term || !user) return + + const easeFactor = EASE[key] + const status: UserProgress['status'] = + key === 'known' ? 'known' : + key === 'ignored' ? 'ignored' : + 'learning' + + saveProgress({ termId: term.id, status, easeFactor }) + + setSessionStats(prev => ({ + known: prev.known + (key === 'known' ? 1 : 0), + learning: prev.learning + (key === 'easy' || key === 'hard' ? 1 : 0), + ignored: prev.ignored + (key === 'ignored' ? 1 : 0), + })) + + if (currentIdx + 1 >= sessionTerms.length) { + setIsDone(true) + } else { + setCurrentIdx(i => i + 1) + setIsFlipped(false) + } + }, [currentIdx, sessionTerms, user, saveProgress]) + + const total = sessionTerms.length + const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0 + const current = sessionTerms[currentIdx] + + if (loadingTerms) { + return ( +
+
+
+ ) + } + + if (sessionTerms.length === 0) { + return ( +
+ check_circle +

Không có thẻ nào để học!

+

Bộ thẻ này chưa có từ nào. Vui lòng thêm từ trước.

+ +
+ ) + } + + if (isDone) { + return ( +
+
+ celebration +

Hoàn thành phiên học!

+

Bạn đã ôn xong {total} thẻ trong phiên này

+
+
+
+
{sessionStats.known}
+
Đã biết
+
+
+
{sessionStats.learning}
+
Đang học
+
+
+
{sessionStats.ignored}
+
Bỏ qua
+
+
+
+ + +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ Phiên học từ vựng +
+ {currentIdx + 1} / {total} từ + +
+
+ {/* Progress bar */} +
+
+
+
+ +
+ {/* Session stats pills */} +
+
+ + {sessionStats.known} biết +
+
+ + {sessionStats.learning} đang học +
+
+ + {sessionStats.ignored} bỏ qua +
+
+ + {/* Flashcard */} + {current && ( +
+
+
setIsFlipped(v => !v)} + role="button" + aria-label={isFlipped ? 'Nhấp để xem từ' : 'Nhấp để xem nghĩa'} + > + {!isFlipped ? ( + <> +
TIẾNG ANH
+
+

{current.word}

+ {current.phonetic && ( + {current.phonetic} + )} + {current.part_of_speech && ( + + {current.part_of_speech} + + )} +
+
+ flip + Nhấp để xem nghĩa +
+ + ) : ( + <> +
NGHĨA
+
+

{current.definition ?? '—'}

+ {current.example && ( +

+ "{current.example}" +

+ )} +
+
+ flip + Nhấp để xem từ +
+ + )} +
+
+ )} + + {/* Action buttons */} +
+
+ + + + +
+ {!isFlipped && ( +

Lật thẻ trước khi đánh giá

+ )} + {isFlipped && ( +

Còn {total - currentIdx - 1} thẻ trong phiên này

+ )} +
+
+
+ ) +} diff --git a/src/features/flash-card/components/FlashCardListPage.tsx b/src/features/flash-card/components/FlashCardListPage.tsx new file mode 100644 index 0000000..7ccf9c8 --- /dev/null +++ b/src/features/flash-card/components/FlashCardListPage.tsx @@ -0,0 +1,152 @@ +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { fetchFlashcardLists } from '../api/flashcard-api' +import { useAuthStore } from '@/store/auth-store' +import { fetchUserProgress } from '../api/flashcard-api' +import type { FlashcardList } from '../api/flashcard-api' + +function ListCard({ list, userId }: { list: FlashcardList; userId: string | null }) { + const navigate = useNavigate() + + const { data: progress = [] } = useQuery({ + queryKey: ['flashcard-progress', userId, list.id], + queryFn: () => fetchUserProgress(userId!, list.id), + enabled: !!userId, + }) + + const countNew = list.total_words - progress.filter(p => p.status !== 'new').length + const countLearning = progress.filter(p => p.status === 'learning').length + const countKnown = progress.filter(p => p.status === 'known').length + const progressPct = list.total_words > 0 + ? Math.round(((countLearning + countKnown) / list.total_words) * 100) + : 0 + + return ( +
+
+
+
+ layers +
+
+

{list.title}

+
+ book + {list.total_words} từ +
+
+
+ + {list.is_public ? 'Công khai' : 'Riêng tư'} + +
+ + {list.description && ( +

{list.description}

+ )} + +
+
+ Tiến độ: {progressPct}% +
+
+
+
+
+ +
+
+

Mới

+

{countNew}

+
+
+

Học

+

{countLearning}

+
+
+

Biết

+

{countKnown}

+
+
+ +
+ + +
+
+ ) +} + +export function FlashCardListPage() { + const user = useAuthStore(s => s.user) + const { data: lists = [], isLoading, isError } = useQuery({ + queryKey: ['flashcard-lists'], + queryFn: fetchFlashcardLists, + }) + + return ( +
+
+
+

Bộ Thẻ Từ Vựng

+

Chọn bộ thẻ để bắt đầu học

+
+
+ filter_list + Sắp xếp: Mới nhất +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map(j =>
)} +
+
+ ))} +
+ ) : isError ? ( +
+

Không thể tải danh sách bộ thẻ. Vui lòng thử lại.

+
+ ) : lists.length === 0 ? ( +
+ library_books +

Chưa có bộ thẻ nào.

+

Bộ thẻ từ vựng TOEIC sẽ được thêm sớm!

+
+ ) : ( +
+ {lists.map(list => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/features/flash-card/components/FlashCardTermsPage.tsx b/src/features/flash-card/components/FlashCardTermsPage.tsx new file mode 100644 index 0000000..2bab35f --- /dev/null +++ b/src/features/flash-card/components/FlashCardTermsPage.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' +import { useAuthStore } from '@/store/auth-store' +import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api' +import type { FlashcardTerm, UserProgress } from '../api/flashcard-api' + +type FilterStatus = 'all' | 'new' | 'learning' | 'known' | 'ignored' + +const STATUS_LABEL: Record = { + new: 'Mới', + learning: 'Đang học', + known: 'Đã biết', + ignored: 'Bỏ qua', +} + +const STATUS_STYLE: Record = { + new: 'bg-slate-100 text-slate-500', + learning: 'bg-blue-50 text-blue-700', + known: 'bg-emerald-50 text-emerald-700', + ignored: 'bg-rose-50 text-rose-600', +} + +interface Props { + listId: number +} + +export function FlashCardTermsPage({ listId }: Props) { + const navigate = useNavigate() + const user = useAuthStore(s => s.user) + const [filter, setFilter] = useState('all') + const [search, setSearch] = useState('') + + const { data: terms = [], isLoading: loadingTerms } = useQuery({ + queryKey: ['flashcard-terms', listId], + queryFn: () => fetchFlashcardTerms(listId), + }) + + const { data: progress = [] } = useQuery({ + queryKey: ['flashcard-progress', user?.id, listId], + queryFn: () => fetchUserProgress(user!.id, listId), + enabled: !!user, + }) + + const progressMap: Record = {} + progress.forEach(p => { progressMap[p.term_id] = p }) + + const getStatus = (termId: number): UserProgress['status'] => + progressMap[termId]?.status ?? 'new' + + const countAll = terms.length + const countNew = terms.filter(t => getStatus(t.id) === 'new').length + const countLearning = terms.filter(t => getStatus(t.id) === 'learning').length + const countKnown = terms.filter(t => getStatus(t.id) === 'known').length + + const filtered = terms.filter(t => { + if (filter !== 'all' && getStatus(t.id) !== filter) return false + if (search.trim()) { + const q = search.toLowerCase() + return ( + t.word.toLowerCase().includes(q) || + (t.definition ?? '').toLowerCase().includes(q) + ) + } + return true + }) + + return ( +
+ {/* Header */} +
+ +
+

Bộ thẻ từ vựng

+ {countAll} từ +
+
+ + {/* Hero actions + stats */} +
+
+ +
+
+ Tổng: {countAll} + Mới: {countNew} + Đang học: {countLearning} + Đã biết: {countKnown} +
+
+ + {/* Filter + Search */} +
+
+
+ search + setSearch(e.target.value)} + placeholder="Tìm kiếm từ..." + className="w-full pl-10 pr-4 py-2.5 bg-white rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" + /> +
+
+ {(['all', 'new', 'learning', 'known', 'ignored'] as FilterStatus[]).map(f => ( + + ))} +
+
+
+ + {/* Terms list */} + {loadingTerms ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : filtered.length === 0 ? ( +
+

Không tìm thấy từ nào phù hợp.

+
+ ) : ( +
+ {filtered.map(term => ( + + ))} +
+ )} +
+ ) +} + +function TermRow({ term, status }: { term: FlashcardTerm; status: UserProgress['status'] }) { + return ( +
+
+
+

{term.word}

+ {term.phonetic && ( + {term.phonetic} + )} +
+ {term.part_of_speech && ( + + {term.part_of_speech} + + )} +
+
+ {term.definition ?? '—'} +
+
+ + {STATUS_LABEL[status]} + + +
+
+ ) +} diff --git a/src/features/vocab/components/Vocabulary.tsx b/src/features/flash-card/components/Vocabulary.tsx similarity index 100% rename from src/features/vocab/components/Vocabulary.tsx rename to src/features/flash-card/components/Vocabulary.tsx diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index aacb9c6..5cb64a8 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -28,7 +28,7 @@ const FEATURES = [ stat: '3 lượt / ngày', }, { - to: '/vocab', + to: '/flash-card', icon: 'menu_book', iconBg: 'bg-amber-50', iconColor: 'text-amber-600', diff --git a/src/features/toeic/api/test-list-api.ts b/src/features/toeic/api/test-list-api.ts new file mode 100644 index 0000000..4684cb5 --- /dev/null +++ b/src/features/toeic/api/test-list-api.ts @@ -0,0 +1,53 @@ +import { supabase } from '@/lib/supabase' +import type { TestRecord, PartRecord } from '@/types' + +export async function fetchTests(): Promise { + const { data, error } = await supabase + .from('test') + .select('id, title, description, total_questions, duration_minutes, test_category(name)') + .order('id') + if (error) throw error + return (data ?? []).map((row: Record) => ({ + id: row.id as number, + title: row.title as string, + description: row.description as string | null, + totalQuestions: row.total_questions as number, + durationMinutes: row.duration_minutes as number, + categoryName: (row.test_category as { name: string } | null)?.name ?? null, + })) +} + +export async function fetchTestWithParts(testId: number): Promise<{ test: TestRecord; parts: PartRecord[] }> { + const { data: testRow, error: testErr } = await supabase + .from('test') + .select('id, title, description, total_questions, duration_minutes, test_category(name)') + .eq('id', testId) + .single() + if (testErr) throw testErr + + const { data: partRows, error: partErr } = await supabase + .from('part') + .select('id, test_id, part_number, title, question_count') + .eq('test_id', testId) + .order('part_number') + if (partErr) throw partErr + + const row = testRow as Record + return { + test: { + id: row.id as number, + title: row.title as string, + description: row.description as string | null, + totalQuestions: row.total_questions as number, + durationMinutes: row.duration_minutes as number, + categoryName: (row.test_category as { name: string } | null)?.name ?? null, + }, + parts: (partRows ?? []).map((p: Record) => ({ + id: p.id as number, + testId: p.test_id as number, + partNumber: p.part_number as number, + title: p.title as string, + questionCount: p.question_count as number, + })), + } +} diff --git a/src/features/toeic/components/TestResult.tsx b/src/features/toeic/components/TestResult.tsx index 0b35070..da151bd 100644 --- a/src/features/toeic/components/TestResult.tsx +++ b/src/features/toeic/components/TestResult.tsx @@ -8,6 +8,8 @@ import { saveTestResult } from '@/lib/progress-service' import { useAwardActivity } from '@/hooks/use-gamification' import { XP_REWARDS } from '@/lib/gamification-service' +const ANSWER_LABELS = ['A', 'B', 'C', 'D'] + function formatTime(s: number) { const m = Math.floor(s / 60) const sec = s % 60 @@ -17,89 +19,72 @@ function formatTime(s: number) { export function TestResult() { const navigate = useNavigate() - const { partId, partName, questions, answers, timeUsed, reset } = useTestStore() + const { testId, testName, parts, answers, timeUsed, reset } = useTestStore() const { isAuthenticated, isLoading } = useRequireAuth() const user = useAuthStore((s) => s.user) const savedRef = useRef(false) const { mutate: awardActivity } = useAwardActivity() + // Flatten all questions across parts + const allQuestions = parts.flatMap(p => p.questions) + useEffect(() => { if (isLoading) return if (!isAuthenticated) navigate({ to: '/toeic' }) }, [isLoading, isAuthenticated, navigate]) - // Save test result once when page mounts (fire-and-forget) useEffect(() => { - if (!user || savedRef.current || questions.length === 0) return + if (!user || savedRef.current || allQuestions.length === 0) return savedRef.current = true + const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length awardActivity({ xp: XP_REWARDS.test }) saveTestResult(user.id, { - partId, - partName, - score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length, - total: questions.length, + testId, + selectedParts: parts.map(p => p.partNumber), + score: correct, + total: allQuestions.length, timeUsed, - answers: questions.map((q, i) => ({ + answers: allQuestions.map(q => ({ questionId: q.id, - selected: answers[i], - correct: answers[i] === q.correctAnswer, + selected: answers[q.id] ?? null, + correct: answers[q.id] === q.correctAnswer, })), }) - }, [user, questions, answers, partId, partName, timeUsed]) + }, [user, allQuestions.length]) - const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length - const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length - const skipped = answers.filter((a) => a === null).length - const total = questions.length - const percent = total > 0 ? Math.round((correct / total) * 100) : 0 - - const circumference = 2 * Math.PI * 52 - const offset = circumference - (percent / 100) * circumference - - function handleRetry() { - navigate({ to: '/toeic/session' }) - } - - function handleHome() { - reset() - navigate({ to: '/' }) - } - - if (questions.length === 0) { + if (allQuestions.length === 0) { return (

Không có dữ liệu bài thi.

-
) } + const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length + const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length + const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length + const total = allQuestions.length + const percent = total > 0 ? Math.round((correct / total) * 100) : 0 + const circumference = 2 * Math.PI * 52 + const offset = circumference - (percent / 100) * circumference + return (
{/* Score header */}
- {/* Circle */}
- = 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'} - strokeWidth="8" - strokeLinecap="round" - strokeDasharray={circumference} - strokeDashoffset={offset} - className="transition-all duration-700" - /> + strokeWidth="8" strokeLinecap="round" + strokeDasharray={circumference} strokeDashoffset={offset} + className="transition-all duration-700" />
{correct}/{total} @@ -107,121 +92,92 @@ export function TestResult() {
- {/* Stats */}
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
-
- Part {partId} — {partName} -
+
{testName}
-
-
{correct}
-
Đúng
-
-
-
{wrong}
-
Sai
-
-
-
{skipped}
-
Bỏ qua
-
-
-
{formatTime(timeUsed)}
-
Thời gian
-
+ {[ + { label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' }, + { label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' }, + { label: 'Bỏ qua', value: skipped, cls: 'bg-slate-50 border-slate-200 text-slate-500' }, + { label: 'Thời gian', value: formatTime(timeUsed), cls: 'bg-blue-50 border-blue-100 text-blue-600' }, + ].map(({ label, value, cls }) => ( +
+
{value}
+
{label}
+
+ ))}
- {/* Actions */}
- -
- {/* Answer review */} -
-

Xem lại đáp án

-
- {questions.map((q, i) => { - const userAnswer = answers[i] - const isCorrect = userAnswer === q.correctAnswer - const isSkipped = userAnswer === null - - return ( -
( +
+

Part {part.partNumber} — {part.partName}

+
+ {part.questions.map((q, i) => { + const userAnswer = answers[q.id] ?? null + const isCorrect = userAnswer === q.correctAnswer + const isSkipped = userAnswer === null || userAnswer === undefined + return ( +
-
- +
+ - {i + 1} - -
-

{q.text}

-
- {q.options.map((opt, j) => ( - {i + 1} +
+ {q.text &&

{q.text}

} +
+ {q.options.map((opt, j) => ( + - {['A', 'B', 'C', 'D'][j]}. {opt} - - ))} + )}> + {ANSWER_LABELS[j]}. {opt} + + ))} +
+ {q.explanation && ( +

+ Giải thích: {q.explanation} +

+ )}
- {q.explanation && ( -

- Giải thích: - {q.explanation} -

- )} + + {isCorrect + ? check_circle + : isSkipped + ? remove_circle + : cancel} +
- - {isCorrect ? ( - check_circle - ) : isSkipped ? ( - remove_circle - ) : ( - cancel - )} -
-
- ) - })} + ) + })} +
-
+ ))}
) } diff --git a/src/features/toeic/components/TestSession.tsx b/src/features/toeic/components/TestSession.tsx index 6850cc7..32be51a 100644 --- a/src/features/toeic/components/TestSession.tsx +++ b/src/features/toeic/components/TestSession.tsx @@ -3,205 +3,149 @@ import { useNavigate } from '@tanstack/react-router' import { cn } from '@/lib/utils' import { useTestStore } from '@/store/test-store' import { useRequireAuth } from '@/hooks/use-require-auth' +import { TestSessionHeader } from './TestSessionHeader' +import { TestSessionSidebar } from './TestSessionSidebar' +import { TestSessionFooter } from './TestSessionFooter' +import type { Question } from '@/types' -const TOTAL_SECONDS = 600 // 10 minutes const ANSWER_LABELS = ['A', 'B', 'C', 'D'] -function formatTime(s: number) { - return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` -} - -export function TestSession() { - const navigate = useNavigate() - const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore() - const [currentQ, setCurrentQ] = useState(0) - const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS) - const { isAuthenticated, isLoading } = useRequireAuth() - - const handleSubmit = useCallback(() => { - submitExam(TOTAL_SECONDS - timeLeft) - navigate({ to: '/toeic/result' }) - }, [submitExam, navigate, timeLeft]) - - // Countdown - useEffect(() => { - if (questions.length === 0) return - const id = setInterval(() => { - setTimeLeft((t) => { - if (t <= 1) { clearInterval(id); handleSubmit(); return 0 } - return t - 1 - }) - }, 1000) - return () => clearInterval(id) - }, [questions.length, handleSubmit]) - - // Redirect if no exam started or not authenticated (wait for auth init) - useEffect(() => { - if (isLoading) return - if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' }) - }, [isLoading, isAuthenticated, questions.length, navigate]) - - if (questions.length === 0) return null - - const question = questions[currentQ] - const answeredCount = answers.filter((a) => a !== null).length - const isUrgent = timeLeft < 60 - +function QuestionCard({ + question, globalNum, answer, onSelect, +}: { + question: Question + globalNum: number + answer: number | null + onSelect: (idx: number) => void +}) { return ( -
- {/* Mobile progress bar */} -
-
- Part {partId} — Câu {currentQ + 1}/{questions.length} - - {formatTime(timeLeft)} - +
+ + Câu {globalNum} + + + {question.passageText && ( +
+ {question.passageText}
-
-
-
-
- -
- {/* Left: Question */} -
-
-
- - Câu {currentQ + 1} - - Part {partId} — {partName} -
-

- {question.text} -

-
- {question.options.map((opt, i) => { - const selected = answers[currentQ] === i - return ( - - ) - })} -
-
- - {/* Navigation */} -
- - {currentQ + 1} / {questions.length} - {currentQ < questions.length - 1 ? ( - - ) : ( - - )} -
-
- - {/* Right panel — desktop only */} -
- {/* Timer */} -
-
Thời gian còn lại
-
- {formatTime(timeLeft)} -
-
phút : giây
-
- - {/* Question dots */} -
-
- Danh sách câu · {answeredCount}/{questions.length} đã trả lời -
-
- {questions.map((_, i) => ( - - ))} -
-
- Đã trả lời - Chưa làm -
-
+ )} + {question.audioUrl && ( +
- - {/* Mobile submit */} -
- + ))}
) } + +export function TestSession() { + const navigate = useNavigate() + const { testName, parts, currentPartIndex, answers, totalSeconds, setAnswer, setCurrentPart, submitExam } = useTestStore() + const { isAuthenticated, isLoading } = useRequireAuth() + const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1) + const [timeUsed, setTimeUsed] = useState(0) + + const handleSubmit = useCallback(() => { + submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed) + navigate({ to: '/toeic/result' }) + }, [submitExam, navigate, totalSeconds, timeLeft, timeUsed]) + + // Timer + useEffect(() => { + if (parts.length === 0) return + const id = setInterval(() => { + if (timeLeft > 0) { + setTimeLeft(t => { if (t <= 1) { clearInterval(id); handleSubmit(); return 0 } return t - 1 }) + } else { + setTimeUsed(t => t + 1) + } + }, 1000) + return () => clearInterval(id) + }, [parts.length, timeLeft, handleSubmit]) + + useEffect(() => { + if (isLoading) return + if (!isAuthenticated || parts.length === 0) navigate({ to: '/toeic' }) + }, [isLoading, isAuthenticated, parts.length, navigate]) + + if (parts.length === 0) return null + + const currentPart = parts[currentPartIndex] + + // Compute global question offset for current part + let globalOffset = 0 + for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length + + return ( +
+ + +
+ + + {/* Main scrollable content */} +
+
+

+ Part {currentPart.partNumber}: {currentPart.partName} +

+ {currentPart.questions.map((q, idx) => ( + setAnswer(q.id, i)} + /> + ))} +
+
+
+ + setCurrentPart(currentPartIndex - 1)} + onNext={() => setCurrentPart(currentPartIndex + 1)} + /> +
+ ) +} diff --git a/src/features/toeic/components/TestSessionFooter.tsx b/src/features/toeic/components/TestSessionFooter.tsx new file mode 100644 index 0000000..c2add22 --- /dev/null +++ b/src/features/toeic/components/TestSessionFooter.tsx @@ -0,0 +1,36 @@ +interface Props { + currentPartIndex: number + totalParts: number + currentPartName: string + onPrev: () => void + onNext: () => void +} + +export function TestSessionFooter({ currentPartIndex, totalParts, currentPartName, onPrev, onNext }: Props) { + return ( +
+ + + + Part {currentPartIndex + 1} / {totalParts} + — {currentPartName} + + + +
+ ) +} diff --git a/src/features/toeic/components/TestSessionHeader.tsx b/src/features/toeic/components/TestSessionHeader.tsx new file mode 100644 index 0000000..aab8b6f --- /dev/null +++ b/src/features/toeic/components/TestSessionHeader.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/lib/utils' + +interface Props { + testName: string + timeLeft: number // seconds remaining; -1 = no limit (count-up mode) + timeUsed: number // seconds elapsed (used when no limit) + onSubmit: () => void +} + +function formatTime(s: number): string { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = s % 60 + if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` +} + +export function TestSessionHeader({ testName, timeLeft, timeUsed, onSubmit }: Props) { + const isUnlimited = timeLeft === -1 + const displaySeconds = isUnlimited ? timeUsed : timeLeft + const isUrgent = !isUnlimited && timeLeft < 300 // last 5 min + + return ( +
+ {testName} + + + {isUnlimited ? : formatTime(displaySeconds)} + + + +
+ ) +} diff --git a/src/features/toeic/components/TestSessionSidebar.tsx b/src/features/toeic/components/TestSessionSidebar.tsx new file mode 100644 index 0000000..d086b03 --- /dev/null +++ b/src/features/toeic/components/TestSessionSidebar.tsx @@ -0,0 +1,86 @@ +import { cn } from '@/lib/utils' +import type { SessionPart } from '@/types' + +interface Props { + parts: SessionPart[] + currentPartIndex: number + answers: Record + onSelectPart: (index: number) => void +} + +export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectPart }: Props) { + // Global question offset per part for sequential numbering + let offset = 0 + const partOffsets: number[] = parts.map(p => { + const o = offset + offset += p.questions.length + return o + }) + + return ( + + ) +} diff --git a/src/features/toeic/components/ToeicTestDetail.tsx b/src/features/toeic/components/ToeicTestDetail.tsx new file mode 100644 index 0000000..72b93fd --- /dev/null +++ b/src/features/toeic/components/ToeicTestDetail.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { cn } from '@/lib/utils' +import { fetchTestWithParts } from '@/features/toeic/api/test-list-api' +import { fetchQuestionsForTest } from '@/hooks/use-questions' +import { useTestStore } from '@/store/test-store' +import { useRequireAuth } from '@/hooks/use-require-auth' + +interface Props { testId: number } + +export function ToeicTestDetail({ testId }: Props) { + const navigate = useNavigate() + const { startExam } = useTestStore() + const { requireAuth } = useRequireAuth() + const [selectedParts, setSelectedParts] = useState([]) + const [loading, setLoading] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['test-detail', testId], + queryFn: () => fetchTestWithParts(testId), + }) + + function togglePart(partNumber: number) { + setSelectedParts(prev => + prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber] + ) + } + + async function handleStart(mode: 'full' | 'parts') { + if (!requireAuth()) return + if (mode === 'parts' && selectedParts.length === 0) return + if (!data) return + setLoading(true) + try { + const partNumbers = mode === 'full' ? undefined : selectedParts + const parts = await fetchQuestionsForTest(testId, partNumbers) + const totalSeconds = mode === 'full' + ? data.test.durationMinutes * 60 + : selectedParts.length * 10 * 60 + startExam({ testId, testName: data.test.title, parts, totalSeconds }) + navigate({ to: '/toeic/session' }) + } finally { + setLoading(false) + } + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ) + } + + if (!data) return null + const { test, parts } = data + + return ( +
+ {/* Back + title */} +
+ +

{test.title}

+
+

{test.totalQuestions} câu · {test.durationMinutes} phút

+ +
+ {/* Full test card */} +
+
+ military_tech +
+ military_tech +

Thi Toàn Bộ

+

{test.totalQuestions} câu · {test.durationMinutes} phút · Toàn bộ {parts.length} parts

+

Mô phỏng bài thi TOEIC thực tế với giới hạn thời gian.

+ +
+ + {/* Part selection card */} +
+ checklist +

Chọn Part Luyện Tập

+

Chọn các part muốn luyện tập

+ +
+ {parts.map((part) => { + const checked = selectedParts.includes(part.partNumber) + return ( + + ) + })} +
+ + +
+
+
+ ) +} diff --git a/src/features/toeic/components/ToeicTestList.tsx b/src/features/toeic/components/ToeicTestList.tsx new file mode 100644 index 0000000..1c7bcc4 --- /dev/null +++ b/src/features/toeic/components/ToeicTestList.tsx @@ -0,0 +1,82 @@ +import { useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { fetchTests } from '@/features/toeic/api/test-list-api' + +export function ToeicTestList() { + const navigate = useNavigate() + const { data: tests = [], isLoading, error } = useQuery({ + queryKey: ['tests'], + queryFn: fetchTests, + }) + + return ( +
+
+

Đề Thi TOEIC

+

Chọn đề thi để bắt đầu luyện tập hoặc thi thử toàn bộ.

+
+ + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {error && ( +
+ Không thể tải danh sách đề thi. Vui lòng thử lại. +
+ )} + + {!isLoading && !error && tests.length === 0 && ( +
+ library_books +

Chưa có đề thi nào. Dữ liệu đang được cập nhật.

+
+ )} + + {tests.length > 0 && ( +
+ {tests.map((test) => ( +
+ {/* Category badge */} + {test.categoryName && ( + + {test.categoryName} + + )} + +

{test.title}

+ {test.description && ( +

{test.description}

+ )} + +
+ + list_alt + {test.totalQuestions} câu + + + timer + {test.durationMinutes} phút + +
+ + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/hooks/use-questions.ts b/src/hooks/use-questions.ts index 790cfb7..80f707d 100644 --- a/src/hooks/use-questions.ts +++ b/src/hooks/use-questions.ts @@ -1,35 +1,96 @@ -import { useQuery } from "@tanstack/react-query" import { supabase } from "@/lib/supabase" -import type { Question } from "@/types" +import type { Question, SessionPart } from "@/types" -const ANSWER_INDEX: Record = { A: 0, B: 1, C: 2, D: 3 } +type AnswerChoiceRow = { value: string; label_text: string | null; is_correct: boolean } +type QuestionRow = { id: number; question_text: string | null; explanation: string | null; group_id: number; answer_choice: AnswerChoiceRow[] } +type GroupRow = { id: number; part_id: number; audio_url: string | null; image_url: string | null; passage_text: string | null } +type PartRow = { id: number; part_number: number } -// Maps a Supabase row to the shared Question interface. -// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3). -function rowToQuestion(row: Record): Question { +function buildOptions(choices: AnswerChoiceRow[]): string[] { + return [...choices].sort((a, b) => a.value.localeCompare(b.value)).map(c => c.label_text ?? '') +} + +function getCorrectIndex(choices: AnswerChoiceRow[]): number { + const sorted = [...choices].sort((a, b) => a.value.localeCompare(b.value)) + const idx = sorted.findIndex(c => c.is_correct) + return idx >= 0 ? idx : 0 +} + +function rowToQuestion(row: QuestionRow, group: GroupRow, partNumber: number): Question { return { - id: row.id as string, - part: row.part as number, - text: row.content as string, - options: row.options as string[], - correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0, - explanation: (row.explanation as string) ?? '', + id: row.id, + partNumber, + text: row.question_text, + options: buildOptions(row.answer_choice), + correctAnswer: getCorrectIndex(row.answer_choice), + explanation: row.explanation, + groupId: row.group_id, + audioUrl: group.audio_url ?? undefined, + imageUrl: group.image_url ?? undefined, + passageText: group.passage_text ?? undefined, } } -// Exported for imperative use (e.g. ToeicPractice click handler). -// part=0 fetches all parts (Full Test). -export async function fetchQuestions(part: number, limit = 10): Promise { - let query = supabase.from('questions').select('*').limit(limit) - if (part > 0) query = query.eq('part', part) - const { data, error } = await query - if (error) throw error - return (data ?? []).map(rowToQuestion) -} +/** + * Fetch all questions for a test, optionally filtered to specific part numbers. + * partNumbers=[] or undefined → fetch all parts of the test. + * Returns questions grouped into SessionPart[] ordered by part_number. + */ +export async function fetchQuestionsForTest( + testId: number, + partNumbers?: number[], +): Promise { + // Step 1: Get parts for this test + let partsQuery = supabase.from('part').select('id, part_number, title').eq('test_id', testId).order('part_number') + if (partNumbers?.length) partsQuery = partsQuery.in('part_number', partNumbers) + const { data: parts, error: partsError } = await partsQuery + if (partsError) throw partsError + if (!parts?.length) return [] -export function useQuestions(part: number, limit = 10) { - return useQuery({ - queryKey: ['questions', part, limit], - queryFn: () => fetchQuestions(part, limit), - }) + const partRows = parts as (PartRow & { title: string })[] + const partIds = partRows.map(p => p.id) + const partNumberById = new Map(partRows.map(p => [p.id, p.part_number])) + const partTitleByNumber = new Map(partRows.map(p => [p.part_number, p.title])) + + // Step 2: Get question_groups for those parts + const { data: groups, error: groupsError } = await supabase + .from('question_group') + .select('id, part_id, audio_url, image_url, passage_text') + .in('part_id', partIds) + if (groupsError) throw groupsError + if (!groups?.length) return [] + + const groupMap = new Map((groups as GroupRow[]).map(g => [g.id, g])) + const groupIds = (groups as GroupRow[]).map(g => g.id) + + // Step 3: Get questions with answer choices + const { data: rows, error } = await supabase + .from('question') + .select('id, question_text, explanation, group_id, answer_choice(value, label_text, is_correct)') + .in('group_id', groupIds) + .order('question_number') + if (error) throw error + + const questions = (rows as QuestionRow[] ?? []) + .map(row => { + const group = groupMap.get(row.group_id)! + const partNumber = partNumberById.get(group.part_id)! + return rowToQuestion(row, group, partNumber) + }) + .filter(q => q.options.length > 0) + + // Group into SessionPart[] ordered by partNumber + const byPart = new Map() + for (const q of questions) { + if (!byPart.has(q.partNumber)) byPart.set(q.partNumber, []) + byPart.get(q.partNumber)!.push(q) + } + + return partRows + .filter(p => byPart.has(p.part_number)) + .map(p => ({ + partNumber: p.part_number, + partName: partTitleByNumber.get(p.part_number) ?? `Part ${p.part_number}`, + questions: byPart.get(p.part_number)!, + })) } diff --git a/src/lib/progress-service.ts b/src/lib/progress-service.ts index f2498d9..002ef4c 100644 --- a/src/lib/progress-service.ts +++ b/src/lib/progress-service.ts @@ -1,22 +1,47 @@ import { supabase } from '@/lib/supabase' +const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const + interface TestResultData { - partId: number - partName: string + testId: number | null + selectedParts: number[] score: number total: number timeUsed: number - answers: { questionId: string; selected: number | null; correct: boolean }[] + answers: { questionId: number; selected: number | null; correct: boolean }[] } /** Fire-and-forget: save test result. Failures are logged but don't block UI. */ export async function saveTestResult(userId: string, data: TestResultData): Promise { - const { error } = await supabase.from('user_progress').insert({ - user_id: userId, - type: 'test', - data, - }) - if (error) console.error('Failed to save test result:', error.message) + const { data: attempt, error: attemptError } = await supabase + .from('user_test_attempt') + .insert({ + user_id: userId, + test_id: data.testId, + selected_parts: data.selectedParts, + time_limit_minutes: 10, + submitted_at: new Date().toISOString(), + time_spent_seconds: data.timeUsed, + total_correct: data.score, + total_questions: data.total, + }) + .select('id') + .single() + + if (attemptError) { + console.error('Failed to save test attempt:', attemptError.message) + return + } + + const answerRows = data.answers.map(a => ({ + attempt_id: attempt.id, + question_id: a.questionId, + selected_value: a.selected !== null ? ANSWER_VALUES[a.selected] : null, + is_correct: a.correct, + })) + + const { error: answersError } = await supabase.from('user_answer').insert(answerRows) + if (answersError) console.error('Failed to save answers:', answersError.message) } /** Fire-and-forget: save writing submission with AI feedback. */ @@ -48,10 +73,9 @@ export async function countTodayWritingSubmissions(userId: string): Promise +} diff --git a/src/routes/flash-card.$listId.tsx b/src/routes/flash-card.$listId.tsx new file mode 100644 index 0000000..4d5d06f --- /dev/null +++ b/src/routes/flash-card.$listId.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router" +import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage" + +export const Route = createFileRoute("/flash-card/$listId")({ + component: TermsPage, +}) + +function TermsPage() { + const { listId } = Route.useParams() + return +} diff --git a/src/routes/flash-card.index.tsx b/src/routes/flash-card.index.tsx new file mode 100644 index 0000000..c7b6a64 --- /dev/null +++ b/src/routes/flash-card.index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router" +import { FlashCardListPage } from "@/features/flash-card/components/FlashCardListPage" + +export const Route = createFileRoute("/flash-card/")({ + component: FlashCardListPage, +}) diff --git a/src/routes/flash-card.tsx b/src/routes/flash-card.tsx new file mode 100644 index 0000000..e12755f --- /dev/null +++ b/src/routes/flash-card.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router" + +export const Route = createFileRoute("/flash-card")({ + component: () => , +}) diff --git a/src/routes/toeic.$testId.tsx b/src/routes/toeic.$testId.tsx new file mode 100644 index 0000000..bb1b90c --- /dev/null +++ b/src/routes/toeic.$testId.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ToeicTestDetail } from '@/features/toeic/components/ToeicTestDetail' + +export const Route = createFileRoute('/toeic/$testId')({ + component: TestDetailPage, +}) + +function TestDetailPage() { + const { testId } = Route.useParams() + return +} diff --git a/src/routes/toeic.index.tsx b/src/routes/toeic.index.tsx index a369e2d..31763f9 100644 --- a/src/routes/toeic.index.tsx +++ b/src/routes/toeic.index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router" -import { ToeicPractice } from "@/features/toeic/components/ToeicPractice" +import { ToeicTestList } from "@/features/toeic/components/ToeicTestList" export const Route = createFileRoute("/toeic/")({ - component: ToeicPractice, + component: ToeicTestList, }) diff --git a/src/routes/vocab.tsx b/src/routes/vocab.tsx deleted file mode 100644 index 2283eaf..0000000 --- a/src/routes/vocab.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" -import { Vocabulary } from "@/features/vocab/components/Vocabulary" - -export const Route = createFileRoute("/vocab")({ - component: Vocabulary, -}) diff --git a/src/store/test-store.ts b/src/store/test-store.ts index 92a7eae..232ba20 100644 --- a/src/store/test-store.ts +++ b/src/store/test-store.ts @@ -1,52 +1,66 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type { Question } from '@/types' +import type { SessionPart } from '@/types' + +interface StartExamConfig { + testId: number | null + testName: string + parts: SessionPart[] + totalSeconds: number // 0 = no limit +} interface TestStore { - partId: number - partName: string - questions: Question[] - answers: (number | null)[] + testId: number | null + testName: string + parts: SessionPart[] + currentPartIndex: number + answers: Record // questionId → answerIndex (0-3), null=unanswered isSubmitted: boolean - timeUsed: number // seconds elapsed when submitted + timeUsed: number // seconds elapsed when submitted + totalSeconds: number // time limit (0 = no limit) - startExam: (partId: number, partName: string, questions: Question[]) => void - setAnswer: (questionIndex: number, answerIndex: number) => void + startExam: (config: StartExamConfig) => void + setAnswer: (questionId: number, answerIndex: number) => void + setCurrentPart: (partIndex: number) => void submitExam: (timeUsed: number) => void reset: () => void } +const INITIAL_STATE = { + testId: null, + testName: '', + parts: [], + currentPartIndex: 0, + answers: {}, + isSubmitted: false, + timeUsed: 0, + totalSeconds: 0, +} + export const useTestStore = create()( persist( (set) => ({ - partId: 2, - partName: '', - questions: [], - answers: [], - isSubmitted: false, - timeUsed: 0, + ...INITIAL_STATE, - startExam: (partId, partName, questions) => - set({ - partId, - partName, - questions, - answers: new Array(questions.length).fill(null), - isSubmitted: false, - timeUsed: 0, - }), + startExam: ({ testId, testName, parts, totalSeconds }) => { + // Pre-fill all question IDs with null (unanswered) + const answers: Record = {} + for (const part of parts) { + for (const q of part.questions) answers[q.id] = null + } + set({ testId, testName, parts, currentPartIndex: 0, answers, isSubmitted: false, timeUsed: 0, totalSeconds }) + }, - setAnswer: (questionIndex, answerIndex) => - set((state) => { - const answers = [...state.answers] - answers[questionIndex] = answerIndex - return { answers } - }), + setAnswer: (questionId, answerIndex) => + set((state) => ({ + answers: { ...state.answers, [questionId]: answerIndex }, + })), + + setCurrentPart: (partIndex) => set({ currentPartIndex: partIndex }), submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }), - reset: () => - set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }), + reset: () => set(INITIAL_STATE), }), { name: 'test-store' }, ), diff --git a/src/temp/local-data.ts b/src/temp/local-data.ts index 13364e4..8305e73 100644 --- a/src/temp/local-data.ts +++ b/src/temp/local-data.ts @@ -11,7 +11,7 @@ * When real database data is available, remove the relevant export and update the consumer. */ -import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types' +import type { VocabWord, WritingFeedback, ToeicPart } from '@/types' // ─── [ACTIVE TEMP] ───────────────────────────────────────────────────────────── // Used by: src/pages/ToeicPractice.tsx @@ -28,8 +28,9 @@ export const TOEIC_PARTS: ToeicPart[] = [ ] // ─── [UNUSED] ────────────────────────────────────────────────────────────────── -// Real questions come from Supabase via fetchQuestions() in src/hooks/use-questions.ts -export const MOCK_QUESTIONS: Question[] = [ +// Real questions come from Supabase via fetchQuestionsForTest() in src/hooks/use-questions.ts +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const MOCK_QUESTIONS: any[] = [ { id: 'q1', part: 2, text: 'What does the man suggest the woman do about the budget report?', options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'], correctAnswer: 1, explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.' }, { id: 'q2', part: 2, text: 'Where most likely are the speakers?', options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'], correctAnswer: 2, explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.' }, { id: 'q3', part: 2, text: 'Why is the man calling?', options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'], correctAnswer: 0, explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.' }, diff --git a/src/types/index.ts b/src/types/index.ts index a87e756..07b67a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,10 +1,40 @@ export interface Question { - id: string - part: number - text: string - options: string[] - correctAnswer: number // 0-3 - explanation: string + id: number // SERIAL from question table + partNumber: number // from part.part_number — needed for session grouping + text: string | null // question_text (null for photo/audio-only questions) + options: string[] // answer_choice.label_text ordered A→D + correctAnswer: number // 0-3 derived from answer_choice.is_correct + explanation: string | null + groupId: number + audioUrl?: string // from question_group + imageUrl?: string // from question_group + passageText?: string // from question_group (Part 6/7) +} + +// One part's worth of questions inside a test session +export interface SessionPart { + partNumber: number + partName: string // e.g. "Mô tả hình ảnh" + questions: Question[] +} + +// A test record from the test table +export interface TestRecord { + id: number + title: string + description: string | null + totalQuestions: number + durationMinutes: number + categoryName: string | null +} + +// A part record from the part table +export interface PartRecord { + id: number + testId: number + partNumber: number + title: string + questionCount: number } export interface VocabWord { diff --git a/supabase/create/update.sql b/supabase/create/update.sql index e69de29..7d71576 100644 --- a/supabase/create/update.sql +++ b/supabase/create/update.sql @@ -0,0 +1,7 @@ +CREATE TABLE test_category ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, -- "TOEIC", "IELTS Academic", "HSK 1"... + slug VARCHAR(100) UNIQUE -- "toeic", "ielts", "hsk-1" +); + +ALTER TABLE test ADD COLUMN category_id INT REFERENCES test_category(id); \ No newline at end of file diff --git a/supabase/migrations/004_full_schema_reset.sql b/supabase/migrations/004_full_schema_reset.sql new file mode 100644 index 0000000..ce4a94a --- /dev/null +++ b/supabase/migrations/004_full_schema_reset.sql @@ -0,0 +1,302 @@ +-- Migration 004: Full schema reset +-- Drops legacy flat tables (questions, vocab, user_progress) +-- Creates new hierarchical schema from supabase/create/ files +-- Adapted for Supabase: users(id) → auth.users(id) UUID +-- Tables kept intact: writing_submissions, user_gamification, xu_transactions, weekly_leaderboard + +-- ============================================================ +-- DROP LEGACY TABLES +-- ============================================================ +DROP TABLE IF EXISTS user_progress CASCADE; +DROP TABLE IF EXISTS vocab CASCADE; +DROP TABLE IF EXISTS questions CASCADE; + +-- ============================================================ +-- TEST STRUCTURE +-- (from create/test.sql + create/update.sql merged) +-- ============================================================ + +CREATE TABLE test_category ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) UNIQUE +); + +CREATE TABLE test ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + total_questions INT DEFAULT 0, + duration_minutes INT DEFAULT 120, + category_id INT REFERENCES test_category(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE part ( + id SERIAL PRIMARY KEY, + test_id INT NOT NULL REFERENCES test(id) ON DELETE CASCADE, + part_number INT NOT NULL, + title VARCHAR(100) NOT NULL, + question_count INT DEFAULT 0, + display_order INT DEFAULT 0, + UNIQUE (test_id, part_number) +); + +CREATE TABLE tag ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE part_tag ( + part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE, + tag_id INT NOT NULL REFERENCES tag(id) ON DELETE CASCADE, + PRIMARY KEY (part_id, tag_id) +); + +CREATE TABLE question_group ( + id SERIAL PRIMARY KEY, + part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE, + audio_url VARCHAR(500), + image_url VARCHAR(500), + passage_text TEXT, + display_order INT DEFAULT 0 +); + +CREATE TABLE question ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES question_group(id) ON DELETE CASCADE, + question_number INT NOT NULL, + question_text TEXT, + explanation TEXT, + display_order INT DEFAULT 0 +); + +CREATE TABLE answer_choice ( + id SERIAL PRIMARY KEY, + question_id INT NOT NULL REFERENCES question(id) ON DELETE CASCADE, + value CHAR(1) NOT NULL CHECK (value IN ('A', 'B', 'C', 'D')), + label_text TEXT, + is_correct BOOLEAN NOT NULL DEFAULT FALSE +); + +-- ============================================================ +-- FLASHCARD SYSTEM +-- (from create/flash_card.sql) +-- adapted: user_id / created_by → auth.users(id) UUID +-- ============================================================ + +CREATE TABLE flashcard_list ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + total_words INT DEFAULT 0, + is_public BOOLEAN DEFAULT TRUE, + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE flashcard_term ( + id SERIAL PRIMARY KEY, + list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE, + word VARCHAR(255) NOT NULL, + part_of_speech VARCHAR(50), + phonetic VARCHAR(100), + definition TEXT, + example TEXT, + image_url VARCHAR(500), + audio_tts_text VARCHAR(255), + audio_lang VARCHAR(10) DEFAULT 'en-US', + display_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user_flashcard_list ( + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE, + enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, list_id) +); + +CREATE TABLE user_flashcard_progress ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE, + list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'new', -- new | learning | known | ignored + ease_factor DECIMAL(4,2) DEFAULT 1.0, -- 1.0=easy | 0.65=medium | 0.1=hard | -1=known/ignored + review_count INT DEFAULT 0, + last_reviewed_at TIMESTAMP, + next_review_at TIMESTAMP, + UNIQUE (user_id, term_id, list_id) +); + +CREATE TABLE user_flashcard_session ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP, + terms_reviewed INT DEFAULT 0, + terms_new INT DEFAULT 0 +); + +CREATE TABLE user_flashcard_review_log ( + id SERIAL PRIMARY KEY, + session_id INT NOT NULL REFERENCES user_flashcard_session(id) ON DELETE CASCADE, + term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + action_value DECIMAL(4,2) NOT NULL, -- 1 | 0.65 | 0.1 | -1 + reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user_flashcard_settings ( + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE, + daily_new_limit INT DEFAULT 20, + shuffle BOOLEAN DEFAULT TRUE, + front_side VARCHAR(10) DEFAULT 'word', -- 'word' | 'definition' + show_all_terms BOOLEAN DEFAULT FALSE, + PRIMARY KEY (user_id, list_id) +); + +-- ============================================================ +-- USER TEST HISTORY +-- (from create/user_test_history.sql) +-- adapted: user_id → auth.users(id) UUID +-- ============================================================ + +CREATE TABLE user_test_attempt ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + test_id INT NOT NULL REFERENCES test(id) ON DELETE CASCADE, + selected_parts INT[], + time_limit_minutes INT, + started_at TIMESTAMP, + submitted_at TIMESTAMP, + time_spent_seconds INT, + total_correct INT DEFAULT 0, + total_questions INT DEFAULT 0, + score DECIMAL(5,2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user_answer ( + id SERIAL PRIMARY KEY, + attempt_id INT NOT NULL REFERENCES user_test_attempt(id) ON DELETE CASCADE, + question_id INT NOT NULL REFERENCES question(id), + selected_value CHAR(1), + is_correct BOOLEAN, + UNIQUE (attempt_id, question_id) +); + +-- ============================================================ +-- INDEXES +-- ============================================================ + +-- test structure +CREATE INDEX idx_part_test_id ON part(test_id); +CREATE INDEX idx_qgroup_part_id ON question_group(part_id); +CREATE INDEX idx_question_group_id ON question(group_id); +CREATE INDEX idx_answer_question_id ON answer_choice(question_id); +CREATE INDEX idx_test_category_id ON test(category_id); + +-- flashcard +CREATE INDEX idx_term_list_id ON flashcard_term(list_id); +CREATE INDEX idx_term_display_order ON flashcard_term(list_id, display_order); +CREATE INDEX idx_progress_user ON user_flashcard_progress(user_id); +CREATE INDEX idx_progress_next_review ON user_flashcard_progress(user_id, next_review_at); +CREATE INDEX idx_progress_status ON user_flashcard_progress(user_id, list_id, status); +CREATE INDEX idx_review_log_session ON user_flashcard_review_log(session_id); +CREATE INDEX idx_enrolled_user ON user_flashcard_list(user_id); + +-- test history +CREATE INDEX idx_attempt_user_id ON user_test_attempt(user_id); +CREATE INDEX idx_attempt_test_id ON user_test_attempt(test_id); +CREATE INDEX idx_answer_attempt_id ON user_answer(attempt_id); + +-- ============================================================ +-- ROW LEVEL SECURITY +-- ============================================================ + +-- test content: public read, no direct user writes +ALTER TABLE test_category ENABLE ROW LEVEL SECURITY; +ALTER TABLE test ENABLE ROW LEVEL SECURITY; +ALTER TABLE part ENABLE ROW LEVEL SECURITY; +ALTER TABLE tag ENABLE ROW LEVEL SECURITY; +ALTER TABLE part_tag ENABLE ROW LEVEL SECURITY; +ALTER TABLE question_group ENABLE ROW LEVEL SECURITY; +ALTER TABLE question ENABLE ROW LEVEL SECURITY; +ALTER TABLE answer_choice ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read test_category" ON test_category FOR SELECT USING (true); +CREATE POLICY "Public read test" ON test FOR SELECT USING (true); +CREATE POLICY "Public read part" ON part FOR SELECT USING (true); +CREATE POLICY "Public read tag" ON tag FOR SELECT USING (true); +CREATE POLICY "Public read part_tag" ON part_tag FOR SELECT USING (true); +CREATE POLICY "Public read question_group" ON question_group FOR SELECT USING (true); +CREATE POLICY "Public read question" ON question FOR SELECT USING (true); +CREATE POLICY "Public read answer_choice" ON answer_choice FOR SELECT USING (true); + +-- flashcard lists/terms: public lists readable by all, private by owner only +ALTER TABLE flashcard_list ENABLE ROW LEVEL SECURITY; +ALTER TABLE flashcard_term ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read public lists" + ON flashcard_list FOR SELECT + USING (is_public = true OR auth.uid() = created_by); + +CREATE POLICY "Owners can insert lists" + ON flashcard_list FOR INSERT + WITH CHECK (auth.uid() = created_by); + +CREATE POLICY "Public read terms of public lists" + ON flashcard_term FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM flashcard_list fl + WHERE fl.id = flashcard_term.list_id + AND (fl.is_public = true OR fl.created_by = auth.uid()) + ) + ); + +-- flashcard user data: users own their rows +ALTER TABLE user_flashcard_list ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_flashcard_progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_flashcard_session ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_flashcard_review_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_flashcard_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users own flashcard_list enrollment" ON user_flashcard_list FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users own flashcard progress" ON user_flashcard_progress FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users own flashcard sessions" ON user_flashcard_session FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users own review logs" ON user_flashcard_review_log FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users own flashcard settings" ON user_flashcard_settings FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); + +-- test attempt history: users own their rows +ALTER TABLE user_test_attempt ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_answer ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users own test attempts" + ON user_test_attempt FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can read own answers" + ON user_answer FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM user_test_attempt uta + WHERE uta.id = user_answer.attempt_id + AND uta.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can insert own answers" + ON user_answer FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM user_test_attempt uta + WHERE uta.id = user_answer.attempt_id + AND uta.user_id = auth.uid() + ) + ); diff --git a/supabase/migrations/005_nullable_test_id.sql b/supabase/migrations/005_nullable_test_id.sql new file mode 100644 index 0000000..8cdd172 --- /dev/null +++ b/supabase/migrations/005_nullable_test_id.sql @@ -0,0 +1,3 @@ +-- Migration 005: Make test_id nullable on user_test_attempt +-- Practice sessions (part-based) don't belong to a specific test record. +ALTER TABLE user_test_attempt ALTER COLUMN test_id DROP NOT NULL;