import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import { useProgress } from '../hooks/useProgress'; import { getTopics, getSections, getLessonsForTopic } from '../content/loader'; import { SkillNode } from '../components/home/SkillNode'; import { StreakBanner } from '../components/home/StreakBanner'; import { Rambi } from '../components/mascot/Rambi'; // Vertical spacing between nodes const NODE_GAP = 120; // Height reserved for a section banner const SECTION_BANNER_HEIGHT = 70; // Max horizontal offset from center for the sine wave const WAVE_AMPLITUDE = 80; // How wide the path container is const PATH_WIDTH = 300; export function HomePage() { const navigate = useNavigate(); const { progress, loading } = useProgress(); const topics = useMemo(() => getTopics(), []); const sections = useMemo(() => getSections(), []); const completedLessonIds = useMemo(() => { if (!progress) return new Set(); return new Set( progress.lessons.filter((l) => l.completed).map((l) => l.lesson_id), ); }, [progress]); const completedTopicIds = useMemo(() => { const completed = new Set(); for (const topic of topics) { const lessons = getLessonsForTopic(topic.id); if ( lessons.length > 0 && lessons.every((l) => completedLessonIds.has(l.id)) ) { completed.add(topic.id); } } return completed; }, [topics, completedLessonIds]); function isTopicLocked(topicId: string): boolean { const topic = topics.find((t) => t.id === topicId); if (!topic) return true; if (topic.prerequisites.length === 0) return false; return !topic.prerequisites.every((prereq) => completedTopicIds.has(prereq), ); } function isTopicActive(topicId: string): boolean { if (isTopicLocked(topicId)) return false; if (completedTopicIds.has(topicId)) return false; return true; } function getCompletedLessonsForTopic(topicId: string): number { const lessons = getLessonsForTopic(topicId); return lessons.filter((l) => completedLessonIds.has(l.id)).length; } function handleTopicClick(topicId: string) { const lessons = getLessonsForTopic(topicId); const nextLesson = lessons.find((l) => !completedLessonIds.has(l.id)); const lesson = nextLesson ?? lessons[0]; if (lesson) { navigate(`/lesson/${topicId}/${lesson.id}`); } } // Compute x offset for each node along a sine wave function getNodeX(index: number): number { return Math.sin(index * 0.9) * WAVE_AMPLITUDE; } // Build layout items: section banners + topic nodes with computed Y positions const sectionMap = useMemo(() => { const map = new Map(); for (const s of sections) { map.set(s.id, { name: s.name, description: s.description }); } return map; }, [sections]); // Compute Y offset for each topic, inserting section banner space before each section const { topicYPositions, sectionBannerPositions, totalHeight } = useMemo(() => { const yPositions: number[] = []; const bannerPositions: { y: number; sectionId: number }[] = []; let currentY = 0; const seenSections = new Set(); for (let i = 0; i < topics.length; i++) { const topic = topics[i]; if (!seenSections.has(topic.section)) { seenSections.add(topic.section); if (sectionMap.has(topic.section)) { bannerPositions.push({ y: currentY, sectionId: topic.section }); currentY += SECTION_BANNER_HEIGHT; } } yPositions.push(currentY); currentY += NODE_GAP; } return { topicYPositions: yPositions, sectionBannerPositions: bannerPositions, totalHeight: currentY + 60, }; }, [topics, sectionMap]); // Node center positions for the SVG path (accounting for section banners) const nodePositions = topics.map((_, i) => ({ x: PATH_WIDTH / 2 + getNodeX(i), y: topicYPositions[i] + 45, // 45 = RING_SIZE/2 })); // Build a smooth curve through all node positions function buildPath(): string { if (nodePositions.length < 2) return ''; const pts = nodePositions; let d = `M ${pts[0].x} ${pts[0].y}`; for (let i = 0; i < pts.length - 1; i++) { const curr = pts[i]; const next = pts[i + 1]; const midY = (curr.y + next.y) / 2; d += ` C ${curr.x} ${midY}, ${next.x} ${midY}, ${next.x} ${next.y}`; } return d; } if (loading) { return ( ); } return ( {/* Streak banner */} {progress?.stats && progress.stats.streak_days > 0 && ( )} {/* Mascot greeting */} Kamusta! I'm Rambi! Choose a topic to practice {/* Skill path */} {topics.length === 0 ? ( No topics available yet. Check back soon! ) : ( {/* SVG connector path */} {/* Section banners */} {sectionBannerPositions.map(({ y, sectionId }) => { const section = sectionMap.get(sectionId); if (!section) return null; return ( Section {sectionId}: {section.name} {section.description} ); })} {/* Nodes */} {topics.map((topic, i) => { const totalLessons = getLessonsForTopic(topic.id).length; const completedCount = getCompletedLessonsForTopic(topic.id); const locked = isTopicLocked(topic.id); const active = isTopicActive(topic.id); const x = getNodeX(i); return ( handleTopicClick(topic.id)} /> ); })} )} ); }