diff --git a/packages/db/src/planner-state.ts b/packages/db/src/planner-state.ts index b75b183..9f3e136 100644 --- a/packages/db/src/planner-state.ts +++ b/packages/db/src/planner-state.ts @@ -102,6 +102,11 @@ export interface PlannerState { * away!!!"). */ credit?: PlannerCreditEntry[] + /** + * Prerequisites the student has waived, per unit. Cleared when the + * course or start year changes, since they belong to this course map. + */ + waivers?: PlannerPrerequisiteWaiver[] /** * Campus the student is planning at, e.g. "Clayton" or "Malaysia". * Filters campus-scoped area-of-study options and requirement groups @@ -120,6 +125,15 @@ export interface PlannerState { campus?: string } +/** + * `prerequisiteCode` counts as met for `unitCode`'s prerequisites only. + * It earns no credit and doesn't touch any other rule or unit. + */ +export interface PlannerPrerequisiteWaiver { + unitCode: string + prerequisiteCode: string +} + /** * One piece of advanced standing. Two flavours in one shape: * diff --git a/packages/webapp/components/planner/left-sidebar.tsx b/packages/webapp/components/planner/left-sidebar.tsx index 9be97d8..1a10186 100644 --- a/packages/webapp/components/planner/left-sidebar.tsx +++ b/packages/webapp/components/planner/left-sidebar.tsx @@ -10,6 +10,7 @@ import { PrinterIcon, Redo2Icon, RotateCcwIcon, + ShieldCheckIcon, Undo2Icon, } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react" @@ -24,16 +25,19 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { buildCsv, downloadBlob, planFileName } from "@/lib/planner/plan-export" +import { cn } from "@/lib/utils" import { CreditDialog } from "./credit-dialog" import { PlanStartControl } from "./plan-basics" import { usePlanner, usePlannerSync } from "./planner-context" +import { TOUCH_HIT_ROW } from "./touch-target" import { useWam } from "./wam-context" +import { WaiverDialog } from "./waiver-dialog" /** * Plan header above the grid: the plan's name (click to rename) and * its start ("Starts Semester 1, 2027") and validation status on the - * left; undo/redo, Results, Credit and Print on the right, with + * left; undo/redo, Results, Credit, Waivers and Print on the right, with * Export to CSV and Reset under More. State-only operations * (no server round-trip). */ @@ -80,6 +84,8 @@ export function LeftSidebar() { const { showResults, toggleShowResults, grades } = useWam() const [creditOpen, setCreditOpen] = useState(false) const creditCount = state.credit?.length ?? 0 + const [waiversOpen, setWaiversOpen] = useState(false) + const waiverCount = state.waivers?.length ?? 0 const errorCount = useMemo(() => { let n = 0 @@ -87,8 +93,20 @@ export function LeftSidebar() { return n }, [validations]) + const validText = useMemo( + () => + [...validations.values()].some((v) => v.reliesOnWaiver) + ? "Every unit meets its prereqs or has them waived, and is offered in its slot" + : "Every unit meets its prereqs and is offered in its slot", + [validations] + ) + const onReset = useCallback(() => { - if (!confirm("Reset the whole plan? This clears every unit you've placed.")) + if ( + !confirm( + "Reset the whole plan? This clears every unit you've placed and any waivers." + ) + ) return dispatch({ type: "reset" }) }, [dispatch]) @@ -149,17 +167,14 @@ export function LeftSidebar() { onClick={() => { if (errorCount === 0) { toast.success("Plan validates cleanly", { - description: - "Every unit meets its prereqs and is offered in its slot.", + description: `${validText}.`, }) return } flashErrors() }} title={ - errorCount === 0 - ? "Every unit meets its prereqs and is offered in its slot" - : "Highlight the units with problems" + errorCount === 0 ? validText : "Highlight the units with problems" } className="ml-1.5 shrink-0 self-center" > @@ -211,6 +226,14 @@ export function LeftSidebar() { onClick={() => setCreditOpen(true)} count={creditCount} /> + } + label="Waivers" + title="Prerequisites waived on this plan" + onClick={() => setWaiversOpen(true)} + count={waiverCount} + className={TOUCH_HIT_ROW} + /> } label="Print" @@ -248,6 +271,7 @@ export function LeftSidebar() { + ) } @@ -262,12 +286,14 @@ function ToolbarButton({ title, onClick, count = 0, + className, }: { icon: React.ReactNode label: string title: string onClick: () => void count?: number + className?: string }) { return ( + ) : null} ) } diff --git a/packages/webapp/components/planner/unit-card.tsx b/packages/webapp/components/planner/unit-card.tsx index e5c5b78..03c5aa8 100644 --- a/packages/webapp/components/planner/unit-card.tsx +++ b/packages/webapp/components/planner/unit-card.tsx @@ -6,6 +6,7 @@ import { CircleAlertIcon, MoreVerticalIcon, MoveIcon, + ShieldCheckIcon, StarIcon, XIcon, } from "lucide-react" @@ -228,7 +229,7 @@ export function UnitCard({ ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing" )} - aria-label={`Details for ${code}`} + aria-label={`Details for ${code}${validation?.reliesOnWaiver ? ", relies on a waived prerequisite" : ""}`} type="button" >
@@ -243,6 +244,14 @@ export function UnitCard({ ) : null} + {validation?.reliesOnWaiver ? ( + + + + ) : null} {isFY ? ( Full year diff --git a/packages/webapp/components/planner/unit-detail-popover.tsx b/packages/webapp/components/planner/unit-detail-popover.tsx index 2d74d19..2b63bb0 100644 --- a/packages/webapp/components/planner/unit-detail-popover.tsx +++ b/packages/webapp/components/planner/unit-detail-popover.tsx @@ -39,9 +39,15 @@ import type { } from "@/lib/planner/types" import { RatingInline } from "@/components/reviews/stars" import { useRating } from "@/components/reviews/use-ratings" +import { + waivableCodes, + waiverAccess, + waiversByUnit, +} from "@/lib/planner/waivers" import { cn } from "@/lib/utils" import { usePlanner } from "./planner-context" +import type { TreeWaivers } from "./requisite-tree-view" /** * Popover that shows everything the student might want to see about @@ -138,6 +144,7 @@ export function UnitDetailView({ requisites, validations, state, + dispatch, plannedCodes, availableYears, } = usePlanner() @@ -182,9 +189,12 @@ export function UnitDetailView({ // the planner may hold this code from another plan year. A fallback // unit (see PlannerUnit.fallbackFor) stands in for its requested year. const contextUnit = units.get(code) - const usingCurrentYear = - selectedYear === - (contextUnit ? (contextUnit.fallbackFor ?? contextUnit.year) : defaultYear) + + const contextYear = contextUnit + ? (contextUnit.fallbackFor ?? contextUnit.year) + : defaultYear + + const usingCurrentYear = selectedYear === contextYear const [otherYearData, setOtherYearData] = useState<{ year: string @@ -260,6 +270,31 @@ export function UnitDetailView({ [isPlaced, state, yearIndex, slotIndex, plannedCodes, units] ) + const access = waiverAccess({ + isPlaced, + usingCurrentYear, + selectedYear, + defaultYear, + }) + + const waivers = useMemo(() => { + if (access === "none") return undefined + + const waiver = (prerequisiteCode: string) => ({ + unitCode: code, + prerequisiteCode, + }) + + return { + waived: waiversByUnit(state).get(code) ?? new Set(), + waivable: waivableCodes(validation), + editable: access === "edit", + onWaiveAction: (c) => dispatch({ type: "add_waiver", waiver: waiver(c) }), + onUnwaiveAction: (c) => + dispatch({ type: "remove_waiver", waiver: waiver(c) }), + } + }, [access, code, state, validation, dispatch]) + // Links name the year whose data is shown: a fallback unit's page is // its own year's. const linkYear = unit?.year ?? selectedYear @@ -333,7 +368,12 @@ export function UnitDetailView({ className="flex gap-2 rounded-control bg-destructive/10 px-2.5 py-1.5 text-xs text-destructive" > - {issue.message} + + {issue.message} + {access === "read" && issue.kind === "prereq_unmet" + ? ` To waive it, switch to the ${contextYear} handbook.` + : null} + ))} {validation.warnings.map((issue, i) => ( @@ -382,6 +422,7 @@ export function UnitDetailView({ block={block} completed={completed} units={units} + waivers={waivers} /> ))} diff --git a/packages/webapp/components/planner/waiver-dialog.tsx b/packages/webapp/components/planner/waiver-dialog.tsx new file mode 100644 index 0000000..12e85d1 --- /dev/null +++ b/packages/webapp/components/planner/waiver-dialog.tsx @@ -0,0 +1,82 @@ +"use client" + +import { TrashIcon } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { placedUnitCodes } from "@/lib/planner/progress" +import { waiverNote } from "@/lib/planner/waivers" +import { cn } from "@/lib/utils" + +import { usePlanner } from "./planner-context" +import { TOUCH_HIT } from "./touch-target" + +/** This plan's waivers, added from a unit's prerequisites. */ +export function WaiverDialog({ + open, + onOpenChangeAction, +}: { + open: boolean + onOpenChangeAction: (v: boolean) => void +}) { + const { state, dispatch, requisites } = usePlanner() + const waivers = state.waivers ?? [] + const placed = placedUnitCodes(state) + + return ( + + + + Prerequisite waivers + + A waiver counts for one unit only and adds no credit points. + + + + {waivers.length > 0 ? ( +
    + {waivers.map((waiver) => { + const { unitCode, prerequisiteCode } = waiver + const note = waiverNote(waiver, placed, requisites) + + return ( +
  • + + {prerequisiteCode} waived for {unitCode} + {note ? ( + + {note} + + ) : null} + + +
  • + ) + })} +
+ ) : ( +

+ No waivers yet. Waive one from a placed unit's details. +

+ )} +
+
+ ) +} diff --git a/packages/webapp/components/unit-detail/requisite-block.tsx b/packages/webapp/components/unit-detail/requisite-block.tsx index ee6b7ec..3547643 100644 --- a/packages/webapp/components/unit-detail/requisite-block.tsx +++ b/packages/webapp/components/unit-detail/requisite-block.tsx @@ -1,4 +1,7 @@ -import { RequisiteTreeView } from "@/components/planner/requisite-tree-view" +import { + RequisiteTreeView, + type TreeWaivers, +} from "@/components/planner/requisite-tree-view" import type { RequisiteBlock } from "@/lib/planner/types" import { cn } from "@/lib/utils" @@ -12,16 +15,19 @@ export function blocksWithRules(blocks: RequisiteBlock[]): RequisiteBlock[] { /** * One requisite block: its heading and its AND/OR tree, with ticks * for the codes in `completed`. `units` names leaf codes with their - * titles when the caller has them (the planner does). + * titles when the caller has them (the planner does). `waivers` only + * reach a prerequisite block, the one kind a waiver applies to. */ export function RequisiteRuleBlock({ block, completed, units, + waivers, }: { block: RequisiteBlock completed: ReadonlySet units?: ReadonlyMap + waivers?: TreeWaivers }) { const isProhibition = block.requisiteType === "prohibition" return ( @@ -39,6 +45,7 @@ export function RequisiteRuleBlock({ completed={completed} isProhibition={isProhibition} units={units} + waivers={block.requisiteType === "prerequisite" ? waivers : undefined} />
) diff --git a/packages/webapp/lib/db/input.test.ts b/packages/webapp/lib/db/input.test.ts index c4b3476..bfd3936 100644 --- a/packages/webapp/lib/db/input.test.ts +++ b/packages/webapp/lib/db/input.test.ts @@ -174,3 +174,27 @@ test("LIKE wildcards are escaped", () => { assert.equal(likeEscape("FIT1045"), "FIT1045") assert.equal(containsPattern("%"), "%\\%%") }) + +test("isPlannerState checks waivers", () => { + const waiver = { unitCode: "FIT3235", prerequisiteCode: "MAT1830" } + + assert.equal(isPlannerState(plan({ waivers: undefined })), true) + assert.equal(isPlannerState(plan({ waivers: [waiver] })), true) + assert.equal(isPlannerState(plan({ waivers: "FIT3235" })), false) + assert.equal(isPlannerState(plan({ waivers: [{ unitCode: 1 }] })), false) + assert.equal(isPlannerState(plan({ waivers: [null] })), false) + assert.equal( + isPlannerState( + plan({ waivers: [{ unitCode: "FIT3235", prerequisiteCode: 1 }] }) + ), + false + ) + assert.equal( + isPlannerState(plan({ waivers: [{ unitCode: "FIT3235" }] })), + false + ) + assert.equal( + isPlannerState(plan({ waivers: Array(201).fill(waiver) })), + false + ) +}) diff --git a/packages/webapp/lib/db/input.ts b/packages/webapp/lib/db/input.ts index 2b79439..91cd4d6 100644 --- a/packages/webapp/lib/db/input.ts +++ b/packages/webapp/lib/db/input.ts @@ -146,5 +146,16 @@ export function isPlannerState(v: unknown): v is PlannerState { if (!optional(e.label, "string")) return false } } + + if (v.waivers !== undefined) { + if (!Array.isArray(v.waivers) || v.waivers.length > 200) return false + + for (const w of v.waivers) { + if (!isObj(w) || typeof w.unitCode !== "string") return false + + if (typeof w.prerequisiteCode !== "string") return false + } + } + return JSON.stringify(v).length <= MAX_PLAN_STATE_CHARS } diff --git a/packages/webapp/lib/db/requisite-rule.ts b/packages/webapp/lib/db/requisite-rule.ts index 542e193..9a1649c 100644 --- a/packages/webapp/lib/db/requisite-rule.ts +++ b/packages/webapp/lib/db/requisite-rule.ts @@ -39,12 +39,14 @@ function slimContainer(raw: unknown): RequisiteContainer | null { } // A leaf without a code (three in the whole corpus) keeps an empty -// code, so it still blocks an AND exactly as the raw leaf did. +// code, so it still blocks an AND exactly as the raw leaf did. Some +// codes carry stray whitespace (ADS2002 names "ADS2001 "), which no +// completed unit would ever match. function slimLeaf(raw: unknown): RequisiteLeaf | null { if (!raw || typeof raw !== "object") return null const l = raw as Record const code = - typeof l.academic_item_code === "string" ? l.academic_item_code : "" + typeof l.academic_item_code === "string" ? l.academic_item_code.trim() : "" return typeof l.academic_item_name === "string" ? { academic_item_code: code, academic_item_name: l.academic_item_name } : { academic_item_code: code } diff --git a/packages/webapp/lib/planner/payload-shape.test.ts b/packages/webapp/lib/planner/payload-shape.test.ts index 2245fdc..f427b5a 100644 --- a/packages/webapp/lib/planner/payload-shape.test.ts +++ b/packages/webapp/lib/planner/payload-shape.test.ts @@ -187,3 +187,31 @@ test("plannerUnitCodes covers AoS, course and component units once each", () => ["FIT1045", "FIT2004", "FIT1047", "SCI1000"] ) }) + +test("a prohibition naming a code with stray whitespace still applies", () => { + const slim = slimRequisiteRule([ + { + parent_connector: { value: "OR" }, + relationships: [leaf("ITO5003 ", "x")], + }, + ]) + + assert.deepEqual( + evaluateProhibition(slim, new Set(["ITO5003"])).conflictingCodes, + ["ITO5003"] + ) +}) + +test("a leaf code with stray whitespace still matches the unit", () => { + const slim = slimRequisiteRule([ + { + parent_connector: { value: "AND" }, + relationships: [leaf("ADS2001 ", "x"), leaf("BFF5525\t\t", "y")], + }, + ]) + + assert.equal( + evaluateRequisiteTree(slim, new Set(["ADS2001", "BFF5525"])).satisfied, + true + ) +}) diff --git a/packages/webapp/lib/planner/personalize-search.test.ts b/packages/webapp/lib/planner/personalize-search.test.ts index 1f02111..4359f9e 100644 --- a/packages/webapp/lib/planner/personalize-search.test.ts +++ b/packages/webapp/lib/planner/personalize-search.test.ts @@ -13,6 +13,7 @@ import { NO_TRANSITIONS, offering as fixtureOffering, planState, + coreq, prereq, requisite, unit as fixtureUnit, @@ -386,3 +387,35 @@ test("personalScore: period fit uses the slot's transitions", () => { assert.equal(score(NO_TRANSITIONS), 1) assert.equal(score(retired), 0) }) + +test("buildPersonalSignals: a waived code isn't suggested for that unit", () => { + const s = planState([{ S1: ["AAA3000", "AAA3001"] }], { + waivers: [{ unitCode: "AAA3000", prerequisiteCode: "AAA1000" }], + }) + + const needed = (requisites: [string, RequisiteBlock[]][]) => + buildPersonalSignals(s, course(), new Map(requisites)).prereqOfPlaced + + assert.equal(needed([["AAA3000", [prereq("AAA1000")]]]).has("AAA1000"), false) + // Another unit still needs it, and so does a corequisite. + assert.equal(needed([["AAA3001", [prereq("AAA1000")]]]).has("AAA1000"), true) + assert.equal(needed([["AAA3000", [coreq("AAA1000")]]]).has("AAA1000"), true) +}) + +test("personalScore: a waiver makes only its unit prereq-ready", () => { + const s = planState([{ S1: [], S2: [] }], { + waivers: [{ unitCode: "AAA3000", prerequisiteCode: "AAA1000" }], + }) + + const score = (code: string) => + personalScore({ + unit: unit(code), + offerings: [offering("S1")], + requisites: [prereq("AAA1000")], + signals: buildPersonalSignals(s, course(), new Map()), + slot: slotContextFor(s, 0, 0), + }).prereqReady + + assert.equal(score("AAA3000"), 1) + assert.equal(score("AAA3001"), 0) +}) diff --git a/packages/webapp/lib/planner/personalize-search.ts b/packages/webapp/lib/planner/personalize-search.ts index 50a1f22..8bfbf5e 100644 --- a/packages/webapp/lib/planner/personalize-search.ts +++ b/packages/webapp/lib/planner/personalize-search.ts @@ -13,6 +13,7 @@ import { slotCalendarYear, startPeriodOf } from "./timeline.ts" import { unitLevel } from "./unit-level.ts" import { TRANSITIONS, type Transitions } from "./transitions.ts" import { completedBefore, offeringIssue } from "./validation.ts" +import { waiversByUnit, withWaived } from "./waivers.ts" /** * Per-plan precomputed signals — derived once when the plan changes, @@ -62,6 +63,7 @@ export interface SlotContext { /** The slot's calendar year. */ slotYear: number transitions: Transitions + waivers: ReadonlyMap> } /** Feature-by-feature breakdown returned with each candidate. */ @@ -154,16 +156,19 @@ export function buildPersonalSignals( const prohibitedByPlaced = new Set() const prereqOfPlaced = new Set() + const waivers = waiversByUnit(state) + for (const code of placed) { const blocks = requisitesByCode.get(code) ?? [] for (const block of blocks) { const leaves = referencedCodes(block.rule) if (block.requisiteType === "prohibition") { for (const c of leaves) prohibitedByPlaced.add(c) - } else if ( - block.requisiteType === "prerequisite" || - block.requisiteType === "corequisite" - ) { + } else if (block.requisiteType === "prerequisite") { + // A waived code isn't one this unit still needs. + for (const c of leaves) + if (!waivers.get(code)?.has(c)) prereqOfPlaced.add(c) + } else if (block.requisiteType === "corequisite") { for (const c of leaves) prereqOfPlaced.add(c) } } @@ -211,6 +216,7 @@ export function slotContextFor( slot?.kind ?? startPeriodOf(state) ), transitions, + waivers: waiversByUnit(state), } } @@ -247,11 +253,16 @@ export function personalScore(args: { const aos = signals.aosWeight.get(unit.code) ?? 0 const fillsGap = signals.fillsGap.has(unit.code) ? 1 : 0 + const prereqsMetBy = withWaived( + slot.completedBefore, + slot.waivers.get(unit.code) + ) + let prereqReady = 1 for (const block of requisites) { if (block.requisiteType !== "prerequisite") continue if (!block.rule || block.rule.length === 0) continue - const res = evaluateRequisiteTree(block.rule, slot.completedBefore) + const res = evaluateRequisiteTree(block.rule, prereqsMetBy) if (!res.satisfied) { prereqReady = 0 break diff --git a/packages/webapp/lib/planner/plan-export.test.ts b/packages/webapp/lib/planner/plan-export.test.ts index 9fa2ae9..7edde75 100644 --- a/packages/webapp/lib/planner/plan-export.test.ts +++ b/packages/webapp/lib/planner/plan-export.test.ts @@ -124,3 +124,40 @@ test("plan file names stay readable", () => { assert.equal(planFileName("a/b: c?", "csv"), "ab c.csv") assert.equal(planFileName(" ", "csv"), "MonMap plan.csv") }) + +test("the CSV lists waivers for placed units, which add no credit points", () => { + let st = defaultState("2027", "C2001", 1) + st = plannerReducer(st, { + type: "add_unit", + yearIndex: 0, + slotIndex: 0, + code: "FIT3235", + }) + st = plannerReducer(st, { + type: "add_waiver", + waiver: { unitCode: "FIT3235", prerequisiteCode: "MAT1830" }, + }) + st = plannerReducer(st, { + type: "add_waiver", + waiver: { unitCode: "FIT3999", prerequisiteCode: "MAT1830" }, + }) + + const rows = parse( + buildCsv(st, { + units: new Map([ + ["FIT3235", { title: "Theory", creditPoints: 6 }], + ["MAT1830", { title: "Discrete maths", creditPoints: 6 }], + ]), + grades: new Map(), + }) + ) + + const waiverRows = rows.filter((r) => r[0] === "Waiver") + const waiverRow = waiverRows[0] + + assert.equal(waiverRows.length, 1) + assert.equal(waiverRow?.[1], "FIT3235: MAT1830 waived") + assert.equal(waiverRow?.at(-1), "") + // FIT3235 only: the waived MAT1830 earns nothing. + assert.equal(rows.at(-1)?.at(-1), "6") +}) diff --git a/packages/webapp/lib/planner/plan-export.ts b/packages/webapp/lib/planner/plan-export.ts index 6de9e0a..ae850bf 100644 --- a/packages/webapp/lib/planner/plan-export.ts +++ b/packages/webapp/lib/planner/plan-export.ts @@ -2,6 +2,7 @@ import { unitSlotCreditPoints } from "./capacity.ts" import { markToGrade } from "./grades.ts" import { summarizePlan } from "./progress.ts" import { slotBlockCredit, slotLabel } from "./timeline.ts" +import { placedWaivers } from "./waivers.ts" import type { PlannerOffering, PlannerState, PlannerUnit } from "./types.ts" export interface CsvContext { @@ -90,6 +91,10 @@ export function buildCsv(state: PlannerState, ctx: CsvContext): string { ) ) } + + for (const { unitCode, prerequisiteCode } of placedWaivers(state)) + rows.push(row("Waiver", [`${unitCode}: ${prerequisiteCode} waived`])) + const total = summarizePlan(state, null, ctx.units, ctx.offerings) rows.push(row("Total", [], String(total.totalCreditPoints))) diff --git a/packages/webapp/lib/planner/state.test.ts b/packages/webapp/lib/planner/state.test.ts index f30b2a9..2f87bb3 100644 --- a/packages/webapp/lib/planner/state.test.ts +++ b/packages/webapp/lib/planner/state.test.ts @@ -7,6 +7,7 @@ import { HISTORY_LIMIT, initialHistory, plannerReducer, + withKnownYear, type PlannerAction, } from "./state.ts" import { planState } from "./test-fixtures.ts" @@ -963,3 +964,140 @@ test("batch applies several actions as one undo step", () => { assert.equal(h.present.years.length, 1) assert.equal(h.present.years[0].slots.length, 2) }) + +const waiver = { unitCode: "FIT3235", prerequisiteCode: "MAT1830" } + +test("add_waiver records a waiver once", () => { + const s = plannerReducer(defaultState("2026", "C2000"), { + type: "add_waiver", + waiver, + }) + + assert.deepEqual(s.waivers, [waiver]) + assert.equal( + plannerReducer(s, { type: "add_waiver", waiver: { ...waiver } }), + s + ) +}) + +test("remove_waiver removes only that pair", () => { + const other = { unitCode: "FIT3235", prerequisiteCode: "FIT2115" } + + const s = apply( + defaultState("2026", "C2000"), + { type: "add_waiver", waiver }, + { type: "add_waiver", waiver: other } + ) + + assert.deepEqual( + plannerReducer(s, { type: "remove_waiver", waiver }).waivers, + [other] + ) +}) + +test("removing a waiver that isn't there is a no-op", () => { + const s = defaultState("2026", "C2000") + + assert.equal(plannerReducer(s, { type: "remove_waiver", waiver }), s) +}) + +test("a repeated waiver takes no undo step, and undo removes a waiver", () => { + let h = initialHistory(defaultState("2026", "C2000")) + h = historyReducer(h, { type: "add_waiver", waiver }) + const once = h + + h = historyReducer(h, { type: "add_waiver", waiver }) + assert.equal(h, once) + + h = historyReducer(h, { type: "undo" }) + assert.equal(h.present.waivers, undefined) +}) + +test("changing the course or start year clears waivers, and undo restores them", () => { + const start = plannerReducer(defaultState("2026", "C2000"), { + type: "add_waiver", + waiver, + }) + + for (const action of [ + { type: "set_course", code: "C2001" }, + { type: "set_year", year: "2027" }, + ] satisfies PlannerAction[]) { + let h = historyReducer(initialHistory(start), action) + assert.equal(h.present.waivers, undefined) + + h = historyReducer(h, { type: "undo" }) + assert.deepEqual(h.present.waivers, [waiver]) + } +}) + +test("withKnownYear clears waivers only when it moves the year", () => { + const plan = plannerReducer(defaultState("2019", "C2000"), { + type: "add_waiver", + waiver, + }) + + const moved = withKnownYear(plan, ["2026", "2027"], "2027") + + assert.equal(moved.courseYear, "2027") + assert.equal(moved.waivers, undefined) + + const kept = withKnownYear(plan, ["2019"], "2027") + + assert.deepEqual(kept, plan) + assert.notEqual(kept, plan) +}) + +test("waivers survive removing the unit", () => { + const start = apply( + defaultState("2026", "C2000"), + { type: "add_unit", yearIndex: 0, slotIndex: 0, code: "FIT3235" }, + { type: "add_waiver", waiver } + ) + + const removed = plannerReducer(start, { + type: "remove_unit", + yearIndex: 0, + slotIndex: 0, + code: "FIT3235", + }) + + assert.deepEqual(removed.waivers, [waiver]) +}) + +test("resetting the map clears waivers, and undo restores them", () => { + const start = plannerReducer(defaultState("2026", "C2000"), { + type: "add_waiver", + waiver, + }) + + let h = historyReducer(initialHistory(start), { type: "reset" }) + + assert.equal(h.present.waivers, undefined) + + h = historyReducer(h, { type: "undo" }) + + assert.deepEqual(h.present.waivers, [waiver]) +}) + +test("a batched course switch clears waivers in one undo step", () => { + const start = plannerReducer(defaultState("2026", "C2000"), { + type: "add_waiver", + waiver, + }) + + let h = historyReducer(initialHistory(start), { + type: "batch", + actions: [ + { type: "set_course", code: "C2001" }, + { type: "set_aos", role: "major", code: "SFTWRDEV01" }, + ], + }) + + assert.equal(h.present.waivers, undefined) + + h = historyReducer(h, { type: "undo" }) + + assert.equal(h.present.courseCode, "C2000") + assert.deepEqual(h.present.waivers, [waiver]) +}) diff --git a/packages/webapp/lib/planner/state.ts b/packages/webapp/lib/planner/state.ts index 4474779..948c096 100644 --- a/packages/webapp/lib/planner/state.ts +++ b/packages/webapp/lib/planner/state.ts @@ -6,6 +6,7 @@ import { slotCapacity, type PeriodKind, type PlannerCreditEntry, + type PlannerPrerequisiteWaiver, type PlannerSlot, type PlannerState, type PlannerYear, @@ -65,6 +66,33 @@ function yearFor( return defaultYear(nth, startPeriodOf(state), only) } +function sameWaiver( + a: PlannerPrerequisiteWaiver, + b: PlannerPrerequisiteWaiver +): boolean { + return a.unitCode === b.unitCode && a.prerequisiteCode === b.prerequisiteCode +} + +function withoutWaivers(state: PlannerState): PlannerState { + const next = { ...state } + delete next.waivers + + return next +} + +/** The plan with its year moved to `fallback` when the database lacks it. */ +export function withKnownYear( + plan: PlannerState, + availableYears: readonly string[], + fallback: string +): PlannerState { + // Always a new object: hydrating it has to re-run the persist effect. + if (plan.courseYear && availableYears.includes(plan.courseYear)) + return { ...plan } + + return withoutWaivers({ ...plan, courseYear: fallback }) +} + export type PlannerAction = | { type: "set_course"; code: string | null } | { type: "set_year"; year: string } @@ -83,6 +111,8 @@ export type PlannerAction = | { type: "set_campus"; campus: string | null } | { type: "add_credit"; entry: PlannerCreditEntry } | { type: "remove_credit"; index: number } + | { type: "add_waiver"; waiver: PlannerPrerequisiteWaiver } + | { type: "remove_waiver"; waiver: PlannerPrerequisiteWaiver } | { type: "add_unit"; yearIndex: number; slotIndex: number; code: string } | { type: "remove_unit"; yearIndex: number; slotIndex: number; code: string } | { @@ -200,22 +230,24 @@ export function plannerReducer( switch (action.type) { case "set_course": if (state.courseCode === action.code) return state - return { + + return withoutWaivers({ ...state, courseCode: action.code, // Clear AoS selections when course changes — the prior picks // are almost certainly invalid for the new course. selectedAos: {}, - } + }) case "set_year": if (state.courseYear === action.year) return state - return { + + return withoutWaivers({ ...state, courseYear: action.year, selectedAos: {}, years: state.years.map(withoutUnits), - } + }) case "set_aos": { const next = { ...state.selectedAos } @@ -260,6 +292,19 @@ export function plannerReducer( return { ...state, credit: next } } + case "add_waiver": + if (state.waivers?.some((w) => sameWaiver(w, action.waiver))) return state + + return { ...state, waivers: [...(state.waivers ?? []), action.waiver] } + + case "remove_waiver": { + const next = state.waivers?.filter((w) => !sameWaiver(w, action.waiver)) + + if (next?.length === state.waivers?.length) return state + + return { ...state, waivers: next } + } + case "add_unit": return withSlot(state, action.yearIndex, action.slotIndex, (slot) => addCode(slot, action.code) @@ -605,12 +650,13 @@ export function plannerReducer( case "reset": { const yearCount = action.yearCount ?? state.years.length - return { + + return withoutWaivers({ ...state, years: Array.from({ length: yearCount }, (_, i) => yearFor(state, i + 1) ), - } + }) } case "hydrate": diff --git a/packages/webapp/lib/planner/timeline.test.ts b/packages/webapp/lib/planner/timeline.test.ts index 11217ff..a216967 100644 --- a/packages/webapp/lib/planner/timeline.test.ts +++ b/packages/webapp/lib/planner/timeline.test.ts @@ -288,3 +288,12 @@ test("planHandbookYears: both calendar years of a mid-year study year", () => { const jan = { ...mid, startPeriod: "S1" as const } assert.deepEqual([...planHandbookYears(jan, years)].sort(), ["2026"]) }) + +test("a plan with a waiver isn't fresh", () => { + const st = plannerReducer(defaultState("2026", "C2000"), { + type: "add_waiver", + waiver: { unitCode: "FIT3235", prerequisiteCode: "MAT1830" }, + }) + + assert.equal(isFreshPlan(st), false) +}) diff --git a/packages/webapp/lib/planner/timeline.ts b/packages/webapp/lib/planner/timeline.ts index d47873c..5ae8dca 100644 --- a/packages/webapp/lib/planner/timeline.ts +++ b/packages/webapp/lib/planner/timeline.ts @@ -30,7 +30,7 @@ export function yearsNeeded(creditPoints: number | null | undefined): number { } /** - * A plan nobody has started on: no units, credit or leave/exchange + * A plan nobody has started on: no units, credit, waivers or leave/exchange * semesters, and setup not finished or skipped. Picked areas of study * don't count — the course panel offers them while the setup card is * still open, and picking one must not skip Create my map. @@ -38,6 +38,8 @@ export function yearsNeeded(creditPoints: number | null | undefined): number { export function isFreshPlan(state: PlannerState): boolean { if (state.setupDone) return false if (state.credit && state.credit.length > 0) return false + + if (state.waivers && state.waivers.length > 0) return false return state.years.every((y) => y.slots.every((s) => s.unitCodes.length === 0 && !s.status) ) diff --git a/packages/webapp/lib/planner/types.ts b/packages/webapp/lib/planner/types.ts index 0834ec8..715b0c2 100644 --- a/packages/webapp/lib/planner/types.ts +++ b/packages/webapp/lib/planner/types.ts @@ -16,6 +16,7 @@ import type { RequirementGroup } from "@monmap/db/curriculum" export type { PeriodKind, PlannerCreditEntry, + PlannerPrerequisiteWaiver, PlannerSlot, PlannerState, PlannerYear, @@ -283,6 +284,8 @@ export interface SlotUnitValidation { errors: ValidationIssue[] /** Soft — render unit amber/yellow. */ warnings: ValidationIssue[] + /** Its prerequisites are only met because of a waiver. */ + reliesOnWaiver?: boolean } export type ValidationIssueKind = diff --git a/packages/webapp/lib/planner/validation.test.ts b/packages/webapp/lib/planner/validation.test.ts index 1089b9a..f39b271 100644 --- a/packages/webapp/lib/planner/validation.test.ts +++ b/packages/webapp/lib/planner/validation.test.ts @@ -873,3 +873,175 @@ test("validatePlan: a retired unit missing from the handbook says so", () => { "FIT1008 is no longer offered from 2027. Faculty of IT advice: Replace with FIT2115 (S1 and S2)." ) }) + +function waiverCheck( + years: Parameters[0], + requisites: [string, RequisiteBlock[]][], + waivers: { unitCode: string; prerequisiteCode: string }[] +) { + const codes = years.flatMap((y) => Object.values(y).flat()) as string[] + + const offerings = Object.fromEntries( + codes.map((c) => [c, ["S1", "S2"] as const]) + ) + + return validatePlan( + planState(years, { waivers }), + unitMap(codes), + offeringMap(offerings), + new Map(requisites), + NO_TRANSITIONS + ) +} + +const waive = (unitCode: string, prerequisiteCode: string) => ({ + unitCode, + prerequisiteCode, +}) + +test("a waiver meets one code of an AND and leaves the rest required", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"] }], + [["AAA3000", [prereq("AAA2000", "AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + const [issue] = out.get(keyFor(0, 0, "AAA3000"))!.errors + + assert.equal(issue?.kind, "prereq_unmet") + assert.match(issue?.message ?? "", /need AAA2000 before AAA3000/) + assert.deepEqual(issue?.relatedCodes, ["AAA2000"]) +}) + +test("waiving one option meets an OR, and the unit relies on the waiver", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"] }], + [["AAA3000", [prereqAny("AAA1000", "AAA1001")]]], + [waive("AAA3000", "AAA1001")] + ) + + const v = out.get(keyFor(0, 0, "AAA3000"))! + + assert.deepEqual(v.errors, []) + assert.equal(v.reliesOnWaiver, true) +}) + +test("a waiver isn't relied on once an equivalent unit meets the rule", () => { + const years = [{ S1: ["AAA1001"], S2: ["AAA3000"] }] + + const out = validatePlan( + planState(years, { waivers: [waive("AAA3000", "AAA1000")] }), + new Map([ + ["AAA1001", unit("AAA1001", { equivalents: ["AAA1000"] })], + ["AAA3000", unit("AAA3000")], + ]), + offeringMap({ AAA1001: ["S1"], AAA3000: ["S2"] }), + new Map([["AAA3000", [prereq("AAA1000")]]]), + NO_TRANSITIONS + ) + + assert.equal(out.get(keyFor(0, 1, "AAA3000"))!.reliesOnWaiver, false) +}) + +test("one block can rely on a waiver while another stays unmet", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"] }], + [["AAA3000", [prereq("AAA1000"), prereq("AAA1001")]]], + [waive("AAA3000", "AAA1000")] + ) + + const v = out.get(keyFor(0, 0, "AAA3000"))! + + assert.deepEqual(v.errors[0]?.relatedCodes, ["AAA1001"]) + assert.equal(v.reliesOnWaiver, true) +}) + +test("a waiver nothing needed isn't reported as relied on", () => { + const out = waiverCheck( + [{ S1: ["AAA1000"], S2: ["AAA3000"] }], + [["AAA3000", [prereq("AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + assert.equal(out.get(keyFor(0, 1, "AAA3000"))!.reliesOnWaiver, false) +}) + +test("a waiver applies only to the unit it names", () => { + const out = waiverCheck( + [{ S1: ["AAA3000", "AAA3001"] }], + [ + ["AAA3000", [prereq("AAA1000")]], + ["AAA3001", [prereq("AAA1000")]], + ], + [waive("AAA3000", "AAA1000")] + ) + + assert.deepEqual(out.get(keyFor(0, 0, "AAA3000"))!.errors, []) + assert.equal( + out.get(keyFor(0, 0, "AAA3001"))!.errors[0]?.kind, + "prereq_unmet" + ) +}) + +test("a waived unit doesn't count as completed for later units", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"], S2: ["AAA3001"] }], + [ + ["AAA3000", [prereq("AAA1000")]], + ["AAA3001", [prereq("AAA1000")]], + ], + [waive("AAA3000", "AAA1000")] + ) + + assert.equal( + out.get(keyFor(0, 1, "AAA3001"))!.errors[0]?.kind, + "prereq_unmet" + ) +}) + +test("a waiver doesn't meet a corequisite naming the same code", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"] }], + [["AAA3000", [prereq("AAA1000"), coreq("AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + const { errors } = out.get(keyFor(0, 0, "AAA3000"))! + + assert.deepEqual( + errors.map((e) => e.kind), + ["coreq_unmet"] + ) +}) + +test("a waived code isn't taken, so it neither trips nor clears a prohibition", () => { + const notPlanned = waiverCheck( + [{ S1: ["AAA3000"] }], + [["AAA3000", [prereq("AAA1000"), prohibition("AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + assert.deepEqual(notPlanned.get(keyFor(0, 0, "AAA3000"))!.errors, []) + + const planned = waiverCheck( + [{ S1: ["AAA1000"], S2: ["AAA3000"] }], + [["AAA3000", [prohibition("AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + assert.equal( + planned.get(keyFor(0, 1, "AAA3000"))!.errors[0]?.kind, + "prohibition_conflict" + ) +}) + +test("a waiver covers a retake of the unit in a later slot", () => { + const out = waiverCheck( + [{ S1: ["AAA3000"], S2: ["AAA3000"] }], + [["AAA3000", [prereq("AAA1000")]]], + [waive("AAA3000", "AAA1000")] + ) + + assert.deepEqual(out.get(keyFor(0, 0, "AAA3000"))!.errors, []) + assert.deepEqual(out.get(keyFor(0, 1, "AAA3000"))!.errors, []) +}) diff --git a/packages/webapp/lib/planner/validation.ts b/packages/webapp/lib/planner/validation.ts index 2e80787..5eb1a35 100644 --- a/packages/webapp/lib/planner/validation.ts +++ b/packages/webapp/lib/planner/validation.ts @@ -29,6 +29,7 @@ import type { ValidationIssue, ValidationIssueKind, } from "./types.ts" +import { waiversByUnit, withWaived } from "./waivers.ts" const MAX_CREDIT_LOAD_PER_SLOT = 24 @@ -40,7 +41,8 @@ const MAX_CREDIT_LOAD_PER_SLOT = 24 * completed = advanced standing + every unit in earlier years + * earlier periods of the same year * concurrent = every other unit in the same slot - * Prereqs are satisfied iff their rule is met by `completed`. + * Prereqs are satisfied iff their rule is met by `completed`, plus any + * prerequisites waived for this unit. * Coreqs are satisfied iff their rule is met by `completed ∪ concurrent`. * Prohibitions trip iff any referenced unit appears in the student's * entire plan (past, present, future) — enrolling in any of them @@ -80,6 +82,8 @@ export interface ValidationInput { */ slotYear: number transitions?: Transitions + /** Prerequisites waived for this unit (see waivers.ts). */ + waived?: ReadonlySet } export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation { @@ -100,14 +104,22 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation { const prereqs = input.requisites.filter( (r) => r.requisiteType === "prerequisite" ) + const prereqsMetBy = withWaived(input.completedBefore, input.waived) + let reliesOnWaiver = false + for (const block of prereqs) { - const res = evaluateRequisiteTree(block.rule, input.completedBefore) + const res = evaluateRequisiteTree(block.rule, prereqsMetBy) if (!res.satisfied) { errors.push({ kind: "prereq_unmet", message: `Prerequisite not satisfied: need ${res.missingText} before ${input.unit.code}.`, relatedCodes: res.missingCodes, }) + } else if ( + input.waived && + !evaluateRequisiteTree(block.rule, input.completedBefore).satisfied + ) { + reliesOnWaiver = true } } @@ -160,7 +172,7 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation { }) } - return { code: input.unit.code, errors, warnings } + return { code: input.unit.code, errors, warnings, reliesOnWaiver } } export function isOfferedInPeriod( @@ -451,6 +463,7 @@ export function validatePlan( // Grown slot by slot below rather than calling completedBefore per // slot, which would walk the plan again for every slot. const completed = completedAtStart(state, unitsByCode, transitions) + const waivers = waiversByUnit(state) for (let y = 0; y < state.years.length; y++) { const year = state.years[y] @@ -528,6 +541,7 @@ export function validatePlan( // downgrades to amber. See hydration.ts / slotHandbookYear. slotYear, transitions, + waived: waivers.get(code), }) out.set(keyFor(y, s, code), v) } diff --git a/packages/webapp/lib/planner/waivers.test.ts b/packages/webapp/lib/planner/waivers.test.ts new file mode 100644 index 0000000..7696e9a --- /dev/null +++ b/packages/webapp/lib/planner/waivers.test.ts @@ -0,0 +1,118 @@ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { coreq, planState, prereq } from "./test-fixtures.ts" +import { + placedWaivers, + waivableCodes, + waiverAccess, + waiverNote, + waiversByUnit, + withWaived, +} from "./waivers.ts" + +test("waiversByUnit groups each unit's waived prerequisites", () => { + const byUnit = waiversByUnit({ + waivers: [ + { unitCode: "AAA2000", prerequisiteCode: "AAA1000" }, + { unitCode: "AAA2000", prerequisiteCode: "AAA1001" }, + { unitCode: "AAA3000", prerequisiteCode: "AAA2000" }, + ], + }) + + assert.deepEqual([...byUnit.get("AAA2000")!], ["AAA1000", "AAA1001"]) + assert.deepEqual([...byUnit.get("AAA3000")!], ["AAA2000"]) +}) + +test("waiversByUnit reads a plan saved before waivers existed", () => { + assert.equal(waiversByUnit({}).size, 0) +}) + +test("withWaived adds to the completed codes without changing them", () => { + const completed = new Set(["AAA1000"]) + + assert.deepEqual( + [...withWaived(completed, new Set(["AAA1001"]))], + ["AAA1000", "AAA1001"] + ) + assert.deepEqual([...completed], ["AAA1000"]) + assert.equal(withWaived(completed, undefined), completed) +}) + +test("waiverAccess edits only where validation reads the tree on screen", () => { + const view = { + isPlaced: true, + usingCurrentYear: true, + selectedYear: "2026", + defaultYear: "2026", + } + + assert.equal(waiverAccess(view), "edit") + // Opened from search or an unplaced chip: no slot to validate. + assert.equal(waiverAccess({ ...view, isPlaced: false }), "read") + // A full-year unit's other half, loaded from another handbook year. + assert.equal( + waiverAccess({ + ...view, + usingCurrentYear: false, + selectedYear: "2027", + defaultYear: "2027", + }), + "read" + ) + // Browsing another year with the picker. + assert.equal( + waiverAccess({ ...view, usingCurrentYear: false, selectedYear: "2025" }), + "none" + ) +}) + +test("waivableCodes offers only unmet prerequisite codes", () => { + assert.deepEqual([...waivableCodes(undefined)], []) + assert.deepEqual( + [ + ...waivableCodes({ + errors: [ + { + kind: "prereq_unmet", + message: "", + // A leaf with no code stays empty, and can't be waived. + relatedCodes: ["AAA1000", ""], + }, + { kind: "coreq_unmet", message: "", relatedCodes: ["AAA1001"] }, + ], + }), + ], + ["AAA1000"] + ) +}) + +test("placedWaivers leaves out a unit no longer on the plan", () => { + const placed = { unitCode: "AAA3000", prerequisiteCode: "AAA1000" } + const removed = { unitCode: "AAA3001", prerequisiteCode: "AAA1000" } + + assert.deepEqual( + placedWaivers( + planState([{ S1: ["AAA3000"] }], { waivers: [placed, removed] }) + ), + [placed] + ) +}) + +test("waiverNote says when a waiver isn't doing anything", () => { + const waiver = { unitCode: "AAA3000", prerequisiteCode: "AAA1000" } + const planned = new Set(["AAA3000"]) + + const note = (blocks?: ReturnType[]) => + waiverNote(waiver, planned, new Map(blocks ? [["AAA3000", blocks]] : [])) + + assert.equal(note([prereq("AAA1000")]), null) + // Not loaded yet: nothing to say. + assert.equal(note(), null) + assert.match(note([prereq("AAA1001")]) ?? "", /No longer/) + assert.match(note([coreq("AAA1000")]) ?? "", /No longer/) + assert.match( + waiverNote(waiver, new Set(), new Map()) ?? "", + /isn't on your plan/ + ) +}) diff --git a/packages/webapp/lib/planner/waivers.ts b/packages/webapp/lib/planner/waivers.ts new file mode 100644 index 0000000..19635ee --- /dev/null +++ b/packages/webapp/lib/planner/waivers.ts @@ -0,0 +1,94 @@ +import { placedUnitCodes } from "./progress.ts" +import { referencedCodes } from "./requisites.ts" +import type { + PlannerPrerequisiteWaiver, + PlannerState, + RequisiteBlock, + SlotUnitValidation, +} from "./types.ts" + +/** Waived prerequisite codes, by the unit they're waived for. */ +export function waiversByUnit( + state: Pick +): Map> { + const out = new Map>() + + for (const { unitCode, prerequisiteCode } of state.waivers ?? []) { + const codes = out.get(unitCode) ?? new Set() + codes.add(prerequisiteCode) + out.set(unitCode, codes) + } + + return out +} + +/** What a unit's prerequisites read as met: never its other requisites. */ +export function withWaived( + completed: ReadonlySet, + waived: ReadonlySet | undefined +): ReadonlySet { + return waived ? new Set([...completed, ...waived]) : completed +} + +/** + * What a unit's details can do with waivers. Editing needs a slot whose + * validation reads the tree on screen. A slot can read another year (a + * full-year unit's other half, or a unit gone from that year's handbook), + * so there it only shows them, and a browsed year's tree isn't the plan's + * at all. + */ +export function waiverAccess(view: { + isPlaced: boolean + usingCurrentYear: boolean + selectedYear: string + defaultYear: string +}): "edit" | "read" | "none" { + if (view.isPlaced && view.usingCurrentYear) return "edit" + + return view.usingCurrentYear || view.selectedYear === view.defaultYear + ? "read" + : "none" +} + +/** The codes a placed unit's unmet prerequisites offer to waive. */ +export function waivableCodes( + validation: Pick | undefined +): Set { + return new Set( + validation?.errors + .filter((e) => e.kind === "prereq_unmet") + .flatMap((e) => e.relatedCodes ?? []) + // A leaf with no code (see slimLeaf) has nothing to name. + .filter(Boolean) + ) +} + +/** Waivers for units still placed, the ones worth printing or exporting. */ +export function placedWaivers( + state: Pick +): PlannerPrerequisiteWaiver[] { + const placed = placedUnitCodes(state) + + return (state.waivers ?? []).filter((w) => placed.has(w.unitCode)) +} + +/** Why a waiver isn't doing anything right now, or null when it might be. */ +export function waiverNote( + waiver: PlannerPrerequisiteWaiver, + placed: ReadonlySet, + requisites: ReadonlyMap +): string | null { + if (!placed.has(waiver.unitCode)) return "This unit isn't on your plan." + + const blocks = requisites.get(waiver.unitCode) + + if (!blocks) return null + + const named = blocks.some( + (b) => + b.requisiteType === "prerequisite" && + referencedCodes(b.rule).has(waiver.prerequisiteCode) + ) + + return named ? null : "No longer one of this unit's prerequisites." +}