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/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/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/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/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) => ( -
+