diff --git a/apps/web/app/courses/[code]/course-detail-client.tsx b/apps/web/app/courses/[code]/course-detail-client.tsx index dfd3314b..4ba2e60d 100644 --- a/apps/web/app/courses/[code]/course-detail-client.tsx +++ b/apps/web/app/courses/[code]/course-detail-client.tsx @@ -33,19 +33,6 @@ export function CourseDetailClient({ courseTabFromSearch(searchParams.get("tab")), ); const [planOpen, setPlanOpen] = useState(false); - const completedCodes = new Set( - state.attempts - .filter((attempt) => attempt.status === "completed") - .map((attempt) => attempt.courseCode), - ); - const plannedCodes = new Set( - state.attempts - .filter( - (attempt) => - attempt.status === "planned" || attempt.status === "enrolled", - ) - .map((attempt) => attempt.courseCode), - ); useEffect(() => { const syncTabFromHistory = () => { @@ -75,10 +62,9 @@ export function CourseDetailClient({ > }> setPlanOpen(true)} - plannedCodes={plannedCodes} requisiteCompletion={requisiteCompletion} /> {planOpen ? ( diff --git a/apps/web/app/structures/[code]/error.tsx b/apps/web/app/structures/[code]/error.tsx new file mode 100644 index 00000000..c2edc48a --- /dev/null +++ b/apps/web/app/structures/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error"; + +export default function StructureError({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/structures/[code]/loading.tsx b/apps/web/app/structures/[code]/loading.tsx new file mode 100644 index 00000000..b71dbead --- /dev/null +++ b/apps/web/app/structures/[code]/loading.tsx @@ -0,0 +1,39 @@ +import { Card } from "@coursemap/ui/primitives/card"; +import { Skeleton } from "@coursemap/ui/primitives/skeleton"; +import { TabsLoading } from "@/ui/common/tabs-loading"; +import { AppShell } from "@/ui/shell"; + +export default function StructureLoading() { + return ( + } + > +
+ Loading programme +
+ + +
+
+
+ {[0, 1].map((index) => ( + + +
+ + + +
+
+ ))} +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/app/structures/[code]/page.tsx b/apps/web/app/structures/[code]/page.tsx new file mode 100644 index 00000000..5cf9bd4e --- /dev/null +++ b/apps/web/app/structures/[code]/page.tsx @@ -0,0 +1,62 @@ +import { notFound } from "next/navigation"; + +import { requirementCourseCodes } from "@/lib/coursemap/requirement-display"; +import { planCourseFromDetails } from "@/lib/coursemap/plan-catalogue"; +import { loadPublishedCoursesByCodes } from "@/lib/coursemap/published-courses"; +import { + loadPublishedStructure, + loadPublishedStructureYears, +} from "@/lib/coursemap/published-structures"; +import type { Course } from "@/lib/coursemap/types"; +import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error"; +import { StructureDetailClient } from "./structure-detail-client"; + +export default async function StructurePage({ + params, + searchParams, +}: { + params: Promise<{ code: string }>; + searchParams: Promise<{ year?: string | string[] }>; +}) { + const { code } = await params; + const requestedYearParam = (await searchParams).year; + const requestedYear = Number( + Array.isArray(requestedYearParam) + ? requestedYearParam[0] + : requestedYearParam, + ); + + let structure = null; + let courses: Course[] = []; + try { + const years = await loadPublishedStructureYears(code); + if (years.length === 0) notFound(); + const thisYear = new Date().getFullYear(); + const academicYear = years.includes(requestedYear) + ? requestedYear + : years.includes(thisYear) + ? thisYear + : years[0]; + structure = await loadPublishedStructure(code, academicYear); + if (structure) { + // The option cards read better with a title and a unit value, so the + // courses the tree names are resolved once here rather than per card. + const details = await loadPublishedCoursesByCodes( + requirementCourseCodes(structure.requirements), + academicYear, + ); + courses = details.map(planCourseFromDetails); + } + } catch { + return ( + + ); + } + + if (!structure) notFound(); + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/structures/[code]/structure-detail-client.tsx b/apps/web/app/structures/[code]/structure-detail-client.tsx new file mode 100644 index 00000000..157e12c0 --- /dev/null +++ b/apps/web/app/structures/[code]/structure-detail-client.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Tabs } from "@coursemap/ui/primitives/tabs"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import type { Course } from "@/lib/coursemap/types"; +import type { StructureDetails } from "@/lib/coursemap/structure-types"; +import { readingTreeContext } from "@/ui/requirements/requirement-presentation"; +import { + StructureDetailTabsList, + StructureDetailView, + structureTabFromSearch, + type StructureTab, +} from "@/ui/requirements/structure-detail-view"; +import { AppShell } from "@/ui/shell"; + +export function StructureDetailClient({ + structure, + courses, +}: { + structure: StructureDetails; + courses: Course[]; +}) { + const searchParams = useSearchParams(); + const [activeTab, setActiveTab] = useState(() => + structureTabFromSearch(searchParams.get("tab")), + ); + + useEffect(() => { + const syncTabFromHistory = () => { + setActiveTab( + structureTabFromSearch( + new URL(window.location.href).searchParams.get("tab"), + ), + ); + }; + window.addEventListener("popstate", syncTabFromHistory); + return () => window.removeEventListener("popstate", syncTabFromHistory); + }, []); + + const selectTab = (tab: StructureTab) => { + setActiveTab(tab); + const url = new URL(window.location.href); + if (tab === "overview") url.searchParams.delete("tab"); + else url.searchParams.set("tab", tab); + window.history.pushState({}, "", `${url.pathname}${url.search}${url.hash}`); + }; + + return ( + selectTab(value as StructureTab)} + className="gap-0" + > + } + breadcrumbSegmentLabels={{ structures: null }} + currentBreadcrumbLabel={structure.name} + > + + + + ); +} diff --git a/apps/web/lib/coursemap/course-types.ts b/apps/web/lib/coursemap/course-types.ts index dc0c3299..dd61fb41 100644 --- a/apps/web/lib/coursemap/course-types.ts +++ b/apps/web/lib/coursemap/course-types.ts @@ -185,6 +185,12 @@ export type CourseDetails = { /** Published courses which can be opened from requisite prose. */ availableCourseCodes: string[]; incompatibilityText: string; + /** + * Whether the reverse lookup for courses this one unlocks actually ran. It + * only runs over published courses, so a draft cannot tell an empty result + * from an unasked question and must say so rather than imply nothing. + */ + unlocksAreKnown: boolean; sourceUrl: string; sourceUpdatedAt: string | null; publicationStatus: "published" | "draft"; diff --git a/apps/web/lib/coursemap/published-courses.ts b/apps/web/lib/coursemap/published-courses.ts index 5a65dd33..72f635bc 100644 --- a/apps/web/lib/coursemap/published-courses.ts +++ b/apps/web/lib/coursemap/published-courses.ts @@ -879,6 +879,9 @@ function detailAsCourseDetails( sessions, sourceUpdatedAt: readNullableString(snapshot.sourceUpdatedAt), sourceUrl: sourceUrl(academicYear, code), + // The published detail always carries a graph array, even when empty. A + // draft projection has no key at all, so the reverse lookup never ran. + unlocksAreKnown: Array.isArray(value.prerequisiteEdges), subject: readString(snapshot.subjectCode, code.slice(0, 4)), subjectName: readNullableString(snapshot.subjectName), unitValue, @@ -1219,6 +1222,8 @@ async function loadListRelationships( sessions: sessionNames, sourceUpdatedAt: snapshot.source_updated_at, sourceUrl: sourceUrl(year.year, code), + // The list query reads prerequisite references only, never the reverse. + unlocksAreKnown: false, subject: snapshot.subject_code ?? code.slice(0, 4), subjectName: snapshot.subject_name, unitValue, diff --git a/apps/web/lib/coursemap/published-structures.ts b/apps/web/lib/coursemap/published-structures.ts new file mode 100644 index 00000000..2dff56eb --- /dev/null +++ b/apps/web/lib/coursemap/published-structures.ts @@ -0,0 +1,251 @@ +import "server-only"; +import { unstable_cache } from "next/cache"; +import { createPublicClient } from "@/lib/supabase/public-server"; +import type { Json } from "@/types/database"; +import { requirementTreeFromSource } from "@/lib/coursemap/requirement-write-tree"; +import { + REQUIREMENT_SOURCE_SECTION_KEYS, + type StructureDetails, + type StructureKind, +} from "@/lib/coursemap/structure-types"; + +const STRUCTURE_CODE_PATTERN = /^[A-Z0-9][A-Z0-9-]{1,31}$/u; +const STRUCTURE_KINDS: StructureKind[] = [ + "programme", + "major", + "minor", + "specialisation", +]; + +type LooseRpcClient = { + rpc: ( + name: string, + args: Record, + ) => Promise<{ data: Json | null; error: { message: string } | null }>; +}; + +function isRecord( + value: Json | undefined, +): value is { [key: string]: Json | undefined } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readArray(value: Json | undefined) { + return Array.isArray(value) ? value : []; +} +function readRecords(value: Json | undefined) { + return readArray(value).filter(isRecord); +} +function readString(value: Json | undefined, fallback = "") { + return typeof value === "string" ? value : fallback; +} +function readNullableString(value: Json | undefined) { + return typeof value === "string" && value.trim() ? value : null; +} +function readNumber(value: Json | undefined, fallback = 0) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} +function readNullableNumber(value: Json | undefined) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * The stored requirement projection, reshaped for the shared tree adapter so + * the published page and the import preview build the same nodes. + */ +function requirementTree( + requirements: Json | undefined, + optionTitles: Json | undefined, +) { + if (!isRecord(requirements)) return null; + const titles = isRecord(optionTitles) ? optionTitles : {}; + return requirementTreeFromSource( + { + groups: readRecords(requirements.ruleGroups).map((group) => ({ + key: readString(group.key), + ruleKey: readNullableString(group.ruleKey), + parentKey: readNullableString(group.parentGroupKey), + label: readNullableString(group.label), + description: readNullableString(group.description), + operator: readString(group.operator, "all_of"), + minimumCount: readNullableNumber(group.minimumCount), + minimumUnits: readNullableNumber(group.minimumUnits), + maximumUnits: readNullableNumber(group.maximumUnits), + sourceText: readNullableString(group.sourceText), + position: readNumber(group.position), + })), + conditions: readRecords(requirements.ruleConditions).map((condition) => ({ + key: readString(condition.key), + groupKey: readString(condition.groupKey), + position: readNumber(condition.position), + kind: readString(condition.conditionKind, "other"), + itemCode: + readNullableString(condition.requiredCourseCode) ?? + readNullableString(condition.requiredStructureCode), + structureKind: readNullableString(condition.structureKind), + requirementMode: + readString(condition.courseRequirementMode) === + "completed_or_concurrent" + ? ("completed_or_concurrent" as const) + : readString(condition.courseRequirementMode) === "completed" + ? ("completed" as const) + : null, + minimumMark: readNullableNumber(condition.minimumMark), + minimumUnits: readNullableNumber(condition.minimumUnits), + maximumUnits: readNullableNumber(condition.maximumUnits), + minimumCount: readNullableNumber(condition.minimumCount), + subjectCode: readNullableString(condition.subjectCode), + minimumLevel: readNullableNumber(condition.minimumCourseLevel), + maximumLevel: readNullableNumber(condition.maximumCourseLevel), + minimumYear: readNullableNumber(condition.minimumYear), + minimumGpa: readNullableNumber(condition.minimumGpa), + minimumWam: readNullableNumber(condition.minimumWam), + tag: readNullableString(condition.tag), + freeText: readNullableString(condition.freeText), + sourceText: readNullableString(condition.sourceText), + })), + options: readRecords(requirements.ruleConditionCourses).map((option) => { + const code = readString(option.sourceCourseCode).toUpperCase(); + return { + conditionKey: readString(option.conditionKey), + position: readNumber(option.position), + kind: readString(option.kind, "course"), + code, + title: + readNullableString(option.title) ?? + readNullableString(titles[code]), + }; + }), + }, + "structure", + ); +} + +function structureFromProjection(value: Json): StructureDetails | null { + if (!isRecord(value)) return null; + const snapshot = isRecord(value.snapshot) ? value.snapshot : {}; + const kind = readString(value.structureKind); + if (!STRUCTURE_KINDS.includes(kind as StructureKind)) return null; + const requirements = requirementTree( + value.requirements, + value.requirementOptionTitles, + ); + return { + code: readString(value.structureCode).toUpperCase(), + kind: kind as StructureKind, + year: readNumber(value.academicYear), + name: readString(snapshot.name, readString(value.structureCode)), + acronym: readNullableString(snapshot.acronym), + shortName: readNullableString(snapshot.shortName), + introduction: readNullableString(snapshot.introduction), + description: readNullableString(snapshot.description), + units: readNullableNumber(snapshot.units), + durationYears: readNullableNumber(snapshot.durationYears), + academicCareer: readNullableString(snapshot.academicCareer), + college: readNullableString(snapshot.college), + modeOfDelivery: readNullableString(snapshot.modeOfDelivery), + selectionRank: readNullableNumber(snapshot.selectionRank), + atar: readNullableNumber(snapshot.atar), + studyAs: readNullableString(snapshot.studyAs), + contactText: readNullableString(snapshot.contactText), + sections: readRecords(value.sections) + .map((section) => ({ + position: readNumber(section.position), + sectionKey: readString(section.sectionKey), + heading: readString(section.heading), + markdown: readString(section.markdown), + })) + .filter( + (section) => + section.heading.trim().length > 0 && + section.markdown.trim().length > 0 && + // The requirement tree already carries this prose. + (!requirements || + !REQUIREMENT_SOURCE_SECTION_KEYS.includes(section.sectionKey)), + ), + learningOutcomes: readRecords(value.learningOutcomes).map((outcome) => ({ + position: readNumber(outcome.position), + outcomeText: readString(outcome.outcomeText), + })), + fees: readRecords(value.fees).map((fee) => ({ + position: readNumber(fee.position), + feeYear: readNullableNumber(fee.feeYear), + audience: readString(fee.audience, "other"), + feeType: readString(fee.feeType, "other"), + amount: readNullableNumber(fee.amount), + currency: readNullableString(fee.currency), + basis: readString(fee.basis, "unknown"), + sourceLabel: readNullableString(fee.sourceLabel), + sourceText: readNullableString(fee.sourceText), + })), + relationships: readRecords(value.relationships).map((relationship) => ({ + position: readNumber(relationship.position), + relationshipKind: readString(relationship.relationshipKind, "other"), + targetKind: readString(relationship.targetKind, "programme"), + targetCode: readString(relationship.targetCode).toUpperCase(), + targetTitle: readNullableString(relationship.targetTitle), + })), + requirements, + }; +} + +/** The published structure for one code and year, or null when none is published. */ +export async function loadPublishedStructure( + code: string, + academicYear: number, +): Promise { + const normalisedCode = code.trim().toUpperCase(); + if ( + !STRUCTURE_CODE_PATTERN.test(normalisedCode) || + !Number.isInteger(academicYear) + ) { + return null; + } + return unstable_cache( + async () => { + const client = createPublicClient() as unknown as LooseRpcClient; + const { data, error } = await client.rpc("published_structure_detail", { + p_academic_year: academicYear, + p_structure_code: normalisedCode, + }); + if (error) throw new Error(error.message); + if (!data) return null; + return structureFromProjection(data); + }, + ["published-structure-detail", String(academicYear), normalisedCode], + { + revalidate: 300, + tags: [ + "published-structure-detail", + `published-structure:${academicYear}:${normalisedCode}`, + ], + }, + )(); +} + +/** Published years for a structure code, newest first. */ +export async function loadPublishedStructureYears( + code: string, +): Promise { + const normalisedCode = code.trim().toUpperCase(); + if (!STRUCTURE_CODE_PATTERN.test(normalisedCode)) return []; + return unstable_cache( + async () => { + const client = createPublicClient() as unknown as LooseRpcClient; + const { data, error } = await client.rpc("published_structure_years", { + p_structure_code: normalisedCode, + }); + if (error) throw new Error(error.message); + return readRecords(data ?? []).map((row) => + readNumber(row.academic_year), + ); + }, + ["published-structure-years", normalisedCode], + { + revalidate: 300, + tags: [ + "published-structure-years", + `published-structure:${normalisedCode}`, + ], + }, + )(); +} diff --git a/apps/web/lib/coursemap/requirement-tree-node.ts b/apps/web/lib/coursemap/requirement-tree-node.ts new file mode 100644 index 00000000..10fb07b5 --- /dev/null +++ b/apps/web/lib/coursemap/requirement-tree-node.ts @@ -0,0 +1,37 @@ +import type { + PlanRequirementCondition, + PlanRequirementGroup, + PlanRequirementOption, +} from "@/lib/coursemap/plan-catalogue"; + +/** + * The requirement display kit reads a superset of the plan catalogue tree: + * the planner never needed a condition's mark, standing, average or the + * catalogue code it names, but a published structure and an import preview do. + * Every addition is optional, so a plan catalogue tree stays assignable and no + * conversion step sits between the two. + */ +export type RequirementTreeOption = PlanRequirementOption & { + title?: string | null; +}; + +export type RequirementTreeCondition = Omit< + PlanRequirementCondition, + "options" +> & { + options: RequirementTreeOption[]; + /** Catalogue code for course, incompatible and structure conditions. */ + itemCode?: string | null; + minimumGpa?: number | null; + minimumMark?: number | null; + minimumWam?: number | null; + minimumYear?: number | null; + requirementMode?: "completed" | "completed_or_concurrent" | null; +}; + +export type RequirementTreeGroup = Omit & { + children: RequirementTreeNode[]; +}; + +export type RequirementTreeNode = + RequirementTreeGroup | RequirementTreeCondition; diff --git a/apps/web/lib/coursemap/requirement-write-tree.ts b/apps/web/lib/coursemap/requirement-write-tree.ts new file mode 100644 index 00000000..ee36aa7b --- /dev/null +++ b/apps/web/lib/coursemap/requirement-write-tree.ts @@ -0,0 +1,171 @@ +import type { + RequirementTreeCondition, + RequirementTreeGroup, + RequirementTreeNode, +} from "@/lib/coursemap/requirement-tree-node"; + +/** + * The stored requirement shape, as both the import write and the published + * read produce it. Structural rather than imported so one adapter serves the + * administrator preview and the published structure page without either side + * converting first. + */ +export type RequirementTreeSource = { + groups: ReadonlyArray<{ + key: string; + ruleKey?: string | null; + parentKey: string | null; + label: string | null; + description: string | null; + operator: string; + minimumCount: number | null; + minimumUnits: number | null; + maximumUnits: number | null; + sourceText?: string | null; + sourceLocator?: string | null; + position: number; + }>; + conditions: ReadonlyArray<{ + key: string; + groupKey: string; + position: number; + kind: string; + itemCode?: string | null; + structureKind?: string | null; + requirementMode?: "completed" | "completed_or_concurrent" | null; + minimumMark?: number | null; + minimumUnits: number | null; + maximumUnits: number | null; + minimumCount: number | null; + subjectCode: string | null; + minimumLevel: number | null; + maximumLevel: number | null; + minimumYear?: number | null; + minimumGpa?: number | null; + minimumWam?: number | null; + tag: string | null; + freeText: string | null; + sourceText?: string | null; + sourceLocator?: string | null; + }>; + options: ReadonlyArray<{ + conditionKey: string; + position: number; + kind: string; + code: string; + title?: string | null; + }>; +}; + +/** + * Builds the display tree for one rule. Node identifiers are positional + * because a write has no database identifiers yet, and the display kit only + * needs them to key a node within the tree it is rendering. + */ +export function requirementTreeFromSource( + source: RequirementTreeSource, + ruleKey?: string, +): RequirementTreeGroup | null { + const groups = source.groups.filter( + (group) => ruleKey === undefined || (group.ruleKey ?? ruleKey) === ruleKey, + ); + const root = groups.find((group) => group.parentKey === null); + if (!root) return null; + + type SourceGroup = RequirementTreeSource["groups"][number]; + type SourceCondition = RequirementTreeSource["conditions"][number]; + type SourceOption = RequirementTreeSource["options"][number]; + + const groupKeys = new Set(groups.map((group) => group.key)); + const childGroupsByParent = new Map(); + for (const group of groups) { + // A parent outside this rule would orphan the group, so treat it as a root + // sibling rather than dropping it silently. + if (group.parentKey === null || !groupKeys.has(group.parentKey)) continue; + const siblings = childGroupsByParent.get(group.parentKey) ?? []; + siblings.push(group); + childGroupsByParent.set(group.parentKey, siblings); + } + const conditionsByGroup = new Map(); + for (const condition of source.conditions) { + if (!groupKeys.has(condition.groupKey)) continue; + const siblings = conditionsByGroup.get(condition.groupKey) ?? []; + siblings.push(condition); + conditionsByGroup.set(condition.groupKey, siblings); + } + const optionsByCondition = new Map(); + for (const option of source.options) { + const siblings = optionsByCondition.get(option.conditionKey) ?? []; + siblings.push(option); + optionsByCondition.set(option.conditionKey, siblings); + } + + let nextId = 0; + function conditionNode(condition: SourceCondition): RequirementTreeCondition { + return { + type: "condition", + conditionKind: condition.kind, + freeText: condition.freeText, + id: (nextId += 1), + itemCode: condition.itemCode ?? null, + maximumLevel: condition.maximumLevel, + maximumUnits: condition.maximumUnits, + minimumCourses: condition.minimumCount, + minimumGpa: condition.minimumGpa ?? null, + minimumLevel: condition.minimumLevel, + minimumMark: condition.minimumMark ?? null, + minimumUnits: condition.minimumUnits, + minimumWam: condition.minimumWam ?? null, + minimumYear: condition.minimumYear ?? null, + options: (optionsByCondition.get(condition.key) ?? []) + .toSorted((left, right) => left.position - right.position) + .map((option) => ({ + code: option.code, + kind: option.kind === "course" ? "course" : "structure", + position: option.position, + structureKind: option.kind === "course" ? null : option.kind, + title: option.title ?? null, + })), + position: condition.position, + projectionKey: condition.key, + requirementMode: condition.requirementMode ?? null, + sourceLocator: condition.sourceLocator ?? "", + sourceText: condition.sourceText ?? "", + structureKind: condition.structureKind ?? null, + subjectCode: condition.subjectCode, + tag: condition.tag, + }; + } + + function groupNode( + group: SourceGroup, + ancestors: ReadonlySet, + ): RequirementTreeGroup { + const nextAncestors = new Set(ancestors).add(group.key); + const children: RequirementTreeNode[] = [ + ...(childGroupsByParent.get(group.key) ?? []) + .filter((child) => !nextAncestors.has(child.key)) + .map((child) => groupNode(child, nextAncestors)), + ...(conditionsByGroup.get(group.key) ?? []).map(conditionNode), + ]; + return { + type: "group", + children: children.toSorted( + (left, right) => left.position - right.position, + ), + description: group.description, + groupKey: group.key, + id: (nextId += 1), + maximumUnits: group.maximumUnits, + minimumCount: group.minimumCount, + minimumUnits: group.minimumUnits, + operator: group.operator, + position: group.position, + sourceLocator: group.sourceLocator ?? "", + sourceText: group.sourceText ?? "", + title: group.label, + }; + } + + return groupNode(root, new Set()); +} diff --git a/apps/web/lib/coursemap/requisite-tree.ts b/apps/web/lib/coursemap/requisite-tree.ts new file mode 100644 index 00000000..48ba7331 --- /dev/null +++ b/apps/web/lib/coursemap/requisite-tree.ts @@ -0,0 +1,366 @@ +import type { + CoursePrerequisiteEdge, + CourseRuleExpression, +} from "@/lib/coursemap/course-types"; +import type { + RequirementTreeCondition, + RequirementTreeOption, +} from "@/lib/coursemap/requirement-tree-node"; + +export type CourseRuleCondition = Exclude< + CourseRuleExpression, + { kind: "group" } +>; + +/** + * One course rule condition in the shared requirement shape, so a requisite + * reads through the same vocabulary as a programme requirement instead of + * falling back to the ANU prose for every kind the narrow summary never + * covered. + */ +export function requisiteConditionNode( + condition: CourseRuleCondition, + position = 0, +): RequirementTreeCondition { + const options: RequirementTreeOption[] = + condition.kind === "course_set_units" + ? condition.courseCodes.map((code, index) => ({ + code, + kind: "course", + position: index, + structureKind: null, + })) + : condition.kind === "structure_set" + ? condition.structureCodes.map((code, index) => ({ + code, + kind: "structure", + position: index, + structureKind: condition.structureKind, + })) + : []; + return { + type: "condition", + conditionKind: condition.kind, + freeText: + condition.kind === "permission" || + condition.kind === "other" || + condition.kind === "structure" + ? condition.text + : null, + id: position, + itemCode: + condition.kind === "course" || condition.kind === "incompatible" + ? condition.code + : condition.kind === "structure" + ? condition.structureCode + : null, + maximumLevel: + condition.kind === "level_units" ? condition.maximumLevel : null, + maximumUnits: null, + minimumCourses: + condition.kind === "structure_set" ? condition.minimumCount : null, + minimumGpa: condition.kind === "gpa" ? condition.minimumGpa : null, + minimumLevel: + condition.kind === "level_units" ? condition.minimumLevel : null, + minimumMark: condition.kind === "course" ? condition.minimumMark : null, + minimumUnits: + condition.kind === "units_total" || + condition.kind === "subject_units" || + condition.kind === "level_units" || + condition.kind === "course_set_units" || + condition.kind === "tagged_units" || + condition.kind === "elective_units" + ? condition.units + : null, + minimumWam: condition.kind === "wam" ? condition.minimumWam : null, + minimumYear: + condition.kind === "year_standing" ? condition.minimumYear : null, + options, + position, + projectionKey: `${condition.kind}-${position}`, + requirementMode: + condition.kind === "course" ? condition.requirementMode : null, + sourceLocator: "", + sourceText: condition.sourceText, + structureKind: + condition.kind === "structure_set" ? condition.structureKind : null, + subjectCode: + condition.kind === "units_total" || + condition.kind === "subject_units" || + condition.kind === "level_units" + ? condition.subject + : null, + tag: condition.kind === "tagged_units" ? condition.tag : null, + }; +} + +/** + * A prerequisite rule drawn as a layered graph. Nodes carry the condition they + * came from rather than finished wording, so the display kit in + * `ui/requirements/requirement-presentation.ts` keeps ownership of how every + * condition kind reads and the graph cannot grow a second vocabulary. + */ +export type RequisiteGraphNode = + | { id: string; depth: number; kind: "current"; code: string } + | { + id: string; + depth: number; + kind: "course"; + code: string; + isAvailable: boolean; + condition: CourseRuleCondition | null; + } + | { + id: string; + depth: number; + kind: "requirement"; + condition: CourseRuleCondition; + } + | { + id: string; + depth: number; + kind: "choice"; + minimumCount: number | null; + operator: "all_of" | "any_of" | "at_least"; + } + | { + id: string; + depth: number; + kind: "unlocked"; + code: string; + isAvailable: boolean; + }; + +export type RequisiteGraphEdge = { + from: string; + to: string; + /** Leaves an alternative group, so this is one of several ways to qualify. */ + alternative: boolean; +}; + +export type RequisiteGraph = { + edges: RequisiteGraphEdge[]; + /** + * Courses the prerequisite rule excludes. Collected so the caller can say so + * separately; an incompatibility is never drawn as something to complete. + */ + incompatibleCodes: string[]; + /** Furthest prerequisite column, counting left from the course itself. */ + maximumDepth: number; + nodes: RequisiteGraphNode[]; + /** + * Where the upstream side came from: the reviewed rule tree, the flat course + * codes detected in the prerequisite prose, or nothing at all. + */ + source: "none" | "references" | "rule"; +}; + +const GRAPH_COURSE_CODE = /^[A-Z]{4}\d{4}[A-Z]?$/u; + +function collectIncompatibleCodes( + expression: CourseRuleExpression, + codes: Set, +) { + if (expression.kind === "group") { + for (const child of expression.conditions) { + collectIncompatibleCodes(child, codes); + } + return; + } + if (expression.kind === "incompatible") codes.add(expression.code); +} + +/** Whether anything survives once incompatibilities are taken out. */ +function hasRequirementContent(expression: CourseRuleExpression): boolean { + if (expression.kind === "incompatible") return false; + if (expression.kind !== "group") return true; + return expression.conditions.some(hasRequirementContent); +} + +/** + * An `all_of` earns a node inside an alternative, where flattening would make + * "one of X, or both Y and Z" read as three equal choices, and at the root. + * + * The root used to be flattened too, on the reasoning that every edge into the + * course already means "and". Readers do not see it that way: several arrows + * converging on one course read as several ways in, and once one of those + * arrows leaves a "Choose one" node the rest are read as further choices. For + * COMP3600 that turned "24 units of COMP, and one of MATH or COMP1600" into + * three alternatives. An explicit node says the requirements are all needed. + * A group with a single child is noise either way. + */ +function groupNeedsNode( + expression: Extract, + childCount: number, + insideAlternative: boolean, + isRoot: boolean, +) { + if (childCount < 2) return false; + if (expression.operator === "all_of") return insideAlternative || isRoot; + return true; +} + +export function buildRequisiteGraph({ + availableCourseCodes, + code, + expression, + prerequisiteEdges, +}: { + availableCourseCodes: ReadonlySet; + code: string; + expression: CourseRuleExpression | null; + prerequisiteEdges: readonly CoursePrerequisiteEdge[]; +}): RequisiteGraph { + const nodes: RequisiteGraphNode[] = []; + const edges: RequisiteGraphEdge[] = []; + const incompatible = new Set(); + const currentId = "course"; + nodes.push({ id: currentId, depth: 0, kind: "current", code }); + + const courseNodeByCode = new Map(); + let counter = 0; + const nextId = (prefix: string) => `${prefix}-${(counter += 1)}`; + + const addCourseNode = ( + courseCode: string, + depth: number, + condition: CourseRuleCondition | null, + ) => { + const existing = courseNodeByCode.get(courseCode); + if (existing) return existing; + const id = nextId("course"); + courseNodeByCode.set(courseCode, id); + nodes.push({ + id, + depth, + kind: "course", + code: courseCode, + isAvailable: availableCourseCodes.has(courseCode), + condition, + }); + return id; + }; + + let source: RequisiteGraph["source"] = "none"; + + if (expression) { + collectIncompatibleCodes(expression, incompatible); + const attach = ( + child: CourseRuleExpression, + parentId: string, + depth: number, + alternative: boolean, + ) => { + if (!hasRequirementContent(child)) return; + if (child.kind === "group") { + const children = child.conditions.filter(hasRequirementContent); + if ( + !groupNeedsNode( + child, + children.length, + alternative, + parentId === currentId, + ) + ) { + // Flattened, so each child inherits the meaning of the edge above it. + for (const grandchild of children) { + attach(grandchild, parentId, depth, alternative); + } + return; + } + const id = nextId("choice"); + nodes.push({ + id, + depth, + kind: "choice", + minimumCount: child.minimumCount, + operator: child.operator, + }); + edges.push({ from: id, to: parentId, alternative }); + for (const grandchild of children) { + attach(grandchild, id, depth + 1, child.operator !== "all_of"); + } + return; + } + if (child.kind === "course" && GRAPH_COURSE_CODE.test(child.code)) { + const id = addCourseNode(child.code, depth, child); + edges.push({ from: id, to: parentId, alternative }); + return; + } + const id = nextId("requirement"); + nodes.push({ id, depth, kind: "requirement", condition: child }); + edges.push({ from: id, to: parentId, alternative }); + }; + attach(expression, currentId, 1, false); + if (nodes.length > 1) source = "rule"; + } + + if (source === "none") { + // No reviewed tree: fall back to the course codes detected upstream, which + // carry no operator and are labelled as detected rather than as the rule. + for (const edge of prerequisiteEdges) { + if (edge.to !== code || edge.from === code) continue; + const id = addCourseNode(edge.from, 1, null); + edges.push({ from: id, to: currentId, alternative: false }); + source = "references"; + } + } + + // Chain further upstream from every course the rule names, so a prerequisite + // of a prerequisite stays visible. Those courses carry no operator here; the + // AND and OR shape of their own rules belongs on their own pages. + const incoming = new Map(); + for (const edge of prerequisiteEdges) { + if (edge.from === edge.to) continue; + incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge]); + } + const depthByNode = new Map(nodes.map((node) => [node.id, node.depth])); + const visitUpstream = (courseCode: string, path: ReadonlySet) => { + const targetId = courseNodeByCode.get(courseCode); + if (!targetId) return; + const targetDepth = depthByNode.get(targetId) ?? 1; + for (const edge of incoming.get(courseCode) ?? []) { + if (path.has(edge.from)) continue; + const existingId = courseNodeByCode.get(edge.from); + const id = existingId ?? addCourseNode(edge.from, targetDepth + 1, null); + const nextDepth = Math.max( + depthByNode.get(id) ?? targetDepth + 1, + targetDepth + 1, + ); + depthByNode.set(id, nextDepth); + const node = nodes.find((candidate) => candidate.id === id); + if (node) node.depth = nextDepth; + if (!edges.some((item) => item.from === id && item.to === targetId)) { + edges.push({ from: id, to: targetId, alternative: false }); + } + visitUpstream(edge.from, new Set([...path, edge.from])); + } + }; + for (const courseCode of [...courseNodeByCode.keys()]) { + visitUpstream(courseCode, new Set([code, courseCode])); + } + + for (const edge of prerequisiteEdges) { + if (edge.from !== code || edge.to === code) continue; + if (nodes.some((node) => node.kind === "unlocked" && node.code === edge.to)) + continue; + const id = nextId("unlocked"); + nodes.push({ + id, + depth: -1, + kind: "unlocked", + code: edge.to, + isAvailable: edge.toIsAvailable || availableCourseCodes.has(edge.to), + }); + edges.push({ from: currentId, to: id, alternative: false }); + } + + return { + edges, + incompatibleCodes: [...incompatible].sort(), + // Always keep one prerequisite column so its empty state has somewhere to sit. + maximumDepth: Math.max(1, ...nodes.map((node) => node.depth)), + nodes, + source, + }; +} diff --git a/apps/web/lib/coursemap/structure-snapshot-view.ts b/apps/web/lib/coursemap/structure-snapshot-view.ts new file mode 100644 index 00000000..fc277fcb --- /dev/null +++ b/apps/web/lib/coursemap/structure-snapshot-view.ts @@ -0,0 +1,73 @@ +import type { CatalogueSnapshotWrite } from "@/lib/catalogue-import/snapshot-write"; +import { requirementTreeFromSource } from "@/lib/coursemap/requirement-write-tree"; +import { + REQUIREMENT_SOURCE_SECTION_KEYS, + type StructureDetails, +} from "@/lib/coursemap/structure-types"; + +/** The reader's view of a structure snapshot that has not been published yet. */ +export function structureDetailsFromWrite( + write: CatalogueSnapshotWrite, +): StructureDetails | null { + const structure = write.structure; + if (!structure || write.kind === "course") return null; + const details = structure.details; + const requirements = requirementTreeFromSource( + write.requirements, + "structure", + ); + return { + code: write.code, + kind: write.kind, + year: write.academicYear, + name: details.name, + acronym: details.acronym, + shortName: details.shortName, + introduction: details.introduction, + description: details.description, + units: details.units, + durationYears: details.durationYears, + academicCareer: details.academicCareer, + college: details.college, + modeOfDelivery: details.modeOfDelivery, + selectionRank: details.selectionRank, + atar: details.atar, + studyAs: details.studyAs, + contactText: details.contactText, + sections: structure.sections + .filter( + (section) => + !requirements || + !REQUIREMENT_SOURCE_SECTION_KEYS.includes(section.sectionKey), + ) + .map((section) => ({ + position: section.position, + sectionKey: section.sectionKey, + heading: section.heading, + markdown: section.markdown, + })), + learningOutcomes: structure.learningOutcomes.map((outcome) => ({ + position: outcome.position, + outcomeText: outcome.outcomeText, + })), + fees: structure.fees.map((fee) => ({ + position: fee.position, + feeYear: fee.feeYear, + audience: fee.audience, + feeType: fee.feeType, + amount: fee.amount, + currency: fee.currency, + basis: fee.basis, + sourceLabel: fee.sourceLabel, + sourceText: fee.sourceText, + })), + relationships: structure.relationships.map((relationship) => ({ + position: relationship.position, + relationshipKind: relationship.relationshipKind, + targetKind: relationship.targetKind, + targetCode: relationship.targetCode, + targetTitle: relationship.targetTitle, + })), + requirements, + }; +} diff --git a/apps/web/lib/coursemap/structure-types.ts b/apps/web/lib/coursemap/structure-types.ts new file mode 100644 index 00000000..8e806852 --- /dev/null +++ b/apps/web/lib/coursemap/structure-types.ts @@ -0,0 +1,104 @@ +import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; +import type { RequirementTreeGroup } from "@/lib/coursemap/requirement-tree-node"; + +export type StructureKind = Exclude; + +/** + * The ANU page states its requirements once, as prose, and the importer turns + * that same prose into the requirement tree. Showing the section as well would + * put the reader through it twice, so the tree stands in for it. + */ +export const REQUIREMENT_SOURCE_SECTION_KEYS = [ + "program-requirements", + "requirements", +]; + +export type StructureSection = { + position: number; + sectionKey: string; + heading: string; + markdown: string; +}; + +export type StructureFee = { + position: number; + feeYear: number | null; + audience: string; + feeType: string; + amount: number | null; + currency: string | null; + basis: string; + sourceLabel: string | null; + sourceText: string | null; +}; + +export type StructureRelationship = { + position: number; + relationshipKind: string; + targetKind: string; + targetCode: string; + targetTitle: string | null; +}; + +/** + * One published academic structure as a reader sees it. The student page and + * the administrator preview both render this, so a draft cannot look different + * from what publishing it would produce. + */ +export type StructureDetails = { + code: string; + kind: StructureKind; + year: number; + name: string; + acronym: string | null; + shortName: string | null; + introduction: string | null; + description: string | null; + units: number | null; + durationYears: number | null; + academicCareer: string | null; + college: string | null; + modeOfDelivery: string | null; + selectionRank: number | null; + atar: number | null; + studyAs: string | null; + contactText: string | null; + sections: StructureSection[]; + learningOutcomes: Array<{ position: number; outcomeText: string }>; + fees: StructureFee[]; + relationships: StructureRelationship[]; + requirements: RequirementTreeGroup | null; +}; + +/** Reader-facing names for the stored relationship kinds. */ +export const STRUCTURE_RELATIONSHIP_LABELS: Record = { + source_reference: "Mentioned by the ANU page", + relevant: "Relevant", + option: "Option", + required: "Required", + incompatible: "Cannot be combined", + other: "Related", +}; + +/** Reader-facing names for the stored fee audiences and bases. */ +export const STRUCTURE_FEE_AUDIENCE_LABELS: Record = { + domestic: "Domestic", + international: "International", + commonwealth_supported: "Commonwealth supported", + other: "Other", +}; + +export const STRUCTURE_FEE_BASIS_LABELS: Record = { + programme: "per programme", + unit: "per unit", + eftsl: "per EFTSL", + annual: "per year", + unknown: "", +}; + +export const STRUCTURE_FEE_TYPE_LABELS: Record = { + student_contribution: "Student contribution", + tuition: "Tuition", + indicative: "Indicative fee", + other: "Fee", +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index a419cbe4..ce4e94a6 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/tests/prereq-graph.test.tsx b/apps/web/tests/prereq-graph.test.tsx new file mode 100644 index 00000000..5b1d205e --- /dev/null +++ b/apps/web/tests/prereq-graph.test.tsx @@ -0,0 +1,258 @@ +import { expect, test } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; +import { PrereqGraph } from "@/ui/prereq-graph"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; + +/** COMP3600 in the local catalogue: 24 units of COMP AND (6 units of MATH OR COMP1600). */ +const comp3600Rule: CourseRuleExpression = { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "subject_units", subject: "COMP", units: 24 }, + { + kind: "group", + operator: "any_of", + minimumCount: null, + conditions: [ + { ...base, kind: "subject_units", subject: "MATH", units: 6 }, + { + ...base, + kind: "course", + code: "COMP1600", + minimumMark: null, + requirementMode: "completed", + }, + ], + }, + ], +}; + +function renderGraph(props: Partial[0]> = {}) { + return render( + + + , + ); +} + +test("every condition of the rule is drawn, including the unit requirements", () => { + renderGraph(); + // Each unit rule is one line that leads with the figure a student needs. + expect(screen.getByText("24 units of COMP courses")).toBeInTheDocument(); + expect(screen.getByText("6 units of MATH courses")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /COMP1600/u })).toHaveAttribute( + "href", + "/courses/COMP1600?year=2026", + ); +}); + +test("alternatives are marked as a choice and the rule's AND is explicit", () => { + renderGraph(); + expect(screen.getByText("Choose one")).toBeInTheDocument(); + // The 24 units of COMP sit beside the choice, not inside it. The root AND + // used to be left for the reader to infer, and several arrows converging on + // the course read as several ways in, so a named junction now holds both. + expect(screen.getByText("All of these")).toBeInTheDocument(); +}); + +test("a nested all_of inside a choice keeps its own group node", () => { + renderGraph({ + expression: { + kind: "group", + operator: "any_of", + minimumCount: null, + conditions: [ + { + ...base, + kind: "course", + code: "COMP1600", + minimumMark: null, + requirementMode: "completed", + }, + { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "subject_units", subject: "MATH", units: 6 }, + { ...base, kind: "units_total", subject: null, units: 24 }, + ], + }, + ], + }, + }); + expect(screen.getByText("Choose one")).toBeInTheDocument(); + expect(screen.getByText("All of these")).toBeInTheDocument(); +}); + +test("an incompatibility is stated as an exclusion, never as a prerequisite", () => { + renderGraph({ + expression: { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { + ...base, + kind: "course", + code: "COMP1600", + minimumMark: null, + requirementMode: "completed", + }, + { ...base, kind: "incompatible", code: "COMP6466" }, + ], + }, + }); + expect( + screen.queryByRole("link", { name: /COMP6466/u }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/Not a prerequisite.*COMP6466/u)).toBeInTheDocument(); +}); + +test("course state carries a word as well as a colour", () => { + renderGraph({ + expression: { + kind: "group", + operator: "any_of", + minimumCount: null, + conditions: [ + { + ...base, + kind: "course", + code: "COMP1600", + minimumMark: null, + requirementMode: "completed", + }, + { + ...base, + kind: "course", + code: "COMP1110", + minimumMark: null, + requirementMode: "completed", + }, + ], + }, + availableCourseCodes: new Set(["COMP3600", "COMP1600", "COMP1110"]), + showStudentState: true, + statusByCode: new Map([ + ["COMP1600", "completed"], + ["COMP1110", "planned"], + ] as const), + }); + expect( + within(screen.getByRole("link", { name: /COMP1600/u })).getByText( + "Completed", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByRole("link", { name: /COMP1110/u })).getByText( + "Planned", + ), + ).toBeInTheDocument(); +}); + +test("unlocks says it is unknown rather than implying nothing follows", () => { + renderGraph({ unlocksAreKnown: false }); + expect(screen.getByText("Not known yet")).toBeInTheDocument(); + expect( + screen.queryByText("No published course lists this one"), + ).not.toBeInTheDocument(); +}); + +test("a known and empty reverse lookup says so plainly", () => { + renderGraph({ unlocksAreKnown: true }); + expect( + screen.getByText("No published course lists this one"), + ).toBeInTheDocument(); +}); + +test("unlocked courses appear when the reverse lookup found some", () => { + renderGraph({ + prerequisiteEdges: [ + { + from: "COMP3600", + to: "COMP4600", + fromIsAvailable: true, + toIsAvailable: true, + }, + ], + }); + expect(screen.getByRole("link", { name: /COMP4600/u })).toHaveAttribute( + "href", + "/courses/COMP4600?year=2026", + ); +}); + +test("without a reviewed rule the graph says where its codes came from", () => { + renderGraph({ + expression: null, + prerequisiteEdges: [ + { + from: "COMP1600", + to: "COMP3600", + fromIsAvailable: true, + toIsAvailable: true, + }, + ], + }); + expect(screen.getByRole("link", { name: /COMP1600/u })).toBeInTheDocument(); + expect( + screen.getByText(/Drawn from the course codes found/u), + ).toBeInTheDocument(); +}); + +test("a course with no rule and no references still explains the gap", () => { + renderGraph({ expression: null, hasPrerequisiteWording: false }); + // Nothing either side, so a sentence rather than three unconnected boxes. + expect( + screen.getByText( + "COMP3600 has no prerequisites, and no published course lists it as one.", + ), + ).toBeInTheDocument(); +}); + +test("nodes in a column are stacked without overlapping", () => { + renderGraph({ showStudentState: true }); + const placed = [ + ...screen.getByTestId("prereq-graph").querySelectorAll("*"), + ] + .filter((element) => element.style.left && element.style.top) + .map((element) => ({ + left: Number.parseFloat(element.style.left), + top: Number.parseFloat(element.style.top), + bottom: + Number.parseFloat(element.style.top) + + Number.parseFloat(element.style.height), + })); + expect(placed.length).toBeGreaterThan(3); + for (const column of new Set(placed.map((item) => item.left))) { + const stacked = placed + .filter((item) => item.left === column) + .sort((left, right) => left.top - right.top); + for (let index = 1; index < stacked.length; index += 1) { + expect(stacked[index].top).toBeGreaterThanOrEqual( + stacked[index - 1].bottom, + ); + } + } +}); diff --git a/apps/web/tests/requisite-rule-summary.test.tsx b/apps/web/tests/requisite-rule-summary.test.tsx new file mode 100644 index 00000000..0131e8df --- /dev/null +++ b/apps/web/tests/requisite-rule-summary.test.tsx @@ -0,0 +1,88 @@ +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; +import { RequisiteRuleSummary } from "@/ui/courses/requisite-summary"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; + +function renderSummary(expression: CourseRuleExpression) { + render( + + + , + ); +} + +test("reads the whole tree, including the kinds the narrow summary drops", () => { + renderSummary({ + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "subject_units", subject: "COMP", units: 24 }, + { ...base, kind: "year_standing", minimumYear: 3 }, + { + kind: "group", + operator: "any_of", + minimumCount: null, + conditions: [ + { ...base, kind: "subject_units", subject: "MATH", units: 6 }, + { + ...base, + kind: "course", + code: "COMP1600", + minimumMark: null, + requirementMode: "completed", + }, + ], + }, + ], + }); + // Each group says what the reader needs, not a pair of near-identical + // "Complete ... of the following" headings. + expect(screen.getByText("You need all of these")).toBeInTheDocument(); + expect(screen.getByText("You need one of these")).toBeInTheDocument(); + // A unit rule leads with the figure rather than burying it under a category. + expect(screen.getByText("24 units of COMP courses")).toBeInTheDocument(); + expect(screen.getByText("At least year 3 standing")).toBeInTheDocument(); + // An alternative separates its options with "or", so it cannot read as a + // list of things to complete. + expect(screen.getByText("or")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "COMP1600" })).toHaveAttribute( + "href", + "/courses/COMP1600?year=2026", + ); +}); + +test("an at_least group says how many of its options must be met", () => { + renderSummary({ + kind: "group", + operator: "at_least", + minimumCount: 2, + conditions: [ + { ...base, kind: "subject_units", subject: "COMP", units: 6 }, + { ...base, kind: "subject_units", subject: "MATH", units: 6 }, + { ...base, kind: "subject_units", subject: "STAT", units: 6 }, + ], + }); + expect(screen.getByText("You need at least 2 of these")).toBeInTheDocument(); +}); + +test("an incompatibility is flagged rather than read as something to complete", () => { + renderSummary({ ...base, kind: "incompatible", code: "COMP6466" }); + // The code is a link, so the sentence is split across elements. + expect(screen.getByText(/Cannot be counted with/)).toBeInTheDocument(); + expect( + screen.getByLabelText("Incompatible", { selector: "svg" }), + ).toBeInTheDocument(); +}); diff --git a/apps/web/ui/admin/catalogue/snapshot-preview.tsx b/apps/web/ui/admin/catalogue/snapshot-preview.tsx index 7155d1fa..70568558 100644 --- a/apps/web/ui/admin/catalogue/snapshot-preview.tsx +++ b/apps/web/ui/admin/catalogue/snapshot-preview.tsx @@ -5,12 +5,18 @@ import { useState } from "react"; import type { CatalogueSnapshotWrite } from "@/lib/catalogue-import/snapshot-write"; import type { CourseDetails } from "@/lib/coursemap/course-types"; +import { structureDetailsFromWrite } from "@/lib/coursemap/structure-snapshot-view"; import { CourseDetailTabsList, CourseDetailView, type CourseTab, } from "@/ui/courses/course-detail-view"; -import { JsonCode } from "@/ui/common/json-code"; +import { readingTreeContext } from "@/ui/requirements/requirement-presentation"; +import { + StructureDetailTabsList, + StructureDetailView, + type StructureTab, +} from "@/ui/requirements/structure-detail-view"; const EMPTY = { completedCourses: [], isAuthenticated: false }; @@ -31,92 +37,31 @@ export function CoursePreview({ course }: { course: CourseDetails }) { ); } -/** A readable rendering of a structure snapshot until the student pages exist. */ +/** + * The reader's view of a structure snapshot, through the same component the + * published page uses. A reviewer judges the requirement tree as a student + * will read it rather than as stored JSON. + */ export function StructurePreview({ write }: { write: CatalogueSnapshotWrite }) { - const structure = write.structure; + const [tab, setTab] = useState("overview"); + const structure = structureDetailsFromWrite(write); if (!structure) return null; - const details = structure.details; - const facts = [ - ["Units", details.units], - [ - "Duration", - details.durationYears ? `${details.durationYears} years` : null, - ], - ["Career", details.academicCareer], - ["College", details.college], - ["Delivery", details.modeOfDelivery], - ["Selection rank", details.selectionRank], - ["ATAR", details.atar], - ].filter( - ([, value]) => value !== null && value !== undefined && value !== "", - ); return ( -
-
-

{details.name}

- {details.introduction ? ( -

{details.introduction}

- ) : null} - {facts.length ? ( -
- {facts.map(([label, value]) => ( -
-
{label}
-
{String(value)}
-
- ))} -
- ) : null} -
- {details.description ? ( -
-

Description

-

{details.description}

-
- ) : null} - {structure.sections.map((section) => ( -
-

{section.heading}

-
{section.markdown}
-
- ))} - {structure.learningOutcomes.length ? ( -
-

Learning outcomes

-
    - {structure.learningOutcomes.map((outcome) => ( -
  1. {outcome.outcomeText}
  2. - ))} -
-
- ) : null} - {structure.relationships.length ? ( -
-

Related structures

-
    - {structure.relationships.map((relationship) => ( -
  • - {relationship.targetCode}{" "} - {relationship.targetTitle ?? ""}{" "} - - ({relationship.targetKind}, {relationship.relationshipKind}) - -
  • - ))} -
-
- ) : null} - {write.requirements.rules.length ? ( -
-

Requirements

-

- {write.requirements.rules[0]?.sourceText} -

- -
- ) : null} -
+ setTab(value as StructureTab)} + className="block" + > +
+ +
+ +
); } diff --git a/apps/web/ui/courses/course-detail-view.tsx b/apps/web/ui/courses/course-detail-view.tsx index 69384670..541b1dd1 100644 --- a/apps/web/ui/courses/course-detail-view.tsx +++ b/apps/web/ui/courses/course-detail-view.tsx @@ -44,6 +44,8 @@ import { import { Hint } from "@/ui/common/hint"; import { PrereqGraph } from "@/ui/prereq-graph"; import type { CourseDetails } from "@/lib/coursemap/course-types"; +import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; +import type { Attempt } from "@/lib/coursemap/types"; import { evaluateRequisiteExpression, type CompletedRequisiteCourse, @@ -57,13 +59,11 @@ import { sessionLabel, unitValueLabel, } from "@/ui/courses/course-detail-format"; -import { - CourseReferenceChips, - CourseReferenceText, -} from "@/ui/courses/course-reference"; +import { CourseReferenceText } from "@/ui/courses/course-reference"; import { RequisiteExpressionSummary, RequisiteProgressSummary, + RequisiteRuleSummary, } from "@/ui/courses/requisite-summary"; export const courseDetailTabs = [ @@ -99,29 +99,23 @@ export function CourseDetailTabsList() { ); } -const EMPTY_CODES: ReadonlySet = new Set(); +const NO_ATTEMPTS: readonly Attempt[] = []; -/** - * The student-facing body of a course page. The student route and the admin - * import review both render this component, so a draft preview cannot drift - * away from what a student will actually see. - */ /** * The student-facing body of a course page. The student route and the admin * import review both render this component, so a draft preview cannot drift * away from what a student will actually see. */ export function CourseDetailView({ - completedCodes = EMPTY_CODES, + attempts = NO_ATTEMPTS, course, onAddToPlan, - plannedCodes = EMPTY_CODES, requisiteCompletion, }: { - completedCodes?: ReadonlySet; + /** The reader's own plan, so the graph can mark what they have done. */ + attempts?: readonly Attempt[]; course: CourseDetails; onAddToPlan?: () => void; - plannedCodes?: ReadonlySet; requisiteCompletion: { completedCourses: CompletedRequisiteCourse[]; enrolledProgrammeCodes?: string[]; @@ -130,6 +124,15 @@ export function CourseDetailView({ }) { const availableCourseCodes = new Set(course.availableCourseCodes); const structuredRule = course.prerequisiteRule?.expression ?? null; + const relationalRule = course.prerequisiteRule?.relationalExpression ?? null; + const statusByCode = new Map( + [...new Set(attempts.map((attempt) => attempt.courseCode))].flatMap( + (attemptCode) => { + const status = requirementCourseStatus(attemptCode, attempts); + return status ? [[attemptCode, status] as const] : []; + }, + ), + ); const requisiteSummary = structuredRule ?? parseRequisiteSummary(course.prerequisiteText); const requisiteProgress = structuredRule @@ -505,11 +508,16 @@ export function CourseDetailView({ 0 + } + statusByCode={statusByCode} + unlocksAreKnown={course.unlocksAreKnown} /> @@ -524,7 +532,7 @@ export function CourseDetailView({ {requisiteProgress && requisiteCompletion.isAuthenticated ? (

- Your completed-course progress + Prerequisites against your completed courses

- ) : null} - {requisiteSummary ? ( + ) : relationalRule ? ( +
+

+ Prerequisite requirements +

+
+ +
+
+ ) : requisiteSummary ? (

- {structuredRule - ? "Prerequisite requirements" - : "Coursemap summary"} + Coursemap summary

- Prerequisites + Prerequisites as published

-
{course.corequisiteText ? (
diff --git a/apps/web/ui/courses/course-reference.tsx b/apps/web/ui/courses/course-reference.tsx index 6e20f217..b748af79 100644 --- a/apps/web/ui/courses/course-reference.tsx +++ b/apps/web/ui/courses/course-reference.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { LockKeyhole } from "lucide-react"; import { Hint } from "@/ui/common/hint"; -import type { CourseDetails } from "@/lib/coursemap/course-types"; export function CourseReferenceText({ academicYear, @@ -40,46 +39,3 @@ export function CourseReferenceText({ ); }); } -export function CourseReferenceChips({ - academicYear, - course, - availableCourseCodes, -}: { - academicYear: number; - course: CourseDetails; - availableCourseCodes: ReadonlySet; -}) { - if (course.prerequisiteCodes.length === 0) return null; - return ( -
-

- Detected course references -

-
- {course.prerequisiteCodes.map((reference) => - availableCourseCodes.has(reference) ? ( - - {reference} - - ) : ( - - - - - ), - )} -
-
- ); -} diff --git a/apps/web/ui/courses/requisite-summary.tsx b/apps/web/ui/courses/requisite-summary.tsx index 80ec9268..417c83c1 100644 --- a/apps/web/ui/courses/requisite-summary.tsx +++ b/apps/web/ui/courses/requisite-summary.tsx @@ -1,12 +1,18 @@ "use client"; import { badgeVariantForTone } from "@/lib/ui"; import { Badge } from "@coursemap/ui/components/badge"; -import { CheckCircle2, Circle } from "lucide-react"; +import { CheckCircle2, Circle, CircleAlert } from "lucide-react"; import { type RequisiteCondition, type RequisiteExpression, type RequisiteProgress, } from "@/lib/coursemap/requisite-summary"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import { requisiteConditionNode } from "@/lib/coursemap/requisite-tree"; +import { + conditionSummary, + conditionTone, +} from "@/ui/requirements/requirement-presentation"; import { CourseReferenceText } from "@/ui/courses/course-reference"; export function RequisiteConditionText({ @@ -220,3 +226,136 @@ export function RequisiteProgressSummary({
); } + +/** + * A requisite rule read through the shared requirement vocabulary. The narrow + * summary above covers the handful of kinds it was written for and returns + * nothing for the rest, which used to leave the reader with the ANU prose + * alone. Course codes stay linked, so this loses nothing the prose carried. + */ +/** + * What a group asks for, in the reader's terms. "Complete all of the + * following" over "Complete one of the following" read alike at a glance, and + * the difference between them is the whole rule. + */ +function groupTitle( + expression: Extract, + childCount: number, +) { + if (expression.operator === "all_of") { + return childCount === 2 + ? "You need both of these" + : "You need all of these"; + } + if (expression.operator === "any_of") return "You need one of these"; + return `You need at least ${expression.minimumCount ?? 1} of these`; +} + +export function RequisiteRuleSummary({ + academicYear, + expression, + availableCourseCodes, + depth = 0, +}: { + academicYear: number; + expression: CourseRuleExpression; + availableCourseCodes: ReadonlySet; + /** Only the outermost group is boxed; nesting a box in a box hid the logic. */ + depth?: number; +}) { + if (expression.kind === "group") { + // A group of one is only its child, so it gets no heading of its own. + if (expression.conditions.length === 1 && expression.conditions[0]) { + return ( + + ); + } + const alternative = expression.operator !== "all_of"; + const list = ( + <> +

+ {groupTitle(expression, expression.conditions.length)} +

+
    + {expression.conditions.map((condition, index) => ( +
  • + {/* An alternative says "or" between its options, so it cannot be + read as a list of things to do. */} + {alternative && index > 0 ? ( + + ) : null} +
    +
    + +
    +
    +
  • + ))} +
+ + ); + return depth === 0 ? ( +
{list}
+ ) : ( +
{list}
+ ); + } + + const condition = requisiteConditionNode(expression); + const tone = conditionTone(condition); + const optionCodes = condition.options + .filter((option) => option.kind === "course") + .map((option) => option.code); + return ( +
+ {tone === "warning" ? ( + + ) : null} +
+

+ +

+ {optionCodes.length ? ( +

+ +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/ui/prereq-graph.tsx b/apps/web/ui/prereq-graph.tsx index 1b5f83a1..4c52b1f2 100644 --- a/apps/web/ui/prereq-graph.tsx +++ b/apps/web/ui/prereq-graph.tsx @@ -1,297 +1,503 @@ "use client"; import Link from "next/link"; -import { Check, LockKeyhole } from "lucide-react"; -import { useMemo } from "react"; +import { + CalendarDays, + Check, + CircleAlert, + CircleDashed, + GaugeCircle, + LockKeyhole, +} from "lucide-react"; +import { Fragment, useId, useMemo } from "react"; +import { Badge } from "@coursemap/ui/components/badge"; import { Hint } from "@/ui/common/hint"; import { cn } from "@/lib/cn"; -import type { CoursePrerequisiteEdge } from "@/lib/coursemap/course-types"; +import type { + CoursePrerequisiteEdge, + CourseRuleExpression, +} from "@/lib/coursemap/course-types"; +import { + buildRequisiteGraph, + requisiteConditionNode, + type RequisiteGraphNode, +} from "@/lib/coursemap/requisite-tree"; +import { conditionSummary } from "@/ui/requirements/requirement-presentation"; -const NODE_H = 46; -const GAP = 14; -const STEP = NODE_H + GAP; +const COLUMN_WIDTH = 168; +/** + * A junction is a short pill ("Choose one", "All of these"), so a column that + * holds nothing else is narrower. At a uniform width the junction that makes + * the AND explicit added a full column and pushed the graph past its card. + */ +const JUNCTION_WIDTH = 116; +const COLUMN_GAP = 36; +const ROW_GAP = 14; +const CHOICE_HEIGHT = 34; +const REQUIREMENT_HEIGHT = 68; +const EMPTY_HEIGHT = 46; +const ARROW_INSET = 3; + +type CourseStatus = "completed" | "enrolled" | "planned"; -type Layout = { - columns: { label: string; codes: string[] }[]; - edges: CoursePrerequisiteEdge[]; - position: Map; - rows: number; +/** A node, or a column's empty state, with somewhere to sit on the canvas. */ +type Placed = { + column: number; + height: number; + id: string; + node: RequisiteGraphNode | null; + top: number; }; -/** - * Display the complete upstream chain and the direct courses this course - * unlocks. The graph is intentionally descriptive: an imported reference is - * not treated as a verified enrolment rule until its source has been reviewed. - */ -function buildLayout( - code: string, - prerequisiteEdges: readonly CoursePrerequisiteEdge[], -): Layout { - const incoming = new Map(); - for (const edge of prerequisiteEdges) { - const existing = incoming.get(edge.to) ?? []; - existing.push(edge); - incoming.set(edge.to, existing); - } +function courseHeight(showStudentState: boolean) { + return showStudentState ? 64 : 46; +} - const level = new Map([[code, 0]]); - const visitUpstream = ( - courseCode: string, - depth: number, - path: Set, - ) => { - for (const edge of incoming.get(courseCode) ?? []) { - if (path.has(edge.from)) continue; - const nextDepth = depth - 1; - const existing = level.get(edge.from); - if (existing === undefined || nextDepth < existing) { - level.set(edge.from, nextDepth); - } - visitUpstream(edge.from, nextDepth, new Set([...path, edge.from])); - } - }; - visitUpstream(code, 0, new Set([code])); +function nodeHeight(node: RequisiteGraphNode, showStudentState: boolean) { + if (node.kind === "choice") return CHOICE_HEIGHT; + if (node.kind === "requirement") return REQUIREMENT_HEIGHT; + if (node.kind === "current") return courseHeight(false); + return courseHeight(showStudentState); +} - for (const edge of prerequisiteEdges) { - if (edge.from === code && edge.to !== code) { - level.set(edge.to, 1); - } - } +const STATUS_LABEL: Record = { + completed: "Completed", + enrolled: "Enrolled", + planned: "Planned", +}; - const minLevel = Math.min(-1, ...level.values()); - const maxLevel = Math.max(1, ...level.values()); - const columns = Array.from( - { length: maxLevel - minLevel + 1 }, - (_, index) => { - const columnLevel = index + minLevel; - return { - label: - columnLevel === 0 - ? "This course" - : columnLevel === 1 - ? "Unlocks" - : columnLevel === -1 - ? "Requires" - : "Then requires", - codes: [...level.entries()] - .filter(([, nodeLevel]) => nodeLevel === columnLevel) - .map(([courseCode]) => courseCode) - .sort(), - }; - }, - ); - const position = new Map(); - columns.forEach((column, col) => - column.codes.forEach((item, row) => position.set(item, { col, row })), - ); - const edges = prerequisiteEdges.filter( - (edge) => position.has(edge.from) && position.has(edge.to), +/** + * The same words and icons the requirement kit puts on a course row, so a + * course means the same thing in the graph and in the card underneath it. + */ +function CourseStatusBadge({ status }: { status: CourseStatus | null }) { + return ( + + {status === "completed" ? ( + ); +} - return { - columns, - edges, - position, - rows: Math.max(1, ...columns.map((column) => column.codes.length)), - }; +function choiceLabel(node: Extract) { + if (node.operator === "all_of") return "All of these"; + if (node.operator === "any_of") return "Choose one"; + return `Choose at least ${node.minimumCount ?? 1}`; } +/** + * The prerequisite rule as a left-to-right dependency graph, with a node for + * every condition the rule states. Alternatives get a group node so an OR + * cannot be mistaken for a list of separate requirements, and a unit rule is + * a node like any other rather than being dropped for naming no single course. + * + * Drawn with layout and SVG rather than the React Flow canvas the reviewer's + * editor uses: every node here is a real link or a real piece of text in + * reading order, which a student on a phone or a screen reader needs and a + * pannable canvas takes away. + */ export function PrereqGraph({ academicYear, + availableCourseCodes, code, - prerequisiteEdges, - completedCodes, + expression, hasPrerequisiteWording, - plannedCodes, + prerequisiteEdges, + showStudentState, + statusByCode, + unlocksAreKnown, }: { academicYear: number; + availableCourseCodes: ReadonlySet; code: string; - prerequisiteEdges: readonly CoursePrerequisiteEdge[]; - completedCodes: ReadonlySet; + expression: CourseRuleExpression | null; hasPrerequisiteWording: boolean; - plannedCodes: ReadonlySet; + prerequisiteEdges: readonly CoursePrerequisiteEdge[]; + showStudentState: boolean; + statusByCode: ReadonlyMap; + unlocksAreKnown: boolean; }) { - const layout = useMemo( - () => buildLayout(code, prerequisiteEdges), - [code, prerequisiteEdges], + const markerId = useId(); + const graph = useMemo( + () => + buildRequisiteGraph({ + availableCourseCodes, + code, + expression, + prerequisiteEdges, + }), + [availableCourseCodes, code, expression, prerequisiteEdges], ); - const { columns, edges, position, rows } = layout; - const height = rows * STEP - GAP; - const columnCount = columns.length; - const availability = new Map([[code, true]]); - for (const edge of prerequisiteEdges) { - availability.set( - edge.from, - availability.get(edge.from) === true || edge.fromIsAvailable, - ); - availability.set( - edge.to, - availability.get(edge.to) === true || edge.toIsAvailable, + + // With nothing on either side there is no chain to draw. Three empty boxes + // with no edges between them read as a diagram that failed to render, and + // drawing arrows to placeholders would invent relationships, so say it. + if (graph.nodes.every((node) => node.kind === "current")) { + return ( +

+ {unlocksAreKnown + ? `${code} has no prerequisites, and no published course lists it as one.` + : `${code} has no prerequisites. Which courses it leads to is not known until it is published.`} +

); } - const yOf = (item: string) => { - const spot = position.get(item); - if (!spot) return 0; - const colRows = columns[spot.col].codes.length; - const offset = (height - (colRows * STEP - GAP)) / 2; - return offset + spot.row * STEP + NODE_H / 2; - }; - const xOf = (item: string) => { - const spot = position.get(item); - return spot ? ((spot.col + 0.5) / columnCount) * 100 : 0; + const columnCount = graph.maximumDepth + 2; + const currentColumn = graph.maximumDepth; + const columnOf = (node: RequisiteGraphNode) => + node.kind === "unlocked" + ? columnCount - 1 + : graph.maximumDepth - node.depth; + + const placed: Placed[] = []; + const byColumn = new Map(); + const push = (entry: Placed) => { + placed.push(entry); + byColumn.set(entry.column, [...(byColumn.get(entry.column) ?? []), entry]); }; + for (const node of graph.nodes) { + push({ + column: columnOf(node), + height: nodeHeight(node, showStudentState), + id: node.id, + node, + top: 0, + }); + } + if ((byColumn.get(currentColumn - 1) ?? []).length === 0) { + push({ + column: currentColumn - 1, + height: EMPTY_HEIGHT, + id: "empty-requires", + node: null, + top: 0, + }); + } + if ((byColumn.get(columnCount - 1) ?? []).length === 0) { + push({ + column: columnCount - 1, + height: EMPTY_HEIGHT, + id: "empty-unlocks", + node: null, + top: 0, + }); + } + + const columnHeights = new Map(); + for (const [column, entries] of byColumn) { + columnHeights.set( + column, + entries.reduce((total, entry) => total + entry.height, 0) + + ROW_GAP * Math.max(0, entries.length - 1), + ); + } + const height = Math.max(EMPTY_HEIGHT, ...columnHeights.values()); + for (const [column, entries] of byColumn) { + let offset = (height - (columnHeights.get(column) ?? 0)) / 2; + for (const entry of entries) { + entry.top = offset; + offset += entry.height + ROW_GAP; + } + } + + const geometry = new Map(placed.map((entry) => [entry.id, entry])); + const columnWidths = Array.from({ length: columnCount }, (_, column) => { + const entries = byColumn.get(column) ?? []; + return entries.length > 0 && + entries.every((entry) => entry.node?.kind === "choice") + ? JUNCTION_WIDTH + : COLUMN_WIDTH; + }); + const leftOf = (column: number) => + columnWidths + .slice(0, column) + .reduce((total, columnWidth) => total + columnWidth + COLUMN_GAP, 0); + const widthOf = (column: number) => columnWidths[column] ?? COLUMN_WIDTH; + const width = leftOf(columnCount - 1) + widthOf(columnCount - 1); return ( -
-
+
+ {/* Centred when it fits; mx-auto has no effect once the diagram is wider + than the card, so a wide graph still scrolls from its left edge. */} +
`${columnWidth}px`) + .join(" "), }} > - {columns.map((column, index) => ( -

- {column.label} -

- ))} +

Requires

+

This course

+

Unlocks

-
- {columns.map((column, colIndex) => { - const colRows = column.codes.length; - const offset = (height - (colRows * STEP - GAP)) / 2; + {placed.map((entry) => { + const style = { + height: entry.height, + left: leftOf(entry.column), + top: entry.top, + width: widthOf(entry.column), + }; + if (!entry.node) { + const unlocks = entry.id === "empty-unlocks"; + const label = unlocks + ? unlocksAreKnown + ? "No published course lists this one" + : "Not known yet" + : hasPrerequisiteWording + ? "See prerequisite requirements" + : "No prerequisite listed"; + const box = ( +

+ {label} +

+ ); + if (!unlocks || unlocksAreKnown) { + return {box}; + } return ( -
- {column.codes.length === 0 && ( -
- {column.label === "Unlocks" - ? "No linked courses" - : hasPrerequisiteWording - ? "See prerequisite requirements" - : "No prerequisite listed"} -
- )} - {column.codes.map((item, row) => { - const isCurrent = item === code; - const isAvailable = availability.get(item) === true; - const isCompleted = completedCodes.has(item); - const isPlanned = plannedCodes.has(item); - const nodeClassName = cn( - "absolute inset-x-0 mx-auto flex w-full max-w-36 items-center justify-center gap-1.5 rounded-lg px-2 font-mono text-[11px] font-medium transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", - isCompleted - ? "bg-emerald-50 dark:bg-emerald-950/60 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-200 dark:ring-emerald-900" - : isCurrent - ? "bg-primary text-white shadow-sm" - : !isAvailable - ? "cursor-not-allowed bg-muted text-muted-foreground ring-1 ring-border" - : isPlanned - ? "bg-card text-foreground/80 ring-1 ring-border hover:bg-accent/50 hover:ring-input" - : "bg-rose-50 dark:bg-rose-950/60 text-rose-700 dark:text-rose-300 ring-1 ring-rose-200 dark:ring-rose-900 hover:bg-rose-100 dark:hover:bg-rose-950/60 hover:ring-rose-300", - ); - const content = ( - <> - {isCompleted && } - {item} - {!isAvailable && ( - - Course details unavailable - - )} - - ); - const style = { top: offset + row * STEP, height: NODE_H }; - - if (isCurrent) { - return ( - - {content} - - ); - } - if (!isAvailable) { - return ( - - - - - ); - } - return ( - - {content} - - ); - })} -
+ + {box} + ); - })} -
+ } + return ( + + ); + })}
+ + {graph.incompatibleCodes.length > 0 ? ( +

+

+ ) : null} + {graph.source === "references" ? ( +

+ Drawn from the course codes found in the prerequisite wording. The + rule has not been reviewed, so any choice between them is not shown. +

+ ) : null}
); } + +function GraphNode({ + academicYear, + node, + showStudentState, + statusByCode, + style, +}: { + academicYear: number; + node: RequisiteGraphNode; + showStudentState: boolean; + statusByCode: ReadonlyMap; + style: { height: number; left: number; top: number; width: number }; +}) { + if (node.kind === "choice") { + return ( +

+ {choiceLabel(node)} +

+ ); + } + + if (node.kind === "requirement") { + const condition = requisiteConditionNode(node.condition); + const summary = conditionSummary(condition) || node.condition.sourceText; + return ( +
+ + + {/* One line that leads with the figure: a category heading over a + detail line repeated itself and left "24 units" in the faintest + text on the node. */} + + {summary} + +
+ ); + } + + if (node.kind === "current") { + return ( + + {node.code} + + ); + } + + const status = statusByCode.get(node.code) ?? null; + const body = ( + <> + {node.code} + {node.kind === "course" && + node.condition?.kind === "course" && + node.condition.requirementMode === "completed_or_concurrent" ? ( + + Completed or taken at the same time + + ) : null} + {showStudentState && node.isAvailable ? ( + + ) : null} + + ); + const className = cn( + "absolute flex flex-col items-center justify-center gap-1 rounded-lg px-2 text-center transition-colors motion-reduce:transition-none", + !node.isAvailable + ? "border border-border bg-muted/40 text-muted-foreground" + : status === "completed" + ? "border border-success/30 bg-success/5 hover:bg-success/10" + : status + ? "border border-primary/30 bg-primary/5 hover:bg-primary/10" + : "border border-border bg-card hover:border-foreground/20 hover:bg-muted/40", + ); + + if (!node.isAvailable) { + return ( + + + + + Not available + + + ); + } + + return ( + + {body} + + ); +} diff --git a/apps/web/ui/requirements/requirement-condition.tsx b/apps/web/ui/requirements/requirement-condition.tsx index 3de5c51b..8ce867e7 100644 --- a/apps/web/ui/requirements/requirement-condition.tsx +++ b/apps/web/ui/requirements/requirement-condition.tsx @@ -1,33 +1,143 @@ "use client"; import { useId, useState } from "react"; -import { ChevronDown, ListChecks, Layers } from "lucide-react"; -import type { PlanRequirementCondition } from "@/lib/coursemap/plan-catalogue"; +import Link from "next/link"; +import { + ChevronDown, + CircleAlert, + GaugeCircle, + Info, + Layers, + ListChecks, +} from "lucide-react"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@coursemap/ui/components/alert"; +import { Badge } from "@coursemap/ui/components/badge"; import { requirementNodeKey } from "@/lib/coursemap/requirement-progress"; import { requirementCourseHeading } from "@/lib/coursemap/requirement-display"; import { + conditionHeading, conditionInterpretation, + conditionTone, unitsDescription, } from "@/ui/requirements/requirement-presentation"; -import type { TreeContext } from "@/ui/requirements/requirement-presentation"; +import type { + RequirementTreeCondition, + TreeContext, +} from "@/ui/requirements/requirement-presentation"; import { RequirementCourseOptions } from "./requirement-course-options"; import { UnitsBar } from "@/ui/requirements/units-bar"; +/** A rule with no course list of its own: units, levels, tags and electives. */ +function StatedCondition({ + condition, +}: { + condition: RequirementTreeCondition; +}) { + const tone = conditionTone(condition); + const interpretation = + conditionInterpretation(condition) || condition.freeText; + if (tone === "warning" || tone === "note") { + return ( + + {tone === "warning" ? ( + + ); + } + return ( +
+
+ + +
+

+ {conditionHeading(condition)} +

+

+ {interpretation} +

+
+ {tone === "limit" ? Limit : null} +
+
+ ); +} + +/** Academic structures a rule offers, for readers with no chooser of their own. */ +function StructureOptions({ + condition, +}: { + condition: RequirementTreeCondition; +}) { + const options = condition.options.filter( + (option) => option.kind !== "course", + ); + return ( +
+

+ {conditionHeading(condition)} +

+

+ {conditionInterpretation(condition)} +

+
    + {options.map((option) => ( +
  • + + + + {option.code} + + {option.title ? ( + + {option.title} + + ) : null} + + +
  • + ))} +
+
+ ); +} + export function RequirementCondition({ condition, context, }: { - condition: PlanRequirementCondition; + condition: RequirementTreeCondition; context: TreeContext; }) { const [expanded, setExpanded] = useState(false); const panelId = useId(); + const showProgress = context.showPlanProgress !== false; if ( condition.conditionKind === "units_total" && condition.minimumUnits === context.unitTarget && condition.maximumUnits === null ) return null; - if (condition.conditionKind === "structure_set") return null; + if (condition.conditionKind === "structure_set") { + if (!context.showStructureOptions) return null; + return condition.options.some((option) => option.kind !== "course") ? ( + + ) : ( + + ); + } const options = condition.options.filter( (option) => option.kind === "course", ); @@ -37,12 +147,7 @@ export function RequirementCondition({ condition.minimumUnits, condition.maximumUnits, ); - if (options.length === 0) - return ( -

- {conditionInterpretation(condition) || condition.freeText} -

- ); + if (options.length === 0) return ; const codes = [...new Set(options.map((option) => option.code))]; const required = condition.minimumCourses !== null && @@ -56,8 +161,9 @@ export function RequirementCondition({ ), ).length; const target = condition.minimumCourses; - const caption = - target !== null + const caption = !showProgress + ? null + : target !== null ? [ done > 0 ? `${done} completed` : null, planned > 0 ? `${planned} planned` : null, @@ -111,7 +217,7 @@ export function RequirementCondition({ className={`size-4 shrink-0 text-muted-foreground transition-transform group-hover:text-foreground motion-reduce:transition-none ${expanded ? "rotate-180" : ""}`} />
- {target !== null && target > 0 ? ( + {!showProgress ? null : target !== null && target > 0 ? (