From 68759733db11af5379d8c75a1b85352790fedfbb Mon Sep 17 00:00:00 2001
From: Tansimran
Date: Sat, 31 Oct 2026 22:01:30 +1100
Subject: [PATCH 1/3] Trim stray whitespace from requisite codes
Some handbook codes carry spaces or tabs, which no completed unit could
ever match. That stopped 16 prerequisites in the 2027 handbook from being
met (ADS2002 needs "ADS2001 ") and hid prohibitions on 6 units, so
FIT5003 with ITO5003 now conflicts.
---
packages/webapp/lib/db/requisite-rule.ts | 6 ++--
.../webapp/lib/planner/payload-shape.test.ts | 28 +++++++++++++++++++
2 files changed, 32 insertions(+), 2 deletions(-)
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
+ )
+})
From 183d99a42d7926be61728aed31de198a47f1823a Mon Sep 17 00:00:00 2001
From: Tansimran
Date: Sat, 31 Oct 2026 22:49:00 +1100
Subject: [PATCH 2/3] Let a plan waive a prerequisite for one unit
A waiver counts as met only for that unit's prerequisites. It earns no
credit, and corequisites, prohibitions, course progress and other units
ignore it. Search ranking treats the unit as ready to take and stops
suggesting the waived code for it. Waivers are saved as part of the plan,
so each course map has its own. Changing the course or start year or
resetting the map clears them, as does loading a plan whose start year
is no longer in the database.
The CSV lists waivers for units on the plan.
---
packages/db/src/planner-state.ts | 14 ++
.../components/planner/planner-context.tsx | 14 +-
packages/webapp/lib/db/input.test.ts | 24 +++
packages/webapp/lib/db/input.ts | 11 ++
.../lib/planner/personalize-search.test.ts | 33 ++++
.../webapp/lib/planner/personalize-search.ts | 21 ++-
.../webapp/lib/planner/plan-export.test.ts | 37 ++++
packages/webapp/lib/planner/plan-export.ts | 5 +
packages/webapp/lib/planner/state.test.ts | 138 ++++++++++++++
packages/webapp/lib/planner/state.ts | 58 +++++-
packages/webapp/lib/planner/timeline.test.ts | 9 +
packages/webapp/lib/planner/timeline.ts | 4 +-
packages/webapp/lib/planner/types.ts | 3 +
.../webapp/lib/planner/validation.test.ts | 172 ++++++++++++++++++
packages/webapp/lib/planner/validation.ts | 20 +-
packages/webapp/lib/planner/waivers.test.ts | 45 +++++
packages/webapp/lib/planner/waivers.ts | 34 ++++
17 files changed, 614 insertions(+), 28 deletions(-)
create mode 100644 packages/webapp/lib/planner/waivers.test.ts
create mode 100644 packages/webapp/lib/planner/waivers.ts
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/planner-context.tsx b/packages/webapp/components/planner/planner-context.tsx
index 4a827ad..f19b6bc 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,
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/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..1921b1a
--- /dev/null
+++ b/packages/webapp/lib/planner/waivers.test.ts
@@ -0,0 +1,45 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+
+import { planState } from "./test-fixtures.ts"
+import { placedWaivers, 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("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]
+ )
+})
diff --git a/packages/webapp/lib/planner/waivers.ts b/packages/webapp/lib/planner/waivers.ts
new file mode 100644
index 0000000..d7caf34
--- /dev/null
+++ b/packages/webapp/lib/planner/waivers.ts
@@ -0,0 +1,34 @@
+import { placedUnitCodes } from "./progress.ts"
+import type { PlannerPrerequisiteWaiver, PlannerState } 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
+}
+
+/** 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))
+}
From 50dfa77ebd1d857e122c63340c616d283d663a6b Mon Sep 17 00:00:00 2001
From: Tansimran
Date: Sat, 31 Oct 2026 22:49:10 +1100
Subject: [PATCH 3/3] Add Waive buttons and a Waivers dialog to the planner
An unmet prerequisite in a placed unit's details gets a Waive button,
placed in the requirement tree so its "all of" and "any of" stay
readable. A waived one shows as met, with Undo. Where the slot reads
another handbook year, the error says which year to switch to. A Waivers
dialog lists the plan's waivers for removal and says when one no longer
applies.
A card relying on a waiver gets a small marker, and the Validate tooltip
and toast say so. Changing the course offers Undo when it clears waivers,
the start year and reset prompts warn about them, and the printed plan
lists waivers for units on the plan.
---
.../components/planner/left-sidebar.tsx | 42 ++++++++--
.../webapp/components/planner/plan-basics.tsx | 15 +++-
.../components/planner/planner-context.tsx | 13 ++-
.../webapp/components/planner/print-sheet.tsx | 11 +++
.../planner/requisite-tree-view.tsx | 52 +++++++++++-
.../webapp/components/planner/unit-card.tsx | 11 ++-
.../planner/unit-detail-popover.tsx | 49 ++++++++++-
.../components/planner/waiver-dialog.tsx | 82 +++++++++++++++++++
.../unit-detail/requisite-block.tsx | 11 ++-
packages/webapp/lib/planner/waivers.test.ts | 77 ++++++++++++++++-
packages/webapp/lib/planner/waivers.ts | 62 +++++++++++++-
11 files changed, 399 insertions(+), 26 deletions(-)
create mode 100644 packages/webapp/components/planner/waiver-dialog.tsx
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}
+ {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))) ? (
+
+ ) : 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 (
+
+ )
+}
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/planner/waivers.test.ts b/packages/webapp/lib/planner/waivers.test.ts
index 1921b1a..7696e9a 100644
--- a/packages/webapp/lib/planner/waivers.test.ts
+++ b/packages/webapp/lib/planner/waivers.test.ts
@@ -1,8 +1,15 @@
import { test } from "node:test"
import assert from "node:assert/strict"
-import { planState } from "./test-fixtures.ts"
-import { placedWaivers, waiversByUnit, withWaived } from "./waivers.ts"
+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({
@@ -32,6 +39,54 @@ test("withWaived adds to the completed codes without changing them", () => {
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" }
@@ -43,3 +98,21 @@ test("placedWaivers leaves out a unit no longer on the plan", () => {
[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
index d7caf34..19635ee 100644
--- a/packages/webapp/lib/planner/waivers.ts
+++ b/packages/webapp/lib/planner/waivers.ts
@@ -1,5 +1,11 @@
import { placedUnitCodes } from "./progress.ts"
-import type { PlannerPrerequisiteWaiver, PlannerState } from "./types.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(
@@ -24,6 +30,39 @@ export function withWaived(
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
@@ -32,3 +71,24 @@ export function placedWaivers(
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."
+}