diff --git a/entrypoints/degree-audit/components/degree-audit-page.tsx b/entrypoints/degree-audit/components/degree-audit-page.tsx index 1e8222c..9e565d8 100644 --- a/entrypoints/degree-audit/components/degree-audit-page.tsx +++ b/entrypoints/degree-audit/components/degree-audit-page.tsx @@ -1,29 +1,104 @@ +import { HStack, VStack } from "@/entrypoints/components/common/helperdivs"; import Title from "@/entrypoints/components/common/text"; import "@/entrypoints/styles/content.css"; import { useAuditContext } from "../providers/audit-provider"; -import DegreeCompletionDonut from "./degree-completion-donut"; +import { SimpleDegreeCompletionDonut } from "./degree-completion-donut"; import RequirementBreakdown from "./requirement-breakdown"; -const DegreeAuditPage = () => { +const GpaSummaryCard = () => { + const { sections } = useAuditContext(); + const gpaSection = sections.find((section) => + section.title.toLowerCase().includes("gpa"), + ); + const gpaRule = gpaSection?.rule[0]; + + if (!gpaRule) { + return null; + } + + return ( +
+
+

GPA Totals

+
+ {gpaRule.appliedHours.toFixed(4)} +
+
+ +
+
+ Required +
+ + {gpaRule.requiredHours.toFixed(4)} + +
+
+
+ Remaining +
+ + {Math.max(gpaRule.remainingHours, 0).toFixed(4)} + +
+
+
+ +

{gpaRule.text}

+
+ ); +}; + +const SidePanel = () => { + return ( + + +
+ +
+
+ ); +}; + +const MainContent = () => { const { progresses, sections } = useAuditContext(); + const checklistSections = sections.filter( + (section) => !section.title.toLowerCase().includes("gpa"), + ); + return ( - <> + - <DegreeCompletionDonut /> <Title text="Degree Checklist" /> - {sections.map( - (section, idx) => - progresses.sections[idx]?.progress.total > 0 && ( + {checklistSections.map((section) => { + const sectionIndex = sections.findIndex((item) => item === section); + return ( + progresses.sections[sectionIndex]?.progress.total > 0 && ( <RequirementBreakdown - key={section.title || `section-${idx}`} + key={section.title || `section-${sectionIndex}`} title={section.title} - hours={progresses.sections[idx].progress} + hours={progresses.sections[sectionIndex].progress} requirements={section.rule ?? []} - colorIndex={idx} + colorIndex={sectionIndex} /> - ), - )} - </> + ) + ); + })} + </VStack> + ); +}; + +const DegreeAuditPage = () => { + return ( + <HStack fill x="between" className="h-full w-full" gap={8}> + <MainContent /> + <SidePanel /> + </HStack> ); }; diff --git a/entrypoints/degree-audit/components/requirement-breakdown.tsx b/entrypoints/degree-audit/components/requirement-breakdown.tsx index 6da217a..a0186cc 100644 --- a/entrypoints/degree-audit/components/requirement-breakdown.tsx +++ b/entrypoints/degree-audit/components/requirement-breakdown.tsx @@ -11,26 +11,64 @@ import { cn } from "@/lib/utils"; import { CaretDownIcon, CaretUpIcon, - CheckSquare, - MinusSquare, PlusCircleIcon, - XSquare, } from "@phosphor-icons/react"; import { CalendarBlankIcon } from "@phosphor-icons/react/dist/ssr"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, MinusIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { useAuditContext } from "../providers/audit-provider"; import { useCourseModalContext } from "../providers/course-modal-provider"; -// Status icon component for requirements -const StatusIcon = ({ status }: { status: Status }) => { - if (status === "Completed") { - return <CheckSquare weight="fill" className="w-6 h-6 text-green-600" />; +type RequirementCompletionState = "completed" | "not-started" | "in-progress"; + +const getRequirementCompletionState = ( + current: number, + total: number, +): RequirementCompletionState => { + if (total > 0 && current >= total) { + return "completed"; } - if (status === "Not Started") { - return <XSquare weight="fill" className="w-6 h-6 text-gray-700" />; + if (current <= 0) { + return "not-started"; } - return <MinusSquare weight="fill" className="w-6 h-6 text-gray-400" />; + return "in-progress"; +}; + +const requirementStatusStyles = { + completed: { + icon: CheckIcon, + className: "bg-[#67B44A] text-white", + }, + "not-started": { + icon: XIcon, + className: "bg-[#425466] text-white", + }, + "in-progress": { + icon: MinusIcon, + className: "bg-[#B7C6D1] text-white", + }, +} as const; + +const StatusIcon = ({ + current, + total, +}: { + current: number; + total: number; +}) => { + const state = getRequirementCompletionState(current, total); + const { icon: Icon, className } = requirementStatusStyles[state]; + + return ( + <div + className={cn( + "flex h-10 w-10 items-center justify-center rounded-md shadow-sm", + className, + )} + > + <Icon className="h-6 w-6" strokeWidth={3} /> + </div> + ); }; // Hours badge component @@ -128,7 +166,10 @@ const RequirementRow = ({ requirement }: { requirement: RequirementRule }) => { className="w-full py-3 px-2 flex items-start gap-3 hover:bg-gray-50 transition-colors" onClick={() => setIsExpanded(!isExpanded)} > - <StatusIcon status={requirement.status} /> + <StatusIcon + current={requirement.appliedHours} + total={requirement.requiredHours} + /> <VStack gap={0} className="flex-1 text-left"> <span className="font-bold text-base">{code}</span> <span className="text-sm text-gray-500">{description}</span> @@ -282,4 +323,4 @@ const RequirementBreakdown = (props: RequirementBreakdownProps) => { ); }; -export default RequirementBreakdown; \ No newline at end of file +export default RequirementBreakdown; diff --git a/entrypoints/degree-audit/planner-view/degree-planner-page.tsx b/entrypoints/degree-audit/planner-view/degree-planner-page.tsx index 5806f5e..b3a945d 100644 --- a/entrypoints/degree-audit/planner-view/degree-planner-page.tsx +++ b/entrypoints/degree-audit/planner-view/degree-planner-page.tsx @@ -7,7 +7,12 @@ import SemesterDropdowns from "./semester-dropdowns"; const SidePanel = () => { return ( - <VStack fill className="h-full" y="stretch" x="center"> + <VStack + fill + className="h-full sticky top-[75px] z-20 bg-white" + y="stretch" + x="center" + > <SimpleDegreeCompletionDonut size={300} /> <div className="w-sm mt-10 p-3 rounded-lg border border-gray-200 bg-[#FAFAF9]"> <CourseSearchContent /> diff --git a/entrypoints/degree-audit/providers/audit-provider.tsx b/entrypoints/degree-audit/providers/audit-provider.tsx index a5f5782..3245b1e 100644 --- a/entrypoints/degree-audit/providers/audit-provider.tsx +++ b/entrypoints/degree-audit/providers/audit-provider.tsx @@ -7,6 +7,7 @@ import { Course, CourseId, CurrentAuditProgress, + RequirementRule, StringSemester, } from "@/lib/general-types"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; @@ -14,6 +15,13 @@ import LoadingPage from "../components/loading-page"; // Context for sharing audit data betw sidebar and main type SemesterInfo = Record<StringSemester, Course[]>; +type RequirementRuleLike = Omit<RequirementRule, "courses"> & { + courses?: Array<CourseId | Course>; +}; +type AuditRequirementLike = Omit<AuditRequirement, "rule"> & { + rule?: RequirementRuleLike[]; + rules?: RequirementRuleLike[]; +}; interface AuditContextType { sections: AuditRequirement[]; @@ -35,6 +43,44 @@ interface AuditContextType { const AuditContext = createContext<AuditContextType | null>(null); +function normalizeCourseDict( + courses: Record<CourseId, Course> | Course[] | null | undefined, +): Record<CourseId, Course> { + if (!courses) { + return {}; + } + + if (Array.isArray(courses)) { + return courses.reduce( + (acc, course) => ({ + ...acc, + [course.id]: course, + }), + {} as Record<CourseId, Course>, + ); + } + + return courses; +} + +function normalizeRequirements( + requirements: AuditRequirementLike[], +): AuditRequirement[] { + return requirements.map((section) => ({ + ...section, + rule: (section.rule ?? section.rules ?? []).map((rule) => ({ + ...rule, + courses: (rule.courses ?? []) + .map((courseRef) => + typeof courseRef === "object" && courseRef !== null + ? courseRef.id + : courseRef, + ) + .filter(Boolean) as CourseId[], + })), + })); +} + export const AuditContextProvider = ({ children, }: { @@ -106,17 +152,10 @@ export const AuditContextProvider = ({ // Load requirements from cache const cached = await getAuditData(currentAuditId!); if (cached) { - setSections( - cached.requirements.map((section) => ({ - ...section, - rules: section.rule.map((rule) => ({ - ...rule, - courses: rule.courses, - })), - })), - ); + const normalizedCourses = normalizeCourseDict(cached.courses); + setSections(normalizeRequirements(cached.requirements)); console.log("[Main] courses", cached.courses); - setCourseDict(cached.courses); + setCourseDict(normalizedCourses); } else console.warn(`[Main] Audit ${currentAuditId} not in cache.`); setLoaded(true); diff --git a/lib/audit-calculations.ts b/lib/audit-calculations.ts index ca0b524..e4b8673 100644 --- a/lib/audit-calculations.ts +++ b/lib/audit-calculations.ts @@ -24,11 +24,15 @@ export function calculateWeightedDegreeCompletion( results.sections.push(sectionProgress); }); - results.total.current = results.sections.reduce( + // Only include non-GPA sections in completion totals + const nonGPASections = results.sections.filter( + (section) => !section.title.toLowerCase().includes("gpa"), + ); + results.total.current = nonGPASections.reduce( (acc, section) => acc + section.progress.current, 0, ); - results.total.total = results.sections.reduce( + results.total.total = nonGPASections.reduce( (acc, section) => acc + section.progress.total, 0, ); diff --git a/lib/backend/db-seeder.ts b/lib/backend/db-seeder.ts index 8ba64aa..277b907 100644 --- a/lib/backend/db-seeder.ts +++ b/lib/backend/db-seeder.ts @@ -1,8 +1,9 @@ import coursesData from "@/assets/ut-courses.json"; +import type { CatalogCourse } from "../general-types"; import { db } from "./db"; const STORAGE_KEY = "db_seed_version"; -const CURRENT_VERSION = "20259-v1"; // Incremented manually when json changes +const CURRENT_VERSION = "20269-v3"; // Incremented manually when json or DB storage format changes export async function seedDatabase() { // Check if we've already seeded this version @@ -14,10 +15,7 @@ export async function seedDatabase() { console.log(`[DB] Seeding database (Version: ${CURRENT_VERSION})...`); try { - const courses = (coursesData as any[]).map((c) => ({ - ...c, - id: c.id ?? crypto.randomUUID(), - })); + const courses = coursesData as CatalogCourse[]; await db.courses.clear(); await db.courses.bulkPut(courses); localStorage.setItem(STORAGE_KEY, CURRENT_VERSION); diff --git a/lib/backend/db.ts b/lib/backend/db.ts index dcb727e..f2d4761 100644 --- a/lib/backend/db.ts +++ b/lib/backend/db.ts @@ -1,12 +1,15 @@ import Dexie from "dexie"; -import { Course } from "../general-types"; +import type { CatalogCourse } from "../general-types"; + export class UTDatabase extends Dexie { - courses!: Dexie.Table<Course, number>; + courses!: Dexie.Table<CatalogCourse, number>; constructor() { super("UTCoursesDB"); - this.version(2).stores({ - courses: "uniqueId, department, number, fullName, semester.code", + // IndexedDB persists the full catalog record; this schema only defines indexes. + this.version(3).stores({ + courses: + "uniqueId, [department+number], fullName, courseName, department, number, creditHours, status, isReserved, instructionMode, *flags, *core, url, scrapedAt, semester.code", }); } } diff --git a/lib/general-types.ts b/lib/general-types.ts index 5fdde45..e9dfe8a 100644 --- a/lib/general-types.ts +++ b/lib/general-types.ts @@ -103,6 +103,57 @@ export type Course = { type: CourseCompletionMethod; }; +/** + * A course instructor entry from the UT course catalog export. + */ +export type CatalogInstructor = { + fullName: string; + firstName: string; + lastName: string; + middleInitial?: string; +}; + +/** + * A single meeting time/location entry from the UT course catalog export. + */ +export type CatalogCourseScheduleEntry = { + days: string; + hours: string; + location: string; +}; + +/** + * Semester metadata from the UT course catalog export. + */ +export type CatalogSemester = { + year: Year; + season: SemesterSeason; + code: string; +}; + +/** + * The full catalog-course JSON shape stored in assets/ut-courses.json and persisted to IndexedDB. + */ +export type CatalogCourse = { + uniqueId: number; + fullName: string; + courseName: string; + department: string; + number: string; + creditHours: number; + status: string; + isReserved: boolean; + instructionMode: string; + instructors: CatalogInstructor[]; + schedule: CatalogCourseScheduleEntry[]; + flags: string[]; + core: string[]; + url: string; + description: string[]; + semester: CatalogSemester; + scrapedAt: number; +}; + export interface DegreeAuditCardProps { title?: string; majors?: string[];