Skip to content

Commit 98e2bf3

Browse files
authored
Merge branch 'main' into fix/issue-97-sticky-sidepanels
2 parents 37fb3db + b931e5e commit 98e2bf3

7 files changed

Lines changed: 209 additions & 35 deletions

File tree

entrypoints/degree-audit/components/degree-audit-page.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,46 @@ const SidePanel = () => {
1111
const gpaSection = sections.find((section) =>
1212
section.title.toLowerCase().includes("gpa"),
1313
);
14-
const gpaRule = gpaSection?.rule?.[0];
14+
const gpaRule = gpaSection?.rule[0];
1515

16+
if (!gpaRule) {
17+
return null;
18+
}
19+
20+
return (
21+
<div className="w-sm rounded-lg border border-gray-200 bg-white p-5 shadow-md">
22+
<div className="flex items-start justify-between gap-4">
23+
<h3 className="text-xl font-bold text-gray-900">GPA Totals</h3>
24+
<div className="rounded-lg bg-[#4A7C59] px-4 py-2 text-lg font-semibold text-white">
25+
{gpaRule.appliedHours.toFixed(4)}
26+
</div>
27+
</div>
28+
29+
<div className="mt-4 flex gap-6">
30+
<div className="flex flex-col gap-1">
31+
<span className="text-sm text-gray-500">Required</span>
32+
<div className="rounded-lg border border-gray-300 px-4 py-2">
33+
<span className="text-lg font-semibold">
34+
{gpaRule.requiredHours.toFixed(4)}
35+
</span>
36+
</div>
37+
</div>
38+
<div className="flex flex-col gap-1">
39+
<span className="text-sm text-gray-500">Remaining</span>
40+
<div className="rounded-lg border border-gray-300 px-4 py-2">
41+
<span className="text-lg font-semibold">
42+
{Math.max(gpaRule.remainingHours, 0).toFixed(4)}
43+
</span>
44+
</div>
45+
</div>
46+
</div>
47+
48+
<p className="mt-4 text-sm text-gray-600">{gpaRule.text}</p>
49+
</div>
50+
);
51+
};
52+
53+
const SidePanel = () => {
1654
return (
1755
<VStack
1856
fill

entrypoints/degree-audit/components/requirement-breakdown.tsx

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,64 @@ import { cn } from "@/lib/utils";
1111
import {
1212
CaretDownIcon,
1313
CaretUpIcon,
14-
CheckSquare,
15-
MinusSquare,
1614
PlusCircleIcon,
17-
XSquare,
1815
} from "@phosphor-icons/react";
1916
import { CalendarBlankIcon } from "@phosphor-icons/react/dist/ssr";
20-
import { CheckIcon } from "lucide-react";
17+
import { CheckIcon, MinusIcon, XIcon } from "lucide-react";
2118
import { useState } from "react";
2219
import { useAuditContext } from "../providers/audit-provider";
2320
import { useCourseModalContext } from "../providers/course-modal-provider";
2421

25-
// Status icon component for requirements
26-
const StatusIcon = ({ status }: { status: Status }) => {
27-
if (status === "Completed") {
28-
return <CheckSquare weight="fill" className="w-6 h-6 text-green-600" />;
22+
type RequirementCompletionState = "completed" | "not-started" | "in-progress";
23+
24+
const getRequirementCompletionState = (
25+
current: number,
26+
total: number,
27+
): RequirementCompletionState => {
28+
if (total > 0 && current >= total) {
29+
return "completed";
2930
}
30-
if (status === "Not Started") {
31-
return <XSquare weight="fill" className="w-6 h-6 text-gray-700" />;
31+
if (current <= 0) {
32+
return "not-started";
3233
}
33-
return <MinusSquare weight="fill" className="w-6 h-6 text-gray-400" />;
34+
return "in-progress";
35+
};
36+
37+
const requirementStatusStyles = {
38+
completed: {
39+
icon: CheckIcon,
40+
className: "bg-[#67B44A] text-white",
41+
},
42+
"not-started": {
43+
icon: XIcon,
44+
className: "bg-[#425466] text-white",
45+
},
46+
"in-progress": {
47+
icon: MinusIcon,
48+
className: "bg-[#B7C6D1] text-white",
49+
},
50+
} as const;
51+
52+
const StatusIcon = ({
53+
current,
54+
total,
55+
}: {
56+
current: number;
57+
total: number;
58+
}) => {
59+
const state = getRequirementCompletionState(current, total);
60+
const { icon: Icon, className } = requirementStatusStyles[state];
61+
62+
return (
63+
<div
64+
className={cn(
65+
"flex h-10 w-10 items-center justify-center rounded-md shadow-sm",
66+
className,
67+
)}
68+
>
69+
<Icon className="h-6 w-6" strokeWidth={3} />
70+
</div>
71+
);
3472
};
3573

3674
// Hours badge component
@@ -128,7 +166,10 @@ const RequirementRow = ({ requirement }: { requirement: RequirementRule }) => {
128166
className="w-full py-3 px-2 flex items-start gap-3 hover:bg-gray-50 transition-colors"
129167
onClick={() => setIsExpanded(!isExpanded)}
130168
>
131-
<StatusIcon status={requirement.status} />
169+
<StatusIcon
170+
current={requirement.appliedHours}
171+
total={requirement.requiredHours}
172+
/>
132173
<VStack gap={0} className="flex-1 text-left">
133174
<span className="font-bold text-base">{code}</span>
134175
<span className="text-sm text-gray-500">{description}</span>
@@ -282,4 +323,4 @@ const RequirementBreakdown = (props: RequirementBreakdownProps) => {
282323
);
283324
};
284325

285-
export default RequirementBreakdown;
326+
export default RequirementBreakdown;

entrypoints/degree-audit/providers/audit-provider.tsx

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ import {
77
Course,
88
CourseId,
99
CurrentAuditProgress,
10+
RequirementRule,
1011
StringSemester,
1112
} from "@/lib/general-types";
1213
import { createContext, useContext, useEffect, useMemo, useState } from "react";
1314
import LoadingPage from "../components/loading-page";
1415

1516
// Context for sharing audit data betw sidebar and main
1617
type SemesterInfo = Record<StringSemester, Course[]>;
18+
type RequirementRuleLike = Omit<RequirementRule, "courses"> & {
19+
courses?: Array<CourseId | Course>;
20+
};
21+
type AuditRequirementLike = Omit<AuditRequirement, "rule"> & {
22+
rule?: RequirementRuleLike[];
23+
rules?: RequirementRuleLike[];
24+
};
1725

1826
interface AuditContextType {
1927
sections: AuditRequirement[];
@@ -35,6 +43,44 @@ interface AuditContextType {
3543

3644
const AuditContext = createContext<AuditContextType | null>(null);
3745

46+
function normalizeCourseDict(
47+
courses: Record<CourseId, Course> | Course[] | null | undefined,
48+
): Record<CourseId, Course> {
49+
if (!courses) {
50+
return {};
51+
}
52+
53+
if (Array.isArray(courses)) {
54+
return courses.reduce(
55+
(acc, course) => ({
56+
...acc,
57+
[course.id]: course,
58+
}),
59+
{} as Record<CourseId, Course>,
60+
);
61+
}
62+
63+
return courses;
64+
}
65+
66+
function normalizeRequirements(
67+
requirements: AuditRequirementLike[],
68+
): AuditRequirement[] {
69+
return requirements.map((section) => ({
70+
...section,
71+
rule: (section.rule ?? section.rules ?? []).map((rule) => ({
72+
...rule,
73+
courses: (rule.courses ?? [])
74+
.map((courseRef) =>
75+
typeof courseRef === "object" && courseRef !== null
76+
? courseRef.id
77+
: courseRef,
78+
)
79+
.filter(Boolean) as CourseId[],
80+
})),
81+
}));
82+
}
83+
3884
export const AuditContextProvider = ({
3985
children,
4086
}: {
@@ -106,17 +152,10 @@ export const AuditContextProvider = ({
106152
// Load requirements from cache
107153
const cached = await getAuditData(currentAuditId!);
108154
if (cached) {
109-
setSections(
110-
cached.requirements.map((section) => ({
111-
...section,
112-
rules: section.rule.map((rule) => ({
113-
...rule,
114-
courses: rule.courses,
115-
})),
116-
})),
117-
);
155+
const normalizedCourses = normalizeCourseDict(cached.courses);
156+
setSections(normalizeRequirements(cached.requirements));
118157
console.log("[Main] courses", cached.courses);
119-
setCourseDict(cached.courses);
158+
setCourseDict(normalizedCourses);
120159
} else console.warn(`[Main] Audit ${currentAuditId} not in cache.`);
121160

122161
setLoaded(true);

lib/audit-calculations.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ export function calculateWeightedDegreeCompletion(
2424

2525
results.sections.push(sectionProgress);
2626
});
27-
results.total.current = results.sections.reduce(
27+
// Only include non-GPA sections in completion totals
28+
const nonGPASections = results.sections.filter(
29+
(section) => !section.title.toLowerCase().includes("gpa"),
30+
);
31+
results.total.current = nonGPASections.reduce(
2832
(acc, section) => acc + section.progress.current,
2933
0,
3034
);
31-
results.total.total = results.sections.reduce(
35+
results.total.total = nonGPASections.reduce(
3236
(acc, section) => acc + section.progress.total,
3337
0,
3438
);

lib/backend/db-seeder.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import coursesData from "@/assets/ut-courses.json";
2+
import type { CatalogCourse } from "../general-types";
23
import { db } from "./db";
34

45
const STORAGE_KEY = "db_seed_version";
5-
const CURRENT_VERSION = "20259-v1"; // Incremented manually when json changes
6+
const CURRENT_VERSION = "20269-v3"; // Incremented manually when json or DB storage format changes
67

78
export async function seedDatabase() {
89
// Check if we've already seeded this version
@@ -14,10 +15,7 @@ export async function seedDatabase() {
1415
console.log(`[DB] Seeding database (Version: ${CURRENT_VERSION})...`);
1516

1617
try {
17-
const courses = (coursesData as any[]).map((c) => ({
18-
...c,
19-
id: c.id ?? crypto.randomUUID(),
20-
}));
18+
const courses = coursesData as CatalogCourse[];
2119
await db.courses.clear();
2220
await db.courses.bulkPut(courses);
2321
localStorage.setItem(STORAGE_KEY, CURRENT_VERSION);

lib/backend/db.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import Dexie from "dexie";
2-
import { Course } from "../general-types";
2+
import type { CatalogCourse } from "../general-types";
3+
34
export class UTDatabase extends Dexie {
4-
courses!: Dexie.Table<Course, number>;
5+
courses!: Dexie.Table<CatalogCourse, number>;
56

67
constructor() {
78
super("UTCoursesDB");
8-
this.version(2).stores({
9-
courses: "uniqueId, department, number, fullName, semester.code",
9+
// IndexedDB persists the full catalog record; this schema only defines indexes.
10+
this.version(3).stores({
11+
courses:
12+
"uniqueId, [department+number], fullName, courseName, department, number, creditHours, status, isReserved, instructionMode, *flags, *core, url, scrapedAt, semester.code",
1013
});
1114
}
1215
}

lib/general-types.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,57 @@ export type Course = {
103103
type: CourseCompletionMethod;
104104
};
105105

106+
/**
107+
* A course instructor entry from the UT course catalog export.
108+
*/
109+
export type CatalogInstructor = {
110+
fullName: string;
111+
firstName: string;
112+
lastName: string;
113+
middleInitial?: string;
114+
};
115+
116+
/**
117+
* A single meeting time/location entry from the UT course catalog export.
118+
*/
119+
export type CatalogCourseScheduleEntry = {
120+
days: string;
121+
hours: string;
122+
location: string;
123+
};
124+
125+
/**
126+
* Semester metadata from the UT course catalog export.
127+
*/
128+
export type CatalogSemester = {
129+
year: Year;
130+
season: SemesterSeason;
131+
code: string;
132+
};
133+
134+
/**
135+
* The full catalog-course JSON shape stored in assets/ut-courses.json and persisted to IndexedDB.
136+
*/
137+
export type CatalogCourse = {
138+
uniqueId: number;
139+
fullName: string;
140+
courseName: string;
141+
department: string;
142+
number: string;
143+
creditHours: number;
144+
status: string;
145+
isReserved: boolean;
146+
instructionMode: string;
147+
instructors: CatalogInstructor[];
148+
schedule: CatalogCourseScheduleEntry[];
149+
flags: string[];
150+
core: string[];
151+
url: string;
152+
description: string[];
153+
semester: CatalogSemester;
154+
scrapedAt: number;
155+
};
156+
106157
export interface DegreeAuditCardProps {
107158
title?: string;
108159
majors?: string[];

0 commit comments

Comments
 (0)