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 (
0 ? `${label} (${count})` : label}
- className="text-muted-foreground"
+ className={cn("text-muted-foreground", className)}
>
{icon}
{label}
diff --git a/packages/webapp/components/planner/plan-basics.tsx b/packages/webapp/components/planner/plan-basics.tsx
index 1381e2b..1bd0e8f 100644
--- a/packages/webapp/components/planner/plan-basics.tsx
+++ b/packages/webapp/components/planner/plan-basics.tsx
@@ -41,7 +41,7 @@ import { usePlanner } from "./planner-context"
* When the student starts: intake (Semester 1 or 2) and starting year.
* One set of controls, used by the first-run setup card and by the
* "Starts …" control beside the plan title. Changing the year on a plan
- * with units asks first, because each year has its own handbook.
+ * with units or waivers asks first, because each year has its own handbook.
*/
function StartFields() {
const { state, dispatch, availableYears, switchYear } = usePlanner()
@@ -49,10 +49,12 @@ function StartFields() {
const hasUnits = state.years.some((y) =>
y.slots.some((s) => s.unitCodes.length > 0)
)
+ const hasWaivers = (state.waivers?.length ?? 0) > 0
function chooseYear(y: string) {
if (y === state.courseYear) return
- if (hasUnits) setPendingYear(y)
+
+ if (hasUnits || hasWaivers) setPendingYear(y)
else void switchYear(y)
}
@@ -68,8 +70,13 @@ function StartFields() {
Start in {pendingYear}?
- Each starting year has its own handbook, so changing it clears the
- units in your plan. You can undo this.
+ Each starting year has its own handbook, so changing it clears the{" "}
+ {hasUnits && hasWaivers
+ ? "units and waivers"
+ : hasWaivers
+ ? "waivers"
+ : "units"}{" "}
+ in your plan. You can undo this.
diff --git a/packages/webapp/components/planner/planner-context.tsx b/packages/webapp/components/planner/planner-context.tsx
index 4a827ad..f7df051 100644
--- a/packages/webapp/components/planner/planner-context.tsx
+++ b/packages/webapp/components/planner/planner-context.tsx
@@ -45,6 +45,7 @@ import {
defaultState,
historyReducer,
initialHistory,
+ withKnownYear,
type PlannerAction,
} from "@/lib/planner/state"
import type {
@@ -255,19 +256,6 @@ export function UnitDataOverlay({
return {children}
}
-/** The plan with its year moved to `fallback` when the database lacks it. */
-function withKnownYear(
- plan: PlannerState,
- availableYears: readonly string[],
- fallback: string
-): PlannerState {
- const year =
- plan.courseYear && availableYears.includes(plan.courseYear)
- ? plan.courseYear
- : fallback
- return { ...plan, courseYear: year }
-}
-
export function PlannerProvider({
children,
initialYear,
@@ -763,6 +751,12 @@ export function PlannerProvider({
try {
// Same year, so the units already cached stay valid: merge.
const c = await loadYearData(year, code, false)
+
+ const { courseCode, waivers } = lastSnapshotRef.current
+
+ const clearsWaivers =
+ courseCode !== code && (waivers?.length ?? 0) > 0
+
// One undo step reverts the whole switch.
dispatch({
type: "batch",
@@ -778,6 +772,11 @@ export function PlannerProvider({
: []),
],
})
+
+ if (clearsWaivers)
+ toast("Waivers cleared", {
+ action: { label: "Undo", onClick: undo },
+ })
} catch (err) {
toast.error("Couldn't load course", {
description: err instanceof Error ? err.message : "Unknown error",
@@ -785,7 +784,7 @@ export function PlannerProvider({
}
})
},
- [state.courseYear, loadYearData]
+ [state.courseYear, loadYearData, undo]
)
// A "Plan this course" link landed on a saved plan for another
diff --git a/packages/webapp/components/planner/print-sheet.tsx b/packages/webapp/components/planner/print-sheet.tsx
index 7a4ad39..ae23a6c 100644
--- a/packages/webapp/components/planner/print-sheet.tsx
+++ b/packages/webapp/components/planner/print-sheet.tsx
@@ -31,6 +31,7 @@ import {
type PlannerState,
type PlannerUnit,
} from "@/lib/planner/types"
+import { placedWaivers } from "@/lib/planner/waivers"
import { cn } from "@/lib/utils"
import { usePlanner, usePlannerSync } from "./planner-context"
@@ -145,6 +146,7 @@ export function PrintSheetView({
)
const credit = state.credit ?? []
+ const waivers = placedWaivers(state)
// A code credited twice, or credited and placed, counts once.
const creditTotal = creditPointsFromCredit(state, placedUnitCodes(state))
@@ -260,6 +262,15 @@ export function PrintSheetView({
{summary.duplicateUnitCodes.join(", ")}.
) : null}
+ {waivers.length > 0 ? (
+
+ Prerequisites waived:{" "}
+ {waivers
+ .map((w) => `${w.prerequisiteCode} for ${w.unitCode}`)
+ .join(", ")}
+ .
+
+ ) : null}
Made with MonMap (monmap.monashcoding.com) by the Monash Association
of Coding. This is not an official Monash document: check your plan
diff --git a/packages/webapp/components/planner/requisite-tree-view.tsx b/packages/webapp/components/planner/requisite-tree-view.tsx
index 6c7e2d7..4067657 100644
--- a/packages/webapp/components/planner/requisite-tree-view.tsx
+++ b/packages/webapp/components/planner/requisite-tree-view.tsx
@@ -2,6 +2,7 @@
import { CheckIcon, MinusIcon } from "lucide-react"
+import { Button } from "@/components/ui/button"
import { normalizeConnector } from "@/lib/planner/requisites"
import type {
RequisiteContainer,
@@ -10,6 +11,19 @@ import type {
} from "@/lib/planner/types"
import { cn } from "@/lib/utils"
+import { TOUCH_HIT_ROW } from "./touch-target"
+
+/** A prerequisite tree's waivers. */
+export interface TreeWaivers {
+ waived: ReadonlySet
+ /** Unmet codes offered a Waive button. */
+ waivable: ReadonlySet
+ /** False shows waivers without Waive or Undo. */
+ editable: boolean
+ onWaiveAction: (code: string) => void
+ onUnwaiveAction: (code: string) => void
+}
+
/**
* Render an AND/OR tree. Each level prefixes a connector label so
* students can read the rule as prose-ish:
@@ -25,11 +39,13 @@ export function RequisiteTreeView({
completed,
isProhibition = false,
units,
+ waivers,
}: {
rule: RequisiteRule | null | undefined
completed: ReadonlySet
isProhibition?: boolean
units?: ReadonlyMap
+ waivers?: TreeWaivers
}) {
if (!rule || rule.length === 0) {
return (
@@ -48,6 +64,7 @@ export function RequisiteTreeView({
completed={completed}
isProhibition={isProhibition}
units={units}
+ waivers={waivers}
/>
))}
@@ -60,12 +77,14 @@ function ContainerNode({
completed,
isProhibition,
units,
+ waivers,
}: {
container: RequisiteContainer
depth: number
completed: ReadonlySet
isProhibition: boolean
units?: ReadonlyMap
+ waivers?: TreeWaivers
}) {
const connector = normalizeConnector(container.parent_connector?.value)
const children = [
@@ -77,6 +96,7 @@ function ContainerNode({
completed={completed}
isProhibition={isProhibition}
units={units}
+ waivers={waivers}
/>
)),
...(container.relationships ?? []).map((l, i) => (
@@ -86,6 +106,7 @@ function ContainerNode({
completed={completed}
isProhibition={isProhibition}
units={units}
+ waivers={waivers}
/>
)),
]
@@ -113,16 +134,22 @@ function LeafNode({
completed,
isProhibition,
units,
+ waivers,
}: {
leaf: RequisiteLeaf
completed: ReadonlySet
isProhibition: boolean
units?: ReadonlyMap
+ waivers?: TreeWaivers
}) {
- const taken = completed.has(leaf.academic_item_code)
+ const code = leaf.academic_item_code
+ const taken = completed.has(code)
+ // Actually taking the unit outranks a waiver for it.
+ const waived = !taken && !!waivers?.waived.has(code)
+ const met = taken || waived
// Prohibitions invert: "taken" is a problem, not a check.
- const good = isProhibition ? !taken : taken
+ const good = isProhibition ? !taken : met
const name =
units?.get(leaf.academic_item_code)?.title ?? leaf.academic_item_name
@@ -133,7 +160,7 @@ function LeafNode({
"flex items-baseline gap-2 rounded-control px-2 py-1",
good ? "text-foreground" : "text-muted-foreground",
isProhibition && taken && "bg-destructive/10 text-destructive",
- !isProhibition && taken && "bg-success-soft"
+ !isProhibition && met && "bg-success-soft"
)}
>
{name}
) : null}
+ {waived ? (
+
+ Waived
+
+ ) : null}
+ {/* One button that swaps its label, so keyboard focus stays on it. */}
+ {waivers?.editable && (waived || (!met && waivers.waivable.has(code))) ? (
+
+ waived ? waivers.onUnwaiveAction(code) : waivers.onWaiveAction(code)
+ }
+ >
+ {waived ? "Undo" : "Waive"}
+
+ ) : 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}
+
+ dispatch({ type: "remove_waiver", waiver })}
+ >
+
+
+
+ )
+ })}
+
+ ) : (
+
+ 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."
+}