diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index f06da012..f89c6a7f 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -24,6 +24,7 @@ import { removePlanCourse, saveProfileAndPlan, setCurrentUserPlanExtensionYears, + setRequirementPlacement, type CoursemapActionResult, } from "@/lib/coursemap/actions"; @@ -52,6 +53,11 @@ type AppContextValue = { attemptedUnits?: number, ) => Promise; removeAttempt: (attemptId: string) => Promise; + /** Moves a course to a part of the degree, or back to automatic with null. */ + setPlacement: ( + courseCode: string, + placement: { structureCode: string; requirementKey: string } | null, + ) => Promise; togglePermission: (attemptId: string) => void; toggleOverloadApproval: (attemptId: string) => void; notify: (message: string, tone?: ToastTone) => void; @@ -332,6 +338,31 @@ export function AppProvider({ })); }, []); + const setPlacement = useCallback( + async ( + courseCode: string, + placement: { structureCode: string; requirementKey: string } | null, + ) => { + const previous = state.placements ?? []; + const others = previous.filter( + (choice) => choice.courseCode !== courseCode, + ); + // The page reallocates at once; a failed save puts the choice back. + setState((current) => ({ + ...current, + placements: placement + ? [...others, { courseCode, ...placement }] + : others, + })); + const result = await setRequirementPlacement(courseCode, placement); + if (!result.ok) { + setState((current) => ({ ...current, placements: previous })); + } + return result; + }, + [state.placements], + ); + const toggleOverloadApproval = useCallback((attemptId: string) => { setState((current) => ({ ...current, @@ -354,6 +385,7 @@ export function AppProvider({ reorderAttempt, updateAttempt, removeAttempt, + setPlacement, togglePermission, toggleOverloadApproval, notify, @@ -368,6 +400,7 @@ export function AppProvider({ reorderAttempt, updateAttempt, removeAttempt, + setPlacement, togglePermission, toggleOverloadApproval, notify, diff --git a/apps/web/app/requirements/requirements.tsx b/apps/web/app/requirements/requirements.tsx index 657c0bed..013650d5 100644 --- a/apps/web/app/requirements/requirements.tsx +++ b/apps/web/app/requirements/requirements.tsx @@ -19,7 +19,16 @@ import type { } from "@/lib/coursemap/onboarding-catalogue"; import type { SelectableStructureKind } from "@/lib/coursemap/programme-structure-options"; import type { Course } from "@/lib/coursemap/types"; -import { requirementTreeProgress } from "@/lib/coursemap/requirement-progress"; +import { + allocateRequirements, + placementOptions, + requirementConditionsByKey, + requirementTreeProgress, +} from "@/lib/coursemap/requirement-progress"; +import { + conditionHeading, + type RequirementTreeCondition, +} from "@/ui/requirements/requirement-presentation"; import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; import { degreeUnitProgress, @@ -49,7 +58,7 @@ export function Requirements({ /** The section to open first, such as the major when adding one. */ initialTab?: PlanStructureKind; }) { - const { state, updateProfile, notify } = useCoursemap(); + const { state, updateProfile, notify, setPlacement } = useCoursemap(); const router = useRouter(); const [tab, setTab] = useState(initialTab); const [choosing, setChoosing] = useState(false); @@ -248,11 +257,72 @@ export function Requirements({ kind === "programme" ? (degree?.units ?? null) : (option?.units ?? null); + const root = requirements?.root ?? null; + const conditions = requirementConditionsByKey(root); + const nodeKeyFor = (projectionKey: string) => + [...conditions].find( + ([, condition]) => condition.projectionKey === projectionKey, + )?.[0]; + // A student's choices name rules by their stable key; a choice + // for a rule this version no longer has is simply not applied. + const pins = new Map( + (state.placements ?? []).flatMap((choice) => { + const nodeKey = + choice.structureCode === code + ? nodeKeyFor(choice.requirementKey) + : undefined; + return nodeKey ? [[choice.courseCode, nodeKey] as const] : []; + }), + ); + const allocation = allocateRequirements({ + root, + attempts: state.attempts, + catalogue, + pins, + }); const treeProgress = requirementTreeProgress({ - root: requirements?.root ?? null, + root, attempts: state.attempts, catalogue, + allocation, }); + const labelFor = (nodeKey: string) => { + const condition = conditions.get(nodeKey); + return condition + ? conditionHeading(condition as RequirementTreeCondition) + : "another requirement"; + }; + const placement = { + allocation, + labelFor, + optionsFor: (courseCode: string) => { + const attempt = state.attempts.find( + (candidate) => candidate.courseCode === courseCode, + ); + const course = attempt + ? planningCourseForAttempt(attempt, catalogue) + : undefined; + return course + ? placementOptions({ root, course }).map((nodeKey) => ({ + nodeKey, + label: labelFor(nodeKey), + })) + : []; + }, + onPlace: (courseCode: string, nodeKey: string | null) => { + const projectionKey = nodeKey + ? conditions.get(nodeKey)?.projectionKey + : undefined; + void setPlacement( + courseCode, + projectionKey + ? { structureCode: code, requirementKey: projectionKey } + : null, + ).then((result) => { + if (!result.ok) notify(result.message, "warning"); + }); + }, + }; return (
{kind !== "programme" && ( @@ -271,6 +341,7 @@ export function Requirements({ context={{ catalogue, progress: treeProgress, + placement, attemptStatusByCode: attemptStatuses, selectedStructureCodes: selectedCodes, unitTarget: target, diff --git a/apps/web/lib/catalogue-import/kinds/structure/contract.ts b/apps/web/lib/catalogue-import/kinds/structure/contract.ts index bb1486a9..f425aaa7 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/contract.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/contract.ts @@ -81,12 +81,22 @@ export type AcademicStructureRequirementGroup = { key: string; operator: "all_of" | "any_of" | "minimum_count"; minimumCount: number | null; + /** See {@link AcademicStructureRequirementCondition.scope}. */ + scope: RequirementScope; title: string | null; sourceText: string; sourceLocator: string; children: AcademicStructureRequirementRule[]; }; +/** + * `part` fills a share of the degree, and a course counted here counts + * nowhere else. `degree` constrains every course the degree counts, such as + * "of which a maximum of 60 units from 1000-level courses", without using any + * course up. + */ +export type RequirementScope = "part" | "degree"; + export type AcademicStructureRequirementCondition = { type: "condition"; key: string; @@ -110,6 +120,9 @@ export type AcademicStructureRequirementCondition = { maximumLevel: number | null; tag: string | null; freeText: string | null; + scope: RequirementScope; + /** A course list that ends "Any other ANU courses": the list is suggestions. */ + includesAnyCourse: boolean; sourceText: string; sourceLocator: string; }; @@ -311,6 +324,8 @@ const requirementConditionSchema: z.ZodType = key: nonEmptyString, operator: z.enum(["all_of", "any_of", "minimum_count"]), minimumCount: z.number().int().positive().nullable().default(null), + scope: z.enum(["part", "degree"]).default("part"), title: nullableString, sourceText: nonEmptyString, sourceLocator: nonEmptyString, @@ -987,6 +1003,7 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = { "key", "operator", "minimumCount", + "scope", "title", "sourceText", "sourceLocator", @@ -997,6 +1014,7 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = { key: { type: "string", minLength: 1 }, operator: { enum: ["all_of", "any_of", "minimum_count"] }, minimumCount: { type: ["integer", "null"], minimum: 1 }, + scope: { enum: ["part", "degree"] }, title: nullableStringSchema, sourceText: { type: "string", minLength: 1 }, sourceLocator: { type: "string", minLength: 1 }, @@ -1025,6 +1043,8 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = { "maximumLevel", "tag", "freeText", + "scope", + "includesAnyCourse", "sourceText", "sourceLocator", ], @@ -1069,6 +1089,8 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = { maximumLevel: { type: ["integer", "null"], minimum: 0 }, tag: nullableStringSchema, freeText: nullableStringSchema, + scope: { enum: ["part", "degree"] }, + includesAnyCourse: { type: "boolean" }, sourceText: { type: "string", minLength: 1 }, sourceLocator: { type: "string", minLength: 1 }, }, diff --git a/apps/web/lib/catalogue-import/kinds/structure/finalise.ts b/apps/web/lib/catalogue-import/kinds/structure/finalise.ts index 7269a900..f0ed9663 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/finalise.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/finalise.ts @@ -10,6 +10,7 @@ import { normaliseAcademicStructureModelExtraction, repairRequirementNodes, } from "./model-canonical.ts"; +import { withListedStructureOptions } from "./listed-options.ts"; import { unsupportedModelWording } from "../../model-evidence.ts"; import { modelResponseProblem, @@ -190,6 +191,10 @@ export function finaliseAcademicStructureExtraction({ const finalised: AcademicStructureExtraction = { ...extraction, description, + relationships: + kind === "programme" + ? withListedStructureOptions(extraction.relationships, pageMarkdown) + : extraction.relationships, requirements: ensureRequirementRootGroup(extraction.requirements), reviewItems, }; diff --git a/apps/web/lib/catalogue-import/kinds/structure/listed-options.ts b/apps/web/lib/catalogue-import/kinds/structure/listed-options.ts new file mode 100644 index 00000000..3e60b586 --- /dev/null +++ b/apps/web/lib/catalogue-import/kinds/structure/listed-options.ts @@ -0,0 +1,85 @@ +import type { + AcademicStructureExtraction, + AcademicStructureKind, + AcademicStructureRelationship, +} from "./contract.ts"; + +type OptionKind = Exclude; + +/** The headings a programme page lists its majors, minors and specialisations under. */ +const LIST_HEADINGS: Record = { + majors: "major", + minors: "minor", + specialisations: "specialisation", + specializations: "specialisation", +}; + +const CODE_SUFFIX: Record = { + major: /-MAJ$/u, + minor: /-MIN$/u, + specialisation: /-(?:HSPC|SPEC)$/u, +}; + +/** A link the page reader has turned into a record code, or a bare code. */ +const LISTED_CODE = + /\[([^\]]+)\]\(([A-Z0-9][A-Z0-9-]{1,31})\)|\b([A-Z0-9]{2,}-(?:MAJ|MIN|SPEC|HSPC))\b/gu; + +/** + * The majors, minors and specialisations a programme page lists under its own + * Majors, Minors and Specialisations headings. The model has left these out + * even when asked, and a student can only choose what is recorded as an + * option, so the lists are read from the page as well. + */ +export function listedStructureOptions( + pageMarkdown: string, +): Omit[] { + const options: Omit[] = []; + let listing: { kind: OptionKind; heading: string; level: number } | null = + null; + for (const line of pageMarkdown.split("\n")) { + const heading = /^(#{1,4})\s+(.+?)\s*$/u.exec(line); + if (heading) { + const level = heading[1].length; + if (listing && level > listing.level) continue; + const kind = LIST_HEADINGS[heading[2].toLowerCase()]; + listing = kind ? { kind, heading: heading[2], level } : null; + continue; + } + if (!listing) continue; + for (const match of line.matchAll(LISTED_CODE)) { + const code = (match[2] ?? match[3]).toUpperCase(); + if (!CODE_SUFFIX[listing.kind].test(code)) continue; + const title = match[1]?.trim() || null; + options.push({ + relationshipKind: "option", + targetKind: listing.kind, + targetCode: code, + targetTitle: title, + sourceText: title ?? code, + sourceLocator: listing.heading, + }); + } + } + return options; +} + +/** Adds the listed options the model did not record, after the ones it did. */ +export function withListedStructureOptions( + relationships: AcademicStructureExtraction["relationships"], + pageMarkdown: string, +): AcademicStructureExtraction["relationships"] { + const recorded = new Set( + relationships + .filter(({ relationshipKind }) => relationshipKind === "option") + .map(({ targetKind, targetCode }) => `${targetKind}:${targetCode}`), + ); + const last = Math.max(0, ...relationships.map(({ position }) => position)); + const added: AcademicStructureExtraction["relationships"] = []; + for (const option of listedStructureOptions(pageMarkdown)) { + const key = `${option.targetKind}:${option.targetCode}`; + if (recorded.has(key)) continue; + recorded.add(key); + added.push({ ...option, position: last + added.length + 1 }); + } + return added.length ? [...relationships, ...added] : relationships; +} diff --git a/apps/web/lib/catalogue-import/kinds/structure/model-canonical.ts b/apps/web/lib/catalogue-import/kinds/structure/model-canonical.ts index 1ae63640..470ade0a 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/model-canonical.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/model-canonical.ts @@ -101,6 +101,7 @@ export function ensureRequirementRootGroup( : "requirements:root", operator: "all_of" as const, minimumCount: null, + scope: "part" as const, title: "Requirements", sourceText: requirements.sourceText ?? rule.sourceText, sourceLocator: requirements.sourceLocator ?? rule.sourceLocator, diff --git a/apps/web/lib/catalogue-import/kinds/structure/project.ts b/apps/web/lib/catalogue-import/kinds/structure/project.ts index 36405e21..eb1ed9b8 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/project.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/project.ts @@ -8,6 +8,7 @@ import { type AcademicStructureExtraction, type AcademicStructureKind, type AcademicStructureRequirementRule, + type RequirementScope, } from "./contract.ts"; import { ACADEMIC_STRUCTURE_SNAPSHOT_SCHEMA_VERSION } from "./prompt.ts"; import { canonicaliseAcademicStructureRelationships } from "./relationship-canonicalisation.ts"; @@ -69,6 +70,7 @@ export type AcademicStructureSnapshotProjection = { minimumCount: number | null; minimumUnits: number | null; maximumUnits: number | null; + scope: RequirementScope; title: string | null; description: string | null; sourceText: string; @@ -96,6 +98,8 @@ export type AcademicStructureSnapshotProjection = { maximumLevel: number | null; tag: string | null; freeText: string | null; + scope: RequirementScope; + includesAnyCourse: boolean; sourceText: string; sourceLocator: string; }>; @@ -161,6 +165,8 @@ function flattenRequirementRule( maximumLevel: rule.maximumLevel, tag: rule.tag, freeText: rule.freeText, + scope: rule.scope, + includesAnyCourse: rule.includesAnyCourse, sourceText: rule.sourceText, sourceLocator: rule.sourceLocator, }); @@ -193,6 +199,7 @@ function flattenRequirementRule( minimumCount: rule.minimumCount, minimumUnits: null, maximumUnits: null, + scope: rule.scope, title: rule.title, description: null, sourceText: rule.sourceText, diff --git a/apps/web/lib/catalogue-import/kinds/structure/prompt.ts b/apps/web/lib/catalogue-import/kinds/structure/prompt.ts index 16aa2f80..28e2b7c7 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/prompt.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/prompt.ts @@ -6,7 +6,7 @@ import { export const ACADEMIC_STRUCTURE_IMPORT_PARSER_VERSION = "coursemap-academic-structure-parser.v5"; export const ACADEMIC_STRUCTURE_IMPORT_PROMPT_VERSION = - "coursemap-academic-structure-prompt.v7"; + "coursemap-academic-structure-prompt.v8"; export const ACADEMIC_STRUCTURE_IMPORT_MAX_OUTPUT_TOKENS = 24_000; export const ACADEMIC_STRUCTURE_SNAPSHOT_SCHEMA_VERSION = "academic-structure-snapshot.v3"; @@ -41,8 +41,8 @@ Source rules: Requirements, learning outcomes, indicative fees, areas of interest and lists of related degrees, majors, minors or specialisations have fields of their own and are never sections. 5. Record every key fact as a summary field with its label and value. Also fill the dedicated field a key fact belongs to, such as durationYears from "Length 4 year full-time", college from "offered by the ANU College of ...", selectionRank from "SELECTION RANK 85" and academicCareer from "Academic career". 6. A relationship needs a literal linked or printed target code. A friendly name without a code is not enough. Record only these three meanings, and nothing that is merely mentioned: - - offered_in: a degree (programme) this major, minor or specialisation can be studied in, such as the Relevant Degrees list. - - option: a major, minor or specialisation a programme lets students choose. + - offered_in: a degree (programme) this major, minor or specialisation can be studied in, such as the Relevant Degrees list. Record one for every degree in that list. + - option: a major, minor or specialisation a programme lets students choose. On a programme page, every entry in its Majors, Minors and Specialisations lists is an option, with the code taken from the entry's link, even when the requirements also name it. - incompatible: a structure that cannot be taken together with this one. 7. A structure that must be taken alongside this one ("must be taken in conjunction with", corequisite majors) is a requirement, not a relationship: add a group titled "Taken with" to the requirement tree holding a structure_list condition with those codes and their structureKind. 8. Extract learning outcomes individually and in source order. @@ -67,7 +67,12 @@ Requirement interpretation: - "a minimum of N units ... from X000-level courses", including a range such as "3000 and 4000-level" -> level with minimumUnits N, minimumLevel and maximumLevel. Combine with subjectCode when the sentence names a subject. - "a maximum of N units may come from ... X000-level courses" -> level with maximumUnits N and the matching level bounds. - "courses tagged as " or "from the list" -> tag with that literal tag and its unit bounds. - - "N units of electives" or "unrestricted electives" -> unrestricted with minimumUnits N. + - "N units of electives", "unrestricted electives" or "N units from completion of elective courses offered by ANU" -> unrestricted with minimumUnits N. + - A course list that ends "Any other ANU courses" (or "any other course") -> course_list with the printed courses and includesAnyCourse true: the list only suggests courses, and any course counts. Every other condition has includesAnyCourse false. +- Every group and condition has a scope. ANU writes a degree's requirements in two layers: + - "requires completion of N units, of which:" introduces rules across the whole degree, such as "A maximum of 60 units may come from completion of 1000-level courses", "A minimum of 48 units ... from 4000-level courses" or "A minimum of 12 units of courses tagged as X". These have scope degree: they constrain every course the degree counts and never use a course up. + - "The N units must include:" introduces the parts of the degree, such as compulsory lists, "one of the following majors" and elective units. These have scope part: a course counted in one part counts in no other. + - A group holding only degree-scope rules is itself degree-scope. When a page has no "of which" layer, every rule is a part. - "completion of one of the following majors/minors/specialisations" plus literal codes -> structure_list with those structureCodes. - Honour an explicit OR between two modelled alternatives, such as a subject condition OR a structure_list of majors, with an any_of group holding both. - Represent explicit AND as an all_of group and explicit OR as an any_of group. diff --git a/apps/web/lib/catalogue-import/version-content.ts b/apps/web/lib/catalogue-import/version-content.ts index d60f9306..bdca011b 100644 --- a/apps/web/lib/catalogue-import/version-content.ts +++ b/apps/web/lib/catalogue-import/version-content.ts @@ -48,7 +48,7 @@ async function readRequirements( ); const groups = await sql` select id, rule_id, parent_group_id, group_key, label, description, operator, minimum_count, - minimum_units, maximum_units, source_text, source_locator, position + minimum_units, maximum_units, source_text, source_locator, position, scope from public.requirement_groups where version_id = ${versionId} order by rule_id, position, id `; const groupKeyById = new Map( @@ -103,6 +103,7 @@ async function readRequirements( minimumCount: num(group.minimum_count), minimumUnits: num(group.minimum_units), maximumUnits: num(group.maximum_units), + ...(group.scope === "degree" ? { scope: "degree" as const } : {}), sourceText: str(group.source_text), sourceLocator: str(group.source_locator), position: Number(group.position), @@ -136,6 +137,10 @@ async function readRequirements( sourceLocator: str(condition.source_locator), reviewState: condition.review_state as ReviewState, confidence: Number(condition.confidence), + ...(condition.scope === "degree" ? { scope: "degree" as const } : {}), + ...(condition.includes_any_course + ? { includesAnyCourse: true as const } + : {}), })), options: options.map((option) => ({ conditionKey: conditionKeyById.get(Number(option.condition_id))!, diff --git a/apps/web/lib/catalogue-sync/persist-source-version.ts b/apps/web/lib/catalogue-sync/persist-source-version.ts index 1febfc01..1f1576aa 100644 --- a/apps/web/lib/catalogue-sync/persist-source-version.ts +++ b/apps/web/lib/catalogue-sync/persist-source-version.ts @@ -344,13 +344,15 @@ async function insertRequirements( const [row] = await tx` insert into public.requirement_groups ( rule_id, version_id, parent_group_id, group_key, label, description, operator, - minimum_count, minimum_units, maximum_units, source_text, source_locator, position + minimum_count, minimum_units, maximum_units, source_text, source_locator, position, + scope ) values ( ${ruleId}, ${snapshotId}, ${group.parentKey === null ? null : groupIds.get(group.parentKey)!}, ${group.key}, ${group.label}, ${group.description}, ${group.operator}, ${group.minimumCount}, ${group.minimumUnits}, ${group.maximumUnits}, - ${group.sourceText}, ${group.sourceLocator}, ${group.position} + ${group.sourceText}, ${group.sourceLocator}, ${group.position}, + ${group.scope ?? "part"} ) returning id `; @@ -377,7 +379,7 @@ async function insertRequirements( item_kind, structure_kind, requirement_mode, minimum_mark, minimum_units, maximum_units, minimum_count, subject_code, minimum_level, maximum_level, minimum_year, minimum_gpa, minimum_wam, tag, free_text, hardness, source_text, source_locator, - review_state, confidence + review_state, confidence, scope, includes_any_course ) values ( ${ruleId}, ${snapshotId}, ${groupId}, ${condition.key}, ${condition.position}, ${condition.kind}, ${conditionItemId}, @@ -388,7 +390,8 @@ async function insertRequirements( ${condition.minimumYear}, ${condition.minimumGpa}, ${condition.minimumWam}, ${condition.tag}, ${condition.freeText}, ${condition.hardness}, ${condition.sourceText}, ${condition.sourceLocator}, ${condition.reviewState}, - ${condition.confidence} + ${condition.confidence}, ${condition.scope ?? "part"}, + ${condition.includesAnyCourse ?? false} ) returning id `; diff --git a/apps/web/lib/catalogue/content.ts b/apps/web/lib/catalogue/content.ts index 1175a12a..bb9a844c 100644 --- a/apps/web/lib/catalogue/content.ts +++ b/apps/web/lib/catalogue/content.ts @@ -70,6 +70,11 @@ export type RequirementWrite = { minimumCount: number | null; minimumUnits: number | null; maximumUnits: number | null; + /** + * `degree` when the group constrains every course the degree counts. + * Absent for a part of the degree, so earlier content keeps its hash. + */ + scope?: "degree"; sourceText: string | null; sourceLocator: string | null; position: number; @@ -102,6 +107,10 @@ export type RequirementWrite = { sourceLocator: string | null; reviewState: ReviewState; confidence: number; + /** As for groups: `degree` when the rule spans the whole degree. */ + scope?: "degree"; + /** A course list ending "Any other ANU courses"; absent when closed. */ + includesAnyCourse?: true; }>; options: Array<{ conditionKey: string; @@ -719,6 +728,7 @@ export function structureCatalogueContent({ minimumCount: group.minimumCount, minimumUnits: group.minimumUnits, maximumUnits: group.maximumUnits, + ...(group.scope === "degree" ? { scope: "degree" as const } : {}), sourceText: group.sourceText, sourceLocator: group.sourceLocator, position: group.position, @@ -754,6 +764,12 @@ export function structureCatalogueContent({ sourceLocator: condition.sourceLocator, reviewState: "automatic" as const, confidence: 1, + ...(condition.scope === "degree" + ? { scope: "degree" as const } + : {}), + ...(condition.includesAnyCourse + ? { includesAnyCourse: true as const } + : {}), })), // Source wording the parser could not model keeps its place as an // `other` condition under the root group so students still see it. diff --git a/apps/web/lib/coursemap/actions.ts b/apps/web/lib/coursemap/actions.ts index 1a1c284b..97ee7d45 100644 --- a/apps/web/lib/coursemap/actions.ts +++ b/apps/web/lib/coursemap/actions.ts @@ -191,3 +191,34 @@ export async function recordCourseAttempt( return failure(error); } } + +/** + * Counts a course towards a chosen part of the student's degree, or hands the + * choice back to Coursemap when no requirement is given. + */ +export async function setRequirementPlacement( + courseCode: string, + placement: { structureCode: string; requirementKey: string } | null, +): Promise { + try { + const supabase = await createClient(); + const { error } = await supabase.rpc( + "set_current_user_requirement_placement", + { + p_course_code: courseCode, + p_structure_code: placement?.structureCode, + p_requirement_key: placement?.requirementKey, + }, + ); + if (error) throw error; + revalidatePath("/requirements"); + return { + ok: true, + message: placement + ? `${courseCode} moved` + : `${courseCode} placed automatically`, + }; + } catch (error) { + return failure(error); + } +} diff --git a/apps/web/lib/coursemap/plan-catalogue.ts b/apps/web/lib/coursemap/plan-catalogue.ts index 3f835b90..e0315f47 100644 --- a/apps/web/lib/coursemap/plan-catalogue.ts +++ b/apps/web/lib/coursemap/plan-catalogue.ts @@ -49,6 +49,13 @@ export type PlanRequirementCondition = { structureKind: string | null; subjectCode: string | null; tag: string | null; + /** + * `part` fills a share of the degree and uses its courses up; `degree` + * constrains every course the degree counts, such as a 1000-level cap. + */ + scope: "part" | "degree"; + /** A course list ending "Any other ANU courses": any course counts. */ + includesAnyCourse: boolean; }; export type PlanRequirementGroup = { @@ -65,6 +72,7 @@ export type PlanRequirementGroup = { sourceLocator: string; sourceText: string; title: string | null; + scope: "part" | "degree"; }; export type PlanRequirementNode = @@ -165,6 +173,7 @@ export function planCourseFromDetails(course: CourseDetails): Course { // Requirement allocation and permission rules are intentionally omitted // until their source structures have been imported and reviewed. countsTowards: [], + tags: course.tags, sourceUrl: course.sourceUrl, lastChanged: course.sourceUpdatedAt ?? "Not listed", parseState: @@ -248,6 +257,8 @@ export function buildAcademicStructureRequirementTree({ structureKind: condition.structure_kind, subjectCode: condition.subject_code, tag: condition.tag, + scope: condition.scope === "degree" ? "degree" : "part", + includesAnyCourse: condition.includes_any_course, }; } @@ -278,6 +289,7 @@ export function buildAcademicStructureRequirementTree({ sourceLocator: group.source_locator ?? "", sourceText: group.source_text ?? "", title: group.label, + scope: group.scope === "degree" ? "degree" : "part", }; } diff --git a/apps/web/lib/coursemap/requirement-progress.ts b/apps/web/lib/coursemap/requirement-progress.ts index cf1a5f9d..32170800 100644 --- a/apps/web/lib/coursemap/requirement-progress.ts +++ b/apps/web/lib/coursemap/requirement-progress.ts @@ -26,7 +26,12 @@ export type RequirementBucketProgress = { plannedUnits: number; }; -type MatchableCourse = { code: string; subject: string; level: number }; +type MatchableCourse = { + code: string; + subject: string; + level: number; + tags?: readonly string[]; +}; type CoursePredicate = (course: MatchableCourse) => boolean; const COURSE_CODE = /^[A-Z]{4}\d{4}[A-Z]?$/; @@ -55,7 +60,8 @@ function conditionPredicates( | "subjectCode" | "minimumLevel" | "maximumLevel" - >, + > & + Partial>, ): CoursePredicate[] { const predicates: CoursePredicate[] = []; const codes = new Set( @@ -81,10 +87,35 @@ function conditionPredicates( predicates.push((course) => levelWithin(course.level, condition.minimumLevel, condition.maximumLevel), ); + } else if (condition.conditionKind === "tagged_units" && condition.tag) { + // A tag is one category however it is capitalised. + const tag = condition.tag.toLowerCase(); + predicates.push( + (course) => + (course.tags ?? []).some( + (candidate) => candidate.toLowerCase() === tag, + ) && + levelWithin( + course.level, + condition.minimumLevel, + condition.maximumLevel, + ), + ); } return predicates; } +/** Electives and open lists take any course, so they are always checkable. */ +function takesAnyCourse( + condition: Pick & + Partial>, +) { + return ( + condition.conditionKind === "elective_units" || + Boolean(condition.includesAnyCourse) + ); +} + export function canMeasureRequirementCondition( condition: Pick< PlanRequirementCondition, @@ -96,10 +127,11 @@ export function canMeasureRequirementCondition( | "minimumUnits" | "maximumUnits" | "minimumCourses" - >, + > & + Partial>, ) { return ( - conditionPredicates(condition).length > 0 && + (conditionPredicates(condition).length > 0 || takesAnyCourse(condition)) && (condition.minimumUnits !== null || condition.maximumUnits !== null || condition.minimumCourses !== null) @@ -338,39 +370,275 @@ function stateFromUnits({ return "not_started"; } +/** Where one counted course landed in a requirement tree. */ +export type CoursePlacement = { + /** The part of the degree the course counts towards, or null for none. */ + nodeKey: string | null; + /** Whether a student chose this part rather than Coursemap. */ + pinned: boolean; + /** A degree-wide cap the course fell beyond, so it counts towards nothing. */ + overCapKey: string | null; +}; + +export type RequirementAllocation = ReadonlyMap; + +type Leaf = { + node: PlanRequirementCondition; + key: string; + scope: "part" | "degree"; + predicates: CoursePredicate[]; + /** Every group above is all_of, so the rule always applies. */ + mandatory: boolean; +}; + +function collectLeaves( + node: PlanRequirementNode, + inherited: "part" | "degree", + mandatory: boolean, + into: Leaf[], +) { + const scope = + inherited === "degree" || node.scope === "degree" ? "degree" : "part"; + if (node.type === "group") { + node.children.forEach((child) => + collectLeaves( + child, + scope, + mandatory && node.operator === "all_of", + into, + ), + ); + return; + } + into.push({ + node, + key: requirementNodeKey(node), + scope: effectiveScope(node, scope, mandatory), + predicates: conditionPredicates(node), + mandatory, + }); +} + +/** + * A rule with a maximum and nothing to reach, such as "a maximum of 60 units + * from 1000-level courses", caps what counts rather than asking for anything, + * so it spans the degree even on a page that writes no "of which" layer. + */ +function isCap(condition: PlanRequirementCondition) { + return ( + condition.maximumUnits !== null && + condition.minimumUnits === null && + condition.minimumCourses === null + ); +} + +/** + * A cap is degree-wide only when it always applies; one alternative of an + * either-or rule limits that alternative, not every course in the degree. + */ +function effectiveScope( + condition: PlanRequirementCondition, + inherited: "part" | "degree", + mandatory: boolean, +): "part" | "degree" { + return inherited === "degree" || + condition.scope === "degree" || + (mandatory && isCap(condition)) + ? "degree" + : "part"; +} + +/** + * How specifically a part asks for a course, lower first, or null when the + * course cannot count there. A course goes to the most specific part that has + * room: named courses before short lists, lists before subject and level + * rules, and open lists and electives last, since they take anything. + */ +function placementRank(leaf: Leaf, course: MatchableCourse): number | null { + const condition = leaf.node; + const listed = condition.options.filter( + (option) => option.kind === "course" && option.code === course.code, + ); + if (listed.length > 0) return condition.options.length; + if (condition.conditionKind === "units_total") return null; + if (leaf.predicates.some((matches) => matches(course))) { + if (condition.conditionKind === "subject_units") { + return condition.minimumLevel !== null || condition.maximumLevel !== null + ? 900 + : 1_000; + } + if (condition.conditionKind === "tagged_units") return 1_500; + return 2_000; + } + if (condition.includesAnyCourse) return 8_000; + if (condition.conditionKind === "elective_units") return 9_000; + return null; +} + +/** + * Assigns each course in the plan to at most one part of the degree. + * + * Degree-wide caps apply first, completed work before planned: a course that + * would take a cap past its limit counts towards nothing and is flagged. Each + * remaining course then goes to the most specific part that still needs units + * and has room under its own maximum, with a student's own choice taking + * precedence wherever the course may count. Courses with a single possible + * home claim their place before courses that could go anywhere. + */ +export function allocateRequirements({ + root, + attempts, + catalogue, + pins = new Map(), +}: { + root: PlanRequirementGroup | null; + attempts: readonly Attempt[]; + catalogue: PlanningCatalogue; + /** A student's chosen part for a course, by course code. */ + pins?: ReadonlyMap; +}): RequirementAllocation { + const placements = new Map(); + if (!root) return placements; + const leaves: Leaf[] = []; + collectLeaves(root, "part", true, leaves); + const credited = orderedCredit(creditedAttempts(attempts, catalogue)); + + // Caps across the degree decide what may count at all. + const excluded = new Map(); + for (const leaf of leaves) { + const cap = leaf.node.maximumUnits; + if (leaf.scope !== "degree" || !leaf.mandatory || cap === null) continue; + let used = 0; + for (const { course, units } of credited) { + if (excluded.has(course.code)) continue; + if (!leaf.predicates.some((matches) => matches(course))) continue; + if (used + units > cap) excluded.set(course.code, leaf.key); + else used += units; + } + } + + const parts = leaves.filter( + (leaf) => + leaf.scope === "part" && leaf.node.conditionKind !== "units_total", + ); + const used = new Map(); + const candidatesFor = (course: MatchableCourse) => + parts + .map((leaf) => ({ leaf, rank: placementRank(leaf, course) })) + .filter( + (candidate): candidate is { leaf: Leaf; rank: number } => + candidate.rank !== null, + ); + const queue = credited + .filter(({ course }) => !excluded.has(course.code)) + .map((entry) => ({ entry, candidates: candidatesFor(entry.course) })) + .toSorted( + (left, right) => + Number(pins.has(right.entry.course.code)) - + Number(pins.has(left.entry.course.code)) || + Math.min(...left.candidates.map(({ rank }) => rank), Infinity) - + Math.min(...right.candidates.map(({ rank }) => rank), Infinity), + ); + + for (const { entry, candidates } of queue) { + const { course, units } = entry; + const room = candidates.filter(({ leaf }) => { + const maximum = leaf.node.maximumUnits; + return maximum === null || (used.get(leaf.key) ?? 0) + units <= maximum; + }); + const pinnedKey = pins.get(course.code); + const pinned = room.find(({ leaf }) => leaf.key === pinnedKey); + const needing = (leaf: Leaf) => { + const target = leaf.node.minimumUnits; + return target === null || (used.get(leaf.key) ?? 0) < target; + }; + const chosen = + pinned ?? + room + .filter(({ leaf }) => needing(leaf)) + .toSorted((left, right) => left.rank - right.rank)[0] ?? + room.toSorted((left, right) => left.rank - right.rank)[0]; + if (chosen) { + used.set(chosen.leaf.key, (used.get(chosen.leaf.key) ?? 0) + units); + } + placements.set(course.code, { + nodeKey: chosen?.leaf.key ?? null, + pinned: Boolean(pinned), + overCapKey: null, + }); + } + for (const [code, capKey] of excluded) { + placements.set(code, { nodeKey: null, pinned: false, overCapKey: capKey }); + } + return placements; +} + +/** Completed work first, then this semester's, then plans, each in plan order. */ +function orderedCredit(credited: readonly CreditedAttempt[]) { + const weight = (status: Attempt["status"]) => + status === "completed" ? 0 : status === "enrolled" ? 1 : 2; + return credited + .map((entry, index) => ({ entry, index })) + .toSorted( + (left, right) => + weight(left.entry.attempt.status) - + weight(right.entry.attempt.status) || left.index - right.index, + ) + .map(({ entry }) => entry); +} + function conditionProgress( condition: PlanRequirementCondition, + scope: "part" | "degree", credited: readonly CreditedAttempt[], + allocation: RequirementAllocation, ): RequirementNodeProgress { + const key = requirementNodeKey(condition); const predicates = conditionPredicates(condition); + // A part counts only what was allocated to it; a degree-wide rule reads + // every course the degree counts, and the total reads them all. + const counts = ({ course }: CreditedAttempt) => { + const placement = allocation.get(course.code); + if (placement?.overCapKey) return false; + if (condition.conditionKind === "units_total") return true; + if (scope === "degree") + return predicates.some((matches) => matches(course)); + return placement?.nodeKey === key; + }; let completedUnits = 0; let plannedUnits = 0; let completedCourses = 0; const matchedCourseCodes: string[] = []; - credited.forEach(({ attempt, course, units }) => { - if (!predicates.some((matches) => matches(course))) return; - matchedCourseCodes.push(course.code); - if (attempt.status === "completed") { - completedUnits += units; + credited.forEach((entry) => { + if (!counts(entry)) return; + matchedCourseCodes.push(entry.course.code); + if (entry.attempt.status === "completed") { + completedUnits += entry.units; completedCourses += 1; } else { - plannedUnits += units; + plannedUnits += entry.units; } }); + const overCap = [...allocation.values()].some( + (placement) => placement.overCapKey === key, + ); const targetUnits = condition.minimumUnits; const maximumUnits = condition.maximumUnits; const targetCourses = condition.minimumCourses; + const state = stateFromUnits({ + targetUnits, + maximumUnits, + targetCourses, + completedUnits, + plannedUnits, + completedCourses, + measurable: + canMeasureRequirementCondition(condition) || + condition.conditionKind === "units_total", + }); return { - key: requirementNodeKey(condition), - state: stateFromUnits({ - targetUnits, - maximumUnits, - targetCourses, - completedUnits, - plannedUnits, - completedCourses, - measurable: canMeasureRequirementCondition(condition), - }), + key, + state: overCap ? "over_limit" : state, targetUnits, maximumUnits, targetCourses, @@ -414,18 +682,36 @@ function groupStateFromChildren( function groupProgress( group: PlanRequirementGroup, + inherited: "part" | "degree", + mandatory: boolean, credited: readonly CreditedAttempt[], + allocation: RequirementAllocation, into: Map, ): RequirementNodeProgress { + const scope = + inherited === "degree" || group.scope === "degree" ? "degree" : "part"; + const childrenMandatory = mandatory && group.operator === "all_of"; const children = group.children.map((child) => child.type === "condition" - ? conditionProgress(child, credited) - : groupProgress(child, credited, into), + ? conditionProgress( + child, + effectiveScope(child, scope, childrenMandatory), + credited, + allocation, + ) + : groupProgress( + child, + scope, + childrenMandatory, + credited, + allocation, + into, + ), ); children.forEach((child) => into.set(child.key, child)); - // Units roll up through the union of matched courses so a course that - // satisfies two sibling rules is only counted once for the parent. + // Parts never share a course, and a degree-wide rule never uses one up, so + // the union of the children's courses is what the group holds. const matchedCourseCodes = [ ...new Set(children.flatMap((child) => child.matchedCourseCodes)), ]; @@ -488,26 +774,35 @@ function groupProgress( /** * Progress for every node in one published requirement tree, keyed by - * `requirementNodeKey`. The requirements page uses this to show each rule's - * completed, planned and remaining units with a satisfied state. - * - * This is an indicative reading, not a formal audit: a course may legitimately - * count toward several rules, and rules Coursemap cannot evaluate are reported + * `requirementNodeKey`, with each course allocated to one part of the degree + * (see `allocateRequirements`). Rules Coursemap cannot evaluate are reported * as `unmeasured` rather than guessed at. */ export function requirementTreeProgress({ root, attempts, catalogue, + allocation, }: { root: PlanRequirementGroup | null; attempts: readonly Attempt[]; catalogue: PlanningCatalogue; + /** From `allocateRequirements`; computed without pins when absent. */ + allocation?: RequirementAllocation; }): RequirementTreeProgress { const progress = new Map(); if (!root) return progress; const credited = creditedAttempts(attempts, catalogue); - const rootProgress = groupProgress(root, credited, progress); + const placements = + allocation ?? allocateRequirements({ root, attempts, catalogue }); + const rootProgress = groupProgress( + root, + "part", + true, + credited, + placements, + progress, + ); progress.set(rootProgress.key, rootProgress); return progress; } @@ -539,3 +834,42 @@ export function requirementBucketStatus(bucket: RequirementBucketProgress): { label: `${target - bucket.completedUnits - bucket.plannedUnits}u short`, }; } + +/** + * The parts of a degree one course may count towards, most specific first, + * for a student choosing where it goes. Degree-wide rules are left out: they + * read every course and are never a place to put one. + */ +export function placementOptions({ + root, + course, +}: { + root: PlanRequirementGroup | null; + course: MatchableCourse; +}): string[] { + if (!root) return []; + const leaves: Leaf[] = []; + collectLeaves(root, "part", true, leaves); + return leaves + .filter( + (leaf) => + leaf.scope === "part" && leaf.node.conditionKind !== "units_total", + ) + .map((leaf) => ({ key: leaf.key, rank: placementRank(leaf, course) })) + .filter( + (option): option is { key: string; rank: number } => option.rank !== null, + ) + .toSorted((left, right) => left.rank - right.rank) + .map(({ key }) => key); +} + +/** Every condition in a tree by its node key, for naming where a course went. */ +export function requirementConditionsByKey(root: PlanRequirementGroup | null) { + const conditions = new Map(); + const visit = (node: PlanRequirementNode) => { + if (node.type === "group") node.children.forEach(visit); + else conditions.set(requirementNodeKey(node), node); + }; + if (root) visit(root); + return conditions; +} diff --git a/apps/web/lib/coursemap/requirement-write-tree.ts b/apps/web/lib/coursemap/requirement-write-tree.ts index ee36aa7b..ae24b622 100644 --- a/apps/web/lib/coursemap/requirement-write-tree.ts +++ b/apps/web/lib/coursemap/requirement-write-tree.ts @@ -24,6 +24,7 @@ export type RequirementTreeSource = { sourceText?: string | null; sourceLocator?: string | null; position: number; + scope?: string | null; }>; conditions: ReadonlyArray<{ key: string; @@ -47,6 +48,8 @@ export type RequirementTreeSource = { freeText: string | null; sourceText?: string | null; sourceLocator?: string | null; + scope?: string | null; + includesAnyCourse?: boolean | null; }>; options: ReadonlyArray<{ conditionKey: string; @@ -134,6 +137,8 @@ export function requirementTreeFromSource( structureKind: condition.structureKind ?? null, subjectCode: condition.subjectCode, tag: condition.tag, + scope: condition.scope === "degree" ? "degree" : "part", + includesAnyCourse: condition.includesAnyCourse ?? false, }; } @@ -164,6 +169,7 @@ export function requirementTreeFromSource( sourceLocator: group.sourceLocator ?? "", sourceText: group.sourceText ?? "", title: group.label, + scope: group.scope === "degree" ? "degree" : "part", }; } diff --git a/apps/web/lib/coursemap/state.ts b/apps/web/lib/coursemap/state.ts index e6d295fc..e1057642 100644 --- a/apps/web/lib/coursemap/state.ts +++ b/apps/web/lib/coursemap/state.ts @@ -122,33 +122,42 @@ export async function loadCoursemapState( }; if (!plan) return state; - const [yearResult, structuresResult, itemsResult, attemptsResult] = - await Promise.all([ - supabase - .from("academic_years") - .select("year") - .eq("id", plan.academic_year_id) - .maybeSingle(), - supabase - .from("plan_structures") - .select("role,catalogue_record_id") - .eq("plan_id", plan.id) - .order("position"), - supabase - .from("plan_items") - .select( - "id,catalogue_record_id,planned_calendar_year,planned_period_code,sort_order", - ) - .eq("plan_id", plan.id) - .order("sort_order"), - supabase - .from("course_attempts") - .select( - "id,catalogue_version_id,academic_period_id,status,mark,grade,units_attempted,units_earned", - ) - .eq("owner_id", viewer.id) - .order("created_at"), - ]); + const [ + yearResult, + structuresResult, + itemsResult, + attemptsResult, + placementsResult, + ] = await Promise.all([ + supabase + .from("academic_years") + .select("year") + .eq("id", plan.academic_year_id) + .maybeSingle(), + supabase + .from("plan_structures") + .select("role,catalogue_record_id") + .eq("plan_id", plan.id) + .order("position"), + supabase + .from("plan_items") + .select( + "id,catalogue_record_id,planned_calendar_year,planned_period_code,sort_order", + ) + .eq("plan_id", plan.id) + .order("sort_order"), + supabase + .from("course_attempts") + .select( + "id,catalogue_version_id,academic_period_id,status,mark,grade,units_attempted,units_earned", + ) + .eq("owner_id", viewer.id) + .order("created_at"), + supabase + .from("plan_requirement_placements") + .select("course_code,structure_code,requirement_key") + .eq("plan_id", plan.id), + ]); const structures = structuresResult.data ?? []; const items = (itemsResult.data ?? []) as unknown as PlanItemRow[]; @@ -307,6 +316,11 @@ export async function loadCoursemapState( }), }, attempts: [...plannedAttempts, ...recordedAttempts], + placements: (placementsResult.data ?? []).map((row) => ({ + courseCode: row.course_code, + structureCode: row.structure_code, + requirementKey: row.requirement_key, + })), }; } catch { return fallback; diff --git a/apps/web/lib/coursemap/structure-samples.ts b/apps/web/lib/coursemap/structure-samples.ts new file mode 100644 index 00000000..75ed0f9a --- /dev/null +++ b/apps/web/lib/coursemap/structure-samples.ts @@ -0,0 +1,204 @@ +import type { SampleStudent } from "@/lib/coursemap/requisite-samples"; +import type { RequirementTreeNode } from "@/lib/coursemap/requirement-tree-node"; +import type { Attempt, Course } from "@/lib/coursemap/types"; + +const COURSE_UNITS = 6; + +/** Stands in for any subject where a rule names none, such as electives. */ +const SAMPLE_SUBJECT = "SMPL"; + +function sampleCourse({ + code, + name, + year, + tags = [], +}: { + code: string; + name: string; + year: number; + tags?: string[]; +}): Course { + const digit = Number(code.charAt(4)); + return { + code, + name, + year, + units: COURSE_UNITS, + level: Number.isFinite(digit) && digit > 0 ? digit * 1000 : 1000, + subject: code.slice(0, 4), + school: "", + convener: "", + sessions: [], + delivery: "", + description: "", + prerequisiteText: "", + prerequisiteCodes: [], + incompatibilities: [], + countsTowards: [], + tags, + sourceUrl: "", + lastChanged: "", + parseState: "Automatic", + accent: "violet", + }; +} + +/** Levels are published on the 1000 scale but some rules record 1–9. */ +function levelDigit(level: number | null) { + if (level === null) return 1; + return Math.min( + 9, + Math.max(1, level < 10 ? level : Math.floor(level / 1000)), + ); +} + +type Draft = { + year: number; + courses: Map; + /** Course codes in the order a student would take them, per rule. */ + rules: string[][]; +}; + +/** + * Invented course codes that count towards a unit rule. They use the top of + * each level's number range and skip any code the sample already holds, so + * a course a rule names is never counted twice. + */ +function fillerCourses( + draft: Draft, + subject: string, + level: number, + units: number, + name: string, + tags: string[] = [], +) { + const codes: string[] = []; + const count = Math.ceil(units / COURSE_UNITS); + for (let serial = 999; codes.length < count && serial >= 900; serial -= 1) { + const code = `${subject}${level}${serial}`; + if (draft.courses.has(code)) continue; + draft.courses.set( + code, + sampleCourse({ code, name, year: draft.year, tags }), + ); + codes.push(code); + } + return codes; +} + +/** The courses that would meet one rule, or nothing for rules not met by courses. */ +function meet(node: RequirementTreeNode, draft: Draft) { + if (node.type === "group") { + const needed = + node.operator === "any_of" + ? 1 + : node.operator === "at_least" + ? Math.max(1, node.minimumCount ?? 1) + : node.children.length; + node.children.slice(0, needed).forEach((child) => meet(child, draft)); + return; + } + const units = node.minimumUnits; + const level = levelDigit(node.minimumLevel); + const listed = node.options.filter((option) => option.kind === "course"); + if (listed.length > 0) { + const needed = + node.minimumCourses ?? + (units !== null ? Math.ceil(units / COURSE_UNITS) : listed.length); + const codes = listed.slice(0, needed).map((option) => { + if (!draft.courses.has(option.code)) { + draft.courses.set( + option.code, + sampleCourse({ + code: option.code, + name: option.title ?? option.code, + year: draft.year, + }), + ); + } + return option.code; + }); + draft.rules.push(codes); + return; + } + if (units === null) return; + switch (node.conditionKind) { + case "subject_units": + if (node.subjectCode) { + draft.rules.push( + fillerCourses( + draft, + node.subjectCode, + level, + units, + `Sample ${node.subjectCode} course`, + ), + ); + } + return; + case "tagged_units": + draft.rules.push( + fillerCourses( + draft, + SAMPLE_SUBJECT, + level, + units, + `Sample ${node.tag ?? "tagged"} course`, + node.tag ? [node.tag] : [], + ), + ); + return; + case "level_units": + case "elective_units": + draft.rules.push( + fillerCourses(draft, SAMPLE_SUBJECT, level, units, "Sample elective"), + ); + return; + default: + return; + } +} + +/** + * A made-up student for previewing a programme, major or minor: nothing yet, + * about half of each rule completed with the next course planned, or every + * rule met. Courses the structure names are used as listed; rules that only + * describe courses get invented ones, so every progress state can be checked + * without a real record. + */ +export function sampleStructurePlan( + root: RequirementTreeNode | null, + reader: SampleStudent, + year: number, +): { courses: Course[]; attempts: Attempt[] } { + const draft: Draft = { year, courses: new Map(), rules: [] }; + if (root && reader !== "new") meet(root, draft); + const attempts: Attempt[] = []; + const seen = new Set(); + for (const codes of draft.rules) { + // Partway completes the first half of a rule and plans the next course, + // so a one-course rule reads as planned rather than already met. + const completed = + reader === "complete" ? codes.length : Math.floor(codes.length / 2); + codes.forEach((code, index) => { + if (seen.has(code)) return; + const status = + index < completed + ? "completed" + : reader === "partway" && index === completed + ? "planned" + : null; + if (!status) return; + seen.add(code); + attempts.push({ + id: `sample-${code}`, + courseCode: code, + academicYear: year, + termId: "sample", + status, + ...(status === "completed" ? { mark: 75 } : {}), + }); + }); + } + return { courses: [...draft.courses.values()], attempts }; +} diff --git a/apps/web/lib/coursemap/types.ts b/apps/web/lib/coursemap/types.ts index 4d9abacc..b804ba01 100644 --- a/apps/web/lib/coursemap/types.ts +++ b/apps/web/lib/coursemap/types.ts @@ -25,6 +25,8 @@ export type Course = { incompatibilities: string[]; permissionText?: string; countsTowards: string[]; + /** Free-form categories degree rules count units against, such as Science. */ + tags?: string[]; sourceUrl: string; lastChanged: string; parseState: "Verified" | "Automatic" | "Review"; @@ -108,8 +110,20 @@ export type Profile = { extensionYears: number; }; +/** + * A student's choice of where a course counts, by the requirement's + * structure code and stable key rather than a database id. + */ +export type RequirementPlacementChoice = { + courseCode: string; + structureCode: string; + requirementKey: string; +}; + export type AppState = { schemaVersion: 1; profile: Profile; attempts: Attempt[]; + /** Courses the student moved to a part of their degree themselves. */ + placements?: RequirementPlacementChoice[]; }; diff --git a/apps/web/tests/placement-control.test.tsx b/apps/web/tests/placement-control.test.tsx new file mode 100644 index 00000000..5ad34dba --- /dev/null +++ b/apps/web/tests/placement-control.test.tsx @@ -0,0 +1,92 @@ +import { expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { CoursePlacement } from "@/lib/coursemap/requirement-progress"; +import { PlacementControl } from "@/ui/requirements/placement-control"; + +const labels: Record = { + computing: "Computing courses", + electives: "Electives", + "level-1000-cap": "1000-level limit", +}; + +function placementFor( + current: CoursePlacement, + options = ["computing", "electives"], +) { + const onPlace = vi.fn(); + return { + onPlace, + placement: { + allocation: new Map([["COMP1100", current]]), + optionsFor: () => + options.map((nodeKey) => ({ nodeKey, label: labels[nodeKey]! })), + labelFor: (nodeKey: string) => labels[nodeKey]!, + onPlace, + }, + }; +} + +test("a student moves a course to another part it qualifies for", async () => { + const user = userEvent.setup(); + const { placement, onPlace } = placementFor({ + nodeKey: "computing", + pinned: false, + overCapKey: null, + }); + render(); + const trigger = screen.getByRole("button", { name: "Where COMP1100 counts" }); + expect(trigger).toHaveAttribute("title", "Where this course counts"); + await user.click(trigger); + expect( + screen.queryByRole("button", { name: "Let Coursemap decide" }), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Electives" })); + expect(onPlace).toHaveBeenCalledWith("COMP1100", "electives"); +}); + +test("a student's choice can be handed back to Coursemap", async () => { + const user = userEvent.setup(); + const { placement, onPlace } = placementFor({ + nodeKey: "electives", + pinned: true, + overCapKey: null, + }); + render(); + const trigger = screen.getByRole("button", { name: "Where COMP1100 counts" }); + expect(trigger).toHaveAttribute( + "title", + "You chose where this course counts", + ); + await user.click(trigger); + await user.click( + screen.getByRole("button", { name: "Let Coursemap decide" }), + ); + expect(onPlace).toHaveBeenCalledWith("COMP1100", null); +}); + +test("a course over a limit says it counts towards nothing", () => { + const { placement } = placementFor({ + nodeKey: null, + pinned: false, + overCapKey: "level-1000-cap", + }); + render(); + expect( + screen.getByText(/Over the limit on 1000-level limit/), + ).toHaveTextContent("counts towards nothing"); + expect( + screen.queryByRole("button", { name: "Where COMP1100 counts" }), + ).not.toBeInTheDocument(); +}); + +test("a course no part accepts says so", () => { + const { placement } = placementFor( + { nodeKey: null, pinned: false, overCapKey: null }, + [], + ); + render(); + expect( + screen.getByText("Doesn't count towards this degree"), + ).toBeInTheDocument(); +}); diff --git a/apps/web/tests/requirement-course-row.test.tsx b/apps/web/tests/requirement-course-row.test.tsx index ca8ef9e3..aa108283 100644 --- a/apps/web/tests/requirement-course-row.test.tsx +++ b/apps/web/tests/requirement-course-row.test.tsx @@ -1,11 +1,9 @@ import { expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; import { RequirementCourseRow } from "@/ui/requirements/requirement-course-row"; -test("unimported courses explain their availability without linking to a 404", async () => { - const user = userEvent.setup(); +test("unimported courses still link to their page and cannot be added", () => { const onAdd = vi.fn(); render( @@ -20,15 +18,12 @@ test("unimported courses explain their availability without linking to a 404", a , ); - expect(screen.queryByRole("link")).not.toBeInTheDocument(); - const course = screen.getByRole("button", { - name: "BUSN1001: not available", - }); - expect(course).toHaveAttribute("aria-disabled", "true"); - await user.tab(); - expect(course).toHaveFocus(); - expect(await screen.findByRole("tooltip")).toHaveTextContent("Not available"); - await user.click(course); - expect(onAdd).not.toHaveBeenCalled(); - expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /BUSN1001/ })).toHaveAttribute( + "href", + "/courses/2026/busn1001", + ); + expect( + screen.queryByRole("button", { name: /Add BUSN1001/ }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Not planned")).toBeVisible(); }); diff --git a/apps/web/tests/requirement-progress.test.mjs b/apps/web/tests/requirement-progress.test.mjs index fe9c49cf..5951d1bd 100644 --- a/apps/web/tests/requirement-progress.test.mjs +++ b/apps/web/tests/requirement-progress.test.mjs @@ -5,7 +5,8 @@ const { "requirement-progress": progressModule } = { "requirement-progress": await import("../lib/coursemap/requirement-progress.ts"), }; -const { requirementTreeProgress, requirementNodeKey } = progressModule; +const { allocateRequirements, requirementTreeProgress, requirementNodeKey } = + progressModule; function course(code, level, units = 6) { return { @@ -187,8 +188,8 @@ test("a maximum-only rule reports over_limit once the plan exceeds it", () => { test("unmeasured mandatory rules prevent certifying their group", () => { const tagRule = condition(1, { - conditionKind: "tagged_units", - tag: "Transdisciplinary Problem-Solving", + conditionKind: "other", + freeText: "Approval of the program convener", minimumUnits: 12, }); const listRule = condition(2, { @@ -251,22 +252,27 @@ test("an empty tree yields no progress", () => { assert.equal(progress.size, 0); }); -test("a bounded unit rule still enforces its maximum after meeting the minimum", () => { +test("a part never takes more than its maximum; the extra course counts nowhere", () => { const rule = condition(1, { conditionKind: "subject_units", subjectCode: "COMP", minimumUnits: 6, maximumUnits: 6, }); - const result = requirementTreeProgress({ - root: group(10, "all_of", [rule]), - attempts: [ - attempt("a", "COMP1100", "completed"), - attempt("b", "COMP1110", "planned"), - ], - catalogue, - }); - assert.equal(result.get(requirementNodeKey(rule)).state, "over_limit"); + const root = group(10, "all_of", [rule]); + const attempts = [ + attempt("a", "COMP1100", "completed"), + attempt("b", "COMP1110", "planned"), + ]; + const result = requirementTreeProgress({ root, attempts, catalogue }); + assert.equal(result.get(requirementNodeKey(rule)).state, "satisfied"); + assert.deepEqual(result.get(requirementNodeKey(rule)).matchedCourseCodes, [ + "COMP1100", + ]); + assert.equal( + allocateRequirements({ root, attempts, catalogue }).get("COMP1110").nodeKey, + null, + ); }); test("completed credit survives a later planned duplicate", () => { @@ -316,8 +322,8 @@ test("minimum_count requires enough measured alternatives", () => { [ condition(1, { minimumUnits: 6, options: [option("COMP1100")] }), condition(2, { - conditionKind: "tagged_units", - tag: "Unknown", + conditionKind: "other", + freeText: "Approval of the program convener", minimumUnits: 6, }), ], @@ -330,3 +336,171 @@ test("minimum_count requires enough measured alternatives", () => { }); assert.equal(result.get(requirementNodeKey(root)).state, "unmeasured"); }); + +const allocationCatalogue = { + courses: [ + course("COMP1100", 1000), + course("COMP1110", 1000), + course("MATH1013", 1000), + course("COMP2100", 2000), + course("COMP3600", 3000), + course("COMP3620", 3000), + course("COMP4450", 4000), + course("ARTH2181", 2000), + { ...course("PHYS2101", 2000), tags: ["Science"] }, + ], + terms, +}; + +/** Shaped like AACOM: caps and minimums "of which", parts it "must include". */ +function degree() { + const cap = condition(1, { + conditionKind: "level_units", + maximumLevel: 1000, + maximumUnits: 12, + scope: "degree", + }); + const research = condition(2, { + conditionKind: "level_units", + subjectCode: "COMP", + minimumLevel: 4000, + minimumUnits: 6, + scope: "degree", + }); + const compulsory = condition(3, { + minimumUnits: 12, + options: [option("COMP2100"), option("COMP3600")], + }); + const advanced = condition(4, { + conditionKind: "subject_units", + subjectCode: "COMP", + minimumLevel: 3000, + minimumUnits: 12, + }); + const ict = condition(5, { + minimumUnits: 6, + options: [option("ARTH2181")], + includesAnyCourse: true, + }); + const electives = condition(6, { + conditionKind: "elective_units", + minimumUnits: 12, + }); + const root = group(10, "all_of", [ + cap, + research, + compulsory, + advanced, + ict, + electives, + ]); + return { root, cap, research, compulsory, advanced, ict, electives }; +} + +const allocationAttempts = [ + attempt("a", "COMP1100", "completed"), + attempt("b", "COMP1110", "completed"), + attempt("c", "MATH1013", "planned"), + attempt("d", "COMP2100", "completed"), + attempt("e", "COMP3600", "completed"), + attempt("f", "COMP3620", "planned"), + attempt("g", "COMP4450", "planned"), + attempt("h", "PHYS2101", "planned"), +]; + +test("each course counts towards one part, most specific first", () => { + const { root, compulsory, advanced, ict, electives } = degree(); + const placed = allocateRequirements({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + }); + const nodeOf = (code) => placed.get(code).nodeKey; + // COMP3600 is a 3000-level COMP course too, but the named list wins. + assert.equal(nodeOf("COMP3600"), requirementNodeKey(compulsory)); + assert.equal(nodeOf("COMP2100"), requirementNodeKey(compulsory)); + assert.equal(nodeOf("COMP3620"), requirementNodeKey(advanced)); + assert.equal(nodeOf("COMP4450"), requirementNodeKey(advanced)); + // An open list takes any course once its own list has nothing left. + assert.equal(nodeOf("COMP1100"), requirementNodeKey(ict)); + assert.equal(nodeOf("COMP1110"), requirementNodeKey(electives)); + assert.equal(nodeOf("PHYS2101"), requirementNodeKey(electives)); +}); + +test("a course beyond a degree-wide cap counts towards nothing", () => { + const { root, cap } = degree(); + const placed = allocateRequirements({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + }); + // Two completed 1000-level courses fill the 12-unit cap; the planned third + // is the one left out, whatever order the plan lists them in. + assert.deepEqual(placed.get("MATH1013"), { + nodeKey: null, + pinned: false, + overCapKey: requirementNodeKey(cap), + }); + const progress = requirementTreeProgress({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + allocation: placed, + }); + assert.equal(progress.get(requirementNodeKey(cap)).state, "over_limit"); +}); + +test("a degree-wide minimum reads courses without using them up", () => { + const { root, research, advanced } = degree(); + const progress = requirementTreeProgress({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + }); + assert.deepEqual( + progress.get(requirementNodeKey(research)).matchedCourseCodes, + ["COMP4450"], + ); + assert.ok( + progress + .get(requirementNodeKey(advanced)) + .matchedCourseCodes.includes("COMP4450"), + ); +}); + +test("electives are measured, and a student's choice moves a course", () => { + const { root, ict, electives } = degree(); + const pins = new Map([["COMP1100", requirementNodeKey(electives)]]); + const placed = allocateRequirements({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + pins, + }); + assert.deepEqual(placed.get("COMP1100"), { + nodeKey: requirementNodeKey(electives), + pinned: true, + overCapKey: null, + }); + // The open list then takes the next course that fits it. + assert.equal(placed.get("COMP1110").nodeKey, requirementNodeKey(ict)); + const progress = requirementTreeProgress({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + allocation: placed, + }); + assert.notEqual( + progress.get(requirementNodeKey(electives)).state, + "unmeasured", + ); + + // A choice the course cannot satisfy is ignored. + const wrong = allocateRequirements({ + root, + attempts: allocationAttempts, + catalogue: allocationCatalogue, + pins: new Map([["COMP1100", requirementNodeKey(degree().compulsory)]]), + }); + assert.equal(wrong.get("COMP1100").pinned, false); +}); diff --git a/apps/web/tests/requirements-workspace.test.tsx b/apps/web/tests/requirements-workspace.test.tsx index 778e999d..59fdaf0c 100644 --- a/apps/web/tests/requirements-workspace.test.tsx +++ b/apps/web/tests/requirements-workspace.test.tsx @@ -87,6 +87,8 @@ const condition: PlanRequirementCondition = { structureKind: null, subjectCode: null, tag: null, + scope: "part", + includesAnyCourse: false, }; const root: PlanRequirementGroup = { type: "group", @@ -102,6 +104,7 @@ const root: PlanRequirementGroup = { sourceLocator: "#source", sourceText: "Original group wording", title: "Complete every item", + scope: "part", }; const catalogue: PlanCatalogue = { academicYear: 2026, @@ -203,6 +206,19 @@ beforeEach(() => { }); afterEach(() => vi.unstubAllGlobals()); +/** A course row's link, which reads as the course's name. */ +function courseLink(code: string) { + const link = screen + .getAllByRole("link") + .find( + (candidate) => + candidate.getAttribute("href") === + `/courses/2026/${code.toLowerCase()}`, + ); + if (!link) throw new Error(`No link to ${code}`); + return link; +} + test("requirements use separate tabs without source disclosures or the summary sidebar", async () => { const user = userEvent.setup(); render(); @@ -216,7 +232,7 @@ test("requirements use separate tabs without source disclosures or the summary s ), ).not.toBeInTheDocument(); expect( - screen.getByRole("heading", { name: /Choose 1 course/ }), + screen.getByRole("heading", { name: /Pick COMP1100 or COMP1110/ }), ).toBeVisible(); await user.click(screen.getByRole("tab", { name: "Minors" })); expect( @@ -291,7 +307,7 @@ test("a course opens the semester chooser and is saved in the selected year", as ); render(); await user.click(screen.getByRole("button", { name: /View courses/ })); - expect(screen.getByRole("link", { name: /COMP1100/ })).toHaveAttribute( + expect(courseLink("COMP1100")).toHaveAttribute( "href", "/courses/2026/comp1100", ); @@ -335,12 +351,10 @@ test("completed study takes precedence over a planned repeat and existing course expect(requirementCourseStatus("COMP1100", state.attempts)).toBe("completed"); render(); await user.click(screen.getByRole("button", { name: /View courses/ })); - const done = screen.getByRole("link", { name: /COMP1100/ }).closest("li")!; + const done = courseLink("COMP1100").closest("li")!; expect(within(done).getByText("Completed")).toBeVisible(); - expect(done).toHaveClass("bg-success/5"); - const planned = screen.getByRole("link", { name: /COMP1110/ }).closest("li")!; + const planned = courseLink("COMP1110").closest("li")!; expect(within(planned).getByText("Planned")).toBeVisible(); - expect(planned).toHaveClass("bg-primary/5"); expect( screen.queryByRole("button", { name: /Add COMP/ }), ).not.toBeInTheDocument(); @@ -370,7 +384,7 @@ test("alternative groups and upper limits remain explicit after simplifying wrap expect(screen.getByText("or")).toBeVisible(); }); -test("unpublished requirement courses are disabled and retain planned status", async () => { +test("unpublished requirement courses still link and retain planned status", async () => { const user = userEvent.setup(); state.attempts = [ { @@ -387,11 +401,8 @@ test("unpublished requirement courses are disabled and retain planned status", a />, ); await user.click(screen.getByRole("button", { name: /View courses/ })); - const course = screen.getByRole("button", { - name: "COMP1100: not available", - }); - expect(course).toHaveAttribute("aria-disabled", "true"); - expect(screen.queryByRole("link")).not.toBeInTheDocument(); + const course = courseLink("COMP1100"); + expect(course).toHaveAttribute("href", "/courses/2026/comp1100"); const row = course.closest("li")!; expect(within(row).getByText("Planned")).toBeVisible(); expect( @@ -474,20 +485,14 @@ test("large requirement sections start collapsed and paginate without search", a const toggle = screen.getByRole("button", { name: /View courses/ }); expect(toggle).toHaveAttribute("aria-expanded", "false"); await user.click(toggle); - expect(screen.getAllByRole("button", { name: /not available/ })).toHaveLength( - 6, - ); - expect( - screen.getAllByRole("button", { name: /not available/ })[0], - ).toHaveTextContent("COMP2019"); + expect(screen.getAllByRole("link")).toHaveLength(6); + expect(screen.getAllByRole("link")[0]).toHaveTextContent("COMP2019"); await user.click(screen.getByRole("button", { name: "Next page" })); expect( screen.getByRole("navigation", { name: "courses pagination" }), ).toHaveTextContent("7–12 of 20"); await user.click(screen.getByRole("button", { name: /Page\s*4/ })); - expect(screen.getAllByRole("button", { name: /not available/ })).toHaveLength( - 2, - ); + expect(screen.getAllByRole("link")).toHaveLength(2); expect( screen.getByRole("navigation", { name: "courses pagination" }), ).toHaveTextContent("19–20 of 20"); diff --git a/apps/web/tests/structure-import-transform.test.mjs b/apps/web/tests/structure-import-transform.test.mjs index fb87596a..d8a8c018 100644 --- a/apps/web/tests/structure-import-transform.test.mjs +++ b/apps/web/tests/structure-import-transform.test.mjs @@ -504,7 +504,7 @@ test("provides a strict OpenRouter prompt and recursive JSON schema", () => { ); assert.equal( ACADEMIC_STRUCTURE_IMPORT_PROMPT_VERSION, - "coursemap-academic-structure-prompt.v7", + "coursemap-academic-structure-prompt.v8", ); assert.equal( ACADEMIC_STRUCTURE_EXTRACTION_SCHEMA_VERSION, @@ -608,3 +608,31 @@ test("provides a strict OpenRouter prompt and recursive JSON schema", () => { /Expected structure kind: programme[\s\S]*BCOMP[\s\S]*2026[\s\S]*source data/, ); }); + +test("records the majors and minors a programme page lists that the model left out", () => { + const model = structuredClone(extraction); + const { extraction: finalised } = finalise(model, { + pageMarkdown: `${pageMarkdown}\n\n## Minors\n\n- [Human-Centred and Creative Computing](HCCC-MIN)\n- [Not a minor](COMP1100)\n\n## Admission\n\n- [Other](ARTS-MIN)`, + }); + const added = finalised.relationships.filter( + ({ targetKind }) => targetKind === "minor", + ); + assert.deepEqual( + added.map( + ({ relationshipKind, targetCode, targetTitle, sourceLocator }) => ({ + relationshipKind, + targetCode, + targetTitle, + sourceLocator, + }), + ), + [ + { + relationshipKind: "option", + targetCode: "HCCC-MIN", + targetTitle: "Human-Centred and Creative Computing", + sourceLocator: "Minors", + }, + ], + ); +}); diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index 30894154..2f4efb14 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -3166,6 +3166,47 @@ export type Database = { }, ] } + plan_requirement_placements: { + Row: { + course_code: string + created_at: string + id: string + owner_id: string + plan_id: string + requirement_key: string + structure_code: string + updated_at: string + } + Insert: { + course_code: string + created_at?: string + id?: string + owner_id: string + plan_id: string + requirement_key: string + structure_code: string + updated_at?: string + } + Update: { + course_code?: string + created_at?: string + id?: string + owner_id?: string + plan_id?: string + requirement_key?: string + structure_code?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "plan_requirement_placements_plan_id_fkey" + columns: ["plan_id"] + isOneToOne: false + referencedRelation: "plans" + referencedColumns: ["id"] + }, + ] + } plan_structures: { Row: { catalogue_record_id: number @@ -3352,6 +3393,7 @@ export type Database = { group_id: number hardness: string id: number + includes_any_course: boolean item_kind: string | null maximum_level: number | null maximum_units: number | null @@ -3366,6 +3408,7 @@ export type Database = { requirement_mode: string | null review_state: string rule_id: number + scope: string source_locator: string | null source_text: string | null structure_kind: string | null @@ -3382,6 +3425,7 @@ export type Database = { group_id: number hardness?: string id?: never + includes_any_course?: boolean item_kind?: string | null maximum_level?: number | null maximum_units?: number | null @@ -3396,6 +3440,7 @@ export type Database = { requirement_mode?: string | null review_state?: string rule_id: number + scope?: string source_locator?: string | null source_text?: string | null structure_kind?: string | null @@ -3412,6 +3457,7 @@ export type Database = { group_id?: number hardness?: string id?: never + includes_any_course?: boolean item_kind?: string | null maximum_level?: number | null maximum_units?: number | null @@ -3426,6 +3472,7 @@ export type Database = { requirement_mode?: string | null review_state?: string rule_id?: number + scope?: string source_locator?: string | null source_text?: string | null structure_kind?: string | null @@ -3470,6 +3517,7 @@ export type Database = { parent_group_id: number | null position: number rule_id: number + scope: string source_locator: string | null source_text: string | null version_id: number @@ -3486,6 +3534,7 @@ export type Database = { parent_group_id?: number | null position?: number rule_id: number + scope?: string source_locator?: string | null source_text?: string | null version_id: number @@ -3502,6 +3551,7 @@ export type Database = { parent_group_id?: number | null position?: number rule_id?: number + scope?: string source_locator?: string | null source_text?: string | null version_id?: number @@ -4372,6 +4422,14 @@ export type Database = { Args: { p_extension_years: number } Returns: undefined } + set_current_user_requirement_placement: { + Args: { + p_course_code: string + p_requirement_key?: string + p_structure_code?: string + } + Returns: undefined + } set_role_permission: { Args: { p_enabled: boolean; p_permission_id: number; p_role_id: number } Returns: boolean diff --git a/apps/web/ui/admin/catalogue/version-preview.tsx b/apps/web/ui/admin/catalogue/version-preview.tsx index 31581988..08dbfba4 100644 --- a/apps/web/ui/admin/catalogue/version-preview.tsx +++ b/apps/web/ui/admin/catalogue/version-preview.tsx @@ -10,12 +10,25 @@ import { type SampleStudent, } from "@/lib/coursemap/requisite-samples"; import { structureDetailsFromWrite } from "@/lib/coursemap/structure-version-view"; +import { sampleStructurePlan } from "@/lib/coursemap/structure-samples"; +import { + allocateRequirements, + requirementConditionsByKey, + requirementTreeProgress, +} from "@/lib/coursemap/requirement-progress"; +import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; import { CourseDetailTabsList, CourseDetailView, type CourseTab, } from "@/ui/courses/course-detail-view"; -import { readingTreeContext } from "@/ui/requirements/requirement-presentation"; +import { + conditionHeading, + readingTreeContext, + type RequirementTreeCondition, + type RequirementTreeGroup, + type TreeContext, +} from "@/ui/requirements/requirement-presentation"; import { StructureDetailTabsList, StructureDetailView, @@ -80,31 +93,108 @@ export function CoursePreview({ course }: { course: CourseDetails }) { ); } +/** + * The requirement tree as a made-up student would see it, through the same + * progress and placement the requirements workspace uses. Where a course + * counts is shown but cannot be moved, since there is no plan to save it to. + */ +function sampleTreeContext( + root: RequirementTreeGroup | null, + reader: SampleStudent, + year: number, + unitTarget: number | null, +): TreeContext { + const { courses, attempts } = sampleStructurePlan(root, reader, year); + const catalogue = { + ...readingTreeContext({ academicYear: year, courses, unitTarget }) + .catalogue, + }; + const allocation = allocateRequirements({ root, attempts, catalogue }); + const conditions = requirementConditionsByKey(root); + const labelFor = (nodeKey: string) => { + const condition = conditions.get(nodeKey); + return condition + ? conditionHeading(condition as RequirementTreeCondition) + : "another requirement"; + }; + return { + catalogue, + attemptStatusByCode: new Map( + attempts.flatMap((attempt) => { + const status = requirementCourseStatus(attempt.courseCode, attempts); + return status ? [[attempt.courseCode, status] as const] : []; + }), + ), + selectedStructureCodes: new Set(), + progress: requirementTreeProgress({ + root, + attempts, + catalogue, + allocation, + }), + unitTarget, + showStructureOptions: true, + placement: { + allocation, + labelFor, + optionsFor: () => [], + onPlace: () => undefined, + readOnly: true, + }, + }; +} + /** * 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. + * will read it rather than as stored JSON, and the reader switch fills it + * with a made-up student, as the course preview does, so every progress + * state can be checked without a real record. */ export function StructurePreview({ write }: { write: CatalogueContent }) { const [tab, setTab] = useState("overview"); - const structure = structureDetailsFromWrite(write); - if (!structure) return null; + const [reader, setReader] = useState("signed-out"); + const structure = useMemo(() => structureDetailsFromWrite(write), [write]); + const treeContext = useMemo( + () => + !structure + ? null + : reader === "signed-out" + ? readingTreeContext({ + academicYear: structure.year, + unitTarget: structure.units, + }) + : sampleTreeContext( + structure.requirements, + reader, + structure.year, + structure.units, + ), + [reader, structure], + ); + if (!structure || !treeContext) return null; return ( setTab(value as StructureTab)} className="block" > -
+
+ setReader(value as PreviewReader)} + > + + {PREVIEW_READERS.map((option) => ( + + {option.label} + + ))} + +
- + ); } diff --git a/apps/web/ui/requirements/placement-control.tsx b/apps/web/ui/requirements/placement-control.tsx new file mode 100644 index 00000000..2006a14d --- /dev/null +++ b/apps/web/ui/requirements/placement-control.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { OptionPicker } from "@/ui/common/option-picker"; +import type { TreeContext } from "./requirement-presentation"; + +const AUTOMATIC = "automatic"; + +/** + * Where one course in the plan counts, and a way to move it to another part + * of the degree it qualifies for. Coursemap's choice is the default; a + * student's choice can be handed back to it. + */ +export function PlacementControl({ + courseCode, + placement, +}: { + courseCode: string; + placement: NonNullable; +}) { + const current = placement.allocation.get(courseCode); + if (!current) return null; + if (current.overCapKey) { + return ( +

+ Over the limit on {placement.labelFor(current.overCapKey).toLowerCase()} + , so it counts towards nothing +

+ ); + } + const options = placement.optionsFor(courseCode); + if (options.length === 0) { + return ( +

+ Doesn't count towards this degree +

+ ); + } + if (placement.readOnly) { + return ( + + {current.nodeKey ? placement.labelFor(current.nodeKey) : "Nothing yet"} + + ); + } + const items = [ + ...options.map((option) => ({ + value: option.nodeKey, + label: option.label, + })), + ...(current.pinned + ? [{ value: AUTOMATIC, label: "Let Coursemap decide" }] + : []), + ]; + return ( + + placement.onPlace(courseCode, next === AUTOMATIC ? null : next) + } + /> + ); +} diff --git a/apps/web/ui/requirements/requirement-condition.tsx b/apps/web/ui/requirements/requirement-condition.tsx index 18ccc7f7..2ff7e435 100644 --- a/apps/web/ui/requirements/requirement-condition.tsx +++ b/apps/web/ui/requirements/requirement-condition.tsx @@ -1,13 +1,21 @@ "use client"; -import { useId, useState } from "react"; +import { + createContext, + useContext, + useId, + useState, + type ReactNode, +} from "react"; import Link from "next/link"; import { + ArrowRight, ChevronDown, + Circle, CircleAlert, - GaugeCircle, + CircleCheck, + CircleDashed, Info, - Layers, - ListChecks, + SquareDashed, } from "lucide-react"; import { Alert, @@ -16,27 +24,348 @@ import { } 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 { isCatalogueKind } from "@/lib/catalogue/content"; import { publicCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds"; +import { cn } from "@/lib/cn"; import { conditionHeading, conditionInterpretation, + conditionSummary, conditionTone, + courseListTitle, + courseSearchHref, + suggestedCourses, + hidesCondition, + listedCourseCounts, + requirementRowStatus, unitsDescription, } from "@/ui/requirements/requirement-presentation"; import type { + RequirementRowStatus, RequirementTreeCondition, TreeContext, } from "@/ui/requirements/requirement-presentation"; import { RequirementCourseOptions } from "./requirement-course-options"; +import { RequirementCourseRow } from "./requirement-course-row"; import { UnitsBar } from "@/ui/requirements/units-bar"; +/** + * A course counting towards a rule, marked by its border alone: solid green + * once completed and dashed purple while planned, so the dash carries the + * difference as well as the colour. Under a cap, completed courses are grey, + * since they use up room rather than make progress. + */ +function CourseChip({ + code, + status, + limit = false, +}: { + code: string; + status: "completed" | "planned" | "enrolled" | undefined; + limit?: boolean; +}) { + const completed = status === "completed"; + return ( +
  • + {code} + + {completed ? " completed" : status ? ` ${status}` : ""} + +
  • + ); +} + +/** The courses a rule holds, and any a degree-wide cap turned away. */ +function CountedCourses({ + condition, + context, + limit = false, +}: { + condition: RequirementTreeCondition; + context: TreeContext; + limit?: boolean; +}) { + const key = requirementNodeKey(condition); + const progress = context.progress.get(key); + if (!context.placement || !progress || progress.state === "unmeasured") { + return null; + } + const overCap = [...context.placement.allocation] + .filter(([, placement]) => placement.overCapKey === key) + .map(([code]) => code); + if (!progress.matchedCourseCodes.length && !overCap.length) return null; + return ( +
    + {progress.matchedCourseCodes.length ? ( +
      + {progress.matchedCourseCodes.map((code) => ( + + ))} +
    + ) : null} + {overCap.length ? ( +

    + Over this limit, so counting towards nothing: {overCap.join(", ")} +

    + ) : null} +
    + ); +} + +/** + * A few courses that would count towards a rule still short of units, and a + * way into the course search with the rule's filters applied for the rest. + */ +function CourseSuggestions({ + condition, + context, +}: { + condition: RequirementTreeCondition; + context: TreeContext; +}) { + const courses = suggestedCourses(condition, context); + const href = courseSearchHref(condition, context.catalogue.academicYear); + if (!courses.length && !href) return null; + return ( +
    + {courses.length ? ( + <> +

    + Suggested courses +

    +
      + {courses.map((course) => ( + + ))} +
    + + ) : null} + {href ? ( + + See all matching courses +
    + ); +} + +/** Rules that read every course the degree counts rather than using any up. */ +function spansDegree(condition: RequirementTreeCondition) { + return ( + condition.scope === "degree" || + (condition.maximumUnits !== null && + condition.minimumUnits === null && + condition.minimumCourses === null) + ); +} + +/** A glyph beside each rule that gives its status a shape as well as a colour. */ +function StatusGlyph({ status }: { status: RequirementRowStatus }) { + const className = "mt-0.5 size-4 shrink-0"; + switch (status.kind) { + case "complete": + return ( +