diff --git a/docs/handbook-internals.md b/docs/handbook-internals.md
index f72ca3a..c5fbc06 100644
--- a/docs/handbook-internals.md
+++ b/docs/handbook-internals.md
@@ -269,6 +269,17 @@ since the October 2026 refresh: its one earlier link,
SFTWRENG02 → `/2025/units/FIT4190`, resolves now that FIT4190 has a
2026 page.
+## Course transitions live outside the handbook
+
+A handbook can't say when a unit stops: 2027 still runs FIT2014, whose
+final offering is S2 2027 per the Faculty of IT. Renamed replacements
+(FIT1008 → FIT2115) also fail the equivalence test above.
+`lib/planner/transitions-it-2027.ts` holds these facts, copied by hand
+from the faculty page, and `transitions.ts` applies them. The slot
+year's own handbook still wins, so `pnpm --filter webapp
+verify:transitions` lists where the two disagree; delete a source once
+the handbooks catch up.
+
## Tree structures inside JSONB
When a UI reaches into a raw curriculum tree (courses, AoS), the
diff --git a/packages/webapp/components/planner/requirements-panel.tsx b/packages/webapp/components/planner/requirements-panel.tsx
index b05a91a..eec4da1 100644
--- a/packages/webapp/components/planner/requirements-panel.tsx
+++ b/packages/webapp/components/planner/requirements-panel.tsx
@@ -395,6 +395,7 @@ function GroupList({
required,
placed: placedCount,
satisfied,
+ viaReplacement,
} = groupProgress(g, plannedCodes, conflicts)
const isChoice = required < g.options.length
const sortedOptions = [...g.options].sort((a, b) => a.localeCompare(b))
@@ -427,18 +428,21 @@ function GroupList({
{sortedOptions.map((code) => {
- const placed = plannedCodes.has(code)
- const placement = placements.get(code)
+ const via = viaReplacement.get(code)
+ const placed = plannedCodes.has(code) || via !== undefined
+ // A replaced option opens the unit placed in its stead.
+ const shown = via?.[0] ?? code
+ const placement = placements.get(shown)
return (
)}
{code}
+ {via ? (
+
+ via {via.join(" + ")}
+
+ ) : null}
diff --git a/packages/webapp/components/planner/unit-search-dialog.tsx b/packages/webapp/components/planner/unit-search-dialog.tsx
index 257b01b..090689a 100644
--- a/packages/webapp/components/planner/unit-search-dialog.tsx
+++ b/packages/webapp/components/planner/unit-search-dialog.tsx
@@ -53,7 +53,8 @@ import type {
PlannerUnit,
} from "@/lib/planner/types"
import type { UnitBundle } from "@/lib/planner/unit-cache"
-import { isOfferedInPeriod } from "@/lib/planner/validation"
+import { statedPeriods } from "@/lib/planner/transitions"
+import { offeringIssue, type OfferingIssue } from "@/lib/planner/validation"
import { cn } from "@/lib/utils"
import { UnitDataOverlay, usePlanner } from "./planner-context"
@@ -221,7 +222,7 @@ function UnitSearchView({
[state, course, requisites]
)
const slotCtx = useMemo(
- () => slotContextFor(state, yearIndex, slotIndex, units),
+ () => slotContextFor(state, yearIndex, slotIndex, { units }),
[state, yearIndex, slotIndex, units]
)
@@ -289,6 +290,21 @@ function UnitSearchView({
])
const items = useMemo(() => ranked.map((r) => r.unit), [ranked])
+
+ const offeringFor = (code: string): OfferingIssue | null => {
+ const unit = units.get(code)
+ const unitOfferings = offerings.get(code)
+
+ return slotKind && unit && unitOfferings
+ ? offeringIssue({
+ unit,
+ offerings: unitOfferings,
+ slotKind,
+ slotYear: slotCtx.slotYear,
+ })
+ : null
+ }
+
// One batched ratings request for the whole list, so the details pane
// finds each row's rating cached instead of asking per row.
useRatings(
@@ -461,6 +477,8 @@ function UnitSearchView({
0
@@ -534,6 +548,8 @@ function GroupHeading({ children }: { children: React.ReactNode }) {
function UnitRow({
unit,
offerings,
+ offeringsLoaded,
+ offering,
slotKind,
placed,
onPlanElsewhere,
@@ -543,6 +559,8 @@ function UnitRow({
}: {
unit: PlannerUnit
offerings: PlannerOffering[]
+ offeringsLoaded: boolean
+ offering: OfferingIssue | null
slotKind: PeriodKind | undefined
/** Already in THIS slot — the only hard non-action. */
placed: boolean
@@ -553,10 +571,12 @@ function UnitRow({
onClick: () => void
}) {
const periods = useMemo(() => {
- const s = new Set()
- for (const o of offerings) s.add(o.periodKind)
+ const s = new Set(offerings.map((o) => o.periodKind))
+
+ if (s.size === 0) for (const p of statedPeriods(unit.code)) s.add(p)
return PERIOD_KIND_ORDER.filter((p) => s.has(p))
- }, [offerings])
+ }, [offerings, unit.code])
+ const note = rowNote(offeringsLoaded, offerings, offering, periods)
return (
On plan
) : null}
- {periods.length === 0 ? (
+ {note ? (
- {offerings.length === 0 ? "Checking offerings…" : "Off-cycle"}
+ {note}
) : (
periods.map((p) => (
@@ -623,6 +643,28 @@ function UnitRow({
)
}
+/** Text in place of a row's period chips, or null to show the chips. */
+function rowNote(
+ loaded: boolean,
+ offerings: readonly PlannerOffering[],
+ offering: OfferingIssue | null,
+ periods: readonly PeriodKind[]
+): string | null {
+ if (!loaded) return "Checking offerings…"
+
+ const kind = offering?.issue.kind
+
+ // Outranks the chips, which may come from an older handbook.
+ if (offering && (kind === "no_longer_offered" || kind === "not_yet_offered"))
+ return offering.label
+
+ if (periods.length > 0) return null
+
+ if (offerings.length > 0) return "Off-cycle"
+
+ return offering?.label ?? "No offerings published"
+}
+
function PeriodChip({
children,
highlighted,
@@ -650,7 +692,7 @@ function PeriodChip({
function FocusedDetails({
code,
slotKind,
- fits,
+ offering,
hasOfferingData,
placed,
settled,
@@ -659,7 +701,7 @@ function FocusedDetails({
}: {
code: string
slotKind: PeriodKind | undefined
- fits: boolean
+ offering: OfferingIssue | null
hasOfferingData: boolean
placed: boolean
/** False while the pointer is passing over rows: fetch nothing yet. */
@@ -679,14 +721,14 @@ function FocusedDetails({
Checking fit for {PERIOD_KIND_LABEL[slotKind]}…
- ) : fits ? (
+ ) : !offering ? (
Fits {PERIOD_KIND_LABEL[slotKind]}
) : (
- Not offered in {PERIOD_KIND_LABEL[slotKind]}
+ {offering.label}
)}
diff --git a/packages/webapp/lib/db/queries.ts b/packages/webapp/lib/db/queries.ts
index 77fd449..f2eb86d 100644
--- a/packages/webapp/lib/db/queries.ts
+++ b/packages/webapp/lib/db/queries.ts
@@ -12,7 +12,8 @@ import {
userGrade,
userPlan,
} from "@monmap/db"
-import { and, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"
+import { and, desc, eq, ilike, inArray, min, or, sql } from "drizzle-orm"
+import { alias } from "drizzle-orm/pg-core"
import { getDb } from "./client.ts"
import { containsPattern } from "./like.ts"
@@ -840,6 +841,17 @@ function kindOrder(k: PlannerAreaOfStudy["kind"]): number {
}
}
+const earlierUnits = alias(units, "earlier_units")
+
+// Earliest handbook year with a page for the unit's code. Raw SQL here
+// renders the outer column unqualified, so `code = code` matches every row.
+function unitFirstYear() {
+ return sql`(${getDb()
+ .select({ year: min(earlierUnits.year) })
+ .from(earlierUnits)
+ .where(eq(earlierUnits.code, units.code))})`
+}
+
/**
* Fetch full unit records for the given codes. Missing codes are
* silently dropped — the caller can diff against the input list.
@@ -859,6 +871,7 @@ async function _fetchUnitsByCode(
creditPoints: units.creditPoints,
level: units.level,
school: units.school,
+ firstYear: unitFirstYear(),
})
.from(units)
.where(and(eq(units.year, year), inArray(units.code, [...codes])))
@@ -870,6 +883,7 @@ async function _fetchUnitsByCode(
creditPoints: r.creditPoints ?? 0,
level: r.level,
school: r.school,
+ firstYear: r.firstYear,
}))
}
export async function fetchUnitsByCode(
@@ -898,6 +912,7 @@ async function _searchUnits(
creditPoints: units.creditPoints,
level: units.level,
school: units.school,
+ firstYear: unitFirstYear(),
})
.from(units)
.where(
@@ -913,6 +928,7 @@ async function _searchUnits(
creditPoints: r.creditPoints ?? 0,
level: r.level,
school: r.school,
+ firstYear: r.firstYear,
}))
}
export const searchUnits = cacheHandbook(_searchUnits)
@@ -1255,6 +1271,11 @@ async function hydrateFromYear(
{ ...u, equivalents: equivalents.get(u.code) ?? [] },
])
)
+ // An empty list says "none"; a missing key means "not loaded".
+ for (const code of unitsByCode.keys()) {
+ if (!offerings.has(code)) offerings.set(code, [])
+ if (!reqs.has(code)) reqs.set(code, [])
+ }
return { units: unitsByCode, offerings, requisites: reqs }
}
diff --git a/packages/webapp/lib/planner/personalize-search.test.ts b/packages/webapp/lib/planner/personalize-search.test.ts
index 574e3b2..1f02111 100644
--- a/packages/webapp/lib/planner/personalize-search.test.ts
+++ b/packages/webapp/lib/planner/personalize-search.test.ts
@@ -10,12 +10,14 @@ import {
topFeatures,
} from "./personalize-search.ts"
import {
+ NO_TRANSITIONS,
offering as fixtureOffering,
planState,
prereq,
requisite,
unit as fixtureUnit,
} from "./test-fixtures.ts"
+import { buildTransitions } from "./transitions.ts"
import type {
PlannerAreaOfStudy,
PlannerCourseWithAoS,
@@ -129,7 +131,7 @@ test("slotContextFor includes earlier slots in completedBefore but not concurren
{ S1: ["FIT1008", "MAT1830"], S2: ["FIT1045"] },
{ S1: ["FIT2099"], S2: [] },
])
- const ctx = slotContextFor(s, 1, 0) // Year 2 S1
+ const ctx = slotContextFor(s, 1, 0, { transitions: NO_TRANSITIONS }) // Year 2 S1
assert.deepEqual([...ctx.completedBefore].sort(), [
"FIT1008",
"FIT1045",
@@ -137,7 +139,7 @@ test("slotContextFor includes earlier slots in completedBefore but not concurren
])
assert.deepEqual([...ctx.concurrentWith].sort(), ["FIT2099"])
assert.equal(ctx.slotKind, "S1")
- assert.equal(ctx.expectedHandbookYear, "2027")
+ assert.equal(ctx.slotYear, 2027)
})
test("personalScore: prereq-ready vs not-ready", () => {
@@ -271,7 +273,10 @@ test("slotContextFor counts credit and equivalents as completed", () => {
const units = new Map([
["FIT1053", fixtureUnit("FIT1053", { equivalents: ["FIT1045"] })],
])
- const ctx = slotContextFor(s, 0, 1, units)
+ const ctx = slotContextFor(s, 0, 1, {
+ units,
+ transitions: NO_TRANSITIONS,
+ })
assert.deepEqual([...ctx.completedBefore].sort(), [
"FIT1045",
"FIT1053",
@@ -356,6 +361,28 @@ test("slotContextFor: a mid-year start's Semester 1 expects the next year's hand
courseYear: "2026",
startPeriod: "S2",
})
- assert.equal(slotContextFor(s, 0, 0).expectedHandbookYear, "2026")
- assert.equal(slotContextFor(s, 0, 1).expectedHandbookYear, "2027")
+ assert.equal(slotContextFor(s, 0, 0).slotYear, 2026)
+ assert.equal(slotContextFor(s, 0, 1).slotYear, 2027)
+})
+
+test("personalScore: period fit uses the slot's transitions", () => {
+ const s = planState([{ S1: [] }, { S1: [] }, { S1: [] }])
+ const retired = buildTransitions([
+ {
+ name: "Faculty of IT",
+ retrieved: "2026-10-30",
+ units: [{ code: "FIT2014", lastOffered: { year: 2027, period: "S2" } }],
+ },
+ ])
+ const score = (transitions: typeof retired) =>
+ personalScore({
+ // 2028 has no handbook, and 2027's runs FIT2014 in S1.
+ unit: fixtureUnit("FIT2014", { year: "2027" }),
+ offerings: [offering("S1")],
+ requisites: [],
+ signals: buildPersonalSignals(s, course(), new Map()),
+ slot: slotContextFor(s, 2, 0, { transitions }),
+ }).periodFit
+ assert.equal(score(NO_TRANSITIONS), 1)
+ assert.equal(score(retired), 0)
})
diff --git a/packages/webapp/lib/planner/personalize-search.ts b/packages/webapp/lib/planner/personalize-search.ts
index ebc09cf..50a1f22 100644
--- a/packages/webapp/lib/planner/personalize-search.ts
+++ b/packages/webapp/lib/planner/personalize-search.ts
@@ -11,7 +11,8 @@ import type {
} from "./types.ts"
import { slotCalendarYear, startPeriodOf } from "./timeline.ts"
import { unitLevel } from "./unit-level.ts"
-import { completedBefore, isOfferedInPeriod } from "./validation.ts"
+import { TRANSITIONS, type Transitions } from "./transitions.ts"
+import { completedBefore, offeringIssue } from "./validation.ts"
/**
* Per-plan precomputed signals — derived once when the plan changes,
@@ -58,8 +59,9 @@ export interface SlotContext {
completedBefore: ReadonlySet
/** Codes already in this slot. */
concurrentWith: ReadonlySet
- /** Expected handbook year for the slot: its calendar year. */
- expectedHandbookYear: string | undefined
+ /** The slot's calendar year. */
+ slotYear: number
+ transitions: Transitions
}
/** Feature-by-feature breakdown returned with each candidate. */
@@ -118,7 +120,8 @@ function aosKindWeight(kind: string): number {
export function buildPersonalSignals(
state: PlannerState,
course: PlannerCourseWithAoS | null,
- requisitesByCode: ReadonlyMap
+ requisitesByCode: ReadonlyMap,
+ transitions: Transitions = TRANSITIONS
): PersonalSignals {
const placed = plannedUnitCodes(state)
@@ -143,7 +146,8 @@ export function buildPersonalSignals(
const fillsGap = new Set()
if (course) {
for (const g of courseRequirementGroups(course)) {
- if (groupProgress(g, placed, course.conflicts).satisfied) continue
+ if (groupProgress(g, placed, course.conflicts, transitions).satisfied)
+ continue
for (const code of g.options) if (!placed.has(code)) fillsGap.add(code)
}
}
@@ -176,24 +180,37 @@ export function slotContextFor(
state: PlannerState,
yearIndex: number,
slotIndex: number,
- units: ReadonlyMap> = new Map()
+ {
+ units = new Map(),
+ transitions = TRANSITIONS,
+ }: {
+ units?: ReadonlyMap>
+ transitions?: Transitions
+ } = {}
): SlotContext {
const slot = state.years[yearIndex]?.slots[slotIndex]
- const completed = completedBefore(state, yearIndex, slotIndex, units)
+
+ const completed = completedBefore(
+ state,
+ yearIndex,
+ slotIndex,
+ units,
+ transitions
+ )
+
const concurrent = slot ? new Set(slot.unitCodes) : new Set()
- const courseYearNum = Number(state.courseYear)
- const expectedHandbookYear = Number.isFinite(courseYearNum)
- ? String(
- slotCalendarYear(state, yearIndex, slot?.kind ?? startPeriodOf(state))
- )
- : undefined
return {
yearIndex,
slotIndex,
slotKind: slot?.kind,
completedBefore: completed,
concurrentWith: concurrent,
- expectedHandbookYear,
+ slotYear: slotCalendarYear(
+ state,
+ yearIndex,
+ slot?.kind ?? startPeriodOf(state)
+ ),
+ transitions,
}
}
@@ -216,7 +233,16 @@ export function personalScore(args: {
rank === undefined || total <= 1 ? 0 : 1 - rank / Math.max(1, total - 1)
const periodFit =
- slot.slotKind && isOfferedInPeriod([...offerings], slot.slotKind) ? 1 : 0
+ slot.slotKind &&
+ !offeringIssue({
+ unit,
+ offerings,
+ slotKind: slot.slotKind,
+ slotYear: slot.slotYear,
+ transitions: slot.transitions,
+ })
+ ? 1
+ : 0
const aos = signals.aosWeight.get(unit.code) ?? 0
const fillsGap = signals.fillsGap.has(unit.code) ? 1 : 0
diff --git a/packages/webapp/lib/planner/progress.test.ts b/packages/webapp/lib/planner/progress.test.ts
index 30f69e2..94f5c54 100644
--- a/packages/webapp/lib/planner/progress.test.ts
+++ b/packages/webapp/lib/planner/progress.test.ts
@@ -10,6 +10,7 @@ import {
summarizePlan,
} from "./progress.ts"
import { offeringMap, planState, unit as fixtureUnit } from "./test-fixtures.ts"
+import { buildTransitions } from "./transitions.ts"
import type { PlannerAreaOfStudy, PlannerCourseWithAoS } from "./types.ts"
const unit = (code: string, creditPoints = 6) =>
@@ -209,6 +210,7 @@ test("groupProgress caps the target at the reachable options", () => {
required: 6,
placed: 6,
satisfied: true,
+ viaReplacement: new Map(),
})
// Without the conflict data the old, unreachable target stands.
assert.equal(groupProgress(sevenGroup, planned).satisfied, false)
@@ -226,3 +228,85 @@ test("summarizeGroups totals each group's capped progress", () => {
[false, true]
)
})
+
+const replacements = buildTransitions([
+ {
+ name: "Faculty of IT",
+ retrieved: "2026-10-30",
+ units: [
+ { code: "FIT2014", replacedBy: [["FIT3235"]] },
+ { code: "FIT3161", replacedBy: [["FIT3230"]] },
+ { code: "FIT3162", replacedBy: [["FIT3231"]] },
+ { code: "FIT3163", replacedBy: [["FIT3230"]] },
+ { code: "FIT3164", replacedBy: [["FIT3231"]] },
+ { code: "FIT3144", replacedBy: [["FIT3230", "FIT3231"]] },
+ ],
+ },
+])
+
+test("groupProgress counts an approved replacement toward the option it replaces", () => {
+ const core = {
+ grouping: "Part A",
+ required: 2,
+ options: ["FIT1045", "FIT2014"],
+ }
+
+ const res = groupProgress(
+ core,
+ new Set(["FIT1045", "FIT3235"]),
+ undefined,
+ replacements
+ )
+
+ assert.equal(res.satisfied, true)
+ assert.deepEqual(res.viaReplacement.get("FIT2014"), ["FIT3235"])
+})
+
+test("groupProgress spends each replacement on one option per group", () => {
+ // 2026 C2001's pick-2 capstones.
+ const capstone = {
+ grouping: "Part D",
+ required: 2,
+ options: ["FIT3161", "FIT3162", "FIT3163", "FIT3164"],
+ }
+
+ const one = groupProgress(
+ capstone,
+ new Set(["FIT3230"]),
+ undefined,
+ replacements
+ )
+
+ assert.equal(one.placed, 1)
+
+ const pair = groupProgress(
+ capstone,
+ new Set(["FIT3230", "FIT3231"]),
+ undefined,
+ replacements
+ )
+
+ assert.equal(pair.satisfied, true)
+})
+
+test("groupProgress needs every unit of a multi-unit replacement", () => {
+ const honours = { grouping: "Project", required: 1, options: ["FIT3144"] }
+
+ const half = groupProgress(
+ honours,
+ new Set(["FIT3230"]),
+ undefined,
+ replacements
+ )
+
+ assert.equal(half.satisfied, false)
+
+ const whole = groupProgress(
+ honours,
+ new Set(["FIT3230", "FIT3231"]),
+ undefined,
+ replacements
+ )
+
+ assert.deepEqual(whole.viaReplacement.get("FIT3144"), ["FIT3230", "FIT3231"])
+})
diff --git a/packages/webapp/lib/planner/progress.ts b/packages/webapp/lib/planner/progress.ts
index 3f65761..c018566 100644
--- a/packages/webapp/lib/planner/progress.ts
+++ b/packages/webapp/lib/planner/progress.ts
@@ -2,6 +2,7 @@ import { creditedCodes, creditPointsFromCredit } from "./credit.ts"
import { slotBlockCredit } from "./timeline.ts"
import { isFullYearUnit, perSlotCreditPoints } from "./full-year.ts"
import { effectiveRequired, reachableOptions } from "./reachable.ts"
+import { TRANSITIONS, type Transitions } from "./transitions.ts"
import type {
PlannerAreaOfStudy,
PlannerCourseWithAoS,
@@ -136,21 +137,50 @@ export interface GroupProgress {
* only when a unit on the plan prohibits an option.
*/
required: number
- /** Options on the plan (placed or credited). */
+ /** Options on the plan (placed or credited) or covered by a replacement. */
placed: number
satisfied: boolean
+ /** Options covered by a replacement on the plan: FIT2014 to [FIT3235]. */
+ viaReplacement: ReadonlyMap
}
/** One requirement group's progress, with the reachability cap. */
export function groupProgress(
group: RequirementGroup,
plannedCodes: ReadonlySet,
- conflicts?: Readonly>
+ conflicts?: Readonly>,
+ transitions: Transitions = TRANSITIONS
): GroupProgress {
const required = effectiveRequired(group, plannedCodes, conflicts)
- let placed = 0
- for (const code of group.options) if (plannedCodes.has(code)) placed++
- return { group, required, placed, satisfied: placed >= required }
+ const literal = group.options.filter((c) => plannedCodes.has(c))
+ const viaReplacement = new Map()
+ // Each plan code covers at most one option per group.
+ const used = new Set(literal)
+
+ for (const code of group.options) {
+ if (plannedCodes.has(code)) continue
+
+ const way = transitions.byCode
+ .get(code)
+ ?.unit.replacedBy?.find((w) =>
+ w.every((c) => plannedCodes.has(c) && !used.has(c))
+ )
+
+ if (!way) continue
+ viaReplacement.set(code, way)
+
+ for (const c of way) used.add(c)
+ }
+
+ const placed = literal.length + viaReplacement.size
+
+ return {
+ group,
+ required,
+ placed,
+ satisfied: placed >= required,
+ viaReplacement,
+ }
}
/**
@@ -160,9 +190,13 @@ export function groupProgress(
export function summarizeGroups(
groups: readonly RequirementGroup[],
plannedCodes: ReadonlySet,
- conflicts?: Readonly>
+ conflicts?: Readonly>,
+ transitions: Transitions = TRANSITIONS
): { groups: GroupProgress[]; totalRequired: number; satisfiedCount: number } {
- const out = groups.map((g) => groupProgress(g, plannedCodes, conflicts))
+ const out = groups.map((g) =>
+ groupProgress(g, plannedCodes, conflicts, transitions)
+ )
+
return {
groups: out,
totalRequired: out.reduce((n, g) => n + g.required, 0),
@@ -207,7 +241,8 @@ export function summarizeAoSProgress(
* them every group keeps its full target, which is the old
* behaviour.
*/
- conflicts?: Readonly>
+ conflicts?: Readonly>,
+ transitions: Transitions = TRANSITIONS
): AoSProgress {
// Group-wise counting: each grouping contributes
// `min(placed_in_group, required)` to the satisfied count. Choice
@@ -224,18 +259,28 @@ export function summarizeAoSProgress(
// Target is capped at what's still reachable: an option prohibited
// by something already on the plan can never be taken, and asking
// for it makes the AoS permanently incomplete.
- const { required, placed } = groupProgress(group, plannedCodes, conflicts)
+ const { required, placed, viaReplacement } = groupProgress(
+ group,
+ plannedCodes,
+ conflicts,
+ transitions
+ )
+
const reachable = new Set(reachableOptions(group, plannedCodes, conflicts))
totalRequired += required
satisfied += Math.min(placed, required)
let placedSoFar = 0
for (const code of group.options) {
- if (plannedCodes.has(code)) {
+ const via = viaReplacement.get(code)
+
+ if (plannedCodes.has(code) || via) {
completedCodes.add(code)
placedSoFar++
- if (!cpSeen.has(code)) {
- cpSeen.add(code)
- plannedCp += unitsByCode.get(code)?.creditPoints ?? 0
+
+ for (const c of via ?? [code]) {
+ if (cpSeen.has(c)) continue
+ cpSeen.add(c)
+ plannedCp += unitsByCode.get(c)?.creditPoints ?? 0
}
} else if (placedSoFar < required && reachable.has(code)) {
// Surface the first `required` unplaced options as "remaining"
diff --git a/packages/webapp/lib/planner/requisites.test.ts b/packages/webapp/lib/planner/requisites.test.ts
index f3e07a5..b9482a1 100644
--- a/packages/webapp/lib/planner/requisites.test.ts
+++ b/packages/webapp/lib/planner/requisites.test.ts
@@ -2,6 +2,7 @@ import { test } from "node:test"
import assert from "node:assert/strict"
import { evaluateProhibition, evaluateRequisiteTree } from "./requisites.ts"
+import { rule } from "./test-fixtures.ts"
import type { RequisiteRule } from "./types.ts"
/** `(A OR B) AND (C OR D)` — the FIT2004 shape. */
@@ -237,3 +238,40 @@ test("FIT4042 shape: a met OR group is left out of missingCodes", () => {
true
)
})
+
+test("missingText: an AND rule asks for every unmet code with 'and'", () => {
+ // FIT3235's 2027 prerequisite.
+ const fit3235 = rule("AND", ["FIT2115", "MAT1830"])
+ assert.equal(
+ evaluateRequisiteTree(fit3235, new Set()).missingText,
+ "FIT2115 and MAT1830"
+ )
+ assert.equal(
+ evaluateRequisiteTree(fit3235, new Set(["MAT1830"])).missingText,
+ "FIT2115"
+ )
+ assert.equal(
+ evaluateRequisiteTree(fit3235, new Set(["FIT2115", "MAT1830"])).missingText,
+ ""
+ )
+})
+
+test("missingText: an OR rule lists its options with 'or'", () => {
+ const any = rule("OR", ["FIT1008", "FIT2085", "FIT1054"])
+ assert.equal(
+ evaluateRequisiteTree(any, new Set()).missingText,
+ "FIT1008, FIT2085 or FIT1054"
+ )
+})
+
+test("missingText: nested groups keep their structure", () => {
+ assert.equal(
+ evaluateRequisiteTree(fit2004Shape, new Set()).missingText,
+ "(FIT1008, FIT2085 or FIT1054) and (FIT1058 or MAT1830)"
+ )
+ // A met branch drops out.
+ assert.equal(
+ evaluateRequisiteTree(fit2004Shape, new Set(["MAT1830"])).missingText,
+ "FIT1008, FIT2085 or FIT1054"
+ )
+})
diff --git a/packages/webapp/lib/planner/requisites.ts b/packages/webapp/lib/planner/requisites.ts
index cfb2cd1..31d1eb3 100644
--- a/packages/webapp/lib/planner/requisites.ts
+++ b/packages/webapp/lib/planner/requisites.ts
@@ -34,6 +34,8 @@ export interface RequisiteEvalResult {
referencedCodes: string[]
/** Subset of referencedCodes the student hasn't completed. */
missingCodes: string[]
+ /** The unmet part, with its connectors: "FIT2115 and MAT1830". */
+ missingText: string
}
export function evaluateRequisiteTree(
@@ -41,7 +43,12 @@ export function evaluateRequisiteTree(
completed: ReadonlySet
): RequisiteEvalResult {
if (!rule || rule.length === 0) {
- return { satisfied: true, referencedCodes: [], missingCodes: [] }
+ return {
+ satisfied: true,
+ referencedCodes: [],
+ missingCodes: [],
+ missingText: "",
+ }
}
const referenced = new Set()
@@ -62,6 +69,7 @@ export function evaluateRequisiteTree(
satisfied,
referencedCodes: [...referenced].sort(),
missingCodes: [...missing].sort(),
+ missingText: satisfied ? "" : describeMissing(rule, completed),
}
}
@@ -84,6 +92,55 @@ function collectMissingCodes(
}
}
+function describeMissing(
+ rule: RequisiteRule,
+ completed: ReadonlySet
+): string {
+ const failing = rule.filter((c) => !evaluateContainer(c, completed))
+
+ return joinWith(
+ failing.map((c) => describeContainer(c, completed, failing.length > 1)),
+ "AND"
+ )
+}
+
+function describeContainer(
+ container: RequisiteContainer,
+ completed: ReadonlySet,
+ hasSiblings: boolean
+): string {
+ const connector = normalizeConnector(container.parent_connector?.value)
+
+ // A failing OR lists every option.
+ const subs = (container.containers ?? []).filter(
+ (s) => connector === "OR" || !evaluateContainer(s, completed)
+ )
+
+ const leaves = (container.relationships ?? []).filter(
+ (l) => connector === "OR" || !evaluateLeaf(l, completed)
+ )
+
+ const count = subs.length + leaves.length
+
+ const text = joinWith(
+ [
+ ...subs.map((s) => describeContainer(s, completed, count > 1)),
+ ...leaves.map((l) => l.academic_item_code),
+ ],
+ connector
+ )
+
+ return hasSiblings && count > 1 ? `(${text})` : text
+}
+
+function joinWith(parts: readonly string[], connector: "AND" | "OR"): string {
+ const word = connector === "OR" ? "or" : "and"
+
+ if (parts.length <= 2) return parts.join(` ${word} `)
+
+ return `${parts.slice(0, -1).join(", ")} ${word} ${parts.at(-1)}`
+}
+
function evaluateContainer(
container: RequisiteContainer,
completed: ReadonlySet
diff --git a/packages/webapp/lib/planner/test-fixtures.ts b/packages/webapp/lib/planner/test-fixtures.ts
index a930c2b..df4c54c 100644
--- a/packages/webapp/lib/planner/test-fixtures.ts
+++ b/packages/webapp/lib/planner/test-fixtures.ts
@@ -3,6 +3,7 @@
* doesn't execute it. Each builder fills the fields a test doesn't
* care about; pass `overrides` for the ones it does.
*/
+import { buildTransitions } from "./transitions.ts"
import type {
PeriodKind,
PlannerOffering,
@@ -13,6 +14,9 @@ import type {
RequisiteRule,
} from "./types.ts"
+/** For tests that use real codes but aren't about course transitions. */
+export const NO_TRANSITIONS = buildTransitions([])
+
export function unit(
code: string,
overrides: Partial = {}
@@ -24,6 +28,7 @@ export function unit(
creditPoints: 6,
level: null,
school: null,
+ firstYear: "2020", // not a new unit
...overrides,
}
}
diff --git a/packages/webapp/lib/planner/transitions-it-2027.ts b/packages/webapp/lib/planner/transitions-it-2027.ts
new file mode 100644
index 0000000..92e09f9
--- /dev/null
+++ b/packages/webapp/lib/planner/transitions-it-2027.ts
@@ -0,0 +1,499 @@
+import type { TransitionSource } from "./transitions.ts"
+
+// From https://www.monash.edu/it/current-students/courses/re-enrolment/undergraduate.
+// "Renamed" rows are left out; the handbook covers them.
+export const IT_2027: TransitionSource = {
+ name: "Faculty of IT",
+ retrieved: "2026-10-30",
+ units: [
+ {
+ code: "FIT1006",
+ appliesTo: "BIT - Business information systems",
+ advice: "Replace with any of ETC1000, ETF1100, ETX1100, STA1010, SCI1020",
+ lastOffered: { year: 2026 },
+ replacedBy: [
+ ["ETC1000"],
+ ["ETF1100"],
+ ["ETX1100"],
+ ["STA1010"],
+ ["SCI1020"],
+ ],
+ },
+ {
+ code: "FIT1008",
+ appliesTo: "BCS",
+ advice: "Replace with FIT2115 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2115"]],
+ },
+ {
+ code: "FIT1013",
+ appliesTo: "BIT",
+ advice: "No longer prerequisite to IBL for BIS major students",
+ lastOffered: { year: 2026 },
+ },
+ {
+ code: "FIT1043",
+ appliesTo: "BCS - Data science",
+ advice: "Replace with FIT2132 (S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2132"]],
+ },
+ {
+ code: "FIT1046",
+ appliesTo: "BIT - Interactive media",
+ advice: "Contact the Faculty for advice",
+ lastOffered: { year: 2026 },
+ },
+ {
+ code: "FIT1048",
+ appliesTo: "BIT - Games development",
+ advice: "Replace with FIT1045",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1045"]],
+ },
+ {
+ code: "FIT1049",
+ appliesTo: "ALL",
+ advice: "Replace with FIT2119 from 2028 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2119"]],
+ },
+ {
+ code: "FIT1051",
+ appliesTo: "BIT - Business information systems, Software development",
+ advice: "Replace with FIT1056 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1056"]],
+ },
+ {
+ code: "FIT1053",
+ appliesTo: "BCSAdv Hons",
+ advice: "Replace with FIT1045 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1045"]],
+ },
+ {
+ code: "FIT1054",
+ appliesTo: "BCSAdv Hons",
+ advice: "Replace with FIT2115 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2115"]],
+ },
+ {
+ code: "FIT1058",
+ appliesTo: "BCS",
+ advice: "Replace with MAT1830 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["MAT1830"]],
+ },
+ {
+ code: "FIT1110",
+ appliesTo: "BIT - HCI minor",
+ advice: "Replace with FIT1059 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1059"]],
+ },
+ {
+ code: "FIT2002",
+ appliesTo: "BIT",
+ advice: "Replace with FIT2130 from 2028 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2130"]],
+ },
+ {
+ code: "FIT2004",
+ appliesTo: "BCS, BSE",
+ advice: "Replace with FIT3234 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3234"]],
+ },
+ {
+ code: "FIT2014",
+ appliesTo: "BCS",
+ advice: "Replace with FIT3235 from 2028",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT3235"]],
+ },
+ {
+ code: "FIT2073",
+ appliesTo: "BIT - Games development",
+ advice: "Replace with FIT1073",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1073"]],
+ },
+ {
+ code: "FIT2081",
+ appliesTo: "BIT - Business information systems, Software development",
+ advice:
+ "BIS/SD major: Replace with FIT2099 (S1) or FIT2133 (S2). Cyber major: Replace with FIT2121 (S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2099"], ["FIT2133"], ["FIT2121"]],
+ },
+ {
+ code: "FIT2082",
+ appliesTo: "BCSAdv Hons",
+ advice: "To be confirmed",
+ lastOffered: { year: 2027, period: "S2" },
+ },
+ {
+ code: "FIT2083",
+ appliesTo: "BCSAdv Hons",
+ advice: "To be confirmed",
+ lastOffered: { year: 2027, period: "S1" },
+ },
+ {
+ code: "FIT2085",
+ appliesTo: "BSE",
+ // The page says "(S2 and S2)".
+ advice: "Replace with FIT2115",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2115"]],
+ },
+ {
+ code: "FIT2087",
+ appliesTo: "BIT - Interactive media",
+ advice: "Replace with FIT3187",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3187"]],
+ },
+ {
+ code: "FIT2090",
+ appliesTo: "BIT - Business information systems",
+ advice: "Replace with FIT2179 (S1)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2179"]],
+ },
+ {
+ code: "FIT2091",
+ appliesTo: "BIT - Interactive media",
+ advice:
+ "If you have not completed both of FIT2091 and FIT2092: a) consider moving to the 2023 course version, or b) replace with FIT1073 and the other one of FIT2087/FIT3187 or FIT2098 (not already completed)",
+ lastOffered: { year: 2026 },
+ },
+ {
+ code: "FIT2092",
+ appliesTo: "BIT - Interactive media",
+ advice:
+ "The other one of FIT2087/FIT3187 or FIT2098 (not already completed)",
+ lastOffered: { year: 2026 },
+ },
+ {
+ code: "FIT2093",
+ appliesTo: "BIT - Computer networks and security",
+ advice: "Replace with FIT1093",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT1093"]],
+ },
+ {
+ code: "FIT2094",
+ appliesTo: "ALL",
+ advice: "Replace with FIT1062 from 2028 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT1062"]],
+ },
+ {
+ code: "FIT2097",
+ appliesTo: "BIT - Games development",
+ advice: "Replace with FIT3097",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3097"]],
+ },
+ {
+ code: "FIT2099",
+ appliesTo: "BCS - Advanced computer science, Algorithms and software",
+ advice: "Replace with FIT2133 from S2 2027 (S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT2133"]],
+ },
+ {
+ code: "FIT2100",
+ appliesTo: "BSE, BIT - Applied cybersecurity, Cybersecurity",
+ advice: "Replace with FIT2134 from 2028",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2134"]],
+ },
+ {
+ code: "FIT2101",
+ appliesTo: "BSE",
+ advice: "Replace with FIT2119 from 2028 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2119"]],
+ },
+ {
+ code: "FIT2104",
+ appliesTo: "BIT - Software development",
+ advice: "Replace with FIT2095 from 2027 (S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2095"]],
+ },
+ {
+ code: "FIT2109",
+ appliesTo: "BCS - Algorithms and software",
+ advice: "To be confirmed",
+ lastOffered: { year: 2027, period: "S2" },
+ },
+ {
+ code: "FIT3039",
+ appliesTo: "BIT - Games and immersive media",
+ advice: "Replace with FIT3222 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3222"]],
+ },
+ {
+ code: "FIT3040",
+ appliesTo: "BIT - Games and immersive media",
+ advice: "Replace with FIT3223 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT3223"]],
+ },
+ {
+ code: "FIT3047",
+ appliesTo:
+ "BIT - Applied cybersecurity, Business information systems, Software development",
+ advice: "Replace with FIT3222 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3222"]],
+ },
+ {
+ code: "FIT3048",
+ appliesTo:
+ "BIT - Applied cybersecurity, Business information systems, Software development",
+ advice: "Replace with FIT3223 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT3223"]],
+ },
+ {
+ code: "FIT3080",
+ appliesTo: "BCS - Data science and artificial intelligence",
+ advice: "Replace with FIT2111 from 2028 (S1)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2111"]],
+ },
+ {
+ code: "FIT3094",
+ appliesTo: "BIT - Games development",
+ advice: "Replace with FIT3172",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3172"]],
+ },
+ {
+ code: "FIT3144",
+ appliesTo: "BCSAdv Hons",
+ advice: "Replace with FIT3230 and FIT3231 from 2027 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230", "FIT3231"]],
+ },
+ {
+ code: "FIT3145",
+ appliesTo: "BIT - Games development",
+ advice: "Replace with FIT2145",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2145"]],
+ },
+ {
+ code: "FIT3152",
+ appliesTo:
+ "BCS - Data science and artificial intelligence, Data science; BIT - Business information systems",
+ advice: "Replace with FIT3229 from 2028 (S1)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT3229"]],
+ },
+ {
+ code: "FIT3159",
+ appliesTo: "BSE",
+ advice: "Replace with FIT2165 from S2 2027 (S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT2165"]],
+ },
+ {
+ code: "FIT3161",
+ appliesTo: "BCS - Advanced computer science, Algorithms and software",
+ advice: "Replace with FIT3230 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230"]],
+ },
+ {
+ code: "FIT3162",
+ appliesTo: "BCS - Advanced computer science, Algorithms and software",
+ advice: "Replace with FIT3231 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT3231"]],
+ },
+ {
+ code: "FIT3163",
+ appliesTo: "BCS - Data science and artificial intelligence, Data science",
+ advice: "Replace with FIT3230 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230"]],
+ },
+ {
+ code: "FIT3164",
+ appliesTo: "BCS - Data science and artificial intelligence, Data science",
+ advice: "Replace with FIT3231 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT3231"]],
+ },
+ {
+ code: "FIT3165",
+ appliesTo: "BIT - Cybersecurity",
+ advice: "Replace with FIT2165 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2165"]],
+ },
+ {
+ code: "FIT3169",
+ appliesTo: "BIT - Interactive media",
+ advice: "Replace with FIT2169",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2169"]],
+ },
+ {
+ code: "FIT3170",
+ appliesTo: "BSE",
+ advice:
+ "2027: Replace 12 point FIT3170 with FIT3170 (6 pts) and FIT3184. 2028: Replace 12 point FIT3170 with FIT2130 (6 pts) and FIT3184, replace 6 pt FIT3170 with FIT2130",
+ lastOffered: { year: 2027, period: "S2" },
+ },
+ {
+ code: "FIT3171",
+ appliesTo: "BSE",
+ advice: "Replace with FIT2094 in 2027 and FIT1062 from 2028",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2094"], ["FIT1062"]],
+ },
+ {
+ code: "FIT3173",
+ appliesTo: "BIT - Cybersecurity",
+ advice: "Replace with FIT2173",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2173"]],
+ },
+ {
+ code: "FIT3175",
+ appliesTo: "BIT - Software development",
+ advice: "Replace with FIT2175",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2175"]],
+ },
+ {
+ code: "FIT3176",
+ appliesTo: "BIT - Software development",
+ advice: "Replace with any available Software development L3 elective",
+ lastOffered: { year: 2026 },
+ },
+ {
+ code: "FIT3179",
+ appliesTo: "BCS - Data science (2024)",
+ advice: "Replace with FIT2179 from 2028 (S1)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT2179"]],
+ },
+ {
+ code: "FIT3181",
+ appliesTo: "BCS - Data science and artificial intelligence, Data science",
+ advice: "Replace with FIT2112 from 2028 (S2)",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT2112"]],
+ },
+ {
+ code: "FIT3188",
+ appliesTo: "BCS - Cybersecurity",
+ advice: "Replace with FIT3230 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230"]],
+ },
+ {
+ code: "FIT3189",
+ appliesTo: "BCS - Cybersecurity",
+ advice: "Replace with FIT3231 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT3231"]],
+ },
+ {
+ code: "FIT3193",
+ appliesTo: "BCS",
+ advice: "Replace with FIT3230 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230"]],
+ },
+ {
+ code: "FIT3194",
+ appliesTo: "BCS",
+ advice: "Replace with FIT3231 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3231"]],
+ },
+ {
+ code: "FIT3196",
+ appliesTo: "BSE",
+ advice: "Replace with FIT2165",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2165"]],
+ },
+ {
+ code: "FIT3197",
+ appliesTo: "BSE",
+ advice: "Replace with FIT2107 (S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2107"]],
+ },
+ {
+ code: "FIT4002",
+ appliesTo: "BSE",
+ advice: "Replace with FIT4048 and FIT4049 from S2 2027 (S1 and S2)",
+ lastOffered: { year: 2027, period: "FULL_YEAR" },
+ replacedBy: [["FIT4048", "FIT4049"]],
+ },
+ {
+ code: "FIT4005",
+ appliesTo: "Honours",
+ advice: "Replace with FIT5125 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT5125"]],
+ },
+ {
+ code: "FIT4165",
+ appliesTo: "BSE",
+ advice: "Replace with FIT4043 from 2028 (S1)",
+ lastOffered: { year: 2027, period: "S1" },
+ replacedBy: [["FIT4043"]],
+ },
+ {
+ code: "MAT1841",
+ advice:
+ "Replace with one of MTH1020, MTH1030, FIT2109 or (from 2028) MAT1003",
+ lastOffered: { year: 2026 },
+ replacedBy: [["MTH1020"], ["MTH1030"], ["FIT2109"], ["MAT1003"]],
+ },
+ // New units with a 2027 page but no 2027 offerings.
+ {
+ code: "FIT2119",
+ firstOffered: { year: 2028 },
+ periods: ["S1", "S2"],
+ },
+ {
+ code: "FIT2130",
+ firstOffered: { year: 2028 },
+ periods: ["S1", "S2"],
+ },
+ {
+ code: "FIT2134",
+ firstOffered: { year: 2028 },
+ },
+ {
+ code: "FIT3229",
+ firstOffered: { year: 2028 },
+ periods: ["S1"],
+ },
+ {
+ code: "FIT3235",
+ firstOffered: { year: 2028 },
+ },
+ {
+ code: "FIT4043",
+ firstOffered: { year: 2028 },
+ periods: ["S1"],
+ },
+ ],
+}
diff --git a/packages/webapp/lib/planner/transitions.test.ts b/packages/webapp/lib/planner/transitions.test.ts
new file mode 100644
index 0000000..9dbc8eb
--- /dev/null
+++ b/packages/webapp/lib/planner/transitions.test.ts
@@ -0,0 +1,139 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+
+import {
+ adviceSentence,
+ afterLastOffering,
+ beforeFirstOffering,
+ buildTransitions,
+ SOURCES,
+ statedPeriods,
+ termLabel,
+ type TransitionSource,
+} from "./transitions.ts"
+import { IT_2027 } from "./transitions-it-2027.ts"
+
+const SOURCE: TransitionSource = {
+ name: "Faculty of IT",
+ retrieved: "2026-10-30",
+ units: [
+ {
+ code: "FIT1008",
+ advice: "Replace with FIT2115",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2115"]],
+ },
+ {
+ code: "MAT1841",
+ lastOffered: { year: 2026 },
+ replacedBy: [["MTH1020"], ["MTH1030"]],
+ },
+ {
+ code: "FIT3144",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT3230", "FIT3231"]],
+ },
+ { code: "FIT2119", firstOffered: { year: 2028 }, periods: ["S1", "S2"] },
+ ],
+}
+
+test("countsAs: a straight swap counts both ways", () => {
+ const { countsAs } = buildTransitions([SOURCE])
+ assert.deepEqual(countsAs.get("FIT1008"), ["FIT2115"])
+ assert.deepEqual(countsAs.get("FIT2115"), ["FIT1008"])
+})
+
+test("countsAs: an either-or list only counts toward the old unit", () => {
+ const { countsAs } = buildTransitions([SOURCE])
+ assert.equal(countsAs.get("MAT1841"), undefined)
+ assert.deepEqual(countsAs.get("MTH1020"), ["MAT1841"])
+ assert.deepEqual(countsAs.get("MTH1030"), ["MAT1841"])
+})
+
+test("countsAs: part of a multi-unit replacement isn't the old unit", () => {
+ const { countsAs } = buildTransitions([SOURCE])
+ assert.deepEqual(countsAs.get("FIT3144"), ["FIT3230", "FIT3231"])
+ assert.equal(countsAs.get("FIT3230"), undefined)
+})
+
+test("buildTransitions: a code in two sources is an error", () => {
+ const other = { ...SOURCE, name: "Faculty of Science" }
+ assert.throws(() => buildTransitions([SOURCE, other]), /FIT1008/)
+})
+
+test("afterLastOffering: a final S1 offering rules out S2 of that year", () => {
+ const last = { year: 2027, period: "S1" } as const
+ assert.equal(afterLastOffering(last, "S1", 2027), false)
+ assert.equal(afterLastOffering(last, "WINTER", 2027), true)
+ assert.equal(afterLastOffering(last, "S2", 2027), true)
+ assert.equal(afterLastOffering(last, "SUMMER_A", 2027), false)
+ assert.equal(afterLastOffering(last, "SUMMER_A", 2028), true)
+})
+
+test("afterLastOffering: a final full-year offering runs to the end of S2", () => {
+ const last = { year: 2027, period: "FULL_YEAR" } as const
+ assert.equal(afterLastOffering(last, "S2", 2027), false)
+ assert.equal(afterLastOffering(last, "S1", 2028), true)
+})
+
+test("afterLastOffering: 'no longer offered' is every year after", () => {
+ const last = { year: 2026 }
+ assert.equal(afterLastOffering(last, "S2", 2026), false)
+ assert.equal(afterLastOffering(last, "S1", 2027), true)
+ assert.equal(afterLastOffering(last, "OTHER", 2027), true)
+})
+
+test("beforeFirstOffering: a unit first run in S2 isn't there in S1", () => {
+ const first = { year: 2027, period: "S2" } as const
+ assert.equal(beforeFirstOffering(first, "S1", 2027), true)
+ assert.equal(beforeFirstOffering(first, "S2", 2027), false)
+ assert.equal(beforeFirstOffering(first, "OTHER", 2027), false)
+ assert.equal(beforeFirstOffering({ year: 2028 }, "S2", 2027), true)
+ assert.equal(beforeFirstOffering({ year: 2028 }, "S1", 2028), false)
+})
+
+test("statedPeriods: a new unit's periods, or none", () => {
+ const transitions = buildTransitions([SOURCE])
+ assert.deepEqual(statedPeriods("FIT2119", transitions), ["S1", "S2"])
+ assert.deepEqual(statedPeriods("FIT1008", transitions), [])
+})
+
+test("termLabel and adviceSentence read as the page does", () => {
+ assert.equal(termLabel({ year: 2027, period: "S2" }), "Semester 2, 2027")
+ assert.equal(termLabel({ year: 2028 }), "2028")
+ const { byCode } = buildTransitions([SOURCE])
+ assert.equal(
+ adviceSentence(byCode.get("FIT1008")!),
+ "Faculty of IT advice: Replace with FIT2115."
+ )
+ assert.equal(adviceSentence(byCode.get("MAT1841")!), null)
+})
+
+test("every source was copied within the last 18 months", () => {
+ for (const source of SOURCES) {
+ const staleOn = new Date(source.retrieved)
+ staleOn.setMonth(staleOn.getMonth() + 18)
+ assert.ok(
+ new Date() < staleOn,
+ `${source.name}'s transitions were copied on ${source.retrieved}. Re-copy the page, or delete the source once the handbooks cover it.`
+ )
+ }
+})
+
+test("IT 2027: one entry per code, and no unit replaces itself", () => {
+ const codes = IT_2027.units.map((u) => u.code)
+ assert.equal(new Set(codes).size, codes.length)
+
+ for (const u of IT_2027.units) {
+ assert.match(u.code, /^[A-Z]{3}\d{4}$/)
+
+ for (const way of u.replacedBy ?? []) {
+ assert.ok(way.length > 0, u.code)
+
+ for (const c of way) {
+ assert.match(c, /^[A-Z]{3}\d{4}$/)
+ assert.notEqual(c, u.code)
+ }
+ }
+ }
+})
diff --git a/packages/webapp/lib/planner/transitions.ts b/packages/webapp/lib/planner/transitions.ts
new file mode 100644
index 0000000..0cc06b1
--- /dev/null
+++ b/packages/webapp/lib/planner/transitions.ts
@@ -0,0 +1,146 @@
+import { PERIOD_KIND_LABEL } from "./teaching-period.ts"
+import { IT_2027 } from "./transitions-it-2027.ts"
+import type { PeriodKind } from "./types.ts"
+
+// Course changes the handbook can't express, copied by hand from faculty
+// pages. The slot year's own handbook still wins (see offeringIssue).
+
+export interface TransitionTerm {
+ year: number
+ /** Omitted when the page names only the year ("from 2028"). */
+ period?: "S1" | "S2" | "FULL_YEAR"
+}
+
+export interface UnitTransition {
+ code: string
+ /** The page's course or major column. */
+ appliesTo?: string
+ /** The page's teach-out advice, shown to students as written. */
+ advice?: string
+ /** A unit "no longer offered" from 2027 has `{ year: 2026 }`. */
+ lastOffered?: TransitionTerm
+ /** Ignored once the handbook lists any offering for the unit. */
+ firstOffered?: TransitionTerm
+ /** "from 2028 (S1 and S2)". Ignored like `firstOffered`. */
+ periods?: readonly PeriodKind[]
+ /** Ways to replace the unit: `[["A", "B"]]` is both, `[["A"], ["B"]]` either. */
+ replacedBy?: readonly (readonly string[])[]
+}
+
+export interface TransitionSource {
+ /** As messages name the publisher: "Faculty of IT". */
+ name: string
+ /** When the page was copied, as an ISO date. */
+ retrieved: string
+ units: readonly UnitTransition[]
+}
+
+export interface TransitionEntry {
+ unit: UnitTransition
+ source: TransitionSource
+}
+
+export interface Transitions {
+ byCode: ReadonlyMap
+ countsAs: ReadonlyMap
+}
+
+/**
+ * Replacements count for requisites both ways (FIT1008 as FIT2115 and back),
+ * except forward for either-or lists and back for part of a multi-unit one.
+ */
+export function buildTransitions(
+ sources: readonly TransitionSource[]
+): Transitions {
+ const byCode = new Map()
+ const countsAs = new Map()
+
+ const add = (from: string, to: string) => {
+ const list = countsAs.get(from)
+
+ if (list) list.push(to)
+ else countsAs.set(from, [to])
+ }
+
+ for (const source of sources) {
+ for (const unit of source.units) {
+ if (byCode.has(unit.code))
+ throw new Error(`${unit.code} has more than one transition entry`)
+ byCode.set(unit.code, { unit, source })
+ const ways = unit.replacedBy ?? []
+
+ if (ways.length === 1) for (const c of ways[0]) add(unit.code, c)
+
+ for (const way of ways) if (way.length === 1) add(way[0], unit.code)
+ }
+ }
+
+ return { byCode, countsAs }
+}
+
+export const SOURCES: readonly TransitionSource[] = [IT_2027]
+export const TRANSITIONS = buildTransitions(SOURCES)
+
+// [start, end] within a calendar year; summers are Jan-Feb. OTHER has no
+// place, so it never compares.
+const SPAN = {
+ SUMMER_A: [0, 0],
+ SUMMER_B: [0, 0],
+ S1: [1, 1],
+ WINTER: [2, 2],
+ S2: [3, 3],
+ FULL_YEAR: [1, 3],
+} as const satisfies Record, readonly number[]>
+
+export function afterLastOffering(
+ last: TransitionTerm,
+ slotKind: PeriodKind,
+ slotYear: number
+): boolean {
+ if (slotYear !== last.year) return slotYear > last.year
+
+ return (
+ !!last.period &&
+ slotKind !== "OTHER" &&
+ SPAN[slotKind][0] > SPAN[last.period][1]
+ )
+}
+
+export function beforeFirstOffering(
+ first: TransitionTerm,
+ slotKind: PeriodKind,
+ slotYear: number
+): boolean {
+ if (slotYear !== first.year) return slotYear < first.year
+
+ return (
+ !!first.period &&
+ slotKind !== "OTHER" &&
+ SPAN[slotKind][1] < SPAN[first.period][0]
+ )
+}
+
+/** A new unit's stated periods, for while the handbook lists none. */
+export function statedPeriods(
+ code: string,
+ transitions: Transitions = TRANSITIONS
+): readonly PeriodKind[] {
+ return transitions.byCode.get(code)?.unit.periods ?? []
+}
+
+/** "Semester 2, 2027", or "2028" when the page names only the year. */
+export function termLabel(term: TransitionTerm): string {
+ return term.period
+ ? `${PERIOD_KIND_LABEL[term.period]}, ${term.year}`
+ : String(term.year)
+}
+
+/** "Faculty of IT advice (BCS): Replace with FIT2115.", or null. */
+export function adviceSentence(entry: TransitionEntry): string | null {
+ const { unit, source } = entry
+
+ if (!unit.advice) return null
+ const scope = unit.appliesTo ? ` (${unit.appliesTo})` : ""
+
+ return `${source.name} advice${scope}: ${unit.advice}.`
+}
diff --git a/packages/webapp/lib/planner/types.ts b/packages/webapp/lib/planner/types.ts
index 1cbc88a..0834ec8 100644
--- a/packages/webapp/lib/planner/types.ts
+++ b/packages/webapp/lib/planner/types.ts
@@ -46,6 +46,8 @@ export interface PlannerUnit {
* offerings and requisites are that year's. See `unit_year_links`.
*/
fallbackFor?: string
+ /** Earliest handbook year with this code; equals `year` for a new unit. */
+ firstYear: string
}
/** A single offering row as the planner needs it. */
@@ -80,11 +82,7 @@ export interface RequisiteLeaf {
export interface RequisiteBlock {
requisiteType:
- | "prerequisite"
- | "corequisite"
- | "prohibition"
- | "permission"
- | "other"
+ "prerequisite" | "corequisite" | "prohibition" | "permission" | "other"
/** May be null — unit has an enrolment rule prose block but no structured tree. */
rule: RequisiteRule | null
}
@@ -289,6 +287,8 @@ export interface SlotUnitValidation {
export type ValidationIssueKind =
| "not_offered_in_period"
+ | "no_longer_offered"
+ | "not_yet_offered"
| "prereq_unmet"
| "coreq_unmet"
| "prohibition_conflict"
diff --git a/packages/webapp/lib/planner/validation.test.ts b/packages/webapp/lib/planner/validation.test.ts
index 4b942eb..1089b9a 100644
--- a/packages/webapp/lib/planner/validation.test.ts
+++ b/packages/webapp/lib/planner/validation.test.ts
@@ -1,14 +1,17 @@
import { test } from "node:test"
import assert from "node:assert/strict"
+import { buildTransitions } from "./transitions.ts"
import {
isOfferedInPeriod,
keyFor,
+ offeringIssue,
validatePlan,
validateUnitInSlot,
} from "./validation.ts"
import {
coreq,
+ NO_TRANSITIONS,
offering,
offeringMap,
planState,
@@ -38,6 +41,7 @@ test("validateUnitInSlot: flags 'not offered' when period has no offering", () =
offerings: [offering("FIT1045", "S1")],
requisites: [],
slotCreditLoad: 6,
+ slotYear: 2026,
})
assert.equal(v.errors.length, 1)
assert.equal(v.errors[0].kind, "not_offered_in_period")
@@ -59,7 +63,7 @@ test("validateUnitInSlot: future-year 'not offered' is a warning, not an error",
offerings: [offering("FIT1045", "S1")],
requisites: [],
slotCreditLoad: 6,
- expectedHandbookYear: "2027",
+ slotYear: 2027,
})
assert.equal(v.errors.length, 0)
assert.equal(v.warnings.length, 1)
@@ -68,7 +72,7 @@ test("validateUnitInSlot: future-year 'not offered' is a warning, not an error",
assert.match(v.warnings[0].message, /2027 offerings may differ/)
})
-test("validateUnitInSlot: matching expectedHandbookYear keeps not-offered as a hard error", () => {
+test("validateUnitInSlot: matching slotYear keeps not-offered as a hard error", () => {
// Slot represents 2026 and we loaded the 2026 handbook for it — no
// stale-data excuse, so a period mismatch is a real blocker (red).
const v = validateUnitInSlot({
@@ -82,7 +86,7 @@ test("validateUnitInSlot: matching expectedHandbookYear keeps not-offered as a h
offerings: [offering("FIT1045", "S1")],
requisites: [],
slotCreditLoad: 6,
- expectedHandbookYear: "2026",
+ slotYear: 2026,
})
assert.equal(v.errors.length, 1)
assert.equal(v.errors[0].kind, "not_offered_in_period")
@@ -107,6 +111,7 @@ test("validateUnitInSlot: term-only schedule is always a warning, never an error
],
requisites: [],
slotCreditLoad: 0,
+ slotYear: 2026,
})
assert.equal(v.errors.length, 0)
assert.equal(v.warnings.length, 1)
@@ -128,6 +133,7 @@ test("validateUnitInSlot: prereq unmet → error lists missing codes", () => {
offerings: [offering("FIT2004", "S1")],
requisites,
slotCreditLoad: 6,
+ slotYear: 2026,
})
const prereqErr = v.errors.find((e) => e.kind === "prereq_unmet")
assert.ok(prereqErr)
@@ -147,6 +153,7 @@ test("validateUnitInSlot: coreq satisfied by concurrent unit", () => {
offerings: [offering("FIT9999", "S1")],
requisites,
slotCreditLoad: 12,
+ slotYear: 2026,
})
assert.equal(v.errors.length, 0)
})
@@ -164,6 +171,7 @@ test("validateUnitInSlot: prohibition fires when paired unit is anywhere in plan
offerings: [offering("FIT1053", "S1")],
requisites,
slotCreditLoad: 6,
+ slotYear: 2026,
})
const pe = v.errors.find((e) => e.kind === "prohibition_conflict")
assert.ok(pe)
@@ -182,6 +190,7 @@ test("validateUnitInSlot: over-credit-load is a warning, not an error", () => {
offerings: [offering("FIT1045", "S1")],
requisites: [],
slotCreditLoad: 30,
+ slotYear: 2026,
})
assert.equal(v.errors.length, 0)
assert.equal(v.warnings.length, 1)
@@ -378,7 +387,8 @@ test("validatePlan: a completed unit WITHOUT an equivalence link does not satisf
["FIT1053", [offering("FIT1053", "S1")]],
["FIT2175", [offering("FIT2175", "S2")]],
]),
- new Map([["FIT2175", [prereq("FIT1045")]]])
+ new Map([["FIT2175", [prereq("FIT1045")]]]),
+ NO_TRANSITIONS
)
const fit2175 = out.get(keyFor(0, 1, "FIT2175"))
assert.ok(fit2175)
@@ -661,3 +671,205 @@ test("validatePlan: a semester unit in the same slot still isn't done before", (
assert.ok(v)
assert.equal(v.errors[0]?.kind, "prereq_unmet")
})
+
+const IT = buildTransitions([
+ {
+ name: "Faculty of IT",
+ retrieved: "2026-10-30",
+ units: [
+ {
+ code: "FIT2014",
+ appliesTo: "BCS",
+ advice: "Replace with FIT3235 from 2028",
+ lastOffered: { year: 2027, period: "S2" },
+ replacedBy: [["FIT3235"]],
+ },
+ {
+ code: "FIT1008",
+ advice: "Replace with FIT2115 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["FIT2115"]],
+ },
+ {
+ code: "FIT1058",
+ advice: "Replace with MAT1830 (S1 and S2)",
+ lastOffered: { year: 2026 },
+ replacedBy: [["MAT1830"]],
+ },
+ { code: "FIT3235", firstOffered: { year: 2028 } },
+ { code: "FIT3201", lastOffered: { year: 2028, period: "S1" } },
+ {
+ code: "FIT2119",
+ firstOffered: { year: 2028 },
+ periods: ["S1", "S2"],
+ },
+ ],
+ },
+])
+
+test("offeringIssue: a unit is red after its final offering, with the faculty's advice", () => {
+ // 2028 has no handbook, and 2027's still runs FIT2014 in S1.
+ const res = offeringIssue({
+ unit: unit("FIT2014", { year: "2027" }),
+ offerings: [offering("FIT2014", "S1")],
+ slotKind: "S1",
+ slotYear: 2028,
+ transitions: IT,
+ })
+
+ assert.equal(res?.severity, "error")
+ assert.equal(res?.issue.kind, "no_longer_offered")
+ assert.equal(res?.label, "Final offering Semester 2, 2027")
+ assert.equal(
+ res?.issue.message,
+ "FIT2014's final offering is Semester 2, 2027. Faculty of IT advice (BCS): Replace with FIT3235 from 2028."
+ )
+})
+
+test("offeringIssue: an Other slot in a unit's final year asks to check the dates", () => {
+ // A term unit forecast from 2027 into 2028, its final year.
+ const res = offeringIssue({
+ unit: unit("FIT3201", { year: "2027" }),
+ offerings: [offering("FIT3201", "OTHER")],
+ slotKind: "OTHER",
+ slotYear: 2028,
+ transitions: IT,
+ })
+
+ assert.equal(res?.severity, "warning")
+ assert.equal(res?.label, "Check the dates")
+})
+
+test("offeringIssue: the slot year's own handbook outranks a transition", () => {
+ const res = offeringIssue({
+ unit: unit("FIT2014", { year: "2028" }),
+ offerings: [offering("FIT2014", "S1")],
+ slotKind: "S1",
+ slotYear: 2028,
+ transitions: IT,
+ })
+
+ assert.equal(res, null)
+})
+
+test("offeringIssue: a new unit is red before it first runs and amber after", () => {
+ const fit3235 = unit("FIT3235", { year: "2027", firstYear: "2027" })
+
+ const before = offeringIssue({
+ unit: fit3235,
+ offerings: [],
+ slotKind: "S1",
+ slotYear: 2027,
+ transitions: IT,
+ })
+
+ assert.equal(before?.severity, "error")
+ assert.equal(before?.issue.kind, "not_yet_offered")
+ assert.equal(
+ before?.issue.message,
+ "FIT3235 is a new unit and first runs in 2028."
+ )
+
+ const after = offeringIssue({
+ unit: fit3235,
+ offerings: [],
+ slotKind: "S1",
+ slotYear: 2028,
+ transitions: IT,
+ })
+
+ assert.equal(after?.severity, "warning")
+ assert.match(after?.issue.message ?? "", /teaching periods aren't published/)
+})
+
+test("offeringIssue: a new unit's stated periods stand in for offerings", () => {
+ const args = {
+ unit: unit("FIT2119", { year: "2027", firstYear: "2027" }),
+ offerings: [],
+ slotYear: 2028,
+ transitions: IT,
+ }
+
+ assert.equal(offeringIssue({ ...args, slotKind: "S2" }), null)
+ const winter = offeringIssue({ ...args, slotKind: "WINTER" })
+ assert.equal(winter?.severity, "warning")
+ assert.equal(
+ winter?.issue.message,
+ "FIT2119 runs in Semester 1 and Semester 2 from 2028, not Winter."
+ )
+})
+
+test("offeringIssue: a new unit with no offerings is amber, an old one stays red", () => {
+ const args = {
+ offerings: [],
+ slotKind: "S1" as const,
+ slotYear: 2027,
+ transitions: IT,
+ }
+
+ const fresh = offeringIssue({
+ ...args,
+ unit: unit("ABC1234", { year: "2027", firstYear: "2027" }),
+ })
+
+ assert.equal(fresh?.severity, "warning")
+ assert.equal(fresh?.label, "No offerings published")
+
+ const old = offeringIssue({
+ ...args,
+ unit: unit("ABC1234", { year: "2027", firstYear: "2020" }),
+ })
+
+ assert.equal(old?.severity, "error")
+})
+
+test("validatePlan: replaced units meet a new unit's prerequisites", () => {
+ // FIT1058 and FIT1008 stand in for MAT1830 and FIT2115.
+ const state = planState([
+ { S1: ["FIT1058"], S2: ["FIT1008"] },
+ { S1: [], S2: [] },
+ { S1: ["FIT3235"], S2: [] },
+ ])
+
+ const out = validatePlan(
+ state,
+ new Map([
+ ["FIT1058", unit("FIT1058")],
+ ["FIT1008", unit("FIT1008")],
+ ["FIT3235", unit("FIT3235", { year: "2027", firstYear: "2027" })],
+ ]),
+ offeringMap({ FIT1058: ["S1"], FIT1008: ["S2"] }),
+ new Map([["FIT3235", [prereq("FIT2115", "MAT1830")]]]),
+ IT
+ )
+
+ const v = out.get(keyFor(2, 0, "FIT3235"))
+ assert.ok(v)
+ assert.equal(v.errors.length, 0)
+ assert.equal(v.warnings[0]?.kind, "not_offered_in_period")
+})
+
+test("validatePlan: a new unit missing from the handbook is a warning", () => {
+ const state = planState([{ S1: [] }, { S1: [] }, { S1: ["FIT3235"] }])
+
+ const v = validatePlan(state, new Map(), new Map(), new Map(), IT).get(
+ keyFor(2, 0, "FIT3235")
+ )
+
+ assert.equal(v?.errors.length, 0)
+ assert.equal(
+ v?.warnings[0]?.message,
+ "FIT3235 is a new unit from 2028 and isn't in the handbook yet."
+ )
+})
+
+test("validatePlan: a retired unit missing from the handbook says so", () => {
+ const state = planState([{ S1: [] }, { S1: ["FIT1008"] }])
+ const out = validatePlan(state, new Map(), new Map(), new Map(), IT)
+ const v = out.get(keyFor(1, 0, "FIT1008"))
+ assert.equal(v?.errors[0]?.kind, "no_longer_offered")
+ assert.equal(
+ v?.errors[0]?.message,
+ "FIT1008 is no longer offered from 2027. Faculty of IT advice: Replace with FIT2115 (S1 and S2)."
+ )
+})
diff --git a/packages/webapp/lib/planner/validation.ts b/packages/webapp/lib/planner/validation.ts
index 2d04554..2e80787 100644
--- a/packages/webapp/lib/planner/validation.ts
+++ b/packages/webapp/lib/planner/validation.ts
@@ -8,6 +8,17 @@ import {
} from "./requisites.ts"
import { PERIOD_KIND_LABEL } from "./teaching-period.ts"
import { slotCalendarYear } from "./timeline.ts"
+import {
+ adviceSentence,
+ afterLastOffering,
+ beforeFirstOffering,
+ termLabel,
+ TRANSITIONS,
+ type TransitionEntry,
+ type TransitionTerm,
+ type Transitions,
+ type UnitTransition,
+} from "./transitions.ts"
import type {
PeriodKind,
PlannerOffering,
@@ -16,6 +27,7 @@ import type {
RequisiteBlock,
SlotUnitValidation,
ValidationIssue,
+ ValidationIssueKind,
} from "./types.ts"
const MAX_CREDIT_LOAD_PER_SLOT = 24
@@ -64,40 +76,26 @@ export interface ValidationInput {
* hydration layer either fell back to the latest available year or
* the slot is past the latest published handbook — so we downgrade
* `not_offered_in_period` to a warning, since Monash routinely
- * rotates units between semesters year-on-year. Defaults to
- * `unit.year` (treats data as authoritative for the slot).
+ * rotates units between semesters year-on-year.
*/
- expectedHandbookYear?: string
+ slotYear: number
+ transitions?: Transitions
}
export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation {
const errors: ValidationIssue[] = []
const warnings: ValidationIssue[] = []
- const expectedYear = input.expectedHandbookYear ?? input.unit.year
- const dataIsReliable = input.unit.year === expectedYear
-
- if (!isOfferedInPeriod(input.offerings, input.slotKind)) {
- // Term-only units (IBL placements / onboarding, trimester field
- // schools, …) have every offering classified as OTHER and so will
- // *never* match an S1/S2 slot, even when the data is fresh. The
- // unit still needs to live somewhere on the planner grid, so flag
- // it as a yellow "runs on its own dates — check them"
- // rather than a red blocker.
- const termOnly =
- input.offerings.length > 0 &&
- input.offerings.every((o) => o.periodKind === "OTHER")
- const issue: ValidationIssue = {
- kind: "not_offered_in_period",
- message: termOnly
- ? `${input.unit.code} runs on its own dates, not the semester calendar, so it may start before or end after ${PERIOD_KIND_LABEL[input.slotKind]}. Check the dates with your faculty.`
- : dataIsReliable
- ? `${input.unit.code} isn't offered in ${PERIOD_KIND_LABEL[input.slotKind]} for ${input.unit.year}.`
- : `${input.unit.code} isn't offered in ${PERIOD_KIND_LABEL[input.slotKind]} in the ${input.unit.year} handbook - ${expectedYear} offerings may differ.`,
- }
- if (termOnly || !dataIsReliable) warnings.push(issue)
- else errors.push(issue)
- }
+ const offering = offeringIssue({
+ unit: input.unit,
+ offerings: input.offerings,
+ slotKind: input.slotKind,
+ slotYear: input.slotYear,
+ transitions: input.transitions,
+ })
+
+ if (offering?.severity === "error") errors.push(offering.issue)
+ else if (offering) warnings.push(offering.issue)
const prereqs = input.requisites.filter(
(r) => r.requisiteType === "prerequisite"
@@ -107,7 +105,7 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation {
if (!res.satisfied) {
errors.push({
kind: "prereq_unmet",
- message: `Prerequisite not satisfied: need ${formatCodeList(res.missingCodes)} before ${input.unit.code}.`,
+ message: `Prerequisite not satisfied: need ${res.missingText} before ${input.unit.code}.`,
relatedCodes: res.missingCodes,
})
}
@@ -125,7 +123,7 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation {
if (!res.satisfied) {
errors.push({
kind: "coreq_unmet",
- message: `Corequisite not satisfied: need ${formatCodeList(res.missingCodes)} by the end of this slot.`,
+ message: `Corequisite not satisfied: need ${res.missingText} by the end of this slot.`,
relatedCodes: res.missingCodes,
})
}
@@ -166,18 +164,240 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation {
}
export function isOfferedInPeriod(
- offerings: PlannerOffering[],
+ offerings: readonly PlannerOffering[],
period: PeriodKind
): boolean {
- return offerings.some((o) => {
- if (o.periodKind === period) return true
- // Year-long offerings cover both S1 and S2 — a FY unit placed in
- // either semester slot is still "being attended" that semester.
- if (o.periodKind === "FULL_YEAR" && (period === "S1" || period === "S2")) {
- return true
- }
- return false
- })
+ return offerings.some((o) => covers(o.periodKind, period))
+}
+
+function covers(offered: PeriodKind, slot: PeriodKind): boolean {
+ // Year-long offerings cover both S1 and S2 — a FY unit placed in
+ // either semester slot is still "being attended" that semester.
+ return (
+ offered === slot ||
+ (offered === "FULL_YEAR" && (slot === "S1" || slot === "S2"))
+ )
+}
+
+export interface OfferingIssue {
+ severity: "error" | "warning"
+ issue: ValidationIssue
+ /** Short form for chips: "Final offering Semester 2, 2027". */
+ label: string
+}
+
+/**
+ * Whether a unit runs in a slot. The slot year's handbook wins, then a
+ * faculty transition; a new unit with no offerings only warns.
+ */
+export function offeringIssue(args: {
+ unit: Pick
+ offerings: readonly PlannerOffering[]
+ slotKind: PeriodKind
+ slotYear: number
+ transitions?: Transitions
+}): OfferingIssue | null {
+ const { unit, offerings, slotKind, slotYear } = args
+ const offered = isOfferedInPeriod(offerings, slotKind)
+
+ if (Number(unit.year) === slotYear && offered) return null
+
+ const entry = (args.transitions ?? TRANSITIONS).byCode.get(unit.code)
+
+ if (entry) {
+ const listed = offerings.length > 0
+ const outside = outsideRun(entry, slotKind, slotYear, listed)
+
+ if (outside) return outside
+ const { firstOffered } = entry.unit
+
+ if (firstOffered && !listed)
+ return statedRunIssue(entry.unit, firstOffered, slotKind)
+ }
+
+ if (offered)
+ return entry ? unorderedIssue(entry.unit, slotKind, slotYear) : null
+
+ return handbookIssue(unit, offerings, slotKind, slotYear)
+}
+
+/** After the unit's final offering, or before a new unit's first. */
+function outsideRun(
+ entry: TransitionEntry,
+ slotKind: PeriodKind,
+ slotYear: number,
+ listed: boolean
+): OfferingIssue | null {
+ const { code, lastOffered, firstOffered } = entry.unit
+
+ if (lastOffered && afterLastOffering(lastOffered, slotKind, slotYear))
+ return noLongerOffered(entry, lastOffered)
+
+ if (
+ !listed &&
+ firstOffered &&
+ beforeFirstOffering(firstOffered, slotKind, slotYear)
+ )
+ return notYetOffered(code, firstOffered)
+
+ return null
+}
+
+/** An OTHER slot can't be ordered against a final offering in its year. */
+function unorderedIssue(
+ t: UnitTransition,
+ slotKind: PeriodKind,
+ slotYear: number
+): OfferingIssue | null {
+ const last = t.lastOffered
+
+ if (slotKind !== "OTHER" || !last?.period || last.year !== slotYear)
+ return null
+
+ return warning(
+ "Check the dates",
+ `${t.code}'s final offering is ${termLabel(last)}, so check this period's dates against it.`,
+ "no_longer_offered"
+ )
+}
+
+function statedRunIssue(
+ t: UnitTransition,
+ first: TransitionTerm,
+ slotKind: PeriodKind
+): OfferingIssue | null {
+ const from = termLabel(first)
+ const period = PERIOD_KIND_LABEL[slotKind]
+
+ if (!t.periods)
+ return warning(
+ "Periods not published",
+ `${t.code} is a new unit from ${from}. Its teaching periods aren't published yet, so check them with your faculty.`
+ )
+
+ if (t.periods.some((p) => covers(p, slotKind))) return null
+ const runs = t.periods.map((p) => PERIOD_KIND_LABEL[p]).join(" and ")
+
+ return warning(
+ `Not offered in ${period}`,
+ `${t.code} runs in ${runs} from ${from}, not ${period}.`
+ )
+}
+
+function handbookIssue(
+ unit: Pick,
+ offerings: readonly PlannerOffering[],
+ slotKind: PeriodKind,
+ slotYear: number
+): OfferingIssue {
+ const { code } = unit
+ const period = PERIOD_KIND_LABEL[slotKind]
+ const notOffered = `Not offered in ${period}`
+
+ // Term-only units (IBL placements / onboarding, trimester field
+ // schools, …) have every offering classified as OTHER and so will
+ // *never* match an S1/S2 slot, even when the data is fresh. The
+ // unit still needs to live somewhere on the planner grid, so flag
+ // it as a yellow "runs on its own dates — check them"
+ // rather than a red blocker.
+ if (offerings.length > 0 && offerings.every((o) => o.periodKind === "OTHER"))
+ return warning(
+ "Runs on its own dates",
+ `${code} runs on its own dates, not the semester calendar, so it may start before or end after ${period}. Check the dates with your faculty.`
+ )
+
+ if (offerings.length === 0 && unit.firstYear === unit.year)
+ return warning(
+ "No offerings published",
+ `${code} is a new unit with no offerings in the ${unit.year} handbook yet. Check when it first runs.`
+ )
+
+ if (Number(unit.year) !== slotYear)
+ return warning(
+ notOffered,
+ `${code} isn't offered in ${period} in the ${unit.year} handbook - ${slotYear} offerings may differ.`
+ )
+
+ return {
+ severity: "error",
+ label: notOffered,
+ issue: {
+ kind: "not_offered_in_period",
+ message: `${code} isn't offered in ${period} for ${unit.year}.`,
+ },
+ }
+}
+
+function warning(
+ label: string,
+ message: string,
+ kind: ValidationIssueKind = "not_offered_in_period"
+): OfferingIssue {
+ return { severity: "warning", label, issue: { kind, message } }
+}
+
+function noLongerOffered(
+ entry: TransitionEntry,
+ last: TransitionTerm
+): OfferingIssue {
+ const { code } = entry.unit
+ const final = last.period ? termLabel(last) : null
+
+ const status = final
+ ? `${code}'s final offering is ${final}.`
+ : `${code} is no longer offered from ${last.year + 1}.`
+
+ return {
+ severity: "error",
+ label: final ? `Final offering ${final}` : "No longer offered",
+ issue: {
+ kind: "no_longer_offered",
+ message: [status, adviceSentence(entry)].filter(Boolean).join(" "),
+ },
+ }
+}
+
+function notYetOffered(code: string, first: TransitionTerm): OfferingIssue {
+ const when = termLabel(first)
+
+ return {
+ severity: "error",
+ label: `New from ${when}`,
+ issue: {
+ kind: "not_yet_offered",
+ message: `${code} is a new unit and first runs in ${when}.`,
+ },
+ }
+}
+
+/** Retired units have no page in later handbooks, and new ones may not yet. */
+function missingUnitIssue(
+ code: string,
+ slotKind: PeriodKind,
+ slotYear: number,
+ transitions: Transitions
+): OfferingIssue {
+ const entry = transitions.byCode.get(code)
+ const outside = entry && outsideRun(entry, slotKind, slotYear, false)
+
+ if (outside) return outside
+ const first = entry?.unit.firstOffered
+
+ if (first)
+ return warning(
+ "Not in the handbook yet",
+ `${code} is a new unit from ${termLabel(first)} and isn't in the handbook yet.`,
+ "unknown_unit"
+ )
+
+ return {
+ severity: "error",
+ label: "Not in this handbook",
+ issue: {
+ kind: "unknown_unit",
+ message: `Unit ${code} not found in this handbook year.`,
+ },
+ }
}
function formatCodeList(codes: string[]): string {
@@ -195,7 +415,8 @@ export function validatePlan(
state: PlannerState,
unitsByCode: ReadonlyMap,
offeringsByCode: ReadonlyMap,
- requisitesByCode: ReadonlyMap
+ requisitesByCode: ReadonlyMap,
+ transitions: Transitions = TRANSITIONS
): Map {
const out = new Map()
@@ -229,28 +450,25 @@ 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)
-
- // Used to derive the expected handbook year per slot so the
- // validator can compare against the actually-loaded `unit.year`.
- // If courseYear isn't a parseable number (shouldn't happen, but be
- // defensive), every slot falls back to treating its data as
- // authoritative — same behaviour as not passing expectedHandbookYear.
- const courseYearNum = Number(state.courseYear)
- const courseYearIsNumeric = Number.isFinite(courseYearNum)
+ const completed = completedAtStart(state, unitsByCode, transitions)
for (let y = 0; y < state.years.length; y++) {
const year = state.years[y]
for (let s = 0; s < year.slots.length; s++) {
const slot = year.slots[s]
- const expectedHandbookYear = courseYearIsNumeric
- ? String(slotCalendarYear(state, y, slot.kind))
- : undefined
+ // The same year hydration loaded the slot's data for, so the
+ // validator can compare it with the loaded `unit.year`.
+ const slotYear = slotCalendarYear(state, y, slot.kind)
+
// Equivalents (advanced twins, cross-listings) count as having
// taken the unit they're equivalent to, so a prereq/coreq naming
// FIT1045 is met by completing FIT1053. Prohibitions are checked
// against literal enrolments (allPlanned), so they're not expanded.
- const concurrent = withEquivalents(slot.unitCodes, unitsByCode)
+ const concurrent = withEquivalents(
+ slot.unitCodes,
+ unitsByCode,
+ transitions
+ )
// Per-slot CP load — full-year units contribute their half-year
// share so a 12 CP FY twin doesn't single-handedly trip the
@@ -264,15 +482,17 @@ export function validatePlan(
for (const code of slot.unitCodes) {
const unit = unitsByCode.get(code)
if (!unit) {
+ const { severity, issue } = missingUnitIssue(
+ code,
+ slot.kind,
+ slotYear,
+ transitions
+ )
+
out.set(keyFor(y, s, code), {
code,
- errors: [
- {
- kind: "unknown_unit",
- message: `Unit ${code} not found in this handbook year.`,
- },
- ],
- warnings: [],
+ errors: severity === "error" ? [issue] : [],
+ warnings: severity === "warning" ? [issue] : [],
})
continue
}
@@ -282,7 +502,10 @@ export function validatePlan(
const earlier = earlierTermUnits(code, slot.unitCodes, offeringsByCode)
const completedForUnit = earlier.length
- ? new Set([...completed, ...withEquivalents(earlier, unitsByCode)])
+ ? new Set([
+ ...completed,
+ ...withEquivalents(earlier, unitsByCode, transitions),
+ ])
: completed
const v = validateUnitInSlot({
@@ -303,13 +526,13 @@ export function validatePlan(
// that fallback kicked in (loaded year ≠ slot year), the period
// mismatch is a stale-data forecast, not a hard fact, and
// downgrades to amber. See hydration.ts / slotHandbookYear.
- expectedHandbookYear,
+ slotYear,
+ transitions,
})
out.set(keyFor(y, s, code), v)
}
- for (const c of withEquivalents(slot.unitCodes, unitsByCode))
- completed.add(c)
+ for (const c of concurrent) completed.add(c)
}
}
@@ -360,9 +583,10 @@ function termNumbers(
*/
export function completedAtStart(
state: PlannerState,
- unitsByCode: ReadonlyMap>
+ unitsByCode: ReadonlyMap>,
+ transitions: Transitions = TRANSITIONS
): Set {
- return withEquivalents(creditedCodes(state), unitsByCode)
+ return withEquivalents(creditedCodes(state), unitsByCode, transitions)
}
/**
@@ -375,14 +599,20 @@ export function completedBefore(
state: PlannerState,
yearIndex: number,
slotIndex: number,
- unitsByCode: ReadonlyMap>
+ unitsByCode: ReadonlyMap>,
+ transitions: Transitions = TRANSITIONS
): Set {
- const out = completedAtStart(state, unitsByCode)
+ const out = completedAtStart(state, unitsByCode, transitions)
for (let y = 0; y <= yearIndex; y++) {
const slots = state.years[y]?.slots ?? []
for (let s = 0; s < slots.length; s++) {
if (y === yearIndex && s >= slotIndex) break
- for (const c of withEquivalents(slots[s].unitCodes, unitsByCode))
+
+ for (const c of withEquivalents(
+ slots[s].unitCodes,
+ unitsByCode,
+ transitions
+ ))
out.add(c)
}
}
@@ -403,17 +633,20 @@ export function keyFor(
* see PlannerUnit.equivalents). Returns a new set; the input is left
* untouched. Used wherever "what has the student effectively completed?"
* feeds requisite satisfaction, so e.g. completing FIT1053 satisfies a
- * prerequisite that names FIT1045. NOT used for prohibition checks,
- * which test literal enrolments.
+ * prerequisite that names FIT1045. Approved replacements count too. NOT
+ * used for prohibition checks, which test literal enrolments.
*/
export function withEquivalents(
codes: Iterable,
- unitsByCode: ReadonlyMap>
+ unitsByCode: ReadonlyMap>,
+ transitions: Transitions = TRANSITIONS
): Set {
const out = new Set()
for (const c of codes) {
out.add(c)
for (const eq of unitsByCode.get(c)?.equivalents ?? []) out.add(eq)
+
+ for (const r of transitions.countsAs.get(c) ?? []) out.add(r)
}
return out
}
diff --git a/packages/webapp/lib/tree/equivalence.test.ts b/packages/webapp/lib/tree/equivalence.test.ts
index cfe9bf8..fa530c0 100644
--- a/packages/webapp/lib/tree/equivalence.test.ts
+++ b/packages/webapp/lib/tree/equivalence.test.ts
@@ -13,6 +13,7 @@ function u(code: string, title: string): PlannerUnit {
creditPoints: 6,
level: null,
school: null,
+ firstYear: "2020",
}
}
diff --git a/packages/webapp/package.json b/packages/webapp/package.json
index f2f0100..df9ac94 100644
--- a/packages/webapp/package.json
+++ b/packages/webapp/package.json
@@ -16,6 +16,7 @@
"warm:pages": "node scripts/warm-pages.ts",
"verify:resolver": "tsx scripts/verify-resolver.mts",
"verify:corpus": "tsx scripts/verify-corpus.mts",
+ "verify:transitions": "tsx scripts/verify-transitions.mts",
"refresh:stats": "tsx scripts/refresh-stats.mts"
},
"dependencies": {
diff --git a/packages/webapp/scripts/verify-transitions.mts b/packages/webapp/scripts/verify-transitions.mts
new file mode 100644
index 0000000..a842719
--- /dev/null
+++ b/packages/webapp/scripts/verify-transitions.mts
@@ -0,0 +1,72 @@
+/**
+ * Checks the faculty transition lists against the ingested handbooks.
+ *
+ * pnpm --filter webapp verify:transitions
+ *
+ * Flags a handbook offering after a unit's final offering, a
+ * replacement no handbook has, and a new unit whose first offering the
+ * handbook already lists. The planner lets the handbook win, so each
+ * finding means the list or the handbook is wrong.
+ *
+ * Reads only. Requires DATABASE_URL from the repo-root .env.
+ */
+import { DATABASE_URL } from "@monmap/db/env"
+import postgres from "postgres"
+
+import { classifyTeachingPeriod } from "../lib/planner/teaching-period.ts"
+import { afterLastOffering, TRANSITIONS } from "../lib/planner/transitions.ts"
+
+const sql = postgres(DATABASE_URL)
+const units = [...TRANSITIONS.byCode.values()].map((e) => e.unit)
+const findings: string[] = []
+
+const offerings = await sql<
+ { year: string; unit_code: string; teaching_period: string | null }[]
+>`
+ SELECT DISTINCT year, unit_code, teaching_period FROM unit_offerings
+ WHERE offered AND unit_code = ANY(${units.map((u) => u.code)})`
+
+for (const unit of units) {
+ const own = offerings.filter((o) => o.unit_code === unit.code)
+ const last = unit.lastOffered
+
+ if (last) {
+ for (const o of own) {
+ const kind = classifyTeachingPeriod(o.teaching_period)
+
+ if (afterLastOffering(last, kind, Number(o.year)))
+ findings.push(
+ `${unit.code}: the ${o.year} handbook runs it in ${o.teaching_period}, after its final offering`
+ )
+ }
+ }
+
+ if (unit.firstOffered && own.length > 0) {
+ const years = [...new Set(own.map((o) => o.year))].sort().join(", ")
+ findings.push(`${unit.code}: the handbook lists offerings in ${years}`)
+ }
+}
+
+const replacements = [
+ ...new Set(units.flatMap((u) => (u.replacedBy ?? []).flat())),
+]
+
+const known = new Set(
+ (
+ await sql<{ code: string }[]>`
+ SELECT DISTINCT code FROM units WHERE code = ANY(${replacements})`
+ ).map((r) => r.code)
+)
+
+for (const code of replacements)
+ if (!known.has(code)) findings.push(`${code}: a replacement no handbook has`)
+
+if (findings.length > 0) {
+ console.error(`${findings.length} transition finding(s):`)
+
+ for (const f of findings) console.error(` ${f}`)
+ process.exit(1)
+}
+
+console.log(`transitions OK (${units.length} units)`)
+process.exit(0)