From f83a1ed2defd891ae6eed57c8ad5b5dfb82cd0c8 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Sun, 20 Sep 2026 19:11:22 +1000 Subject: [PATCH 1/6] feat: render the requirement tree instead of describing it Imports have produced a fully structured requirement tree since the prompt began mapping ANU wording to typed conditions, but nothing rendered it. The Bachelor of Computing carries twelve typed conditions and thirty-six options, every one resolved to a catalogue item, and the preview showed the requirements section's prose, then the same prose again as the rule's source text, then the tree as a block of JSON. The requirement kit in ui/requirements already formatted units_total, course_set_units, structure_set, subject_units, level_units, tagged_units and elective_units, including level ranges and the maximum-only ceiling ANU states as "a maximum of 60 units may come from 1000-level courses". It is extended to the rest of the vocabulary rather than replaced, and now reads a tree built from a snapshot write as readily as one from a plan. Structures gain a published read and a student page, neither of which existed: published_structure_detail resolves through catalogue_item_years.published_snapshot_id and returns details, sections, outcomes, fees, relationships and the requirement tree to anonymous readers, drafts excluded. The admin preview and the student page render through one StructureDetailView, so a reviewer judges the record as a student will see it. --- apps/web/app/structures/[code]/error.tsx | 7 + apps/web/app/structures/[code]/loading.tsx | 39 ++ apps/web/app/structures/[code]/page.tsx | 62 +++ .../[code]/structure-detail-client.tsx | 72 ++++ .../web/lib/coursemap/published-structures.ts | 251 ++++++++++++ .../lib/coursemap/requirement-tree-node.ts | 37 ++ .../lib/coursemap/requirement-write-tree.ts | 171 +++++++++ apps/web/lib/coursemap/requisite-tree.ts | 92 +++++ .../lib/coursemap/structure-snapshot-view.ts | 73 ++++ apps/web/lib/coursemap/structure-types.ts | 104 +++++ .../ui/admin/catalogue/snapshot-preview.tsx | 115 ++---- apps/web/ui/courses/requisite-summary.tsx | 89 ++++- .../ui/requirements/requirement-condition.tsx | 136 ++++++- .../requirement-course-options.tsx | 4 +- .../requirements/requirement-course-row.tsx | 81 ++-- .../requirements/requirement-presentation.ts | 207 +++++++++- apps/web/ui/requirements/requirement-tree.tsx | 8 +- .../structure-catalogue-error.tsx | 35 ++ .../ui/requirements/structure-detail-view.tsx | 357 ++++++++++++++++++ ...60920200000_published_structure_detail.sql | 190 ++++++++++ 20 files changed, 1980 insertions(+), 150 deletions(-) create mode 100644 apps/web/app/structures/[code]/error.tsx create mode 100644 apps/web/app/structures/[code]/loading.tsx create mode 100644 apps/web/app/structures/[code]/page.tsx create mode 100644 apps/web/app/structures/[code]/structure-detail-client.tsx create mode 100644 apps/web/lib/coursemap/published-structures.ts create mode 100644 apps/web/lib/coursemap/requirement-tree-node.ts create mode 100644 apps/web/lib/coursemap/requirement-write-tree.ts create mode 100644 apps/web/lib/coursemap/requisite-tree.ts create mode 100644 apps/web/lib/coursemap/structure-snapshot-view.ts create mode 100644 apps/web/lib/coursemap/structure-types.ts create mode 100644 apps/web/ui/requirements/structure-catalogue-error.tsx create mode 100644 apps/web/ui/requirements/structure-detail-view.tsx create mode 100644 supabase/migrations/20260920200000_published_structure_detail.sql 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/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..9ff2006e --- /dev/null +++ b/apps/web/lib/coursemap/requisite-tree.ts @@ -0,0 +1,92 @@ +import type { 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, + }; +} 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/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/requisite-summary.tsx b/apps/web/ui/courses/requisite-summary.tsx index 80ec9268..a9767193 100644 --- a/apps/web/ui/courses/requisite-summary.tsx +++ b/apps/web/ui/courses/requisite-summary.tsx @@ -1,12 +1,19 @@ "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 { + conditionHeading, + conditionInterpretation, + conditionTone, +} from "@/ui/requirements/requirement-presentation"; import { CourseReferenceText } from "@/ui/courses/course-reference"; export function RequisiteConditionText({ @@ -220,3 +227,83 @@ 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. + */ +export function RequisiteRuleSummary({ + academicYear, + expression, + availableCourseCodes, +}: { + academicYear: number; + expression: CourseRuleExpression; + availableCourseCodes: ReadonlySet; +}) { + if (expression.kind === "group") { + const title = + expression.operator === "all_of" + ? "Complete all of the following" + : expression.operator === "any_of" + ? "Complete one of the following" + : `Complete at least ${expression.minimumCount ?? 1} of the following`; + return ( +
+

{title}

+
    + {expression.conditions.map((condition, index) => ( +
  • + +
  • + ))} +
+
+ ); + } + + 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} +
+

+ + {conditionHeading(condition)} + + {" · "} + +

+ {optionCodes.length ? ( +

+ +

+ ) : null} +
+
+ ); +} 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 ? (