Skip to content

Commit ef0c830

Browse files
authored
Phase 7 real gpa credit cards (#187)
* 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 * Fix GPA card: correct Counted GPA, card width, dark-mode text Three fixes to the Phase 7 GPA/Credit cards: - Counted GPA was wrong: parseHours matched /\d+/ + parseInt, so the scraped "3.7766 GPA" cell truncated to 3. Parse a full decimal (/\d+(?:\.\d+)?/ + parseFloat) so GPA cells keep their fractional part. Hours/course columns are always integers, so this is a no-op for them — the only snapshot changes are the two GPA rows (3 -> 3.7766, 3 -> 3.6091). - Card width collapsed: the side-panel VStack defaults to items-start, so the card shrank to its content instead of filling the w-sm column. Add w-full to both cards. - Dark mode: hardcoded text-gray-500/600/700 don't flip, so the text was unreadable on the dark card. Switch to the theme-aware text-muted token, and give the "Required" GPA box an explicit text-text (it was inheriting default black, invisible in dark mode). * GPA card: Figma rounding + diagnostic log for stored GPA value - Match the Figma design: card corners rounded-lg -> rounded-2xl, and the Required/Counted value boxes rounded-lg -> rounded-full (pill shaped). Credit card rounded to match. - Add a temporary [GPA card] console.log of required/counted/ruleText/ parsedSummary. A whole-number `counted` (e.g. 3) with "3.7766 GPA" in ruleText means the stored audit predates the parseFloat scraper fix and needs a re-scrape — the truncated decimal cannot be recovered from storage, only re-parsed at scrape time. * GPA card: show major name and match Figma sizing - Render the degree/major name (currentAuditName) in green under the "GPA Totals" title, matching the Figma mock. - Tighten to the mock's proportions: p-5 -> p-4, title text-xl -> text-base, value pills and labels scaled down, gaps reduced, info icon h-6 -> h-5. Credit card sized to match. * Fetch descriptoin
1 parent d1f0458 commit ef0c830

6 files changed

Lines changed: 85 additions & 27 deletions

File tree

domain/audit.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface RequirementRule {
99
progressUnit: RequirementProgressUnit;
1010
status: Status;
1111
courses: CourseId[];
12+
summary?: string;
1213
}
1314

1415
export interface AuditRequirement {
@@ -56,11 +57,6 @@ export interface AuditHistoryData {
5657
error?: string;
5758
}
5859

59-
/**
60-
* The display name for an audit history entry, title-first: the audit's own
61-
* title, else its majors joined, else null. Callers supply their own final
62-
* fallback (an id, "Degree Requirements", etc.).
63-
*/
6460
export function getAuditDisplayName(
6561
entry: AuditHistoryEntry | undefined,
6662
): string | null {

features/audit-scraping/audit-page-parser.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import type { RequirementProgressUnit } from "@/domain/progress";
2121
// --- Helper Functions ---
2222

2323
export function parseHours(text: string): number {
24-
const match = text.match(/\d+/);
25-
return match ? parseInt(match[0], 10) : 0;
24+
// Match a full decimal so GPA cells like "3.7766 GPA" keep their fractional
25+
// part. Hours/course columns are always integers, so this is a no-op for them.
26+
const match = text.match(/\d+(?:\.\d+)?/);
27+
return match ? parseFloat(match[0]) : 0;
2628
}
2729

2830
export function parseRequirementProgress(text: string): {
@@ -145,6 +147,21 @@ export function scrapeRequirementSections(
145147
(cells[5] as HTMLElement).innerText,
146148
);
147149

150+
// After the progress columns, GPA rules carry a summary sentence
151+
// ("X hours for a total of Y points were used to calculate the GPA.")
152+
// while course-based rules carry a course-preview cell. Capture the
153+
// former: the first trailing cell that is neither a course preview nor
154+
// the show/hide-details link.
155+
const summaryCell = Array.from(cells)
156+
.slice(6)
157+
.find(
158+
(cell) =>
159+
!cell.classList.contains("course_preview") &&
160+
!cell.querySelector("a.details"),
161+
);
162+
const summary =
163+
(summaryCell as HTMLElement | undefined)?.innerText.trim() || undefined;
164+
148165
const rule: RequirementRule = {
149166
text: (cells[2] as HTMLElement).innerText.trim(),
150167
requiredHours: requiredProgress.value,
@@ -153,6 +170,7 @@ export function scrapeRequirementSections(
153170
progressUnit: requiredProgress.unit,
154171
status: getRuleStatus(row.classList),
155172
courses: [],
173+
...(summary ? { summary } : {}),
156174
};
157175

158176
// Parse details row (courses)

features/dashboard/degree-audit-page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ import RequirementBreakdown, {
1515
} from "./requirement-breakdown";
1616

1717
const SidePanel = () => {
18-
const { sections } = useAuditContext();
18+
const { sections, currentAuditName } = useAuditContext();
1919

2020
const gpaSection = sections.find((section) => isGpaSection(section.title));
2121
const gpaRule = gpaSection?.rules[0];
22-
const gpaSummary = parseGpaSummary(gpaRule?.text);
22+
const gpaSummary = parseGpaSummary(gpaRule?.summary);
23+
24+
console.log("[GPA card]", {
25+
required: gpaRule?.requiredHours,
26+
counted: gpaRule?.appliedHours,
27+
summaryText: gpaRule?.summary,
28+
parsedSummary: gpaSummary,
29+
});
2330

2431
const creditSection = sections.find((section) =>
2532
isCreditSection(section.title),
@@ -34,6 +41,7 @@ const SidePanel = () => {
3441
<VStack gap={4} className="w-sm mt-4">
3542
{gpaRule ? (
3643
<GPATotalsCard
44+
degreeName={currentAuditName}
3745
required={gpaRule.requiredHours}
3846
counted={gpaRule.appliedHours}
3947
summary={gpaSummary}

features/dashboard/gpa-credit-cards.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const STATUS_ICON_STATE: Record<Status, FramedStatusIconState> = {
1111
};
1212

1313
type GPATotalsProps = {
14+
degreeName: string;
1415
required: number;
1516
counted: number;
1617
summary: GpaSummary | null;
@@ -20,7 +21,7 @@ const InfoIcon = () => (
2021
<svg
2122
aria-hidden="true"
2223
viewBox="0 0 24 24"
23-
className="h-6 w-6 shrink-0 text-dap-dark"
24+
className="h-5 w-5 shrink-0 text-dap-dark"
2425
fill="none"
2526
>
2627
<path
@@ -69,36 +70,43 @@ const FramedStatusIcon = ({ state }: { state: FramedStatusIconState }) => (
6970
);
7071

7172
export const GPATotalsCard = ({
73+
degreeName,
7274
required,
7375
counted,
7476
summary,
7577
}: GPATotalsProps) => {
7678
return (
77-
<div className="p-5 rounded-lg border border-gray-200 bg-background shadow-md">
79+
<div className="w-full p-4 rounded-2xl border border-gray-200 bg-background shadow-md">
7880
<HStack x="between" y="top" fill>
79-
<h3 className="text-xl font-bold text-text">GPA Totals</h3>
81+
<h3 className="text-base font-bold text-text">GPA Totals</h3>
8082
<InfoIcon />
8183
</HStack>
8284

83-
<HStack gap={6} className="mt-4">
85+
<p className="mt-1 text-sm font-semibold text-dap-plan-green">
86+
{degreeName}
87+
</p>
88+
89+
<HStack gap={4} className="mt-3">
8490
<VStack gap={1}>
85-
<span className="text-sm text-gray-500">Required</span>
86-
<div className="px-4 py-2 bg-background border border-gray-300 rounded-lg">
87-
<span className="text-lg font-semibold">{required.toFixed(4)}</span>
91+
<span className="text-xs text-muted">Required</span>
92+
<div className="px-4 py-1.5 bg-background border border-gray-300 rounded-full">
93+
<span className="text-base font-semibold text-text">
94+
{required.toFixed(4)}
95+
</span>
8896
</div>
8997
</VStack>
9098
<VStack gap={1}>
91-
<span className="text-sm text-gray-500">Counted</span>
92-
<div className="px-4 py-2 bg-dap-green rounded-lg">
93-
<span className="text-lg font-semibold text-white">
99+
<span className="text-xs text-muted">Counted</span>
100+
<div className="px-4 py-1.5 bg-dap-green rounded-full">
101+
<span className="text-base font-semibold text-white">
94102
{counted.toFixed(4)}
95103
</span>
96104
</div>
97105
</VStack>
98106
</HStack>
99107

100108
{summary ? (
101-
<p className="mt-4 text-sm text-gray-600">
109+
<p className="mt-3 text-xs text-muted">
102110
{summary.hoursUsed} hours for a total of {summary.points} points were
103111
used to calculate the GPA.
104112
</p>
@@ -120,14 +128,14 @@ export const CreditHourTotalsCard = ({
120128
requirements,
121129
}: CreditHourTotalsProps) => {
122130
return (
123-
<div className="p-5 rounded-lg border border-gray-200 bg-background shadow-md">
124-
<h3 className="text-xl font-bold text-text">Credit Hour Totals</h3>
131+
<div className="w-full p-4 rounded-2xl border border-gray-200 bg-background shadow-md">
132+
<h3 className="text-base font-bold text-text">Credit Hour Totals</h3>
125133

126-
<VStack gap={3} className="mt-4">
134+
<VStack gap={2.5} className="mt-3">
127135
{requirements.map((req) => (
128-
<HStack key={req.text} gap={3} y="middle">
136+
<HStack key={req.text} gap={2.5} y="middle">
129137
<FramedStatusIcon state={STATUS_ICON_STATE[req.status]} />
130-
<span className="text-sm text-gray-700">{req.text}</span>
138+
<span className="text-xs text-muted">{req.text}</span>
131139
</HStack>
132140
))}
133141
</VStack>

tests/scraping/__snapshots__/audit-scraper.test.ts.snap

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ OR
654654
{
655655
"rules": [
656656
{
657-
"appliedHours": 3,
657+
"appliedHours": 3.7766,
658658
"courses": [
659659
"course-1",
660660
"course-2",
@@ -674,10 +674,11 @@ OR
674674
"remainingHours": 0,
675675
"requiredHours": 2,
676676
"status": "Completed",
677+
"summary": "42 hours for a total of 158.62 points were used to calculate the GPA.",
677678
"text": "A University GPA of 2.00 is required on all courses undertaken (including credit by examination, correspondence, and extension) for which a grade or symbol other than Q, W, X, or CR is recorded.",
678679
},
679680
{
680-
"appliedHours": 3,
681+
"appliedHours": 3.6091,
681682
"courses": [
682683
"course-1",
683684
"course-2",
@@ -693,6 +694,7 @@ OR
693694
"remainingHours": 0,
694695
"requiredHours": 2,
695696
"status": "Completed",
697+
"summary": "24 hours for a total of 86.62 points were used to calculate the GPA.",
696698
"text": "Students must earn a grade point average of 2.0 in all mathematics and science courses required by the degree.",
697699
},
698700
],

tests/scraping/audit-scraper.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,30 @@ describe("degree audit scraper", () => {
7171
log.mockRestore();
7272
}
7373
});
74+
75+
test("captures the GPA summary sentence, not course-rule previews", async () => {
76+
const document = await loadAuditDocument("audit-results-real.html");
77+
const log = spyOn(console, "log").mockImplementation(() => {});
78+
79+
try {
80+
const { requirements } = parseAuditPage(document);
81+
const rules = requirements.flatMap((requirement) => requirement.rules);
82+
83+
const summaries = rules
84+
.map((rule) => rule.summary)
85+
.filter((summary): summary is string => summary !== undefined);
86+
87+
// The GPA rules carry their calculation sentence...
88+
expect(summaries.length).toBeGreaterThan(0);
89+
// ...and every captured summary is that sentence — never a course preview
90+
// or other trailing-cell content.
91+
for (const summary of summaries) {
92+
expect(summary).toMatch(
93+
/^\d+(?:\.\d+)? hours for a total of \d+(?:\.\d+)? points/,
94+
);
95+
}
96+
} finally {
97+
log.mockRestore();
98+
}
99+
});
74100
});

0 commit comments

Comments
 (0)