From 894699dcea06be742e1ea4d1c5896a06856ded49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:11 +0000 Subject: [PATCH 1/6] feat: store requirement scope and course placements --- apps/web/app/providers.tsx | 33 ++++ apps/web/lib/coursemap/actions.ts | 31 ++++ apps/web/lib/coursemap/plan-catalogue.ts | 12 ++ .../lib/coursemap/requirement-write-tree.ts | 6 + apps/web/lib/coursemap/state.ts | 68 ++++--- apps/web/lib/coursemap/types.ts | 14 ++ apps/web/types/database.ts | 58 ++++++ supabase/migrations/017_requirement_scope.sql | 168 +++++++++++++++++ .../migrations/018_requirement_placements.sql | 116 ++++++++++++ .../tests/database/requirement_placements.sql | 174 ++++++++++++++++++ 10 files changed, 653 insertions(+), 27 deletions(-) create mode 100644 supabase/migrations/017_requirement_scope.sql create mode 100644 supabase/migrations/018_requirement_placements.sql create mode 100644 supabase/tests/database/requirement_placements.sql 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/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-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/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/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/supabase/migrations/017_requirement_scope.sql b/supabase/migrations/017_requirement_scope.sql new file mode 100644 index 00000000..d3320ffc --- /dev/null +++ b/supabase/migrations/017_requirement_scope.sql @@ -0,0 +1,168 @@ +-- Tell a degree's parts from the rules across it. +-- +-- ANU writes a degree's requirements in two layers: "192 units, of which a +-- maximum of 60 units may come from 1000-level courses" constrains every +-- course the degree counts, while "the 192 units must include 48 units of +-- compulsory courses" is a part that a course fills and then counts nowhere +-- else. Whatever the parts leave is electives. The requirement tree kept both +-- layers as plain nodes, so a planner could not allocate a course to one part +-- or know what was left over for electives. +-- +-- A group or condition now carries its scope, and a course list records when +-- it ends "Any other ANU courses", which makes the list suggestions only. + +alter table public.requirement_groups + add column scope text default 'part'::text not null; + +alter table public.requirement_groups + add constraint requirement_groups_scope_check check ((scope = any (array['part'::text, 'degree'::text]))); + +alter table public.requirement_conditions + add column scope text default 'part'::text not null, + add column includes_any_course boolean default false not null; + +alter table public.requirement_conditions + add constraint requirement_conditions_scope_check check ((scope = any (array['part'::text, 'degree'::text]))); + +-- Only a course list can be open to any other course. +alter table public.requirement_conditions + add constraint requirement_conditions_includes_any_course_check check (((not includes_any_course) or (condition_kind = 'course_set_units'::text))); + +comment on column public.requirement_groups.scope is 'part fills a share of the degree and uses its courses up; degree constrains every course the degree counts without using any up.'; +comment on column public.requirement_conditions.scope is 'part fills a share of the degree and uses its courses up; degree constrains every course the degree counts without using any up.'; +comment on column public.requirement_conditions.includes_any_course is 'A course list ending "Any other ANU courses": its courses are suggestions and any course counts.'; + +create or replace function private.requirement_projection(p_version_id bigint) returns jsonb + language sql stable + set search_path to '' + as $$ + select jsonb_build_object( + 'rules', coalesce(( + select jsonb_agg(jsonb_build_object( + 'key', rules.rule_kind, + 'ruleKind', rules.rule_kind, + 'hardness', rules.hardness, + 'sourceText', rules.source_text, + 'reviewState', rules.review_state, + 'confidence', rules.confidence + ) order by rules.position, case rules.rule_kind + when 'prerequisite' then 1 when 'corequisite' then 2 + when 'incompatibility' then 3 when 'permission' then 4 + when 'assumed_knowledge' then 5 else 6 end) + from public.requirement_rules as rules + where rules.version_id = p_version_id + ), '[]'::jsonb), + 'ruleGroups', coalesce(( + select jsonb_agg(jsonb_build_object( + 'key', groups.group_key, + 'ruleKey', rules.rule_kind, + 'parentGroupKey', parents.group_key, + 'operator', groups.operator, + 'minimumCount', groups.minimum_count, + 'minimumUnits', groups.minimum_units, + 'maximumUnits', groups.maximum_units, + 'scope', groups.scope, + 'label', groups.label, + 'description', groups.description, + 'sourceText', groups.source_text, + 'position', groups.position + ) order by rules.rule_kind, groups.position, groups.id) + from public.requirement_groups as groups + join public.requirement_rules as rules on rules.id = groups.rule_id + left join public.requirement_groups as parents on parents.id = groups.parent_group_id + where groups.version_id = p_version_id + ), '[]'::jsonb), + 'ruleConditions', coalesce(( + select jsonb_agg(jsonb_build_object( + 'key', conditions.condition_key, + 'ruleKey', rules.rule_kind, + 'groupKey', groups.group_key, + 'position', conditions.position, + 'conditionKind', conditions.condition_kind, + 'requiredCourseCode', case + when conditions.condition_kind in ('course', 'incompatible') then items.code + end, + 'requiredStructureCode', case + when conditions.condition_kind = 'structure' then items.code + end, + 'structureKind', conditions.structure_kind, + 'minimumUnits', conditions.minimum_units, + 'maximumUnits', conditions.maximum_units, + 'minimumCount', conditions.minimum_count, + 'minimumMark', conditions.minimum_mark, + 'subjectCode', conditions.subject_code, + 'minimumCourseLevel', conditions.minimum_level, + 'maximumCourseLevel', conditions.maximum_level, + 'minimumGpa', conditions.minimum_gpa, + 'minimumYear', conditions.minimum_year, + 'minimumWam', conditions.minimum_wam, + 'tag', conditions.tag, + 'freeText', conditions.free_text, + 'scope', conditions.scope, + 'includesAnyCourse', conditions.includes_any_course, + 'courseRequirementMode', conditions.requirement_mode, + 'hardness', conditions.hardness, + 'sourceText', conditions.source_text, + 'reviewState', conditions.review_state, + 'confidence', conditions.confidence + ) order by rules.rule_kind, conditions.position, conditions.id) + from public.requirement_conditions as conditions + join public.requirement_rules as rules on rules.id = conditions.rule_id + join public.requirement_groups as groups on groups.id = conditions.group_id + left join public.catalogue_codes as items on items.id = conditions.code_id + where conditions.version_id = p_version_id + ), '[]'::jsonb), + 'ruleConditionCourses', coalesce(( + select jsonb_agg(jsonb_build_object( + 'conditionKey', conditions.condition_key, + 'position', options.position, + 'kind', options.kind, + 'sourceCourseCode', options.code, + 'title', options.title, + 'sourceText', options.source_text + ) order by conditions.id, options.position) + from public.requirement_condition_options as options + join public.requirement_conditions as conditions on conditions.id = options.condition_id + where options.version_id = p_version_id + ), '[]'::jsonb), + 'ruleCourseReferences', coalesce(( + select jsonb_agg(jsonb_build_object( + 'ruleKey', rules.rule_kind, + 'referencedCourseCode', items.code, + 'sourceText', item_references.source_text, + 'reviewState', item_references.review_state, + 'confidence', item_references.confidence + ) order by rules.rule_kind, items.code) + from public.requirement_item_references as item_references + join public.requirement_rules as rules on rules.id = item_references.rule_id + join public.catalogue_codes as items on items.id = item_references.code_id + where item_references.version_id = p_version_id + ), '[]'::jsonb), + 'prerequisiteCodes', coalesce(( + select jsonb_agg(codes.code order by codes.code) + from ( + select items.code + from public.requirement_item_references as item_references + join public.requirement_rules as rules on rules.id = item_references.rule_id + join public.catalogue_codes as items on items.id = item_references.code_id + where rules.version_id = p_version_id and rules.rule_kind = 'prerequisite' + union + select items.code + from public.requirement_conditions as conditions + join public.requirement_rules as rules on rules.id = conditions.rule_id + join public.catalogue_codes as items on items.id = conditions.code_id + where rules.version_id = p_version_id + and rules.rule_kind = 'prerequisite' + and conditions.condition_kind = 'course' + union + select options.code + from public.requirement_condition_options as options + join public.requirement_conditions as conditions on conditions.id = options.condition_id + join public.requirement_rules as rules on rules.id = conditions.rule_id + where rules.version_id = p_version_id + and rules.rule_kind = 'prerequisite' + and options.kind = 'course' + ) as codes + ), '[]'::jsonb) + ); +$$; diff --git a/supabase/migrations/018_requirement_placements.sql b/supabase/migrations/018_requirement_placements.sql new file mode 100644 index 00000000..2225408f --- /dev/null +++ b/supabase/migrations/018_requirement_placements.sql @@ -0,0 +1,116 @@ +-- A student's choice of where a course counts in their degree. +-- +-- Coursemap allocates each course in a plan to one part of the degree, most +-- specific first. A student may move a course to another part it qualifies +-- for, such as counting a course as an elective instead of towards a list. +-- The choice belongs to the plan and names the requirement by its structure +-- code and stable key, so it survives the structure being republished; a +-- choice the course no longer qualifies for is ignored rather than enforced. + +create table if not exists public.plan_requirement_placements ( + id uuid default gen_random_uuid() not null, + plan_id uuid not null, + owner_id uuid not null, + course_code text not null, + structure_code text not null, + requirement_key text not null, + created_at timestamp with time zone default now() not null, + updated_at timestamp with time zone default now() not null, + constraint plan_requirement_placements_course_code_check check ((course_code ~ '^[A-Z]{4}[0-9]{4}[A-Z]?$'::text)), + constraint plan_requirement_placements_structure_code_check check ((btrim(structure_code) <> ''::text)), + constraint plan_requirement_placements_requirement_key_check check ((btrim(requirement_key) <> ''::text)) +); + +alter table only public.plan_requirement_placements + add constraint plan_requirement_placements_pkey primary key (id); + +alter table only public.plan_requirement_placements + add constraint plan_requirement_placements_plan_course_unique unique (plan_id, course_code); + +alter table only public.plan_requirement_placements + add constraint plan_requirement_placements_plan_id_fkey foreign key (plan_id) + references public.plans(id) on delete cascade; + +alter table only public.plan_requirement_placements + add constraint plan_requirement_placements_owner_id_fkey foreign key (owner_id) + references auth.users(id) on delete cascade; + +create index plan_requirement_placements_owner_id_idx + on public.plan_requirement_placements using btree (owner_id); + +alter table public.plan_requirement_placements enable row level security; + +create policy plan_requirement_placements_owner_select on public.plan_requirement_placements + for select to authenticated + using ((( select auth.uid() as uid) = owner_id)); + +create policy plan_requirement_placements_owner_insert on public.plan_requirement_placements + for insert to authenticated + with check ((( select auth.uid() as uid) = owner_id)); + +create policy plan_requirement_placements_owner_update on public.plan_requirement_placements + for update to authenticated + using ((( select auth.uid() as uid) = owner_id)) + with check ((( select auth.uid() as uid) = owner_id)); + +create policy plan_requirement_placements_owner_delete on public.plan_requirement_placements + for delete to authenticated + using ((( select auth.uid() as uid) = owner_id)); + +revoke all on table public.plan_requirement_placements from public, anon, authenticated, service_role; +grant all on table public.plan_requirement_placements to service_role; +grant select, insert, update, delete on table public.plan_requirement_placements to authenticated; + +comment on table public.plan_requirement_placements is 'A student''s choice of which part of their degree a course counts towards.'; + +-- Sets or clears where a course counts in the caller's primary plan. The +-- plan is found from the session, so a caller can only ever write their own. +create or replace function public.set_current_user_requirement_placement( + p_course_code text, + p_structure_code text default null, + p_requirement_key text default null +) returns void + language plpgsql + set search_path to '' + as $$ +declare + v_user_id uuid := (select auth.uid()); + v_plan_id uuid; +begin + if v_user_id is null then + raise exception using + errcode = '42501', + message = 'You must be signed in to update a plan.'; + end if; + + select plans.id into v_plan_id + from public.plans + where plans.owner_id = v_user_id and plans.is_primary and plans.status = 'active'; + + if v_plan_id is null then + raise exception using + errcode = 'P0002', + message = 'Your primary plan was not found.'; + end if; + + if p_requirement_key is null then + delete from public.plan_requirement_placements + where plan_id = v_plan_id and course_code = upper(btrim(p_course_code)); + return; + end if; + + insert into public.plan_requirement_placements ( + plan_id, owner_id, course_code, structure_code, requirement_key + ) values ( + v_plan_id, v_user_id, upper(btrim(p_course_code)), upper(btrim(p_structure_code)), + btrim(p_requirement_key) + ) + on conflict (plan_id, course_code) do update + set structure_code = excluded.structure_code, + requirement_key = excluded.requirement_key, + updated_at = now(); +end; +$$; + +revoke all on function public.set_current_user_requirement_placement(text, text, text) from public, anon, authenticated, service_role; +grant all on function public.set_current_user_requirement_placement(text, text, text) to authenticated; diff --git a/supabase/tests/database/requirement_placements.sql b/supabase/tests/database/requirement_placements.sql new file mode 100644 index 00000000..df9a2744 --- /dev/null +++ b/supabase/tests/database/requirement_placements.sql @@ -0,0 +1,174 @@ +begin; +\ir ../helpers/catalogue-fixtures.inc + +create extension if not exists pgtap with schema extensions; + +select extensions.plan(11); + +select extensions.ok( + has_function_privilege( + 'authenticated', + 'public.set_current_user_requirement_placement(text,text,text)', + 'execute' + ) + and not has_function_privilege( + 'anon', + 'public.set_current_user_requirement_placement(text,text,text)', + 'execute' + ) + and exists ( + select 1 + from pg_proc as functions + where functions.oid = + 'public.set_current_user_requirement_placement(text,text,text)'::regprocedure + and not functions.prosecdef + and functions.proconfig @> array['search_path=""']::text[] + ), + 'the placement RPC is security invoker, has a fixed search path and is for signed-in users only' +); + +insert into auth.users ( + instance_id, id, aud, role, email, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at +) values + ( + '00000000-0000-0000-0000-000000000000', + '98000000-0000-4000-8000-000000000001', + 'authenticated', 'authenticated', 'placement-owner@example.test', + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '00000000-0000-0000-0000-000000000000', + '98000000-0000-4000-8000-000000000002', + 'authenticated', 'authenticated', 'placement-other@example.test', + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ); + +insert into public.plans ( + owner_id, academic_year_id, name, is_primary, commencement_year, study_load +) +select users.id, (select id from public.academic_years where year = 2026), + 'Placement plan', true, 2026, 'full_time' +from auth.users as users +where users.id in ( + '98000000-0000-4000-8000-000000000001', + '98000000-0000-4000-8000-000000000002' +); + +set local role anon; + +select extensions.throws_ok( + $$ select public.set_current_user_requirement_placement('COMP1100', 'AACOM', 'electives') $$, + '42501', + null, + 'anonymous visitors cannot place a course' +); + +reset role; + +select set_config('request.jwt.claim.sub', '98000000-0000-4000-8000-000000000001', true); +select set_config('request.jwt.claim.role', 'authenticated', true); +set local role authenticated; + +select extensions.lives_ok( + $$ select public.set_current_user_requirement_placement(' comp1100 ', 'aacom', ' electives ') $$, + 'a student places a course in their primary plan' +); + +select extensions.results_eq( + $$ + select course_code, structure_code, requirement_key + from public.plan_requirement_placements + $$, + $$ values ('COMP1100'::text, 'AACOM'::text, 'electives'::text) $$, + 'the placement is stored normalised against the caller''s plan' +); + +select public.set_current_user_requirement_placement('COMP1100', 'AACOM', 'computing-list'); + +select extensions.results_eq( + $$ select count(*)::int, min(requirement_key) from public.plan_requirement_placements $$, + $$ values (1, 'computing-list'::text) $$, + 'placing a course again moves it rather than adding a second placement' +); + +select extensions.throws_ok( + $$ + insert into public.plan_requirement_placements ( + plan_id, owner_id, course_code, structure_code, requirement_key + ) + select plans.id, '98000000-0000-4000-8000-000000000002', 'COMP1110', 'AACOM', 'electives' + from public.plans + where plans.owner_id = '98000000-0000-4000-8000-000000000001' + $$, + '42501', + null, + 'a student cannot write a placement under another owner' +); + +select extensions.throws_ok( + $$ select public.set_current_user_requirement_placement('Not a code', 'AACOM', 'electives') $$, + '23514', + null, + 'placements name a course code' +); + +select set_config('request.jwt.claim.sub', '98000000-0000-4000-8000-000000000002', true); + +select extensions.is_empty( + $$ select 1 from public.plan_requirement_placements $$, + 'a student cannot see another student''s placements' +); + +select public.set_current_user_requirement_placement('COMP1100', 'AACOM', null); + +select set_config('request.jwt.claim.sub', '98000000-0000-4000-8000-000000000001', true); + +select extensions.results_eq( + $$ select count(*)::int from public.plan_requirement_placements $$, + $$ values (1) $$, + 'clearing a placement touches only the caller''s own plan' +); + +select public.set_current_user_requirement_placement('COMP1100', 'AACOM', null); + +select extensions.is_empty( + $$ select 1 from public.plan_requirement_placements $$, + 'clearing a placement hands the course back to Coursemap' +); + +reset role; + +-- Degree-wide scope and open lists belong to the requirement tree. +select pg_temp.create_course_snapshot('SCOP1000', 2029::smallint, 'Scope fixture'); + +create temporary table fixture on commit drop as +select snapshots.id as version_id, snapshots.academic_year_id +from public.catalogue_versions as snapshots +join public.course_version_details as details on details.version_id = snapshots.id +where details.title = 'Scope fixture'; + +insert into public.requirement_rules (version_id, academic_year_id, rule_kind, source_text) +select version_id, academic_year_id, 'prerequisite', 'Scope fixture rule.' from fixture; + +insert into public.requirement_groups (rule_id, version_id, group_key, operator) +select rules.id, rules.version_id, 'root', 'all_of' +from public.requirement_rules as rules join fixture on fixture.version_id = rules.version_id; + +select extensions.throws_ok( + $$ + insert into public.requirement_conditions ( + rule_id, version_id, group_id, condition_key, position, condition_kind, + minimum_units, includes_any_course + ) + select groups.rule_id, groups.version_id, groups.id, 'open-total', 0, 'units_total', 24, true + from public.requirement_groups as groups join fixture on fixture.version_id = groups.version_id + $$, + '23514', + null, + 'only course lists can be open to any course' +); + +select * from extensions.finish(); + +rollback; From c623c745c4e68cd658073995b86dc27a9eb88cde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:11 +0000 Subject: [PATCH 2/6] feat: allocate plan courses to degree requirements, with electives --- .../web/lib/coursemap/requirement-progress.ts | 394 ++++++++++++++++-- apps/web/tests/requirement-progress.test.mjs | 204 ++++++++- 2 files changed, 553 insertions(+), 45 deletions(-) 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/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); +}); From 8f309bfd8093b8236341fa2b76220f25d5a9fe2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:11 +0000 Subject: [PATCH 3/6] feat: read a programme's scope and listed majors and minors on import --- .../kinds/structure/contract.ts | 22 +++++ .../kinds/structure/finalise.ts | 5 ++ .../kinds/structure/listed-options.ts | 85 +++++++++++++++++++ .../kinds/structure/model-canonical.ts | 1 + .../kinds/structure/project.ts | 7 ++ .../kinds/structure/prompt.ts | 13 ++- .../lib/catalogue-import/version-content.ts | 7 +- .../catalogue-sync/persist-source-version.ts | 11 ++- apps/web/lib/catalogue/content.ts | 16 ++++ .../tests/structure-import-transform.test.mjs | 30 ++++++- 10 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 apps/web/lib/catalogue-import/kinds/structure/listed-options.ts 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/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", + }, + ], + ); +}); From 14255b06d907797527ff75c7c96d3fa4bb8bf9ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:11 +0000 Subject: [PATCH 4/6] feat: preview structures as sample students --- apps/web/lib/coursemap/structure-samples.ts | 204 ++++++++++++++++++ .../ui/admin/catalogue/version-preview.tsx | 114 ++++++++-- 2 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 apps/web/lib/coursemap/structure-samples.ts 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/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} + + ))} + +
- + ); } From e4ff447c0b4cff909d6594a0ad98632e29caede8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:11 +0000 Subject: [PATCH 5/6] feat: compact requirement rows with placement controls and suggestions --- apps/web/app/requirements/requirements.tsx | 77 ++- apps/web/tests/placement-control.test.tsx | 91 +++ .../web/tests/requirement-course-row.test.tsx | 23 +- .../web/tests/requirements-workspace.test.tsx | 35 +- .../web/ui/requirements/placement-control.tsx | 72 +++ .../ui/requirements/requirement-condition.tsx | 585 +++++++++++++----- .../requirement-course-options.tsx | 30 +- .../requirements/requirement-course-row.tsx | 123 ++-- .../requirements/requirement-presentation.ts | 334 +++++++++- apps/web/ui/requirements/requirement-tree.tsx | 258 ++++++-- apps/web/ui/requirements/units-bar.tsx | 49 +- 11 files changed, 1341 insertions(+), 336 deletions(-) create mode 100644 apps/web/tests/placement-control.test.tsx create mode 100644 apps/web/ui/requirements/placement-control.tsx 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/tests/placement-control.test.tsx b/apps/web/tests/placement-control.test.tsx new file mode 100644 index 00000000..4d383ac8 --- /dev/null +++ b/apps/web/tests/placement-control.test.tsx @@ -0,0 +1,91 @@ +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(); + expect(screen.getByText("Counts towards")).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Where COMP1100 counts" }), + ); + 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(); + expect(screen.getByText("You counted it towards")).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Where COMP1100 counts" }), + ); + 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/requirements-workspace.test.tsx b/apps/web/tests/requirements-workspace.test.tsx index 778e999d..7c3662d8 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( 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 ( +