From 2efd1497c66a8b92bb8f1784e8b2902816bc74ad Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sat, 26 Sep 2026 14:28:13 +1000 Subject: [PATCH 1/2] fix: centre the dashboard calendar and align its heading The month grid now sits in the middle of the card, the note about study periods without dates is gone, and the month buttons overhang the header row so the month name lines up with the neighbouring card's heading. --- apps/web/ui/dashboard/month-calendar.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/ui/dashboard/month-calendar.tsx b/apps/web/ui/dashboard/month-calendar.tsx index 987e4587..f37002da 100644 --- a/apps/web/ui/dashboard/month-calendar.tsx +++ b/apps/web/ui/dashboard/month-calendar.tsx @@ -128,7 +128,9 @@ export function MonthCalendar({

{monthLabel(focus)}

-
+ {/* The buttons overhang the row so the heading lines up with the + neighbouring card's heading. */} +
-
+
{weekdayLabels.map((label) => (
- {events.some((event) => event.startsOn && event.endsOn) ? null : ( -

- No dates to show for your selected study periods. -

- )} ); From 161bb9388500dad352179377da7268700a7e6a67 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sat, 26 Sep 2026 14:28:13 +1000 Subject: [PATCH 2/2] feat: build the degree composition from the student's degree The dashboard composition was a fixed sample. It now divides the degree into programme core, major, minors and electives from the published rules and the student's choices, places each plan course in the first section with room, and sizes electives from what remains. Sections are laid out largest first, with the rest stacked beside it. An unchosen major or minor offers to add one and links to that tab on the requirements page, which now accepts a tab parameter. Course slots show the course on hover and fit inside their block, and the legend moves into the header. --- apps/web/app/dashboard/dashboard.tsx | 37 +- apps/web/app/dashboard/page.tsx | 11 +- apps/web/app/requirements/page.tsx | 15 +- apps/web/app/requirements/requirements.tsx | 5 +- apps/web/hooks/use-element-size.ts | 29 ++ apps/web/lib/coursemap/degree-composition.ts | 228 ++++++++++++ apps/web/lib/coursemap/plan-catalogue.ts | 2 +- .../web/lib/coursemap/requirement-progress.ts | 24 +- apps/web/tests/composition-layout.test.mjs | 21 ++ apps/web/tests/degree-composition.test.mjs | 182 ++++++++++ .../ui/dashboard/composition-block.module.css | 101 +++++- apps/web/ui/dashboard/composition-block.tsx | 170 +++++++++ .../ui/dashboard/composition-invitation.tsx | 41 +++ apps/web/ui/dashboard/composition-layout.ts | 50 +++ apps/web/ui/dashboard/composition-slot.tsx | 69 ++++ apps/web/ui/dashboard/degree-composition.tsx | 329 +++++++----------- 16 files changed, 1100 insertions(+), 214 deletions(-) create mode 100644 apps/web/hooks/use-element-size.ts create mode 100644 apps/web/lib/coursemap/degree-composition.ts create mode 100644 apps/web/tests/composition-layout.test.mjs create mode 100644 apps/web/tests/degree-composition.test.mjs create mode 100644 apps/web/ui/dashboard/composition-block.tsx create mode 100644 apps/web/ui/dashboard/composition-invitation.tsx create mode 100644 apps/web/ui/dashboard/composition-layout.ts create mode 100644 apps/web/ui/dashboard/composition-slot.tsx diff --git a/apps/web/app/dashboard/dashboard.tsx b/apps/web/app/dashboard/dashboard.tsx index 074e3f6a..9dbef930 100644 --- a/apps/web/app/dashboard/dashboard.tsx +++ b/apps/web/app/dashboard/dashboard.tsx @@ -19,6 +19,8 @@ import { 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 { degreeComposition } from "@/lib/coursemap/degree-composition"; import { cumulativeDashboardUnits, currentDashboardTermId, @@ -70,7 +72,13 @@ function finishLabelFor( return `${term?.name ?? last.label} ${last.year}`; } -export function Dashboard({ catalogue }: { catalogue: PlanCatalogue }) { +export function Dashboard({ + catalogue, + choices, +}: { + catalogue: PlanCatalogue; + choices: OnboardingCatalogue; +}) { const { state } = useCoursemap(); const previewMetrics = process.env.NODE_ENV === "development"; const degree = catalogue.degrees.find( @@ -159,6 +167,31 @@ export function Dashboard({ catalogue }: { catalogue: PlanCatalogue }) { [catalogue.structureRequirements, planningCatalogue, state.attempts], ); + const composition = useMemo(() => { + const inYear = (items: T[]) => + items.filter((item) => item.catalogueYear === catalogue.academicYear); + return degreeComposition({ + degreeUnits: unitTarget, + profile: state.profile, + programme: + inYear(choices.degrees).find( + (item) => item.code === state.profile.degreeCode, + ) ?? null, + structureOptions: [...inYear(choices.majors), ...inYear(choices.minors)], + requirements: catalogue.structureRequirements, + attempts: state.attempts, + catalogue: planningCatalogue, + }); + }, [ + catalogue.academicYear, + catalogue.structureRequirements, + choices, + planningCatalogue, + state.attempts, + state.profile, + unitTarget, + ]); + const academicInputs = useMemo( () => ({ ...planningCatalogue, attempts: state.attempts }), [planningCatalogue, state.attempts], @@ -313,6 +346,8 @@ export function Dashboard({ catalogue }: { catalogue: PlanCatalogue }) {
[ course.code, diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 3d65cf27..55e69441 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1,5 +1,6 @@ import { PlanningCatalogueError } from "@/ui/plan/planning-catalogue-error"; import { loadCurrentUserPlanCatalogue } from "@/lib/coursemap/plan-catalogue"; +import { loadOnboardingCatalogue } from "@/lib/coursemap/onboarding-catalogue"; import { Dashboard } from "./dashboard"; export const dynamic = "force-dynamic"; @@ -9,13 +10,17 @@ export const dynamic = "force-dynamic"; * which offers onboarding, rather than being redirected into it. */ export default async function DashboardPage() { - let catalogue; + let data; try { - catalogue = await loadCurrentUserPlanCatalogue(); + const [catalogue, choices] = await Promise.all([ + loadCurrentUserPlanCatalogue(), + loadOnboardingCatalogue(), + ]); + data = { catalogue, choices }; } catch { return ( ); } - return ; + return ; } diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index f440f7f2..19aa9faa 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -1,5 +1,6 @@ import { PlanningCatalogueError } from "@/ui/plan/planning-catalogue-error"; import { + isPlanStructureKind, loadCurrentUserPlanCatalogue, planCourseFromDetails, } from "@/lib/coursemap/plan-catalogue"; @@ -10,7 +11,12 @@ import { Requirements } from "./requirements"; export const dynamic = "force-dynamic"; -export default async function RequirementsPage() { +export default async function RequirementsPage({ + searchParams, +}: { + searchParams: Promise<{ tab?: string }>; +}) { + const { tab } = await searchParams; let data; try { const [catalogue, choices] = await Promise.all([ @@ -50,5 +56,10 @@ export default async function RequirementsPage() { ); } - return ; + return ( + + ); } diff --git a/apps/web/app/requirements/requirements.tsx b/apps/web/app/requirements/requirements.tsx index ae66829e..4c552371 100644 --- a/apps/web/app/requirements/requirements.tsx +++ b/apps/web/app/requirements/requirements.tsx @@ -38,13 +38,16 @@ const sections = [ export function Requirements({ catalogue, choices, + initialTab = "programme", }: { catalogue: PlanCatalogue; choices: OnboardingCatalogue; + /** The section to open first, such as the major when adding one. */ + initialTab?: PlanStructureKind; }) { const { state, updateProfile, notify } = useCoursemap(); const router = useRouter(); - const [tab, setTab] = useState("programme"); + const [tab, setTab] = useState(initialTab); const [choosing, setChoosing] = useState(false); const [pending, setPending] = useState(false); const [addingCourse, setAddingCourse] = useState(null); diff --git a/apps/web/hooks/use-element-size.ts b/apps/web/hooks/use-element-size.ts new file mode 100644 index 00000000..fe1296ce --- /dev/null +++ b/apps/web/hooks/use-element-size.ts @@ -0,0 +1,29 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; + +/** + * The rendered size of an element, following resizes. Until the first + * measurement it reports `fallback`, so server and first client render agree. + */ +export function useElementSize(fallback: { + width: number; + height: number; +}) { + const ref = useRef(null); + const [size, setSize] = useState(fallback); + useEffect(() => { + const element = ref.current; + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + setSize((current) => + current.width === width && current.height === height + ? current + : { width, height }, + ); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + return [ref, size] as const; +} diff --git a/apps/web/lib/coursemap/degree-composition.ts b/apps/web/lib/coursemap/degree-composition.ts new file mode 100644 index 00000000..528e707b --- /dev/null +++ b/apps/web/lib/coursemap/degree-composition.ts @@ -0,0 +1,228 @@ +import type { + PlanRequirementNode, + PlanStructureKind, + PlanStructureRequirements, +} from "@/lib/coursemap/plan-catalogue"; +import type { ProgrammeOption } from "@/lib/coursemap/onboarding-catalogue"; +import type { Attempt, Profile } from "@/lib/coursemap/types"; +import { + creditedAttempts, + requirementNodeMatcher, + requirementTargetUnits, +} from "@/lib/coursemap/requirement-progress"; +import type { PlanningCatalogue } from "@/lib/planner"; + +/** Most ANU courses are worth 6 units, so empty space is shown in 6-unit slots. */ +export const COMPOSITION_SLOT_UNITS = 6; + +export type CompositionKind = "core" | "major" | "minor" | "electives"; + +export type CompositionCourse = { + code: string; + name: string; + units: number; + status: "completed" | "planned"; +}; + +export type CompositionSection = { + key: string; + kind: CompositionKind; + /** The chosen major or minor, when the section belongs to one. */ + structureName: string | null; + /** Units the section occupies in the degree, or null when unknown. */ + targetUnits: number | null; + /** True when the section is an invitation to choose a major or minor. */ + unchosen: boolean; + courses: CompositionCourse[]; +}; + +type Matcher = NonNullable>; + +function structureKindsWithin(node: PlanRequirementNode): PlanStructureKind[] { + if (node.type === "group") return node.children.flatMap(structureKindsWithin); + const kind = node.structureKind; + return kind === "major" || kind === "minor" ? [kind] : []; +} + +/** + * Reads the programme's top-level rules: course rules become the programme + * core, and rules that ask for a major or minor state how many units to reserve + * for one before it is chosen. + */ +function programmeShape(root: PlanRequirementNode | null) { + const core = { targetUnits: 0, matchers: [] as Matcher[] }; + const reserved: Partial> = {}; + const children = root?.type === "group" ? root.children : []; + children.forEach((node) => { + const kinds = structureKindsWithin(node); + if (kinds.length > 0) { + const units = requirementTargetUnits(node); + if (units !== null) reserved[kinds[0] as "major" | "minor"] = units; + return; + } + const matcher = requirementNodeMatcher(node); + if (!matcher) return; + core.matchers.push(matcher); + core.targetUnits += requirementTargetUnits(node) ?? 0; + }); + return { core, reserved }; +} + +/** + * How the student's degree divides into programme core, major, minors and + * electives, with each plan course placed in the first section it counts + * toward that still has room. Courses that fit nowhere else, or overflow a + * full section, fall to electives, as they do in an ANU degree. + * + * Like the requirements panel, this is an indicative reading of published + * rules rather than a formal audit. + */ +export function degreeComposition({ + degreeUnits, + profile, + programme, + structureOptions, + requirements, + attempts, + catalogue, +}: { + degreeUnits: number | null; + profile: Pick; + /** The programme's offered majors and minors, when known. */ + programme: Pick | null; + /** Majors and minors for the plan's year, for names and unit sizes. */ + structureOptions: readonly Pick[]; + requirements: readonly PlanStructureRequirements[]; + attempts: readonly Attempt[]; + catalogue: PlanningCatalogue; +}): CompositionSection[] { + const requirementsFor = (code: string, kind: PlanStructureKind) => + requirements.find( + (item) => item.structureCode === code && item.structureKind === kind, + )?.root ?? null; + const { core, reserved } = programmeShape( + requirementsFor(profile.degreeCode, "programme"), + ); + + const sections: CompositionSection[] = []; + const matchers = new Map(); + const add = ( + section: Omit, + matches: Matcher | null, + ) => { + sections.push({ ...section, courses: [] }); + if (matches) matchers.set(section.key, matches); + }; + + if (core.matchers.length > 0) { + add( + { + key: "core", + kind: "core", + structureName: null, + targetUnits: core.targetUnits || null, + unchosen: false, + }, + (course) => core.matchers.some((matches) => matches(course)), + ); + } + + const chosen = (kind: "major" | "minor", code: string, index: number) => { + const option = structureOptions.find((item) => item.code === code); + const root = requirementsFor(code, kind); + add( + { + key: `${kind}-${code}`, + kind, + structureName: option?.name ?? code, + // A second minor cannot borrow the first one's programme reservation. + targetUnits: + option?.units ?? + (root ? requirementTargetUnits(root) : null) ?? + (index === 0 ? (reserved[kind] ?? null) : null), + unchosen: false, + }, + root ? requirementNodeMatcher(root) : null, + ); + }; + const invitation = (kind: "major" | "minor") => { + const offered = + kind === "major" ? programme?.majorCodes : programme?.minorCodes; + if (reserved[kind] === undefined && !offered?.length) return; + add( + { + key: `${kind}-unchosen`, + kind, + structureName: null, + targetUnits: reserved[kind] ?? null, + unchosen: true, + }, + null, + ); + }; + + if (profile.majorCode) chosen("major", profile.majorCode, 0); + else invitation("major"); + if (profile.minorCodes.length > 0) { + profile.minorCodes.forEach((code, index) => chosen("minor", code, index)); + } else { + invitation("minor"); + } + + // Only a minor the programme requires takes units from electives before it + // is chosen; an optional one is offered without reserving space. + const allocated = sections.reduce( + (total, section) => + section.kind === "minor" && + section.unchosen && + reserved.minor === undefined + ? total + : total + (section.targetUnits ?? 0), + 0, + ); + const electives: CompositionSection = { + key: "electives", + kind: "electives", + structureName: null, + targetUnits: + degreeUnits === null ? null : Math.max(0, degreeUnits - allocated), + unchosen: false, + courses: [], + }; + + // Completed courses claim space first so planned ones overflow instead. + const credited = creditedAttempts(attempts, catalogue).sort( + (a, b) => + Number(b.attempt.status === "completed") - + Number(a.attempt.status === "completed") || + a.course.code.localeCompare(b.course.code), + ); + const used = new Map(); + credited.forEach(({ attempt, course, units }) => { + const home = + sections.find((section) => { + if (!matchers.get(section.key)?.(course)) return false; + const target = section.targetUnits; + return ( + target === null || (used.get(section.key) ?? 0) + units <= target + ); + }) ?? electives; + used.set(home.key, (used.get(home.key) ?? 0) + units); + home.courses.push({ + code: course.code, + name: course.name, + units, + status: attempt.status === "completed" ? "completed" : "planned", + }); + }); + + const showElectives = + (electives.targetUnits ?? 0) > 0 || electives.courses.length > 0; + return showElectives ? [...sections, electives] : sections; +} + +/** Units a section shows: its target, or what the plan puts there if larger. */ +export function compositionSectionUnits(section: CompositionSection) { + const mapped = section.courses.reduce((total, item) => total + item.units, 0); + return Math.max(section.targetUnits ?? 0, mapped); +} diff --git a/apps/web/lib/coursemap/plan-catalogue.ts b/apps/web/lib/coursemap/plan-catalogue.ts index f0aa9597..bdb51d01 100644 --- a/apps/web/lib/coursemap/plan-catalogue.ts +++ b/apps/web/lib/coursemap/plan-catalogue.ts @@ -128,7 +128,7 @@ type CatalogueRecordRow = { id: number; }; -function isPlanStructureKind(value: string): value is PlanStructureKind { +export function isPlanStructureKind(value: string): value is PlanStructureKind { return ["programme", "major", "minor", "specialisation"].includes(value); } diff --git a/apps/web/lib/coursemap/requirement-progress.ts b/apps/web/lib/coursemap/requirement-progress.ts index c2766e39..cf1a5f9d 100644 --- a/apps/web/lib/coursemap/requirement-progress.ts +++ b/apps/web/lib/coursemap/requirement-progress.ts @@ -111,6 +111,23 @@ function collectPredicates(node: PlanRequirementNode): CoursePredicate[] { return node.children.flatMap(collectPredicates); } +/** + * Whether a course is named by, or falls under a subject or level rule + * anywhere inside, the node. Returns null when nothing inside is measurable. + */ +export function requirementNodeMatcher( + node: PlanRequirementNode, +): CoursePredicate | null { + const predicates = collectPredicates(node); + if (predicates.length === 0) return null; + return (course) => predicates.some((matches) => matches(course)); +} + +/** The unit goal a top-level requirement states, from its bounds or prose. */ +export function requirementTargetUnits(node: PlanRequirementNode) { + return bucketTargetUnits(node); +} + /** * Published rule prose leads with its unit count — "30 units from the * completion of the following compulsory courses". The lead is dropped from the @@ -269,13 +286,14 @@ export function requirementNodeKey(node: PlanRequirementNode) { return `${node.type}-${node.id}`; } -type CreditedAttempt = { +export type CreditedAttempt = { attempt: Attempt; - course: MatchableCourse & { units?: number }; + course: MatchableCourse & { name: string; units?: number }; units: number; }; -function creditedAttempts( +/** Active plan entries, one per course, with the catalogue row they credit. */ +export function creditedAttempts( attempts: readonly Attempt[], catalogue: PlanningCatalogue, ): CreditedAttempt[] { diff --git a/apps/web/tests/composition-layout.test.mjs b/apps/web/tests/composition-layout.test.mjs new file mode 100644 index 00000000..d0179a2d --- /dev/null +++ b/apps/web/tests/composition-layout.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +const { compositionLayout } = + await import("../ui/dashboard/composition-layout.ts"); + +test("the largest section takes the left column and the rest stack by size", () => { + const [major, core, electives] = compositionLayout([24, 84, 60], 600, 240); + assert.deepEqual([core.x, core.y, core.height], [0, 0, 240]); + assert.equal(electives.x, major.x); + assert.equal(electives.y, 0); + assert.equal(major.y, electives.height); + assert.ok(electives.height > major.height); +}); + +test("with four sections the two largest each get a column", () => { + const rects = compositionLayout([72, 36, 24, 12], 1440, 480); + assert.equal(rects[0].x, 0); + assert.equal(rects[1].height, 480); + assert.equal(rects[2].x, rects[3].x); +}); diff --git a/apps/web/tests/degree-composition.test.mjs b/apps/web/tests/degree-composition.test.mjs new file mode 100644 index 00000000..b634267a --- /dev/null +++ b/apps/web/tests/degree-composition.test.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +const { degreeComposition } = + await import("../lib/coursemap/degree-composition.ts"); + +function course(code, level) { + return { + code, + name: `${code} name`, + year: 2026, + units: 6, + level, + subject: code.slice(0, 4), + }; +} + +const catalogue = { + courses: [ + course("COMP1100", 1000), + course("COMP1110", 1000), + course("COMP2100", 2000), + course("MATH1005", 1000), + course("ARTH1006", 1000), + ], + terms: [{ id: "2026-s1", year: 2026, name: "Semester 1", shortName: "S1" }], +}; + +const attempt = (courseCode, status) => ({ + id: courseCode, + courseCode, + termId: "2026-s1", + academicYear: 2026, + status, +}); + +function condition(id, overrides) { + return { + type: "condition", + id, + conditionKind: "course_set_units", + freeText: null, + maximumLevel: null, + maximumUnits: null, + minimumCourses: null, + minimumLevel: null, + minimumUnits: null, + options: [], + position: id, + projectionKey: `c${id}`, + sourceLocator: "", + sourceText: "", + structureKind: null, + subjectCode: null, + tag: null, + ...overrides, + }; +} + +const root = (children) => ({ + type: "group", + id: 100, + operator: "all_of", + children, + description: null, + groupKey: "root", + maximumUnits: null, + minimumCount: null, + minimumUnits: null, + position: 0, + sourceLocator: "", + sourceText: "", + title: null, +}); + +const option = (code) => ({ + code, + kind: "course", + position: 0, + structureKind: null, +}); + +const programme = { + structureCode: "BCOMP", + structureKind: "programme", + structureName: "Bachelor of Computing", + snapshotId: 1, + unmodelled: [], + root: root([ + condition(1, { + minimumUnits: 12, + options: [option("MATH1005"), option("COMP1100")], + }), + condition(2, { + conditionKind: "other", + structureKind: "major", + minimumUnits: 48, + }), + ]), +}; + +const major = { + structureCode: "COMP-MAJ", + structureKind: "major", + structureName: "Computer Science", + snapshotId: 2, + unmodelled: [], + root: root([ + condition(3, { + conditionKind: "subject_units", + subjectCode: "COMP", + minimumUnits: 48, + }), + ]), +}; + +function compose( + profile, + attempts, + offered = { majorCodes: [], minorCodes: [] }, +) { + return degreeComposition({ + degreeUnits: 144, + profile: { degreeCode: "BCOMP", majorCode: "", minorCodes: [], ...profile }, + programme: offered, + structureOptions: [ + { code: "COMP-MAJ", name: "Computer Science", units: 48 }, + ], + requirements: [programme, major], + attempts, + catalogue, + }); +} + +test("orders programme core, major and electives and sizes electives from the rest", () => { + const sections = compose({ majorCode: "COMP-MAJ" }, []); + assert.deepEqual( + sections.map((section) => [section.kind, section.targetUnits]), + [ + ["core", 12], + ["major", 48], + ["electives", 84], + ], + ); + assert.equal(sections[1].structureName, "Computer Science"); +}); + +test("places each course in the first section with room and overflows to electives", () => { + const sections = compose({ majorCode: "COMP-MAJ" }, [ + attempt("MATH1005", "completed"), + attempt("COMP1100", "completed"), + attempt("COMP1110", "planned"), + attempt("ARTH1006", "planned"), + ]); + const codes = Object.fromEntries( + sections.map((section) => [ + section.kind, + section.courses.map((item) => item.code), + ]), + ); + assert.deepEqual(codes.core, ["COMP1100", "MATH1005"]); + assert.deepEqual(codes.major, ["COMP1110"]); + assert.deepEqual(codes.electives, ["ARTH1006"]); + assert.equal(sections[0].courses[0].status, "completed"); +}); + +test("a required major is reserved and offered before it is chosen", () => { + const major = compose({}, []).find((section) => section.kind === "major"); + assert.equal(major.unchosen, true); + assert.equal(major.targetUnits, 48); +}); + +test("an optional minor is offered without taking units from electives", () => { + const sections = compose({ majorCode: "COMP-MAJ" }, [], { + majorCodes: ["COMP-MAJ"], + minorCodes: ["ARTH-MIN"], + }); + const minor = sections.find((section) => section.kind === "minor"); + assert.equal(minor.unchosen, true); + assert.equal(minor.targetUnits, null); + assert.equal(sections.at(-1).targetUnits, 84); +}); diff --git a/apps/web/ui/dashboard/composition-block.module.css b/apps/web/ui/dashboard/composition-block.module.css index 6e33508b..6cf0058b 100644 --- a/apps/web/ui/dashboard/composition-block.module.css +++ b/apps/web/ui/dashboard/composition-block.module.css @@ -27,12 +27,16 @@ font-size: 14px; line-height: 18px; } +.name, .units { - display: flex; - flex-direction: column; font-size: 12px; line-height: 16px; } +.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .slots { position: absolute; inset: 6px; @@ -53,11 +57,48 @@ background: transparent; color: var(--color-foreground); opacity: 0; - transition: opacity 220ms cubic-bezier(0.22, 1, 0.36, 1); + transition: + opacity 220ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 120ms ease-out, + transform 120ms ease-out; +} +.code { + transition: transform 120ms ease-out; +} +.openIcon { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + transition: opacity 120ms ease-out; +} +.slot[data-status="Completed"]:hover, +.slot[data-status="Completed"]:focus-visible { + background-color: rgb(16 185 129 / 0.16); +} +.slot[data-status="Planned"]:hover, +.slot[data-status="Planned"]:focus-visible { + background-color: rgb(139 92 246 / 0.18); +} +.slot:not([data-status="Unallocated"]):hover, +.slot:not([data-status="Unallocated"]):focus-visible { + transform: translateY(-1px); +} +.slot:hover .openIcon, +.slot:focus-visible .openIcon { + opacity: 1; +} +.slot[data-status="Unallocated"]:hover, +.slot[data-status="Unallocated"]:focus-visible { + border-color: var(--color-muted-foreground); } .slot[data-status="Completed"] { border-color: #10b981; } +.slot[data-status="More"] { + border: 1px solid var(--color-border); + color: var(--color-muted-foreground); +} .slot[data-status="Unallocated"] { border: 1px dashed var(--color-border); color: var(--color-muted-foreground); @@ -66,7 +107,8 @@ opacity: 0; pointer-events: none; } -.block[data-open="true"] .units { +.block[data-open="true"] .units, +.block[data-open="true"] .name { display: none; } .block[data-open="true"] .heading { @@ -85,9 +127,15 @@ @media (prefers-reduced-motion: reduce) { .block, .slot, + .code, + .openIcon, .trigger { transition: none; } + .slot:not([data-status="Unallocated"]):hover, + .slot:not([data-status="Unallocated"]):focus-visible { + transform: none; + } } .emptyLabel { @@ -121,3 +169,48 @@ transition: none; } } + +/* An unchosen major or minor. Like the other blocks, it clears to the card on + hover, where a dashed place invites the student to choose one. */ +.invitation { + display: block; + color: #172033; + transition: background-color 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.invitation .heading { + transition: opacity 120ms ease-out; +} +.invitationAction { + position: absolute; + inset: 6px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed var(--color-muted-foreground); + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: var(--color-foreground); + opacity: 0; + transition: opacity 120ms ease-out; +} +.invitation:hover, +.invitation:focus-visible { + background-color: var(--color-card); +} +.invitation:hover .heading, +.invitation:focus-visible .heading { + opacity: 0; +} +.invitation:hover .invitationAction, +.invitation:focus-visible .invitationAction { + opacity: 1; +} +@media (prefers-reduced-motion: reduce) { + .invitation, + .invitation .heading, + .invitationAction { + transition: none; + } +} diff --git a/apps/web/ui/dashboard/composition-block.tsx b/apps/web/ui/dashboard/composition-block.tsx new file mode 100644 index 00000000..58cc45b6 --- /dev/null +++ b/apps/web/ui/dashboard/composition-block.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { + COMPOSITION_SLOT_UNITS, + compositionSectionUnits, + type CompositionSection, +} from "@/lib/coursemap/degree-composition"; +import { CompositionSlot } from "./composition-slot"; +import styles from "./composition-block.module.css"; + +/** Inset and gap of the slot grid, matching composition-block.module.css. */ +const SLOT_INSET = 6; +const SLOT_GAP = 5; +const MIN_SLOT = { width: 56, height: 30 }; +/** Slots read best a little wider than tall. */ +const SLOT_ASPECT = 1.5; + +/** + * The slot grid for a block: as many cells as fit at a readable size, filled + * edge to edge. Courses always get a cell (or a "more" cell); empty 6-unit + * places are only a hint of remaining room, so surplus ones are left out. + */ +function slotGrid( + courses: number, + slots: number, + width: number, + height: number, +) { + const areaWidth = width - SLOT_INSET * 2; + const areaHeight = height - SLOT_INSET * 2; + const fit = (size: number, minimum: number) => + Math.max(1, Math.floor((size + SLOT_GAP) / (minimum + SLOT_GAP))); + const maxColumns = fit(areaWidth, MIN_SLOT.width); + const maxRows = fit(areaHeight, MIN_SLOT.height); + let best = { columns: 1, rows: 1, score: Infinity }; + for (let columns = 1; columns <= maxColumns; columns += 1) { + for (let rows = 1; rows <= maxRows; rows += 1) { + const cells = columns * rows; + // Never add empty places beyond the section's room, and never hide a + // course when a larger grid could still show it. + if (cells > Math.max(slots, 1)) continue; + const cellWidth = (areaWidth - (columns - 1) * SLOT_GAP) / columns; + const cellHeight = (areaHeight - (rows - 1) * SLOT_GAP) / rows; + const shape = Math.abs(Math.log(cellWidth / cellHeight / SLOT_ASPECT)); + const hidden = slots - cells; + const hiddenCourses = Math.max(0, courses - cells); + const score = shape + hidden * 0.08 + hiddenCourses * 10; + if (score < best.score) best = { columns, rows, score }; + } + } + return { columns: best.columns, rows: best.rows }; +} + +/** + * A section of the degree that opens into its courses on hover or activation, + * with an empty slot for each 6 units still to fill. + */ +export function CompositionBlock({ + section, + title, + colour, + width, + height, + courseHref, + emptyHref, +}: { + section: CompositionSection; + title: string; + colour: string; + /** Rendered size of the block in pixels, to fit its slots. */ + width: number; + height: number; + courseHref: (code: string) => string; + emptyHref: string; +}) { + const [open, setOpen] = useState(false); + const units = compositionSectionUnits(section); + const mapped = section.courses.reduce((total, item) => total + item.units, 0); + const emptySlots = Math.ceil( + Math.max(0, units - mapped) / COMPOSITION_SLOT_UNITS, + ); + const total = section.courses.length + emptySlots; + const { columns, rows } = slotGrid( + section.courses.length, + total, + width, + height, + ); + const cells = columns * rows; + // When courses outnumber the cells, the last cell counts the rest. + const overflow = + section.courses.length > cells ? section.courses.length - cells + 1 : 0; + const slots = [ + ...section.courses, + ...Array.from({ length: emptySlots }, () => null), + ].slice(0, overflow ? cells - 1 : cells); + const summary = [ + title, + section.structureName, + `${units} units`, + `${mapped} mapped`, + ].filter(Boolean); + return ( +
{ + if (event.pointerType === "mouse") setOpen(true); + }} + onPointerLeave={() => setOpen(false)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setOpen(false); + event.currentTarget.querySelector("button")?.focus(); + } + }} + > + +
+ {slots.map((course, index) => ( + + ))} + {overflow ? ( + + +{overflow} + + ) : null} +
+
+ ); +} diff --git a/apps/web/ui/dashboard/composition-invitation.tsx b/apps/web/ui/dashboard/composition-invitation.tsx new file mode 100644 index 00000000..eb62c103 --- /dev/null +++ b/apps/web/ui/dashboard/composition-invitation.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import { Plus } from "lucide-react"; +import styles from "./composition-block.module.css"; + +/** A place in the degree for a major or minor the student has not chosen. */ +export function CompositionInvitation({ + title, + action, + units, + href, + colour, +}: { + title: string; + /** What choosing does, such as "Add a major". */ + action: string; + units: number | null; + href: string; + /** Background colour class, matching the chosen section's fill. */ + colour: string; +}) { + return ( + + + {title} + + {units === null ? "Optional" : `${units} units`} + + + + + ); +} diff --git a/apps/web/ui/dashboard/composition-layout.ts b/apps/web/ui/dashboard/composition-layout.ts new file mode 100644 index 00000000..22cf0f6d --- /dev/null +++ b/apps/web/ui/dashboard/composition-layout.ts @@ -0,0 +1,50 @@ +export type LayoutRect = { + x: number; + y: number; + width: number; + height: number; +}; + +/** + * Lays values out largest first: the largest takes a full-height column on the + * left (the two largest when there are four or more), and the rest stack in a + * final column, largest on top. Widths and heights follow the values, so the + * area reads as a treemap while smaller sections sit beneath each other rather + * than becoming thin columns. Returns one rectangle per value, in input order. + */ +export function compositionLayout( + values: readonly number[], + width: number, + height: number, +): LayoutRect[] { + const rects: LayoutRect[] = values.map(() => ({ + x: 0, + y: 0, + width: 0, + height: 0, + })); + const total = values.reduce((sum, value) => sum + value, 0); + if (total <= 0) return rects; + const order = values + .map((value, index) => ({ value, index })) + .sort((a, b) => b.value - a.value || a.index - b.index); + const solo = order.length >= 4 ? 2 : Math.min(1, order.length - 1); + const columns = [ + ...order.slice(0, solo).map((item) => [item]), + order.slice(solo), + ].filter((column) => column.length > 0); + + let x = 0; + columns.forEach((column) => { + const columnTotal = column.reduce((sum, item) => sum + item.value, 0); + const columnWidth = (columnTotal / total) * width; + let y = 0; + column.forEach((item) => { + const itemHeight = (item.value / columnTotal) * height; + rects[item.index] = { x, y, width: columnWidth, height: itemHeight }; + y += itemHeight; + }); + x += columnWidth; + }); + return rects; +} diff --git a/apps/web/ui/dashboard/composition-slot.tsx b/apps/web/ui/dashboard/composition-slot.tsx new file mode 100644 index 00000000..41ed53a0 --- /dev/null +++ b/apps/web/ui/dashboard/composition-slot.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { ArrowUpRight, Plus } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@coursemap/ui/primitives/tooltip"; +import type { CompositionCourse } from "@/lib/coursemap/degree-composition"; +import styles from "./composition-block.module.css"; + +const statusLabel = { completed: "Completed", planned: "Planned" } as const; + +/** One course in an expanded composition block, or an empty place for one. */ +export function CompositionSlot({ + course, + href, + emptyLabel, + emptyUnits, +}: { + course: CompositionCourse | null; + href: string; + /** Accessible name for an empty slot, naming where it leads. */ + emptyLabel: string; + emptyUnits: number; +}) { + if (!course) { + return ( + + {emptyUnits} units +