diff --git a/apps/web/package.json b/apps/web/package.json index c3c0f698..b584a978 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "react-day-picker": "^9.12.0", "react-dom": "19.2.1", "react-draggable": "^4.5.0", + "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.1.13" diff --git a/apps/web/src/app/dashboard/components/degree-charts.tsx b/apps/web/src/app/dashboard/components/degree-charts.tsx new file mode 100644 index 00000000..623947f3 --- /dev/null +++ b/apps/web/src/app/dashboard/components/degree-charts.tsx @@ -0,0 +1,455 @@ +"use client"; + +import type { api } from "@albert-plus/server/convex/_generated/api"; +import type { FunctionReturnType } from "convex/server"; +import { useEffect, useMemo, useState } from "react"; +import { + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; + +interface ProgramRequirementsChartProps { + programs: Record< + string, + FunctionReturnType | undefined + >; + userCourses: + | FunctionReturnType + | undefined; + courses: Record< + string, + FunctionReturnType | undefined + >; + isLoading: boolean; +} + +const COLORS = [ + "#3b82f6", // blue + "#10b981", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#ec4899", // pink + "#06b6d4", // cyan + "#84cc16", // lime + "#f97316", // orange + "#6366f1", // indigo +]; + +// Helper function to lighten a color (for uncompleted credits) +const lightenColor = (color: string, percent: number = 40): string => { + const num = parseInt(color.replace("#", ""), 16); + const r = Math.min( + 255, + ((num >> 16) & 0xff) + + Math.floor((255 - ((num >> 16) & 0xff)) * (percent / 100)), + ); + const g = Math.min( + 255, + ((num >> 8) & 0xff) + + Math.floor((255 - ((num >> 8) & 0xff)) * (percent / 100)), + ); + const b = Math.min( + 255, + (num & 0xff) + Math.floor((255 - (num & 0xff)) * (percent / 100)), + ); + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; +}; + +const getRequirementsByCategory = ( + programs: Record< + string, + FunctionReturnType | undefined + >, + courseLookup: Map< + string, + FunctionReturnType + >, +) => { + if (!programs) return null; + + // Get the first defined program + const program = Object.values(programs).find((p) => p !== undefined); + if (!program) return null; + + // Calculate total credits for each category + const groupedRequirements: Record< + string, + { credits: number; courses: string[][]; displayName: string } + > = {}; + + // FIXME: currently no merging of programs if more than one listed (dual degree), assumes only one program for now + const requirements = program.requirements; + if (!requirements) return null; + + for (const requirement of requirements) { + const courses = requirement.courses; + + // CASE: requirements is type option + if (requirement.type === "options") { + const uniquePrefixes = [ + ...new Set(requirement.courses.map((c: string) => c.split(" ")[0])), + ]; + + if (uniquePrefixes.length === 1) { + // All same prefix - assign all credits to that one prefix + const prefix = uniquePrefixes[0]; + const displayName = + courseLookup.get(courses[0])?.programName || "Other"; + const key = displayName === "Other" ? "Other" : prefix; + if (!groupedRequirements[key]) { + groupedRequirements[key] = { + credits: 0, + courses: [], + displayName: displayName, + }; + } + groupedRequirements[key].credits += requirement.creditsRequired; + groupedRequirements[key].courses.push(courses); + } else { + // Mixed prefixes - assign to "Other" category + if (!groupedRequirements.Other) { + groupedRequirements.Other = { + credits: 0, + courses: [], + displayName: "Other", + }; + } + groupedRequirements.Other.credits += requirement.creditsRequired; + groupedRequirements.Other.courses.push(courses); + } + } + // CASE: Required/Alternative type - calculate actual credits per course + else { + for (const courseCode of courses) { + const prefix = courseCode.split(" ")[0]; + const course = courseLookup.get(courseCode); + const displayName = course?.programName || "Other"; + const key = displayName === "Other" ? "Other" : prefix; + const credits = course?.credits ?? 4; // fallback to 4 credits if not found + + if (!groupedRequirements[key]) { + groupedRequirements[key] = { + credits: 0, + courses: [], + displayName: displayName, + }; + } + groupedRequirements[key].credits += credits; + groupedRequirements[key].courses.push([courseCode]); + } + } + } + + return { + ...program, + requirementsByCategory: groupedRequirements, + }; +}; + +export function ProgramRequirementsChart({ + programs, + userCourses, + courses, + isLoading, +}: ProgramRequirementsChartProps) { + const [showCompletion, setShowCompletion] = useState(false); + const [hasAnimated, setHasAnimated] = useState(false); + + // Create a lookup map from courses Record + const courseLookup = useMemo(() => { + const lookup = new Map< + string, + FunctionReturnType + >(); + for (const [code, courseData] of Object.entries(courses)) { + if (courseData !== undefined) { + lookup.set(code, courseData); + } + } + return lookup; + }, [courses]); + + // Get grouped requirements using the lookup + const program = useMemo( + () => getRequirementsByCategory(programs, courseLookup), + [programs, courseLookup], + ); + + const { chartData, totalCredits, totalCompletedCredits, overallPercentage } = + useMemo(() => { + if (!program || program === null) { + return { + chartData: [], + totalCredits: 0, + totalCompletedCredits: 0, + overallPercentage: 0, + }; + } + + const courseToCategory = new Map(); + for (const [prefix, data] of Object.entries( + program.requirementsByCategory, + )) { + for (const courseGroup of data.courses) { + for (const course of courseGroup) { + courseToCategory.set(course.toLowerCase(), prefix); + } + } + } + + const completedCreditsByCategory: Record = {}; + if (userCourses) { + for (const userCourse of userCourses) { + const category = courseToCategory.get( + userCourse.courseCode.toLowerCase(), + ); + if (category) { + completedCreditsByCategory[category] = + (completedCreditsByCategory[category] || 0) + + (userCourse.course?.credits || 0); + } + } + } + + // Transform the data for the chart + const data = Object.entries(program.requirementsByCategory) + .map(([prefix, data]) => { + const completed = completedCreditsByCategory[prefix] || 0; + const percentage = + data.credits > 0 ? Math.round((completed / data.credits) * 100) : 0; + return { + category: data.displayName, + credits: data.credits, + completedCredits: completed, + remainingCredits: data.credits - completed, + percentage, + }; + }) + .sort((a, b) => { + // "Other" should always be at the bottom + if (a.category === "Other") return 1; + if (b.category === "Other") return -1; + // Otherwise, maintain alphabetical order + return a.category.localeCompare(b.category); + }); + + // Calculate totals + const totalCredits = data.reduce((sum, item) => sum + item.credits, 0); + const totalCompletedCredits = data.reduce( + (sum, item) => sum + item.completedCredits, + 0, + ); + const overallPercentage = + totalCredits > 0 + ? Math.round((totalCompletedCredits / totalCredits) * 100) + : 0; + + return { + chartData: data, + totalCredits, + totalCompletedCredits, + overallPercentage, + }; + }, [program, userCourses]); + + // Prepare pie chart data based on toggle state + const pieChartData = useMemo(() => { + if (chartData.length === 0) return []; + + if (!showCompletion) { + // Default view: just total credits per category + return chartData.map((item, index) => ({ + name: item.category, + value: item.credits, + fill: COLORS[index % COLORS.length], + })); + } else { + // Completion view: split each category into completed and remaining + const splitData: Array<{ + name: string; + value: number; + fill: string; + isCompleted: boolean; + category: string; + }> = []; + + chartData.forEach((item, index) => { + const baseColor = COLORS[index % COLORS.length]; + const departmentName = item.category; + + // Add completed portion (darker - original color) + if (item.completedCredits > 0) { + splitData.push({ + name: `${departmentName} (Completed)`, + value: item.completedCredits, + fill: baseColor, + isCompleted: true, + category: item.category, + }); + } + + // Add remaining portion (lighter shade) + if (item.remainingCredits > 0) { + splitData.push({ + name: `${departmentName} (Remaining)`, + value: item.remainingCredits, + fill: lightenColor(baseColor), + isCompleted: false, + category: item.category, + }); + } + }); + + return splitData; + } + }, [chartData, showCompletion]); + + // Mark as animated after first data load + useEffect(() => { + if (pieChartData.length > 0 && !hasAnimated) { + const timer = setTimeout(() => { + setHasAnimated(true); + }, 2000); + + return () => clearTimeout(timer); + } + }, [pieChartData, hasAnimated]); + + if (isLoading) { + return ( + + + Program Requirements + Loading... + + + ); + } + + if (program === null) { + return ( + + + Program Requirements + Program not found + + + ); + } + + return ( + + +
+
+ Program Requirements by Subject + + {program.name} - Total: {totalCredits} credits + + • {overallPercentage}% Complete ({totalCompletedCredits}/ + {totalCredits} credits) + + +
+
+
+ setShowCompletion(checked === true)} + /> + +
+
+ +
+ + + + {pieChartData.map((entry) => ( + + ))} + + + {showCompletion + ? `${totalCompletedCredits}/${totalCredits}` + : totalCredits} + + + credits + + { + if (active && payload && payload.length) { + const data = payload[0].payload as { + name: string; + value: number; + fill: string; + }; + return ( +
+
+ {data.name} + + Credits: {data.value} + +
+
+ ); + } + return null; + }} + /> + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index b24db84f..1494b460 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,8 +1,85 @@ -import { redirect } from "next/navigation"; +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import { + type RequestForQueries, + useConvexAuth, + useQueries, + useQuery, +} from "convex/react"; +import type { FunctionReturnType } from "convex/server"; +import { ProgramRequirementsChart } from "@/app/dashboard/components/degree-charts"; const HomePage = () => { - // TODO: homepage is not ready yet, hide if from MVP for now - redirect("/dashboard/register"); + const { isAuthenticated } = useConvexAuth(); + const student = useQuery( + api.students.getCurrentStudent, + !isAuthenticated ? "skip" : {}, + ); + + const userCourses = useQuery( + api.userCourses.getUserCourses, + !isAuthenticated ? "skip" : {}, + ); + + const programQueries: RequestForQueries = {}; + + if (isAuthenticated && student) { + for (const program of student.programs) { + programQueries[program._id] = { + query: api.programs.getProgramById, + args: { id: program._id }, + }; + } + } + + const programs: Record< + string, + FunctionReturnType + > = useQueries(programQueries); + + // Collect all unique course codes from all programs of type required/alternative + const allCourseCodes = new Set(); + for (const [_, programData] of Object.entries(programs)) { + if (programData?.requirements) { + for (const requirement of programData.requirements) { + if ( + requirement.type === "required" || + requirement.type === "alternative" + ) { + for (const courseCode of requirement.courses) { + allCourseCodes.add(courseCode); + } + } + } + } + } + + // Fetch all courses of type required/alternative + const courseQueries: RequestForQueries = {}; + for (const code of allCourseCodes) { + courseQueries[code] = { + query: api.courses.getCourseByCode, + args: { code }, + }; + } + + const courses = useQueries(courseQueries); + + const isProgramsLoading = + !isAuthenticated || + student === undefined || + Object.keys(programs).length === 0 || + Object.values(programs).some((p) => p === undefined); + + return ( + + ); }; export default HomePage; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx new file mode 100644 index 00000000..8b42f210 --- /dev/null +++ b/apps/web/src/components/ui/chart.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +