Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 88 additions & 13 deletions entrypoints/degree-audit/components/degree-audit-page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-sm rounded-lg border border-gray-200 bg-white p-5 shadow-md">
<div className="flex items-start justify-between gap-4">
<h3 className="text-xl font-bold text-gray-900">GPA Totals</h3>
<div className="rounded-lg bg-[#4A7C59] px-4 py-2 text-lg font-semibold text-white">
{gpaRule.appliedHours.toFixed(4)}
</div>
</div>

<div className="mt-4 flex gap-6">
<div className="flex flex-col gap-1">
<span className="text-sm text-gray-500">Required</span>
<div className="rounded-lg border border-gray-300 px-4 py-2">
<span className="text-lg font-semibold">
{gpaRule.requiredHours.toFixed(4)}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-gray-500">Remaining</span>
<div className="rounded-lg border border-gray-300 px-4 py-2">
<span className="text-lg font-semibold">
{Math.max(gpaRule.remainingHours, 0).toFixed(4)}
</span>
</div>
</div>
</div>

<p className="mt-4 text-sm text-gray-600">{gpaRule.text}</p>
</div>
);
};

const SidePanel = () => {
return (
<VStack
fill
className="h-full sticky top-[75px] z-20 bg-white"
y="stretch"
x="center"
>
<SimpleDegreeCompletionDonut size={300} />
<div className="mt-10">
<GpaSummaryCard />
</div>
</VStack>
);
};

const MainContent = () => {
const { progresses, sections } = useAuditContext();
const checklistSections = sections.filter(
(section) => !section.title.toLowerCase().includes("gpa"),
);

return (
<>
<VStack fill className="w-full">
<Title text="Degree Progress Overview" />
<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>
);
};

Expand Down
67 changes: 54 additions & 13 deletions entrypoints/degree-audit/components/requirement-breakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -282,4 +323,4 @@ const RequirementBreakdown = (props: RequirementBreakdownProps) => {
);
};

export default RequirementBreakdown;
export default RequirementBreakdown;
Original file line number Diff line number Diff line change
Expand Up @@ -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 />
Expand Down
59 changes: 49 additions & 10 deletions entrypoints/degree-audit/providers/audit-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import {
Course,
CourseId,
CurrentAuditProgress,
RequirementRule,
StringSemester,
} from "@/lib/general-types";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
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[];
Expand All @@ -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,
}: {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions lib/audit-calculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
8 changes: 3 additions & 5 deletions lib/backend/db-seeder.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions lib/backend/db.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
}
Expand Down
Loading
Loading