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 +