Skip to content

Commit d1f0458

Browse files
authored
Phase 7: Real data for GPA / Credit-Hour cards (#186)
* Dashboard: wire GPA/Credit-Hour cards to real scraped audit data The scraper already stores the GPA Totals and Credit Hour Totals sections; only the last render step was faked (cleanup plan Phase 7). - Move parseGpaSummary out of degree-audit-page.tsx into audit-calculations.ts as pure business logic, and unit-test it — including the real snapshot's GPA sentence, which carries no "hours … points" and so returns null. - Credit card: derive from the scraped Credit Hour Totals section (isCreditSection), mapping each rule to { status, text } and rendering only when non-empty. Render the scraped sentence directly instead of the fragile "{hours} of {description}" template. CreditRequirement is now { status: Status; text: string }; both hardcoded 21/36 arrays are gone. Status passes through all three values so in-progress requirements display honestly (green check / blue dash / x). - GPA card: drop the fabricated default props; footer prop is now summary: GpaSummary | null and the sentence renders only when non-null (no more "0 hours … 0 points" on real audits). - Delete the local RequirementStatusIcon met-boolean wrapper (it name-collided with a different component in requirement-breakdown) and call FramedStatusIcon directly via a Status -> state map. * remove test
1 parent c99df73 commit d1f0458

3 files changed

Lines changed: 67 additions & 72 deletions

File tree

features/audit/audit-calculations.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ export const isCreditSection = (title: string): boolean =>
1717
export const isGpaSection = (title: string): boolean =>
1818
title.toLowerCase().includes("gpa");
1919

20+
export interface GpaSummary {
21+
hoursUsed: number;
22+
points: number;
23+
}
24+
25+
/**
26+
* Extract the "X hours … Y points" figures from a GPA rule's sentence, if
27+
* present. Real scraped GPA text often does not carry these numbers (it states
28+
* the required average instead), in which case this returns null and the UI
29+
* omits the footer rather than fabricating zeros.
30+
*/
31+
export function parseGpaSummary(text: string | undefined): GpaSummary | null {
32+
if (!text) return null;
33+
34+
const match = text.match(/(\d+(?:\.\d+)?)\s+hours.*?(\d+(?:\.\d+)?)\s+points/i);
35+
if (!match) return null;
36+
37+
return {
38+
hoursUsed: Number(match[1]),
39+
points: Number(match[2]),
40+
};
41+
}
42+
2043
// Give unnamed audits a readable fallback so the UI never shows a blank source.
2144
function getAuditName(
2245
audit: CompositeAuditData["audits"][number],

features/dashboard/degree-audit-page.tsx

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,46 @@ import { HStack, VStack } from "@/components/ui/stack";
22
import Title from "@/components/ui/text";
33
import { CourseSearchPanel } from "@/features/course-search/course-search-panel";
44
import { useAuditContext } from "@/features/audit/audit-provider";
5-
import { isGpaSection } from "@/features/audit/audit-calculations";
5+
import {
6+
isCreditSection,
7+
isGpaSection,
8+
parseGpaSummary,
9+
} from "@/features/audit/audit-calculations";
610
import { groupAuditSections } from "./section-groups";
711
import DegreeSidePanel from "./degree-side-panel";
812
import { CreditHourTotalsCard, GPATotalsCard } from "./gpa-credit-cards";
913
import RequirementBreakdown, {
1014
UnifiedDegreeCard,
1115
} from "./requirement-breakdown";
1216

13-
function parseGpaSummary(text: string | undefined) {
14-
if (!text) {
15-
return null;
16-
}
17-
18-
const match = text.match(
19-
/(\d+(?:\.\d+)?)\s+hours.*?(\d+(?:\.\d+)?)\s+points/i,
20-
);
21-
if (!match) {
22-
return null;
23-
}
24-
25-
return {
26-
hoursUsed: Number(match[1]),
27-
points: Number(match[2]),
28-
};
29-
}
30-
3117
const SidePanel = () => {
3218
const { sections } = useAuditContext();
19+
3320
const gpaSection = sections.find((section) => isGpaSection(section.title));
3421
const gpaRule = gpaSection?.rules[0];
3522
const gpaSummary = parseGpaSummary(gpaRule?.text);
3623

24+
const creditSection = sections.find((section) =>
25+
isCreditSection(section.title),
26+
);
27+
const creditRequirements = (creditSection?.rules ?? []).map((rule) => ({
28+
status: rule.status,
29+
text: rule.text,
30+
}));
31+
3732
return (
3833
<DegreeSidePanel searchPanel={<CourseSearchPanel />}>
3934
<VStack gap={4} className="w-sm mt-4">
4035
{gpaRule ? (
4136
<GPATotalsCard
4237
required={gpaRule.requiredHours}
4338
counted={gpaRule.appliedHours}
44-
hoursUsed={gpaSummary?.hoursUsed ?? 0}
45-
points={gpaSummary?.points ?? 0}
39+
summary={gpaSummary}
4640
/>
4741
) : null}
48-
<CreditHourTotalsCard
49-
requirements={[
50-
{
51-
met: true,
52-
hours: 21,
53-
description: "upper-division coursework in residence.",
54-
},
55-
{
56-
met: false,
57-
hours: 36,
58-
description: "upper-division coursework required.",
59-
},
60-
]}
61-
/>
42+
{creditRequirements.length > 0 ? (
43+
<CreditHourTotalsCard requirements={creditRequirements} />
44+
) : null}
6245
</VStack>
6346
</DegreeSidePanel>
6447
);

features/dashboard/gpa-credit-cards.tsx

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { HStack, VStack } from "@/components/ui/stack";
2+
import type { GpaSummary } from "@/features/audit/audit-calculations";
3+
import type { Status } from "@/domain/course";
24

35
type FramedStatusIconState = "completed" | "not-started" | "in-progress";
46

7+
const STATUS_ICON_STATE: Record<Status, FramedStatusIconState> = {
8+
Completed: "completed",
9+
"In Progress": "in-progress",
10+
"Not Started": "not-started",
11+
};
12+
513
type GPATotalsProps = {
614
required: number;
715
counted: number;
8-
hoursUsed: number;
9-
points: number;
16+
summary: GpaSummary | null;
1017
};
1118

1219
const InfoIcon = () => (
@@ -61,15 +68,10 @@ const FramedStatusIcon = ({ state }: { state: FramedStatusIconState }) => (
6168
</svg>
6269
);
6370

64-
const RequirementStatusIcon = ({ met }: { met: boolean }) => (
65-
<FramedStatusIcon state={met ? "completed" : "not-started"} />
66-
);
67-
6871
export const GPATotalsCard = ({
69-
required = 2.0,
70-
counted = 4.0,
71-
hoursUsed = 80,
72-
points = 320,
72+
required,
73+
counted,
74+
summary,
7375
}: GPATotalsProps) => {
7476
return (
7577
<div className="p-5 rounded-lg border border-gray-200 bg-background shadow-md">
@@ -95,50 +97,37 @@ export const GPATotalsCard = ({
9597
</VStack>
9698
</HStack>
9799

98-
<p className="mt-4 text-sm text-gray-600">
99-
{hoursUsed} hours for a total of {points} points were used to calculate
100-
the GPA.
101-
</p>
100+
{summary ? (
101+
<p className="mt-4 text-sm text-gray-600">
102+
{summary.hoursUsed} hours for a total of {summary.points} points were
103+
used to calculate the GPA.
104+
</p>
105+
) : null}
102106
</div>
103107
);
104108
};
105109

106-
type CreditRequirement = {
107-
met: boolean;
108-
hours: number;
109-
description: string;
110+
export type CreditRequirement = {
111+
status: Status;
112+
text: string;
110113
};
111114

112115
type CreditHourTotalsProps = {
113116
requirements: CreditRequirement[];
114117
};
115118

116119
export const CreditHourTotalsCard = ({
117-
requirements = [
118-
{
119-
met: true,
120-
hours: 21,
121-
description: "hours of upper-division coursework in residence.",
122-
},
123-
{
124-
met: false,
125-
hours: 36,
126-
description: "hours of upper-division coursework required.",
127-
},
128-
],
120+
requirements,
129121
}: CreditHourTotalsProps) => {
130122
return (
131123
<div className="p-5 rounded-lg border border-gray-200 bg-background shadow-md">
132124
<h3 className="text-xl font-bold text-text">Credit Hour Totals</h3>
133125

134126
<VStack gap={3} className="mt-4">
135-
{requirements.map((req, idx) => (
136-
<HStack key={idx} gap={3} y="middle">
137-
<RequirementStatusIcon met={req.met} />
138-
<span className="text-sm text-gray-700">
139-
<span className="font-semibold">{req.hours} hours</span> of{" "}
140-
{req.description}
141-
</span>
127+
{requirements.map((req) => (
128+
<HStack key={req.text} gap={3} y="middle">
129+
<FramedStatusIcon state={STATUS_ICON_STATE[req.status]} />
130+
<span className="text-sm text-gray-700">{req.text}</span>
142131
</HStack>
143132
))}
144133
</VStack>

0 commit comments

Comments
 (0)