diff --git a/src/App.jsx b/src/App.jsx index 9956acc..ee0fc63 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,6 +36,7 @@ import VocabDashboard from './domains/vocab/pages/VocabDashboard' import DailyLearning from './domains/vocab/pages/DailyLearning' import TestPage from './domains/vocab/pages/TestPage' import WordListPage from './domains/vocab/pages/WordListPage' +import StatsPage from './domains/vocab/pages/StatsPage' import { useChat } from './contexts/ChatContext' import { useSettings } from './contexts/SettingsContext' @@ -368,6 +369,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx new file mode 100644 index 0000000..66d5984 --- /dev/null +++ b/src/domains/vocab/pages/StatsPage.jsx @@ -0,0 +1,517 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + IconButton, + Paper, + Grid, + Tabs, + Tab, + CircularProgress, + Alert, + Chip, + LinearProgress, + List, + ListItem, + ListItemText, + Tooltip, +} from '@mui/material' +import { + ArrowBack as BackIcon, + TrendingUp as TrendingUpIcon, + CalendarMonth as CalendarIcon, + Warning as WarningIcon, +} from '@mui/icons-material' +import { statsService, voiceService } from '../services/vocabService' +import { + LEVEL_LABELS, + LEVEL_COLORS, + DIFFICULTY_LABELS, + VOICE_TYPES, +} from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 학습 캘린더 히트맵 컴포넌트 +function LearningCalendar({ data }) { + const today = new Date() + const startDate = new Date(today) + startDate.setDate(startDate.getDate() - 83) // 12주 전 + + const weeks = [] + let currentDate = new Date(startDate) + + // 12주 데이터 생성 + for (let w = 0; w < 12; w++) { + const week = [] + for (let d = 0; d < 7; d++) { + const dateStr = currentDate.toISOString().split('T')[0] + const dayData = data?.find(d => d.date === dateStr) + week.push({ + date: dateStr, + count: dayData?.learnedCount || 0, + isToday: dateStr === today.toISOString().split('T')[0], + }) + currentDate.setDate(currentDate.getDate() + 1) + } + weeks.push(week) + } + + const getColor = (count) => { + if (count === 0) return '#ebedf0' + if (count < 20) return '#9be9a8' + if (count < 40) return '#40c463' + if (count < 55) return '#30a14e' + return '#216e39' + } + + const dayLabels = ['일', '월', '화', '수', '목', '금', '토'] + + return ( + + + {/* 요일 라벨 */} + + {dayLabels.map((label, idx) => ( + + {idx % 2 === 1 ? label : ''} + + ))} + + + {/* 히트맵 그리드 */} + {weeks.map((week, wIdx) => ( + + {week.map((day, dIdx) => ( + + + + ))} + + ))} + + + {/* 범례 */} + + 적음 + {[0, 10, 30, 45, 55].map((count, idx) => ( + + ))} + 많음 + + + ) +} + +// 취약 단어 목록 컴포넌트 +function WeakWordsList({ words, onPlayTTS, playingWordId }) { + if (!words || words.length === 0) { + return ( + + 취약 단어가 없습니다 + + ) + } + + return ( + + {words.map((item, index) => ( + + + + {item.english} + + + + } + secondary={item.korean} + /> + onPlayTTS?.(item)} + disabled={playingWordId === item.wordId} + > + + + + ))} + + ) +} + +// 레벨별 진행률 차트 +function LevelProgressChart({ data }) { + if (!data) return null + + return ( + + {Object.entries(LEVEL_LABELS).map(([level, label]) => { + const levelData = data[level] || { total: 0, learned: 0 } + const progress = levelData.total > 0 + ? (levelData.learned / levelData.total) * 100 + : 0 + + return ( + + + + {label} + + + {levelData.learned}/{levelData.total} + + + + + ) + })} + + ) +} + +// 난이도 분포 차트 +function DifficultyChart({ data }) { + if (!data) return null + + const total = Object.values(data).reduce((sum, val) => sum + val, 0) + + const colors = { + EASY: '#4caf50', + NORMAL: '#2196f3', + HARD: '#ff9800', + } + + return ( + + {/* 막대 그래프 */} + + {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => { + const count = data[key] || 0 + const height = total > 0 ? (count / total) * 100 : 0 + + return ( + + + {count} + + + + {label} + + + ) + })} + + + ) +} + +// 통계 요약 카드 +function StatCard({ title, value, subtitle, icon: Icon, color }) { + return ( + + + + + {title} + + + {value} + + {subtitle && ( + + {subtitle} + + )} + + {Icon && ( + + + + )} + + + ) +} + +export default function StatsPage() { + const navigate = useNavigate() + const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간 + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // 통계 데이터 + const [overviewStats, setOverviewStats] = useState(null) + const [calendarData, setCalendarData] = useState([]) + const [weakWords, setWeakWords] = useState([]) + const [levelProgress, setLevelProgress] = useState(null) + const [difficultyDist, setDifficultyDist] = useState(null) + + // TTS + const [playingWordId, setPlayingWordId] = useState(null) + + useEffect(() => { + fetchAllStats() + }, []) + + useEffect(() => { + fetchPeriodStats() + }, [tab]) + + const fetchAllStats = async () => { + try { + setLoading(true) + setError(null) + + const [overviewRes, dailyRes, weakRes] = await Promise.all([ + statsService.getOverall(TEMP_USER_ID), + statsService.getDaily(TEMP_USER_ID, { limit: 84 }), + statsService.getWeakness(TEMP_USER_ID), + ]) + + setOverviewStats(overviewRes?.data) + setCalendarData(dailyRes?.data?.dailyStats || []) + setWeakWords(weakRes?.data?.weakWords || []) + setLevelProgress(overviewRes?.data?.levelProgress) + setDifficultyDist(overviewRes?.data?.difficultyDistribution) + } catch (err) { + console.error('Fetch stats error:', err) + setError('통계를 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + } + + const fetchPeriodStats = async () => { + // 기간별 통계는 getDaily로 처리 + try { + const limits = [7, 30, 90] // 일간, 주간, 월간 + const response = await statsService.getDaily(TEMP_USER_ID, { + limit: limits[tab], + }) + // 기간별 통계 처리 + } catch (err) { + console.error('Period stats error:', err) + } + } + + const handlePlayTTS = async (word) => { + if (playingWordId) return + + try { + setPlayingWordId(word.wordId) + const response = await voiceService.synthesize({ + text: word.english, + voiceType: VOICE_TYPES.FEMALE, + }) + + if (response?.data?.audioUrl) { + const audio = new Audio(response.data.audioUrl) + audio.onended = () => setPlayingWordId(null) + audio.onerror = () => setPlayingWordId(null) + await audio.play() + } else { + setPlayingWordId(null) + } + } catch (err) { + console.error('TTS error:', err) + setPlayingWordId(null) + } + } + + if (loading) { + return ( + + + + + + ) + } + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + 학습 통계 + + + + {error && ( + setError(null)}> + {error} + + )} + + {/* 기간 탭 */} + setTab(v)} + sx={{ mb: 3 }} + variant="fullWidth" + > + + + + + + {/* 요약 카드 */} + + + + + + + + + + + + + + + + {/* 학습 캘린더 */} + + + 학습 기록 + + + + + {/* 레벨별 진행률 */} + + + 레벨별 진행률 + + + + + {/* 난이도 분포 */} + + + 난이도 분포 + + + + + {/* 취약 단어 */} + + + + 취약 단어 TOP 10 + + navigate('/vocab/daily?mode=weak')} + /> + + + + + ) +}