From a0054b050436f5a40258d7ee244faf5156d9c801 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sat, 26 Sep 2026 15:41:48 +1000 Subject: [PATCH 1/3] feat: read GPA from grade codes and price tuition from domestic fees Grade point average skipped every result carrying a grade code other than PS, NCN or WN, so recorded HD to N results never counted. A mark still decides the points; the code covers results without one. Plan courses now carry their latest published domestic fee, taken as listed. The tuition estimate sums those fees across the plan, splits them by study year and counts the priced courses per year. Academic terms also report their GPA and each marked course, for the per-semester charts. --- apps/web/lib/academic/metrics.ts | 24 +- apps/web/lib/coursemap/academic-metrics.ts | 103 ++-- apps/web/lib/coursemap/plan-catalogue.ts | 11 + apps/web/lib/coursemap/types.ts | 2 + apps/web/tests/academic-metrics.test.mjs | 118 +++-- .../dashboard/university-metrics-preview.tsx | 444 ------------------ 6 files changed, 124 insertions(+), 578 deletions(-) delete mode 100644 apps/web/ui/dashboard/university-metrics-preview.tsx diff --git a/apps/web/lib/academic/metrics.ts b/apps/web/lib/academic/metrics.ts index 3777d999..bb6ed1a7 100644 --- a/apps/web/lib/academic/metrics.ts +++ b/apps/web/lib/academic/metrics.ts @@ -52,14 +52,30 @@ export function weightedAverageMark(results: readonly MarkedResult[]) { : null; } +/** Grade points for result codes that carry them without a mark. */ +const CODE_POINTS: Record = { + HD: 7, + D: 6, + CR: 5, + P: 4, + PS: 4, + N: 0, + NCN: 0, + WN: 0, +}; + // ANU: https://www.anu.edu.au/students/program-administration/assessments-exams/grade-point-average-gpa export function gradePointAverage(results: readonly MarkedResult[]) { const included = results.flatMap((result) => { let points: number; - if (result.resultCode === "PS") points = 4; - else if (result.resultCode === "NCN" || result.resultCode === "WN") - points = 0; - else if (result.resultCode || result.mark === undefined) return []; + const code = result.resultCode?.toUpperCase(); + // A recorded mark decides the grade; the code covers results without one. + if (result.mark === undefined && code && code in CODE_POINTS) + points = CODE_POINTS[code]; + else if (code === "PS") points = 4; + else if (code === "NCN" || code === "WN") points = 0; + else if ((code && !(code in CODE_POINTS)) || result.mark === undefined) + return []; else points = result.mark >= 80 diff --git a/apps/web/lib/coursemap/academic-metrics.ts b/apps/web/lib/coursemap/academic-metrics.ts index e1772427..5a9d974c 100644 --- a/apps/web/lib/coursemap/academic-metrics.ts +++ b/apps/web/lib/coursemap/academic-metrics.ts @@ -8,7 +8,6 @@ import { type MarkedResult, } from "@/lib/academic/metrics"; import { termLabel } from "@/lib/coursemap/dashboard-series"; -import type { CourseFee } from "@/lib/coursemap/course-types"; import type { Attempt, Course, Term } from "@/lib/coursemap/types"; import { isActiveAttempt, @@ -66,8 +65,12 @@ export type AcademicTermPoint = { label: string; year: number; wam: number; + /** Null when no result in the period carries grade points. */ + gpa: number | null; units: number; courses: number; + /** Each marked course in the period, in course code order. */ + marks: { code: string; mark: number }[]; }; /** One point per teaching period that has at least one mark, oldest first. */ @@ -87,8 +90,16 @@ export function academicTermPoints( label: termLabel(term), year: term.year, wam, + gpa: gradePointAverage(inTerm), units: inTerm.reduce((sum, row) => sum + row.units, 0), courses: inTerm.length, + marks: inTerm + .flatMap((row) => + row.mark === undefined + ? [] + : [{ code: row.course.code, mark: row.mark }], + ) + .sort((a, b) => a.code.localeCompare(b.code)), }, ]; }); @@ -149,73 +160,25 @@ export function gradeDistribution(inputs: AcademicInputs): GradeTally[] { /* Tuition */ /* ------------------------------------------------------------------ */ -/** 48 units is one full-time equivalent year at ANU. */ -const UNITS_PER_EFTSL = 48; - -/** Preferred fee types, most specific first. */ -const FEE_TYPE_ORDER = ["student_contribution", "tuition", "indicative"]; - -/** - * Published fees keyed by course code. - * - * Fees live on CourseDetails, not on the planning Course, so the caller has to - * supply them. Passing an empty lookup yields null and the caller shows an - * empty state — which is the honest result until fee data is loaded for a plan. - */ -export type CourseFeeLookup = ReadonlyMap; - export type TuitionEstimate = { total: number; - currency: string; - audience: string; - /** Courses in the plan that carry a usable published fee. */ + /** Courses in the plan that carry a published domestic fee. */ pricedCourses: number; plannedCourses: number; - /** Latest fee year contributing to the estimate. */ - feeYear: number | null; + /** Estimated fees per study year, oldest first. */ + byYear: { year: number; amount: number; courses: number }[]; }; -function feeAmountFor( - fees: readonly CourseFee[], - units: number, - audience: string, -) { - const fee = fees - .filter( - (item) => - item.amount !== null && - item.audience === audience && - FEE_TYPE_ORDER.includes(item.feeType), - ) - .sort( - (a, b) => - FEE_TYPE_ORDER.indexOf(a.feeType) - FEE_TYPE_ORDER.indexOf(b.feeType), - ) - .at(0); - if (!fee || fee.amount === null) return null; - // Annual and unknown bases cannot be attributed to a single course. - if (fee.basis === "course") return { amount: fee.amount, fee }; - if (fee.basis === "unit") return { amount: fee.amount * units, fee }; - if (fee.basis === "eftsl") - return { amount: (fee.amount * units) / UNITS_PER_EFTSL, fee }; - return null; -} - /** - * Sums published fees across every course in the plan. Returns null when no - * course has a usable fee, so the caller shows an empty state rather than a + * Sums each plan course's published domestic fee, as listed. Returns null when + * no course has one, so the caller shows an empty state rather than a * misleading zero. */ export function tuitionEstimate({ attempts, courses, snapshotCourses, - fees, - audience = "domestic", -}: Omit & { - fees: CourseFeeLookup; - audience?: string; -}): TuitionEstimate | null { +}: Omit): TuitionEstimate | null { const catalogue = { courses, snapshotCourses, terms: [] }; const planned = attempts.filter(isActiveAttempt).flatMap((attempt) => { const course = planningCourseForAttempt(attempt, catalogue); @@ -223,28 +186,28 @@ export function tuitionEstimate({ }); let total = 0; let pricedCourses = 0; - let currency = "AUD"; - let feeYear: number | null = null; + const byYear = new Map(); planned.forEach(({ attempt, course }) => { - const priced = feeAmountFor( - fees.get(course.code) ?? [], - unitsForAttempt(attempt, course), - audience, - ); - if (!priced) return; - total += priced.amount; + const amount = course.domesticFee; + if (amount === null || amount === undefined) return; + total += amount; pricedCourses += 1; - currency = priced.fee.currency ?? currency; - if (priced.fee.feeYear !== null) - feeYear = Math.max(feeYear ?? priced.fee.feeYear, priced.fee.feeYear); + const year = Number(attempt.termId.slice(0, 4)) || attempt.academicYear; + if (year) { + const entry = byYear.get(year) ?? { amount: 0, courses: 0 }; + byYear.set(year, { + amount: entry.amount + amount, + courses: entry.courses + 1, + }); + } }); if (pricedCourses === 0) return null; return { total, - currency, - audience, pricedCourses, plannedCourses: planned.length, - feeYear, + byYear: [...byYear] + .sort(([a], [b]) => a - b) + .map(([year, entry]) => ({ year, ...entry })), }; } diff --git a/apps/web/lib/coursemap/plan-catalogue.ts b/apps/web/lib/coursemap/plan-catalogue.ts index bdb51d01..3f835b90 100644 --- a/apps/web/lib/coursemap/plan-catalogue.ts +++ b/apps/web/lib/coursemap/plan-catalogue.ts @@ -174,9 +174,20 @@ export function planCourseFromDetails(course: CourseDetails): Course { ? "Review" : "Automatic", accent: course.accent, + domesticFee: domesticFee(course.fees), }; } +/** The latest domestic fee amount, taken as published without conversion. */ +function domesticFee(fees: CourseDetails["fees"]) { + return ( + fees + .filter((fee) => fee.audience === "domestic" && fee.amount !== null) + .sort((a, b) => (b.feeYear ?? 0) - (a.feeYear ?? 0)) + .at(0)?.amount ?? null + ); +} + export function buildAcademicStructureRequirementTree({ groups, conditions, diff --git a/apps/web/lib/coursemap/types.ts b/apps/web/lib/coursemap/types.ts index ca64c50f..4d9abacc 100644 --- a/apps/web/lib/coursemap/types.ts +++ b/apps/web/lib/coursemap/types.ts @@ -29,6 +29,8 @@ export type Course = { lastChanged: string; parseState: "Verified" | "Automatic" | "Review"; accent: Accent; + /** The published domestic fee for the course, as listed, when there is one. */ + domesticFee?: number | null; }; export type Degree = { diff --git a/apps/web/tests/academic-metrics.test.mjs b/apps/web/tests/academic-metrics.test.mjs index 91b8c5db..0918d047 100644 --- a/apps/web/tests/academic-metrics.test.mjs +++ b/apps/web/tests/academic-metrics.test.mjs @@ -34,9 +34,9 @@ const terms = [ ]; const courses = [ - { code: "COMP1100", year: 2025, units: 6 }, - { code: "MATH1005", year: 2025, units: 6 }, - { code: "COMP2100", year: 2025, units: 12 }, + { code: "COMP1100", year: 2025, units: 6, domesticFee: 1110 }, + { code: "MATH1005", year: 2025, units: 6, domesticFee: null }, + { code: "COMP2100", year: 2025, units: 12, domesticFee: 2400 }, ]; const attempt = (overrides) => ({ @@ -128,75 +128,73 @@ test("returns every grade band so an empty band still holds its place", () => { ); }); -test("prices per-unit fees by the units actually attempted", () => { - const estimate = tuitionEstimate({ +test("lists each semester's marked courses and its grade point average", () => { + const [point] = academicTermPoints({ courses, + terms, attempts: [ - attempt({ courseCode: "COMP2100", termId: "2025-s1", status: "planned" }), + attempt({ courseCode: "MATH1005", termId: "2025-s1", mark: 85 }), + attempt({ courseCode: "COMP1100", termId: "2025-s1", mark: 65 }), ], - fees: new Map([ - [ - "COMP2100", - [ - { - amount: 100, - audience: "domestic", - basis: "unit", - currency: "AUD", - feeType: "student_contribution", - feeYear: 2025, - }, - ], - ], - ]), }); - assert.equal(estimate.total, 1200); - assert.equal(estimate.pricedCourses, 1); - assert.equal(estimate.feeYear, 2025); + assert.deepEqual(point.marks, [ + { code: "COMP1100", mark: 65 }, + { code: "MATH1005", mark: 85 }, + ]); + // A credit (5) and a high distinction (7) over equal units. + assert.equal(point.gpa, 6); }); -test("returns null rather than a zero estimate when no fee is published", () => { - const estimate = tuitionEstimate({ +test("reads grade points from a grade code when no mark is recorded", () => { + const { gpa } = academicSummary({ courses, + terms, attempts: [ - attempt({ courseCode: "COMP1100", termId: "2025-s1", status: "planned" }), + attempt({ courseCode: "COMP1100", termId: "2025-s1", resultCode: "HD" }), + attempt({ courseCode: "MATH1005", termId: "2025-s1", resultCode: "CR" }), ], - fees: new Map(), }); - assert.equal(estimate, null); + assert.equal(gpa, 6); }); -test("ignores fees for another audience and unattributable bases", () => { - const fees = new Map([ - [ - "COMP1100", - [ - { - amount: 5000, - audience: "international", - basis: "course", - currency: "AUD", - feeType: "tuition", - feeYear: 2025, - }, - { - amount: 40000, - audience: "domestic", - basis: "annual", - currency: "AUD", - feeType: "tuition", - feeYear: 2025, - }, - ], +test("a mark decides grade points even when a grade code is stored", () => { + const { gpa } = academicSummary({ + courses, + terms, + attempts: [ + attempt({ + courseCode: "COMP1100", + termId: "2025-s1", + mark: 72, + resultCode: "D", + }), + ], + }); + assert.equal(gpa, 6); +}); + +test("sums each course's raw domestic fee by study year", () => { + const estimate = tuitionEstimate({ + courses, + attempts: [ + attempt({ courseCode: "COMP1100", termId: "2025-s1" }), + attempt({ courseCode: "COMP2100", termId: "2026-s1", status: "planned" }), + attempt({ courseCode: "MATH1005", termId: "2026-s1", status: "planned" }), ], + }); + assert.equal(estimate.total, 3510); + assert.equal(estimate.pricedCourses, 2); + assert.equal(estimate.plannedCourses, 3); + assert.deepEqual(estimate.byYear, [ + { year: 2025, amount: 1110, courses: 1 }, + { year: 2026, amount: 2400, courses: 1 }, ]); - const attempts = [ - attempt({ courseCode: "COMP1100", termId: "2025-s1", status: "planned" }), - ]; - assert.equal(tuitionEstimate({ courses, attempts, fees }), null); - assert.equal( - tuitionEstimate({ courses, attempts, fees, audience: "international" }) - .total, - 5000, - ); +}); + +test("returns null rather than a zero estimate when no fee is published", () => { + const estimate = tuitionEstimate({ + courses, + attempts: [attempt({ courseCode: "MATH1005", termId: "2025-s1" })], + }); + assert.equal(estimate, null); }); diff --git a/apps/web/ui/dashboard/university-metrics-preview.tsx b/apps/web/ui/dashboard/university-metrics-preview.tsx deleted file mode 100644 index 6dcd1022..00000000 --- a/apps/web/ui/dashboard/university-metrics-preview.tsx +++ /dev/null @@ -1,444 +0,0 @@ -"use client"; - -import { useState, type ReactNode } from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { DonutChart, TrendChart, chartColours } from "./metric-charts"; -import { MetricCardView } from "./metric-cards"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { ChartHoverCard } from "@/ui/common/chart-tooltip"; -import { Card, CardContent } from "@coursemap/ui/primitives/card"; -import { Button } from "@coursemap/ui/primitives/button"; - -// Design-only fixtures. These do not represent the signed-in student's records. -const gpa = [ - { term: "S1 '25", value: 5.8 }, - { term: "S2 '25", value: 6.1 }, - { term: "S1 '26", value: 5.9 }, - { term: "S2 '26", value: 6.4 }, -]; -const grades = [ - { grade: "N", count: 1 }, - { grade: "Pass", count: 2 }, - { grade: "CR", count: 4 }, - { grade: "D", count: 5 }, - { grade: "HD", count: 8 }, -]; -const colours = [ - "var(--color-muted-foreground)", - "#fbbf24", - "#60a5fa", - "#a78bfa", - "#34d399", -]; -const marksBySemester = [ - { - label: "S1 '26", - courses: [ - { code: "COMP2100", mark: 78 }, - { code: "COMP2300", mark: 74 }, - { code: "MATH1013", mark: 82 }, - { code: "FINM2001", mark: 70 }, - ], - }, - { - label: "S2 '26", - courses: [ - { code: "COMP3600", mark: 88 }, - { code: "FINM3008", mark: 79 }, - { code: "COMP3900", mark: 85 }, - { code: "MATH2301", mark: 76 }, - ], - }, -]; -const fees = [ - { year: "2026", amount: 10200 }, - { year: "2027", amount: 10800 }, - { year: "2028", amount: 11400 }, -]; -const axis = { - fontSize: 10, - fill: "var(--color-muted-foreground)", - fontFamily: "inherit", -}; - -function Metric({ - title, - value, - unit, - header, - children, -}: { - title: string; - value?: string; - unit?: string; - header?: ReactNode; - children: ReactNode; -}) { - return ( - - - {header ? ( -
- {header} -
- ) : ( -
- {value ? ( -

- {value} - - {unit} - -

- ) : null} -

- {title} -

-
- )} - {children} -
-
- ); -} - -export function TuitionMetric({ preview = false }: { preview?: boolean }) { - return ( - - {preview ? ( -
- {fees.map((fee, i) => ( -
- {fee.year} -
-
entry.amount))) * 100}%`, - background: ["#60a5fa", "#818cf8", "#a78bfa"][i], - }} - /> -
- - ${fee.amount.toLocaleString("en-AU")} - -
- ))} -
- ) : ( -
-

Estimate unavailable

-

- Tuition will appear when fee information is available. -

-
- )} - - ); -} - -export function UniversityMetricsPreview() { - const [semesterIndex, setSemesterIndex] = useState( - marksBySemester.length - 1, - ); - const semester = marksBySemester[semesterIndex]; - const average = Math.round( - semester.courses.reduce((sum, course) => sum + course.mark, 0) / - semester.courses.length, - ); - return ( -
- -

GPA

- - 6.4 - - / 7 - - -
- } - > -
- - - - - - - - - - - } - wrapperStyle={{ zIndex: 200 }} - cursor={{ stroke: "var(--color-border)" }} - /> - - - -
- {gpa.map((point) => ( - {point.term} - ))} -
-
- - -

Grades

- - 40% HD - - - } - > -
- - - - - - } - wrapperStyle={{ zIndex: 200 }} - cursor={{ fill: "var(--color-muted)", fillOpacity: 0.3 }} - /> - - {grades.map((g, i) => ( - - ))} - - - -
-
- - -

- - {average}% - - - Avg. - -

{" "} -
- - - {semester.label} - - -
- - } - > -
- {semester.courses.map((course, i) => ( -
- {course.code} -
- - -
- {course.mark} -
- ))} -
-
-
- ); -} - -// Design-only planning values for the dashboard preview. -export function PlanningMetricsPreview() { - return ( -
- - ), - }} - /> - - ), - }} - /> - - ), - }} - /> -
- ); -} From da3d23908ba6922a796c548d6475869b88f2701a Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sat, 26 Sep 2026 15:41:48 +1000 Subject: [PATCH 2/3] chore: seed local dashboard states for review pnpm db:seed:dashboard replaces a local student's attempts and plan items with a known state, publishing sample courses with domestic fees only where a course year has no published version. Loopback databases only. --- .../scripts/local/seed-dashboard-states.mjs | 305 ++++++++++++++++++ package.json | 1 + 2 files changed, 306 insertions(+) create mode 100644 apps/web/scripts/local/seed-dashboard-states.mjs diff --git a/apps/web/scripts/local/seed-dashboard-states.mjs b/apps/web/scripts/local/seed-dashboard-states.mjs new file mode 100644 index 00000000..69dc3762 --- /dev/null +++ b/apps/web/scripts/local/seed-dashboard-states.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +/** + * Puts a local student's plan into a known state so the dashboard can be + * reviewed empty, early, midway and near completion. + * + * pnpm db:seed:dashboard [email] + * + * Loopback databases only. The student's course attempts and plan items are + * replaced; sample courses are published only where a course year has no + * published version, so imported catalogue data is never overwritten. + */ + +import { fileURLToPath } from "node:url"; + +import { createLocalDatabaseClient } from "../catalogue/lib/local-database.mjs"; + +const FIXTURE_ADMIN = "90000000-0000-4000-8000-000000000001"; + +// Bachelor of Computing core courses first, so the composition's core fills +// before electives do. +const SAMPLE_COURSES = [ + ["COMP1100", "Programming as Problem Solving", 1000], + ["COMP1110", "Structured Programming", 1000], + ["MATH1005", "Discrete Mathematical Models", 1000], + ["COMP1600", "Foundations of Computing", 1000], + ["COMP2100", "Software Design Methodologies", 2000], + ["COMP2300", "Computer Organisation and Program Execution", 2000], + ["COMP2400", "Relational Databases", 2000], + ["MATH2222", "Introduction to Mathematical Thinking", 2000], + ["STAT1008", "Quantitative Research Methods", 1000], + ["MATH1013", "Mathematics and Applications 1", 1000], + ["INFS2024", "Information Systems Analysis", 2000], + ["DESN2010", "Design Thinking", 2000], + ["ENGN1211", "Discovering Engineering", 1000], + ["MATH2301", "Games, Graphs and Machines", 2000], + ["STAT1003", "Statistical Techniques", 1000], + ["INFS3002", "Information Systems Project", 3000], + ["MGMT2009", "Entrepreneurship and Innovation", 2000], + ["SOCY2166", "Social Science of the Internet", 2000], + ["COMP3600", "Algorithms", 3000], + ["COMP3900", "Human-Computer Interaction", 3000], + ["INFS3024", "Information Systems Security", 3000], + ["MATH2307", "Bayesian Statistics", 2000], + ["ARTH2181", "Art and Technology", 2000], + ["ENVS2015", "Sustainable Systems", 2000], + ["SCOM3029", "Science Communication", 3000], + ["ASIA3032", "Digital Asia", 3000], + ["MUSI3309", "Music and Computation", 3000], + ["MGMT3027", "Managing Technology", 3000], +]; + +// Marks cycle through the grade bands so every bar has something in it. +const MARKS = [86, 74, 91, 63, 78, 55, 82, 69, 88, 72, 95, 58, 80, 67, 84]; + +/** + * Each state lists its semesters in order. Completed semesters carry marks, + * the current semester is enrolled, and later semesters are planned. + */ +const STATES = { + empty: { commencementYear: 2026, semesters: [] }, + "first-year": { + commencementYear: 2026, + semesters: [ + { year: 2026, code: "S1", status: "completed", courses: 4 }, + { year: 2026, code: "S2", status: "enrolled", courses: 4 }, + ], + }, + midway: { + commencementYear: 2025, + semesters: [ + { year: 2025, code: "S1", status: "completed", courses: 4 }, + { year: 2025, code: "S2", status: "completed", courses: 4 }, + { year: 2026, code: "S1", status: "completed", courses: 4 }, + { year: 2026, code: "S2", status: "enrolled", courses: 4 }, + { year: 2027, code: "S1", status: "planned", courses: 3 }, + { year: 2027, code: "S2", status: "planned", courses: 2 }, + ], + }, + "final-year": { + commencementYear: 2024, + semesters: [ + { year: 2024, code: "S1", status: "completed", courses: 4 }, + { year: 2024, code: "S2", status: "completed", courses: 4 }, + { year: 2025, code: "S1", status: "completed", courses: 4 }, + { year: 2025, code: "S2", status: "completed", courses: 4 }, + { year: 2026, code: "S1", status: "completed", courses: 4 }, + { year: 2026, code: "S2", status: "enrolled", courses: 4 }, + ], + }, +}; + +// Indicative domestic fees per 6-unit course, by subject group. +const DOMESTIC_FEES = { + COMP: 1180, + MATH: 1180, + STAT: 1180, + ENGN: 1180, + INFS: 1180, + DESN: 1180, + ENVS: 1180, + MGMT: 2485, +}; +const DEFAULT_DOMESTIC_FEE = 2640; + +const PERIOD_DATES = { + S1: { name: "Semester 1", starts: "02-23", ends: "06-28", order: 1 }, + S2: { name: "Semester 2", starts: "07-27", ends: "11-22", order: 2 }, +}; + +function gradeFor(mark) { + if (mark >= 80) return "HD"; + if (mark >= 70) return "D"; + if (mark >= 60) return "CR"; + if (mark >= 50) return "P"; + return "N"; +} + +async function academicYearId(sql, year) { + const [row] = await sql` + insert into public.academic_years (year) values (${year}) + on conflict (year) do update set year = excluded.year + returning id`; + return row.id; +} + +/** A period for attempts to reference; draft, so the catalogue ignores it. */ +async function periodId(sql, year, code) { + const [existing] = await sql` + select id from public.academic_periods + where calendar_year = ${year} and code = ${code}`; + if (existing) return existing.id; + const dates = PERIOD_DATES[code]; + const [row] = await sql` + insert into public.academic_periods ( + calendar_year, code, name, short_name, starts_on, ends_on, sort_order, status + ) values ( + ${year}, ${code}, ${dates.name}, ${code}, + ${`${year}-${dates.starts}`}, ${`${year}-${dates.ends}`}, + ${dates.order}, 'draft' + ) + returning id`; + return row.id; +} + +/** The content hash that marks a version this script published. */ +const seedHash = (code, year, generation) => [ + `${code}:${year}:dashboard-seed${generation}`, + `published:${code}`, +]; + +/** + * The course's record and published version for a year. Publishes a sample + * version when none is published, and replaces this script's own earlier + * versions that were published without a fee. Sealed versions cannot gain + * fees, so a fresh version is published instead. + */ +async function publishedCourse(sql, [code, title, level], year) { + const yearId = await academicYearId(sql, year); + await sql` + insert into public.catalogue_codes (kind, code) values ('course', ${code}) + on conflict (kind, code) do nothing`; + const [record] = await sql` + insert into public.catalogue_records (code_id, kind, academic_year_id) + select id, 'course', ${yearId} from public.catalogue_codes + where kind = 'course' and code = ${code} + on conflict (code_id, academic_year_id) + do update set kind = excluded.kind + returning id, published_version_id`; + if (record.published_version_id) { + const [earlier] = await sql` + select 1 from public.catalogue_versions + where id = ${record.published_version_id} + and content_hash = md5(${seedHash(code, year, "")[0]}) + || md5(${seedHash(code, year, "")[1]})`; + if (!earlier) { + return { recordId: record.id, versionId: record.published_version_id }; + } + } + const [hash, published] = seedHash(code, year, ":fees"); + const [version] = await sql` + insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, content_hash, created_by + ) values ( + ${record.id}, 'course', ${yearId}, 'manual', + md5(${hash}) || md5(${published}), + ${FIXTURE_ADMIN} + ) + returning id`; + const subject = code.slice(0, 4); + await sql` + insert into public.course_version_details ( + version_id, title, unit_value_kind, units, eftsl, level, subject_code, + subject_name, school, college, academic_career, convener_text, + delivery_summary, introduction, description, workload_text, + workload_hours, inherent_requirements, prescribed_texts, + offering_status, source_updated_at + ) values ( + ${version.id}, ${title}, 'fixed', 6, 0.125, ${level}, ${subject}, + ${subject}, 'Local dashboard sample', 'ANU', 'UGRD', 'Sample convenor', + 'In person at Acton campus', 'Sample course for dashboard review.', + 'Sample course for dashboard review.', 'Approximately ten hours per week.', + 10, 'None listed.', 'No prescribed text.', 'offered', + ${`${year}-01-01 00:00:00+10`} + )`; + const fee = DOMESTIC_FEES[subject] ?? DEFAULT_DOMESTIC_FEE; + await sql` + insert into public.course_fees ( + version_id, position, fee_year, audience, fee_type, amount, currency, + basis, source_label, source_text + ) values ( + ${version.id}, 1, ${year}, 'domestic', 'student_contribution', ${fee}, + 'AUD', 'course', 'Domestic student contribution', + ${`Domestic student contribution: $${fee.toLocaleString("en-AU")}`} + )`; + await sql` + update public.catalogue_records set published_version_id = ${version.id} + where id = ${record.id}`; + return { recordId: record.id, versionId: version.id }; +} + +export async function seedDashboardState({ + state, + email = "test@test.com", + createClient = createLocalDatabaseClient, +}) { + const plan = STATES[state]; + if (!plan) { + throw new Error( + `Unknown state "${state}". Use one of: ${Object.keys(STATES).join(", ")}.`, + ); + } + const sql = await createClient(); + try { + await sql.begin(async (tx) => { + const [owner] = await tx` + select plans.id as plan_id, plans.owner_id + from public.plans + join auth.users on users.id = plans.owner_id + where users.email = ${email} and plans.is_primary`; + if (!owner) { + throw new Error( + `${email} has no primary plan. Finish onboarding first.`, + ); + } + await tx`delete from public.course_attempts where owner_id = ${owner.owner_id}`; + await tx`delete from public.plan_items where plan_id = ${owner.plan_id}`; + await tx` + update public.plans set commencement_year = ${plan.commencementYear} + where id = ${owner.plan_id}`; + + let next = 0; + for (const semester of plan.semesters) { + for (let index = 0; index < semester.courses; index += 1) { + const course = SAMPLE_COURSES[next]; + const mark = MARKS[next % MARKS.length]; + next += 1; + if (semester.status === "planned") { + // Planned courses point at the current catalogue year's record. + const { recordId } = await publishedCourse(tx, course, 2026); + await tx` + insert into public.plan_items ( + plan_id, owner_id, catalogue_record_id, sort_order, + planned_calendar_year, planned_period_code + ) values ( + ${owner.plan_id}, ${owner.owner_id}, ${recordId}, ${next}, + ${semester.year}, ${semester.code} + )`; + continue; + } + const { versionId } = await publishedCourse( + tx, + course, + semester.year, + ); + const completed = semester.status === "completed"; + await tx` + insert into public.course_attempts ( + owner_id, academic_period_id, status, mark, grade, + units_attempted, units_earned, source, catalogue_version_id + ) values ( + ${owner.owner_id}, + ${await periodId(tx, semester.year, semester.code)}, + ${semester.status}, + ${completed ? mark : null}, + ${completed ? gradeFor(mark) : null}, + 6, ${completed ? 6 : 0}, 'user_entered', ${versionId} + )`; + } + } + }); + } finally { + await sql.end({ timeout: 5 }); + } +} + +const isMainModule = process.argv[1] + ? fileURLToPath(import.meta.url) === process.argv[1] + : false; + +if (isMainModule) { + const [state, email] = process.argv.slice(2); + await seedDashboardState({ state, email }); + console.log(`Seeded the "${state}" dashboard state.`); +} diff --git a/package.json b/package.json index 91943280..d18a527a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "db:stop": "supabase stop", "db:reset": "node apps/web/scripts/local/reset-preview.mjs", "db:seed:preview": "node apps/web/scripts/local/seed-preview.mjs", + "db:seed:dashboard": "node apps/web/scripts/local/seed-dashboard-states.mjs", "db:test": "supabase test db", "db:lint": "supabase db lint --local --schema public,private --level warning --fail-on warning", "db:types": "supabase gen types typescript --local --schema public > apps/web/types/database.ts", From 6ac0337a1949d93b26b3ce0f228c53b0c01ab2e9 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sat, 26 Sep 2026 15:41:48 +1000 Subject: [PATCH 3/3] feat: show the student's own data on the dashboard The dashboard showed sample cards in development and older real-data cards in production. Both now show one set of cards built from the plan: GPA by semester, grades, estimated tuition by year and the average mark per semester, then key dates, planning coverage by study year and the upcoming semester load. Empty cards keep a muted outline of their chart, and the loading screen pulses the same outlines. Degree progress uses two rings cut into semesters, completed outside and enrolled or planned inside, with a tooltip beside the hovered segment. The requirements header uses the same rings in a slimmer card. The course table lists courses in the order they are taken with coloured grade chips, status chips use tints in dark mode, and tooltips keep to one line. Charts draw in once the page has hydrated, so a slow start no longer runs the animations unseen. --- apps/web/app/animations.css | 73 ++ apps/web/app/dashboard/dashboard.tsx | 233 +++--- apps/web/app/dashboard/loading.tsx | 126 +++- apps/web/app/dashboard/page.tsx | 39 +- apps/web/app/requirements/requirements.tsx | 14 +- apps/web/hooks/use-entered.ts | 22 + .../web/ui/common/semester-progress-rings.tsx | 259 +++++++ .../web/ui/dashboard/academic-metric-card.tsx | 39 + apps/web/ui/dashboard/average-mark-metric.tsx | 110 +++ apps/web/ui/dashboard/chart-tip.tsx | 26 + .../ui/dashboard/composition-block.module.css | 8 +- apps/web/ui/dashboard/composition-slot.tsx | 5 +- apps/web/ui/dashboard/coverage-metric.tsx | 124 ++++ apps/web/ui/dashboard/degree-composition.tsx | 25 +- .../web/ui/dashboard/degree-progress-hero.tsx | 64 +- apps/web/ui/dashboard/gpa-metric.tsx | 102 +++ apps/web/ui/dashboard/grade-bars.tsx | 51 -- apps/web/ui/dashboard/grade-colours.ts | 20 + apps/web/ui/dashboard/grades-metric.tsx | 110 +++ apps/web/ui/dashboard/key-dates-metric.tsx | 91 +++ apps/web/ui/dashboard/metric-cards.tsx | 691 ------------------ apps/web/ui/dashboard/metric-charts.tsx | 210 ------ apps/web/ui/dashboard/metric-empty.tsx | 27 - apps/web/ui/dashboard/metric-skeletons.tsx | 148 ++++ apps/web/ui/dashboard/metric-visuals.tsx | 54 -- apps/web/ui/dashboard/plan-course-table.tsx | 22 +- apps/web/ui/dashboard/requirements-table.tsx | 8 +- apps/web/ui/dashboard/status-badge-tint.ts | 13 + apps/web/ui/dashboard/trend-bars.tsx | 65 -- apps/web/ui/dashboard/tuition-metric.tsx | 121 +++ .../web/ui/dashboard/upcoming-load-metric.tsx | 100 +++ .../ui/requirements/structure-progress.tsx | 39 +- 32 files changed, 1698 insertions(+), 1341 deletions(-) create mode 100644 apps/web/hooks/use-entered.ts create mode 100644 apps/web/ui/common/semester-progress-rings.tsx create mode 100644 apps/web/ui/dashboard/academic-metric-card.tsx create mode 100644 apps/web/ui/dashboard/average-mark-metric.tsx create mode 100644 apps/web/ui/dashboard/chart-tip.tsx create mode 100644 apps/web/ui/dashboard/coverage-metric.tsx create mode 100644 apps/web/ui/dashboard/gpa-metric.tsx delete mode 100644 apps/web/ui/dashboard/grade-bars.tsx create mode 100644 apps/web/ui/dashboard/grade-colours.ts create mode 100644 apps/web/ui/dashboard/grades-metric.tsx create mode 100644 apps/web/ui/dashboard/key-dates-metric.tsx delete mode 100644 apps/web/ui/dashboard/metric-cards.tsx delete mode 100644 apps/web/ui/dashboard/metric-charts.tsx delete mode 100644 apps/web/ui/dashboard/metric-empty.tsx create mode 100644 apps/web/ui/dashboard/metric-skeletons.tsx delete mode 100644 apps/web/ui/dashboard/metric-visuals.tsx create mode 100644 apps/web/ui/dashboard/status-badge-tint.ts delete mode 100644 apps/web/ui/dashboard/trend-bars.tsx create mode 100644 apps/web/ui/dashboard/tuition-metric.tsx create mode 100644 apps/web/ui/dashboard/upcoming-load-metric.tsx diff --git a/apps/web/app/animations.css b/apps/web/app/animations.css index 0e677ac1..bb723334 100644 --- a/apps/web/app/animations.css +++ b/apps/web/app/animations.css @@ -211,6 +211,79 @@ } } +/* Dashboard entrance. Each keyframe sets only its starting point, so a mark + animates to whatever size it renders at. Marks hold that first frame until + an ancestor gains `data-entered`, which happens once the page has hydrated + and painted; otherwise a slow start would run the animations unseen. + `--enter-delay` staggers rows. */ +@keyframes enter-grow-up { + from { + transform: scaleY(0); + } +} +@keyframes enter-grow-across { + from { + transform: scaleX(0); + } +} +@keyframes enter-draw { + from { + stroke-dasharray: 0 100; + } +} +@keyframes enter-reveal { + from { + clip-path: inset(0 100% 0 0); + } +} +@keyframes enter-pop { + from { + opacity: 0; + transform: scale(0.96); + } +} +.enter-grow-up, +.enter-chart-bars .recharts-bar-rectangle { + transform-origin: bottom; + animation: enter-grow-up 620ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.enter-chart-bars .recharts-bar-rectangle { + transform-box: fill-box; +} +.enter-grow-across { + transform-origin: left; + animation: enter-grow-across 700ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.enter-draw { + animation: enter-draw 900ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.enter-chart-line .recharts-surface { + animation: enter-reveal 900ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.enter-pop { + animation: enter-pop 450ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.enter-grow-up, +.enter-chart-bars .recharts-bar-rectangle, +.enter-grow-across, +.enter-draw, +.enter-chart-line .recharts-surface, +.enter-pop { + animation-delay: var(--enter-delay, 0ms); + animation-play-state: paused; +} +[data-entered] + :is( + .enter-grow-up, + .enter-chart-bars .recharts-bar-rectangle, + .enter-grow-across, + .enter-draw, + .enter-chart-line .recharts-surface, + .enter-pop + ) { + animation-play-state: running; +} + /* One reset disables every animation above for readers who ask for it. */ @media (prefers-reduced-motion: reduce) { *, diff --git a/apps/web/app/dashboard/dashboard.tsx b/apps/web/app/dashboard/dashboard.tsx index 9dbef930..8f2c8ddc 100644 --- a/apps/web/app/dashboard/dashboard.tsx +++ b/apps/web/app/dashboard/dashboard.tsx @@ -1,39 +1,36 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, type CSSProperties } from "react"; import { useCoursemap } from "@/app/providers"; +import { useEntered } from "@/hooks/use-entered"; import { DegreeProgressHero } from "@/ui/dashboard/degree-progress-hero"; -import { - buildMetricViews, - MetricCardView, - type MetricCourse, -} from "@/ui/dashboard/metric-cards"; +import { KeyDatesMetric } from "@/ui/dashboard/key-dates-metric"; +import { CoverageMetric } from "@/ui/dashboard/coverage-metric"; +import { UpcomingLoadMetric } from "@/ui/dashboard/upcoming-load-metric"; import { MonthCalendar } from "@/ui/dashboard/month-calendar"; import { PlanEmptyState } from "@/ui/dashboard/plan-empty-state"; import { PlanDetailPanel } from "@/ui/dashboard/plan-detail-panel"; import type { PlanCourseRow } from "@/ui/dashboard/plan-course-table"; -import { - UniversityMetricsPreview, - PlanningMetricsPreview, -} from "@/ui/dashboard/university-metrics-preview"; +import { GpaMetric } from "@/ui/dashboard/gpa-metric"; +import { GradesMetric } from "@/ui/dashboard/grades-metric"; +import { TuitionMetric } from "@/ui/dashboard/tuition-metric"; +import { AverageMarkMetric } from "@/ui/dashboard/average-mark-metric"; import { DegreeComposition } from "@/ui/dashboard/degree-composition"; import { AppShell } from "@/ui/shell"; import type { PlanCatalogue } from "@/lib/coursemap/plan-catalogue"; import type { OnboardingCatalogue } from "@/lib/coursemap/onboarding-catalogue"; +import type { UniversityCalendarEvent } from "@/lib/coursemap/university-calendar"; import { degreeComposition } from "@/lib/coursemap/degree-composition"; import { - cumulativeDashboardUnits, currentDashboardTermId, dashboardCalendarEvents, dashboardTermLoads, - type DashboardTermPoint, } from "@/lib/coursemap/dashboard-series"; import { academicSummary, academicTermPoints, gradeDistribution, tuitionEstimate, - type CourseFeeLookup, } from "@/lib/coursemap/academic-metrics"; import { planRisks } from "@/lib/coursemap/plan-risks"; import { requirementBucketProgress } from "@/lib/coursemap/requirement-progress"; @@ -41,7 +38,11 @@ import { planTimelineTerms, planTimelineYears, } from "@/lib/coursemap/plan-timeline"; -import type { Term } from "@/lib/coursemap/types"; +import { + gradeBands, + gradeForMark, + type GradeCode, +} from "@/lib/academic/metrics"; import { degreeUnitProgress, effectiveStatus, @@ -50,37 +51,21 @@ import { unitsForAttempt, } from "@/lib/planner"; -const NO_COURSE_FEES: CourseFeeLookup = new Map(); - -function finishLabelFor( - termLoads: readonly DashboardTermPoint[], - terms: readonly Term[], -) { - const last = [...termLoads].reverse().find((load) => load.units > 0); - if (!last) return null; - const term = terms.find((item) => item.id === last.id); - if (term?.endsOn) { - const date = new Date(`${term.endsOn}T00:00:00`); - return date.toLocaleDateString("en-AU", { - month: "short", - year: "numeric", - }); - } - const suffix = last.id.split("-").at(-1); - if (suffix === "s1") return `Jun ${last.year}`; - if (suffix === "s2") return `Nov ${last.year}`; - return `${term?.name ?? last.label} ${last.year}`; -} - export function Dashboard({ catalogue, choices, + keyDates, + todayIso, }: { catalogue: PlanCatalogue; choices: OnboardingCatalogue; + /** The next few university key dates. */ + keyDates: UniversityCalendarEvent[]; + /** Today in Canberra, as YYYY-MM-DD. */ + todayIso: string; }) { const { state } = useCoursemap(); - const previewMetrics = process.env.NODE_ENV === "development"; + const entered = useEntered(); const degree = catalogue.degrees.find( (item) => item.code === state.profile.degreeCode, ); @@ -141,10 +126,6 @@ export function Dashboard({ dashboardTermLoads({ ...planningCatalogue, attempts: state.attempts }), [planningCatalogue, state.attempts], ); - const cumulativeUnits = useMemo( - () => cumulativeDashboardUnits(termLoads), - [termLoads], - ); const calendarEvents = useMemo( () => dashboardCalendarEvents({ @@ -208,31 +189,59 @@ export function Dashboard({ () => gradeDistribution(academicInputs), [academicInputs], ); - // Fees live on CourseDetails, which the plan catalogue does not carry, so the - // estimate stays empty until fee data is loaded alongside the plan. const tuition = useMemo( - () => tuitionEstimate({ ...academicInputs, fees: NO_COURSE_FEES }), + () => tuitionEstimate(academicInputs), [academicInputs], ); const planCourseRows = useMemo(() => { + // Timeline terms are already in date order, with unscheduled last. + const termOrder = new Map( + timelineTerms.map((term, index) => [term.id, index]), + ); const termName = new Map( - timelineTerms.map((term) => [term.id, term.shortName]), + timelineTerms.map((term) => { + const code = term.id.split("-").at(-1); + const name = + code === "s1" + ? "Semester 1" + : code === "s2" + ? "Semester 2" + : term.name; + return [term.id, `${name} ${term.year}`]; + }), ); + const gradeCodes = new Set(gradeBands.map((band) => band.code)); return planned .map(({ attempt, course }) => { const status = effectiveStatus(attempt, state.attempts, catalogue); + const code = attempt.resultCode?.toUpperCase(); return { code: course.code, name: course.name, units: unitsForAttempt(attempt, course), - termLabel: termName.get(attempt.termId) ?? "Unscheduled", + termId: attempt.termId, + termLabel: + attempt.termId === "unscheduled" + ? "Unscheduled" + : (termName.get(attempt.termId) ?? "Unscheduled"), grade: attempt.resultCode ?? attempt.mark?.toString() ?? "—", + gradeCode: + code && gradeCodes.has(code) + ? (code as GradeCode) + : attempt.mark !== undefined + ? gradeForMark(attempt.mark) + : null, status, statusLabel: statusLabel(status), }; }) - .sort((a, b) => a.code.localeCompare(b.code)); + .sort( + (a, b) => + (termOrder.get(a.termId) ?? Infinity) - + (termOrder.get(b.termId) ?? Infinity) || + a.code.localeCompare(b.code), + ); }, [catalogue, planned, state.attempts, timelineTerms]); const risks = useMemo( @@ -246,71 +255,18 @@ export function Dashboard({ [buckets, planningCatalogue, progress, state.attempts], ); - const metricViews = useMemo(() => { - const coursesInTerm = (termId: string | undefined): MetricCourse[] => - termId - ? planned - .filter((item) => item.attempt.termId === termId) - .map((item) => ({ - code: item.course.code, - units: unitsForAttempt(item.attempt, item.course), - ready: !["blocked", "approval"].includes( - effectiveStatus( - item.attempt, - state.attempts, - planningCatalogue, - ), - ), - })) - : []; - + // The upcoming load runs from the current semester, or between semesters + // from the next one with courses. + const upcomingLoads = useMemo(() => { const currentIndex = termLoads.findIndex( (load) => load.id === currentTermId, ); - const firstPlannedIndex = termLoads.findIndex((load) => load.planned > 0); - const focusIndex = currentIndex >= 0 ? currentIndex : firstPlannedIndex; - const focus = focusIndex >= 0 ? termLoads[focusIndex] : undefined; - const upcoming = focusIndex >= 0 ? termLoads.slice(focusIndex) : []; - // Between semesters the focus already is the next one to start. - const next = + const focusIndex = currentIndex >= 0 - ? upcoming.slice(1).find((load) => load.units > 0) - : focus; - - return buildMetricViews({ - unitTarget, - progress, - enrolledUnits, - cumulative: cumulativeUnits, - buckets, - focusTermLabel: focus?.label ?? null, - focusCourses: coursesInTerm(focus?.id), - nextTermLabel: next?.label ?? null, - nextCourses: coursesInTerm(next?.id), - upcoming, - finishLabel: finishLabelFor(termLoads, timelineTerms), - academic, - markTrend, - grades, - tuition, - }); - }, [ - academic, - buckets, - grades, - markTrend, - tuition, - cumulativeUnits, - currentTermId, - enrolledUnits, - planned, - planningCatalogue, - progress, - state.attempts, - termLoads, - timelineTerms, - unitTarget, - ]); + ? currentIndex + : termLoads.findIndex((load) => load.planned > 0); + return focusIndex >= 0 ? termLoads.slice(focusIndex) : []; + }, [currentTermId, termLoads]); if (!degree) { return ( @@ -322,29 +278,33 @@ export function Dashboard({ return ( -
+

Dashboard

- {previewMetrics ? ( - - ) : ( -
- - - - -
- )} +
+ + + + +
- +
+ +
-
+
- {previewMetrics ? ( - - ) : ( -
- {(["mark-trend", "grade-mix", "semester-bars"] as const).map( - (id) => ( - - ), - )} -
- )} +
+ + + +
} + empty={{ label: "", skeleton }} + /> + ); +} + +/** The dashboard's layout in outline, so nothing moves when it loads. */ export default function DashboardLoading() { return (
Loading dashboard -
- {Array.from({ length: 4 }, (_, index) => ( - - - - + +
+ {[ + gpaSkeleton, + gradesSkeleton, + tuitionSkeleton, + averageMarkSkeleton, + ].map((skeleton, index) => ( + ))}
-
- {[0, 1].map((index) => ( - - -
- - + + + +
+
+
+
+ + +
+ {[0, 1, 2, 3].map((index) => ( + + ))}
- - ))} -
-
- {Array.from({ length: 3 }, (_, index) => ( - - - - - ))} -
-
- {[0, 1].map((index) => ( - +
+ + + +
+ + +
+ + +
+ {/* The largest section on the left, the rest stacked beside it. */} +
+ +
+ + +
+
+
+
+ + - - - ))} +
+ {Array.from({ length: 35 }, (_, index) => ( + + ))} +
+ + +
+ +
+ {[keyDatesSkeleton, coverageSkeleton, upcomingLoadSkeleton].map( + (skeleton, index) => ( + + ), + )}
+ + + +
+ + +
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+
); diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 55e69441..f588b327 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1,22 +1,57 @@ import { PlanningCatalogueError } from "@/ui/plan/planning-catalogue-error"; import { loadCurrentUserPlanCatalogue } from "@/lib/coursemap/plan-catalogue"; import { loadOnboardingCatalogue } from "@/lib/coursemap/onboarding-catalogue"; +import { + decorateUniversityCalendarEvents, + upcomingUniversityCalendarEvents, +} from "@/lib/coursemap/university-calendar"; +import { loadPublishedUniversityCalendar } from "@/lib/coursemap/university-calendar-data"; import { Dashboard } from "./dashboard"; export const dynamic = "force-dynamic"; +const KEY_DATES_SHOWN = 3; + +/** + * The next few published key dates from today in Canberra. Next year's + * calendar is included so late-year dashboards still look ahead. A calendar + * that cannot load leaves the card empty rather than failing the page. + */ +async function upcomingKeyDates(todayIso: string) { + const year = Number(todayIso.slice(0, 4)); + try { + const calendars = await Promise.all([ + loadPublishedUniversityCalendar(year), + loadPublishedUniversityCalendar(year + 1), + ]); + return upcomingUniversityCalendarEvents( + decorateUniversityCalendarEvents( + calendars.flatMap((calendar) => calendar.events), + ), + todayIso, + KEY_DATES_SHOWN, + ); + } catch { + return []; + } +} + /** * Student home. Students without a primary plan see the dashboard empty state, * which offers onboarding, rather than being redirected into it. */ export default async function DashboardPage() { + const todayIso = new Intl.DateTimeFormat("en-CA", { + timeZone: "Australia/Sydney", + }).format(new Date()); let data; try { - const [catalogue, choices] = await Promise.all([ + const [catalogue, choices, keyDates] = await Promise.all([ loadCurrentUserPlanCatalogue(), loadOnboardingCatalogue(), + upcomingKeyDates(todayIso), ]); - data = { catalogue, choices }; + data = { catalogue, choices, keyDates, todayIso }; } catch { return ( diff --git a/apps/web/app/requirements/requirements.tsx b/apps/web/app/requirements/requirements.tsx index 4c552371..9dd0bdff 100644 --- a/apps/web/app/requirements/requirements.tsx +++ b/apps/web/app/requirements/requirements.tsx @@ -21,7 +21,11 @@ import type { SelectableStructureKind } from "@/lib/coursemap/programme-structur import type { Course } from "@/lib/coursemap/types"; import { requirementTreeProgress } from "@/lib/coursemap/requirement-progress"; import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; -import { degreeUnitProgress } from "@/lib/planner"; +import { + degreeUnitProgress, + planningCourseForAttempt, + unitsForAttempt, +} from "@/lib/planner"; import { RequirementGroupView } from "@/ui/requirements/requirement-tree"; import { StructureProgress } from "@/ui/requirements/structure-progress"; import { StructureEmptyState } from "@/ui/requirements/structure-empty-state"; @@ -143,9 +147,13 @@ export function Requirements({

Requirements

attempt.status === "enrolled") + .reduce((total, attempt) => { + const course = planningCourseForAttempt(attempt, catalogue); + return course ? total + unitsForAttempt(attempt, course) : total; + }, 0)} progress={degreeUnitProgress( state.attempts, degree?.units ?? 0, diff --git a/apps/web/hooks/use-entered.ts b/apps/web/hooks/use-entered.ts new file mode 100644 index 00000000..ad8d2f96 --- /dev/null +++ b/apps/web/hooks/use-entered.ts @@ -0,0 +1,22 @@ +"use client"; +import { useEffect, useState } from "react"; + +/** + * True from the second frame after mount. Entrance animations wait for it + * (through a `data-entered` attribute) so they play once the page is + * interactive instead of running out while hydration blocks painting. + */ +export function useEntered() { + const [entered, setEntered] = useState(false); + useEffect(() => { + let second = 0; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => setEntered(true)); + }); + return () => { + cancelAnimationFrame(first); + cancelAnimationFrame(second); + }; + }, []); + return entered; +} diff --git a/apps/web/ui/common/semester-progress-rings.tsx b/apps/web/ui/common/semester-progress-rings.tsx new file mode 100644 index 00000000..da7e924d --- /dev/null +++ b/apps/web/ui/common/semester-progress-rings.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useEntered } from "@/hooks/use-entered"; +import { cn } from "@/lib/cn"; +import { STANDARD_TERM_UNITS } from "@/lib/planner"; + +type Series = { + label: string; + units: number; + className: string; + /** Colour while the segment is hovered, when it differs. */ + activeClassName?: string; +}; + +type Hover = { + ring: number; + index: number; + /** Tooltip anchor in viewport pixels, just outside the rings. */ + x: number; + y: number; + /** Shifts the tooltip away from the anchor so it clears the rings. */ + transform: string; + title: string; + detail: string; +}; + +/** Gap between semester segments, as a share of the ring's length. */ +const GAP = 1.2; +/** Rendered size of the rings in pixels, matching `size-28`. */ +const SIZE = 112; +/** How far outside the rings the tooltip sits, in pixels. */ +const CLEARANCE = 12; + +/** + * One ring cut into semester-sized segments. Series fill it in order from + * the top, so a half-finished semester shows as a half-filled segment. The + * hovered segment grows outward and the others dim. + */ +function SegmentedRing({ + ring, + radius, + hitInset = 0, + target, + series, + hover, + onHover, +}: { + ring: number; + radius: number; + /** Extra hover area on the ring's inner side, in view-box units. */ + hitInset?: number; + target: number; + series: readonly Series[]; + hover: Hover | null; + onHover: (hover: Hover | null) => void; +}) { + const semesters = Math.max(1, Math.ceil(target / STANDARD_TERM_UNITS)); + const span = 100 / semesters; + let filled = 0; + const ranges = series.map((item) => { + const from = filled; + filled += item.units; + return { ...item, from, to: from + item.units }; + }); + const arc = (from: number, to: number, className: string, key: string) => { + const start = (from / target) * 100; + const end = (to / target) * 100; + return end > start ? ( + + ) : null; + }; + return Array.from({ length: semesters }, (_, index) => { + // Each segment covers one semester's worth of units, trimmed by the gap. + const low = index * STANDARD_TERM_UNITS; + const high = Math.min(target, low + STANDARD_TERM_UNITS); + const trim = ((GAP / 2) * target) / 100; + const parts = ranges.map((range) => ({ + ...range, + units: Math.max(0, Math.min(high, range.to) - Math.max(low, range.from)), + })); + const summary = parts + .filter((part) => part.units > 0) + .map((part) => `${part.units} ${part.label}`); + const title = `Semester ${index + 1}`; + const detail = summary.length ? summary.join(" · ") : "Nothing yet"; + const active = hover?.ring === ring && hover.index === index; + // The tooltip sits outside the rings in line with the segment's middle: + // to the right of right-hand segments, above top ones, and so on. + const angle = ((index + 0.5) / semesters) * 2 * Math.PI; + const anchor = (svg: SVGSVGElement | null) => { + const box = svg?.getBoundingClientRect(); + const x = Math.sin(angle); + const y = -Math.cos(angle); + const reach = SIZE / 2 + CLEARANCE; + const shift = (value: number) => + value > 0.35 ? "0%" : value < -0.35 ? "-100%" : "-50%"; + return { + x: (box?.left ?? 0) + SIZE / 2 + reach * x, + y: (box?.top ?? 0) + SIZE / 2 + reach * y, + transform: `translate(${shift(x)}, ${shift(y)})`, + }; + }; + const show = (svg: SVGSVGElement | null) => + onHover({ ring, index, ...anchor(svg), title, detail }); + return ( + show(event.currentTarget.ownerSVGElement)} + onPointerLeave={() => onHover(null)} + onFocus={(event) => show(event.currentTarget.ownerSVGElement)} + onBlur={() => onHover(null)} + > + {/* A wide transparent stroke makes the thin arc easy to hover. The + inner ring's reaches further into the middle. */} + + {arc(low + trim, high - trim, "stroke-muted-foreground/20", "track")} + {parts.map((part) => + arc( + Math.max(low + trim, part.from), + Math.min(high - trim, part.to), + cn( + "enter-draw", + active + ? (part.activeClassName ?? part.className) + : part.className, + ), + part.label, + ), + )} + + ); + }); +} + +/** + * Degree progress as two rings cut into semesters: completed units on the + * outside, enrolled and planned units inside. Hovering a segment shows its + * units just outside the rings, beside that segment. + */ +export function SemesterProgressRings({ + completed, + enrolled, + planned, + target, + children, +}: { + completed: number; + enrolled: number; + /** Planned units not yet enrolled in. */ + planned: number; + target: number; + children: ReactNode; +}) { + const [hover, setHover] = useState(null); + const entered = useEntered(); + return ( +
+ {/* Unrotated so pointer positions match the tooltip's coordinates. */} + + + + + + + + {children} + + {/* Portalled so the card's edge cannot clip it. */} + {hover + ? createPortal( + , + document.body, + ) + : null} +
+ ); +} diff --git a/apps/web/ui/dashboard/academic-metric-card.tsx b/apps/web/ui/dashboard/academic-metric-card.tsx new file mode 100644 index 00000000..93a74c95 --- /dev/null +++ b/apps/web/ui/dashboard/academic-metric-card.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Card, CardContent } from "@coursemap/ui/primitives/card"; + +/** + * The frame for the dashboard's academic cards: a heading row and a chart. + * Until there is data, the card shows a muted outline of its own chart and + * states why in the heading row, so the row keeps its shape and each card + * still reads as the graph it will become. + */ +export function AcademicMetricCard({ + header, + empty, + children, +}: { + header: ReactNode; + /** Why the card is empty and the chart outline to show meanwhile. */ + empty?: { label: string; skeleton: ReactNode } | null; + children?: ReactNode; +}) { + return ( + + +
+ {header} + {empty ? ( + {empty.label} + ) : null} +
+ {empty ? ( + + ) : ( + children + )} +
+
+ ); +} diff --git a/apps/web/ui/dashboard/average-mark-metric.tsx b/apps/web/ui/dashboard/average-mark-metric.tsx new file mode 100644 index 00000000..d8700b01 --- /dev/null +++ b/apps/web/ui/dashboard/average-mark-metric.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@coursemap/ui/primitives/button"; +import type { AcademicTermPoint } from "@/lib/coursemap/academic-metrics"; +import { AcademicMetricCard } from "./academic-metric-card"; +import { averageMarkSkeleton } from "./metric-skeletons"; +import { brand } from "./grade-colours"; + +/** Marks from 80 are a high distinction. */ +const HIGH_DISTINCTION = 80; +const VISIBLE_COURSES = 4; + +/** Each course's mark in one semester, stepping back through past ones. */ +export function AverageMarkMetric({ + points, +}: { + points: readonly AcademicTermPoint[]; +}) { + const semesters = points.filter((point) => point.marks.length > 0); + const [picked, setPicked] = useState(null); + const last = semesters.length - 1; + const index = Math.min(picked ?? last, last); + const semester = semesters[index]; + return ( + +

+ {semester ? ( + + {Math.round(semester.wam)}% + + ) : null} + {semester ? ( + Avg. mark + ) : ( + Avg. mark + )} +

+ {semester ? ( +
+ + + {semester.label} + + +
+ ) : null} + + } + > + {semester ? ( +
    + {semester.marks.slice(0, VISIBLE_COURSES).map((course) => ( +
  • + {course.code} + + + {Math.round(course.mark)} + +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/ui/dashboard/chart-tip.tsx b/apps/web/ui/dashboard/chart-tip.tsx new file mode 100644 index 00000000..95bcaaf7 --- /dev/null +++ b/apps/web/ui/dashboard/chart-tip.tsx @@ -0,0 +1,26 @@ +"use client"; + +/** + * A one-line Recharts hover card: the point's label and its value, nothing + * else. It shares the `.coursemap-tooltip` surface with every other tooltip. + */ +export function ChartTip({ + active, + payload, + label, + format = (value) => String(value), +}: { + active?: boolean; + payload?: readonly { value?: string | number }[]; + label?: string | number; + format?: (value: number) => string; +}) { + const value = payload?.[0]?.value; + if (!active || value === undefined) return null; + return ( +
+ {label} + · {format(Number(value))} +
+ ); +} diff --git a/apps/web/ui/dashboard/composition-block.module.css b/apps/web/ui/dashboard/composition-block.module.css index 6cf0058b..f614c8ce 100644 --- a/apps/web/ui/dashboard/composition-block.module.css +++ b/apps/web/ui/dashboard/composition-block.module.css @@ -52,7 +52,7 @@ min-height: 0; min-width: 0; overflow: hidden; - border: 1.5px solid #8b5cf6; + border: 1.5px solid var(--color-primary); border-radius: 4px; background: transparent; color: var(--color-foreground); @@ -74,11 +74,11 @@ } .slot[data-status="Completed"]:hover, .slot[data-status="Completed"]:focus-visible { - background-color: rgb(16 185 129 / 0.16); + background-color: color-mix(in oklab, var(--color-success) 16%, transparent); } .slot[data-status="Planned"]:hover, .slot[data-status="Planned"]:focus-visible { - background-color: rgb(139 92 246 / 0.18); + background-color: color-mix(in oklab, var(--color-primary) 18%, transparent); } .slot:not([data-status="Unallocated"]):hover, .slot:not([data-status="Unallocated"]):focus-visible { @@ -93,7 +93,7 @@ border-color: var(--color-muted-foreground); } .slot[data-status="Completed"] { - border-color: #10b981; + border-color: var(--color-success); } .slot[data-status="More"] { border: 1px solid var(--color-border); diff --git a/apps/web/ui/dashboard/composition-slot.tsx b/apps/web/ui/dashboard/composition-slot.tsx index 41ed53a0..10d6ea62 100644 --- a/apps/web/ui/dashboard/composition-slot.tsx +++ b/apps/web/ui/dashboard/composition-slot.tsx @@ -59,10 +59,7 @@ export function CompositionSlot({ - {course.name} - - {course.code} · {status} · {course.units} units - + {course.name} ); diff --git a/apps/web/ui/dashboard/coverage-metric.tsx b/apps/web/ui/dashboard/coverage-metric.tsx new file mode 100644 index 00000000..8e8b2695 --- /dev/null +++ b/apps/web/ui/dashboard/coverage-metric.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@coursemap/ui/primitives/tooltip"; +import type { DashboardTermPoint } from "@/lib/coursemap/dashboard-series"; +import { STANDARD_TERM_UNITS } from "@/lib/planner"; +import { AcademicMetricCard } from "./academic-metric-card"; +import { coverageSkeleton } from "./metric-skeletons"; + +/** A full-time study year: two standard semesters. */ +const YEAR_UNITS = STANDARD_TERM_UNITS * 2; + +/** + * How much of each study year has a place in the plan, against a full-time + * year, split into completed and still-to-come units. + */ +export function CoverageMetric({ + termLoads, + unitTarget, + mapped, +}: { + termLoads: readonly DashboardTermPoint[]; + unitTarget: number | null; + mapped: number; +}) { + const years = [ + ...termLoads + .reduce((all, load) => { + const year = all.get(load.year) ?? { completed: 0, planned: 0 }; + all.set(load.year, { + completed: year.completed + load.completed, + planned: year.planned + load.planned, + }); + return all; + }, new Map()) + .entries(), + ].map(([year, units]) => ({ year, ...units })); + const ceiling = Math.max( + YEAR_UNITS, + ...years.map((year) => year.completed + year.planned), + ); + const percent = + unitTarget && unitTarget > 0 + ? Math.min(100, Math.round((mapped / unitTarget) * 100)) + : null; + return ( + +

Planning coverage

+ {percent !== null && mapped > 0 ? ( + + {percent}% + + placed + + + ) : null} + + } + > +
+
    + {years.map((year) => { + const placed = year.completed + year.planned; + return ( + + +
  • +
    + + + +
    +
  • +
    + + {[ + year.completed ? `${year.completed} completed` : null, + year.planned ? `${year.planned} planned` : null, + ] + .filter(Boolean) + .join(" · ") || "Nothing placed"} + +
    + ); + })} +
+
+ {years.map((year) => ( + + {year.year} + + ))} +
+
+
+ ); +} diff --git a/apps/web/ui/dashboard/degree-composition.tsx b/apps/web/ui/dashboard/degree-composition.tsx index a4c17b48..bf07d47c 100644 --- a/apps/web/ui/dashboard/degree-composition.tsx +++ b/apps/web/ui/dashboard/degree-composition.tsx @@ -1,5 +1,6 @@ "use client"; +import type { CSSProperties } from "react"; import { Card, CardContent } from "@coursemap/ui/primitives/card"; import { compositionSectionUnits, @@ -76,8 +77,8 @@ export function DegreeComposition({

Degree composition

{[ - ["Completed", "bg-emerald-500"], - ["Planned", "bg-violet-500"], + ["Completed", "bg-success"], + ["Planned", "bg-primary"], ["Unallocated", "bg-muted-foreground/40"], ].map(([label, colour]) => ( @@ -104,14 +105,18 @@ export function DegreeComposition({ return (
{section.unchosen ? ( (null); + const dim = (id: string) => active !== null && active !== id; return ( {total > 0 && ( - {progress.percent}% - + )}
@@ -86,7 +85,8 @@ export function DegreeProgressHero({

setActive(null)} role="group" aria-label={segments .map((segment) => `${segment.label}: ${segment.units} units`) @@ -95,26 +95,34 @@ export function DegreeProgressHero({ {segments .filter((segment) => segment.units > 0) .map((segment) => ( - - - - - {`${segment.label}: ${segment.units} units`} - + setActive(segment.id)} + onFocus={() => setActive(segment.id)} + onBlur={() => setActive(null)} + className={cn( + // The hovered part grows taller and the rest dim. + "h-2.5 cursor-default transition-[height,opacity,border-radius] duration-200 ease-out outline-none first:rounded-l-full last:rounded-r-full focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none", + active === segment.id && "h-5 rounded-md", + dim(segment.id) && "opacity-40", + segment.className, + )} + /> ))}
{segments.map((segment) => ( -
+