From 76acfa52a85997f3f94e1a8ca1b19bbbcf11b210 Mon Sep 17 00:00:00 2001 From: Tansimran Date: Fri, 30 Oct 2026 11:57:47 +1100 Subject: [PATCH 1/4] Word unmet prerequisites with the rule's own "and" and "or" FIT3235 needs FIT2115 and MAT1830, but the planner said "need FIT2115 or MAT1830": the message joined the missing codes with "or" whatever the rule's connector. It now follows the rule, leaves out branches already met, and brackets nested groups: "(FIT1008 or FIT1054) and MAT1830". --- .../webapp/lib/planner/requisites.test.ts | 31 +++++++++++++- packages/webapp/lib/planner/requisites.ts | 42 +++++++++++++++++++ packages/webapp/lib/planner/validation.ts | 5 ++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/webapp/lib/planner/requisites.test.ts b/packages/webapp/lib/planner/requisites.test.ts index f3e07a5..569e4df 100644 --- a/packages/webapp/lib/planner/requisites.test.ts +++ b/packages/webapp/lib/planner/requisites.test.ts @@ -1,7 +1,12 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { evaluateProhibition, evaluateRequisiteTree } from "./requisites.ts" +import { + describeMissing, + 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 +242,27 @@ test("FIT4042 shape: a met OR group is left out of missingCodes", () => { true ) }) + +test("describeMissing: an AND rule asks for every unmet code with 'and'", () => { + // FIT3235's 2027 prerequisite. + const fit3235 = rule("AND", ["FIT2115", "MAT1830"]) + assert.equal(describeMissing(fit3235, new Set()), "FIT2115 and MAT1830") + assert.equal(describeMissing(fit3235, new Set(["MAT1830"])), "FIT2115") +}) + +test("describeMissing: an OR rule lists its options with 'or'", () => { + const any = rule("OR", ["FIT1008", "FIT2085", "FIT1054"]) + assert.equal(describeMissing(any, new Set()), "FIT1008, FIT2085 or FIT1054") +}) + +test("describeMissing: nested groups keep their structure", () => { + assert.equal( + describeMissing(fit2004Shape, new Set()), + "(FIT1008, FIT2085 or FIT1054) and (FIT1058 or MAT1830)" + ) + // A met branch drops out. + assert.equal( + describeMissing(fit2004Shape, new Set(["MAT1830"])), + "FIT1008, FIT2085 or FIT1054" + ) +}) diff --git a/packages/webapp/lib/planner/requisites.ts b/packages/webapp/lib/planner/requisites.ts index cfb2cd1..5ac5d5b 100644 --- a/packages/webapp/lib/planner/requisites.ts +++ b/packages/webapp/lib/planner/requisites.ts @@ -84,6 +84,48 @@ function collectMissingCodes( } } +/** The unmet part of a rule with its connectors: "FIT2115 and MAT1830". */ +export function describeMissing( + rule: RequisiteRule | null | undefined, + 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/validation.ts b/packages/webapp/lib/planner/validation.ts index 2d04554..bdaaf11 100644 --- a/packages/webapp/lib/planner/validation.ts +++ b/packages/webapp/lib/planner/validation.ts @@ -2,6 +2,7 @@ import { creditedCodes } from "./credit.ts" import { perSlotCreditPoints } from "./full-year.ts" import { plannedUnitCodes } from "./progress.ts" import { + describeMissing, evaluateProhibition, evaluateRequisiteTree, referencedCodes, @@ -107,7 +108,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 ${describeMissing(block.rule, input.completedBefore)} before ${input.unit.code}.`, relatedCodes: res.missingCodes, }) } @@ -125,7 +126,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 ${describeMissing(block.rule, combined)} by the end of this slot.`, relatedCodes: res.missingCodes, }) } From 8ccd2eea2e561a9ac9641489608a8e6e882afaef Mon Sep 17 00:00:00 2001 From: Tansimran Date: Fri, 30 Oct 2026 11:59:27 +1100 Subject: [PATCH 2/4] Follow the Faculty of IT's 2027 course changes in the planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handbook can't say when a unit stops, so the planner offered FIT2014 in 2028 (the 2027 handbook still runs it) and told a 2026 starter with FIT1008 and FIT1058 that FIT3235's prerequisites were unmet, because its 2027 rule names their replacements, FIT2115 and MAT1830. lib/planner/transitions-it-2027.ts copies the faculty's re-enrolment page: final offerings, approved replacements, and when new units first run. The planner now: - flags a unit placed after its final offering or before its first, with the faculty's advice ("Replace with FIT3235 from 2028") - counts an approved replacement toward prerequisites, both ways - counts a replacement toward the course requirement it replaces ("FIT2014 via FIT3235") - warns rather than errors for a new unit with no offerings yet - says "no longer offered" instead of "not found" for retired units The slot year's own handbook still wins whenever it lists an offering, so the data goes stale safely. The search dialog uses the same check for its fit chip and ranking, and no longer shows "Checking offerings…" forever for a unit with none. --- docs/handbook-internals.md | 9 + .../components/planner/planner-context.tsx | 3 +- .../components/planner/requirements-panel.tsx | 8 +- .../components/planner/unit-search-dialog.tsx | 60 ++- packages/webapp/lib/db/queries.ts | 4 + .../lib/planner/personalize-search.test.ts | 5 +- .../webapp/lib/planner/personalize-search.ts | 24 +- packages/webapp/lib/planner/progress.test.ts | 73 +++ packages/webapp/lib/planner/progress.ts | 48 +- packages/webapp/lib/planner/test-fixtures.ts | 4 + .../webapp/lib/planner/transitions-it-2027.ts | 499 ++++++++++++++++++ .../webapp/lib/planner/transitions.test.ts | 114 ++++ packages/webapp/lib/planner/transitions.ts | 118 +++++ packages/webapp/lib/planner/types.ts | 10 +- .../webapp/lib/planner/validation.test.ts | 165 +++++- packages/webapp/lib/planner/validation.ts | 252 +++++++-- 16 files changed, 1302 insertions(+), 94 deletions(-) create mode 100644 packages/webapp/lib/planner/transitions-it-2027.ts create mode 100644 packages/webapp/lib/planner/transitions.test.ts create mode 100644 packages/webapp/lib/planner/transitions.ts diff --git a/docs/handbook-internals.md b/docs/handbook-internals.md index f72ca3a..10ba44c 100644 --- a/docs/handbook-internals.md +++ b/docs/handbook-internals.md @@ -269,6 +269,15 @@ 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.ts` holds these facts, copied by hand from +faculty pages. The slot year's own handbook still wins; 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/planner-context.tsx b/packages/webapp/components/planner/planner-context.tsx index 4a827ad..bb7d33d 100644 --- a/packages/webapp/components/planner/planner-context.tsx +++ b/packages/webapp/components/planner/planner-context.tsx @@ -241,7 +241,8 @@ export function UnitDataOverlay({ const ctx = usePlanner() const value = useMemo(() => { if (!bundle) return ctx - const maps = mergeUnitMaps(ctx, bundle) + // Search results are fully hydrated: no offerings means none. + const maps = mergeUnitMaps(ctx, bundle, Object.keys(bundle.units)) return { ...ctx, ...maps, diff --git a/packages/webapp/components/planner/requirements-panel.tsx b/packages/webapp/components/planner/requirements-panel.tsx index b05a91a..2cdc74b 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, + coveredBy, } = groupProgress(g, plannedCodes, conflicts) const isChoice = required < g.options.length const sortedOptions = [...g.options].sort((a, b) => a.localeCompare(b)) @@ -427,7 +428,9 @@ function GroupList({
    {sortedOptions.map((code) => { - const placed = plannedCodes.has(code) + const by = coveredBy.get(code) + const placed = by !== undefined + const via = by && by[0] !== code ? by.join(" + ") : null const placement = placements.get(code) return (
  • @@ -454,6 +457,9 @@ function GroupList({ )} {code} + {via ? ( + via {via} + ) : null}
  • diff --git a/packages/webapp/components/planner/unit-search-dialog.tsx b/packages/webapp/components/planner/unit-search-dialog.tsx index 257b01b..b4e0691 100644 --- a/packages/webapp/components/planner/unit-search-dialog.tsx +++ b/packages/webapp/components/planner/unit-search-dialog.tsx @@ -53,7 +53,7 @@ import type { PlannerUnit, } from "@/lib/planner/types" import type { UnitBundle } from "@/lib/planner/unit-cache" -import { isOfferedInPeriod } from "@/lib/planner/validation" +import { offeringIssue, type OfferingIssue } from "@/lib/planner/validation" import { cn } from "@/lib/utils" import { UnitDataOverlay, usePlanner } from "./planner-context" @@ -289,6 +289,17 @@ function UnitSearchView({ ]) const items = useMemo(() => ranked.map((r) => r.unit), [ranked]) + const issueFor = (code: string): OfferingIssue | null => { + const u = units.get(code) + return slotKind && u + ? offeringIssue({ + unit: u, + offerings: offerings.get(code) ?? [], + slotKind, + slotYear: slotCtx.expectedHandbookYear, + }) + : 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 +472,8 @@ function UnitSearchView({ 0 @@ -534,6 +543,8 @@ function GroupHeading({ children }: { children: React.ReactNode }) { function UnitRow({ unit, offerings, + offeringsLoaded, + issue, slotKind, placed, onPlanElsewhere, @@ -543,6 +554,8 @@ function UnitRow({ }: { unit: PlannerUnit offerings: PlannerOffering[] + offeringsLoaded: boolean + issue: OfferingIssue | null slotKind: PeriodKind | undefined /** Already in THIS slot — the only hard non-action. */ placed: boolean @@ -557,6 +570,16 @@ function UnitRow({ for (const o of offerings) s.add(o.periodKind) return PERIOD_KIND_ORDER.filter((p) => s.has(p)) }, [offerings]) + // Overrides the period chips, which may be from an older handbook. + const transitionLabel = + issue?.issue.kind === "no_longer_offered" || + issue?.issue.kind === "not_yet_offered" + ? issue.label + : null + const note = cn( + "text-[10px] italic", + focused ? "text-accent-foreground/70" : "text-muted-foreground" + ) return ( diff --git a/packages/webapp/components/planner/unit-search-dialog.tsx b/packages/webapp/components/planner/unit-search-dialog.tsx index d3ebd8a..090689a 100644 --- a/packages/webapp/components/planner/unit-search-dialog.tsx +++ b/packages/webapp/components/planner/unit-search-dialog.tsx @@ -290,18 +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.expectedHandbookYear, + 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( @@ -569,6 +572,7 @@ function UnitRow({ }) { const periods = useMemo(() => { 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, unit.code]) @@ -647,12 +651,17 @@ function rowNote( periods: readonly PeriodKind[] ): string | null { if (!loaded) return "Checking offerings…" - // Outranks the chips, which may come from an older handbook. + 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" } diff --git a/packages/webapp/lib/db/queries.ts b/packages/webapp/lib/db/queries.ts index 9abe734..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,9 +871,7 @@ async function _fetchUnitsByCode( creditPoints: units.creditPoints, level: units.level, school: units.school, - firstYear: sql`( - select min(u2.year) from units u2 where u2.code = ${units.code} - )`, + firstYear: unitFirstYear(), }) .from(units) .where(and(eq(units.year, year), inArray(units.code, [...codes]))) @@ -902,6 +912,7 @@ async function _searchUnits( creditPoints: units.creditPoints, level: units.level, school: units.school, + firstYear: unitFirstYear(), }) .from(units) .where( @@ -917,6 +928,7 @@ async function _searchUnits( creditPoints: r.creditPoints ?? 0, level: r.level, school: r.school, + firstYear: r.firstYear, })) } export const searchUnits = cacheHandbook(_searchUnits) diff --git a/packages/webapp/lib/planner/personalize-search.test.ts b/packages/webapp/lib/planner/personalize-search.test.ts index 1928877..1f02111 100644 --- a/packages/webapp/lib/planner/personalize-search.test.ts +++ b/packages/webapp/lib/planner/personalize-search.test.ts @@ -139,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", () => { @@ -361,8 +361,8 @@ 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", () => { @@ -370,6 +370,7 @@ test("personalScore: period fit uses the slot's transitions", () => { const retired = buildTransitions([ { name: "Faculty of IT", + retrieved: "2026-10-30", units: [{ code: "FIT2014", lastOffered: { year: 2027, period: "S2" } }], }, ]) diff --git a/packages/webapp/lib/planner/personalize-search.ts b/packages/webapp/lib/planner/personalize-search.ts index ac2816f..50a1f22 100644 --- a/packages/webapp/lib/planner/personalize-search.ts +++ b/packages/webapp/lib/planner/personalize-search.ts @@ -59,8 +59,8 @@ 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 } @@ -189,6 +189,7 @@ export function slotContextFor( } = {} ): SlotContext { const slot = state.years[yearIndex]?.slots[slotIndex] + const completed = completedBefore( state, yearIndex, @@ -196,20 +197,19 @@ export function slotContextFor( 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, } } @@ -238,7 +238,7 @@ export function personalScore(args: { unit, offerings, slotKind: slot.slotKind, - slotYear: slot.expectedHandbookYear, + slotYear: slot.slotYear, transitions: slot.transitions, }) ? 1 diff --git a/packages/webapp/lib/planner/progress.test.ts b/packages/webapp/lib/planner/progress.test.ts index 42f613e..94f5c54 100644 --- a/packages/webapp/lib/planner/progress.test.ts +++ b/packages/webapp/lib/planner/progress.test.ts @@ -232,6 +232,7 @@ test("summarizeGroups totals each group's capped progress", () => { const replacements = buildTransitions([ { name: "Faculty of IT", + retrieved: "2026-10-30", units: [ { code: "FIT2014", replacedBy: [["FIT3235"]] }, { code: "FIT3161", replacedBy: [["FIT3230"]] }, @@ -249,12 +250,14 @@ test("groupProgress counts an approved replacement toward the option it replaces 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"]) }) @@ -266,36 +269,44 @@ test("groupProgress spends each replacement on one option per group", () => { 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 a95f2e0..c018566 100644 --- a/packages/webapp/lib/planner/progress.ts +++ b/packages/webapp/lib/planner/progress.ts @@ -156,18 +156,24 @@ export function groupProgress( 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, @@ -190,6 +196,7 @@ export function summarizeGroups( const out = groups.map((g) => groupProgress(g, plannedCodes, conflicts, transitions) ) + return { groups: out, totalRequired: out.reduce((n, g) => n + g.required, 0), @@ -258,15 +265,18 @@ export function summarizeAoSProgress( 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) { const via = viaReplacement.get(code) + if (plannedCodes.has(code) || via) { completedCodes.add(code) placedSoFar++ + for (const c of via ?? [code]) { if (cpSeen.has(c)) continue cpSeen.add(c) diff --git a/packages/webapp/lib/planner/requisites.ts b/packages/webapp/lib/planner/requisites.ts index 7d24552..31d1eb3 100644 --- a/packages/webapp/lib/planner/requisites.ts +++ b/packages/webapp/lib/planner/requisites.ts @@ -97,6 +97,7 @@ function describeMissing( completed: ReadonlySet ): string { const failing = rule.filter((c) => !evaluateContainer(c, completed)) + return joinWith( failing.map((c) => describeContainer(c, completed, failing.length > 1)), "AND" @@ -109,14 +110,18 @@ function describeContainer( 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)), @@ -124,12 +129,15 @@ function describeContainer( ], 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)}` } diff --git a/packages/webapp/lib/planner/test-fixtures.ts b/packages/webapp/lib/planner/test-fixtures.ts index ee32cb4..df4c54c 100644 --- a/packages/webapp/lib/planner/test-fixtures.ts +++ b/packages/webapp/lib/planner/test-fixtures.ts @@ -28,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 index bd6fd31..92e09f9 100644 --- a/packages/webapp/lib/planner/transitions-it-2027.ts +++ b/packages/webapp/lib/planner/transitions-it-2027.ts @@ -1,9 +1,10 @@ import type { TransitionSource } from "./transitions.ts" -// From https://www.monash.edu/it/current-students/courses/re-enrolment/undergraduate -// on 30 October 2026. "Renamed" rows are left out; the handbook covers them. +// 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", diff --git a/packages/webapp/lib/planner/transitions.test.ts b/packages/webapp/lib/planner/transitions.test.ts index c59b2a4..9dbc8eb 100644 --- a/packages/webapp/lib/planner/transitions.test.ts +++ b/packages/webapp/lib/planner/transitions.test.ts @@ -6,6 +6,7 @@ import { afterLastOffering, beforeFirstOffering, buildTransitions, + SOURCES, statedPeriods, termLabel, type TransitionSource, @@ -14,6 +15,7 @@ import { IT_2027 } from "./transitions-it-2027.ts" const SOURCE: TransitionSource = { name: "Faculty of IT", + retrieved: "2026-10-30", units: [ { code: "FIT1008", @@ -55,7 +57,7 @@ test("countsAs: part of a multi-unit replacement isn't the old unit", () => { }) test("buildTransitions: a code in two sources is an error", () => { - const other = { name: "Faculty of Science", units: [SOURCE.units[0]] } + const other = { ...SOURCE, name: "Faculty of Science" } assert.throws(() => buildTransitions([SOURCE, other]), /FIT1008/) }) @@ -107,13 +109,27 @@ test("termLabel and adviceSentence read as the page does", () => { 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 index 6268f7f..0cc06b1 100644 --- a/packages/webapp/lib/planner/transitions.ts +++ b/packages/webapp/lib/planner/transitions.ts @@ -30,6 +30,8 @@ export interface UnitTransition { 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[] } @@ -52,25 +54,32 @@ export function buildTransitions( ): 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 TRANSITIONS = buildTransitions([IT_2027]) +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. @@ -89,6 +98,7 @@ export function afterLastOffering( slotYear: number ): boolean { if (slotYear !== last.year) return slotYear > last.year + return ( !!last.period && slotKind !== "OTHER" && @@ -102,6 +112,7 @@ export function beforeFirstOffering( slotYear: number ): boolean { if (slotYear !== first.year) return slotYear < first.year + return ( !!first.period && slotKind !== "OTHER" && @@ -127,7 +138,9 @@ export function termLabel(term: TransitionTerm): string { /** "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 94190e3..0834ec8 100644 --- a/packages/webapp/lib/planner/types.ts +++ b/packages/webapp/lib/planner/types.ts @@ -47,7 +47,7 @@ export interface PlannerUnit { */ fallbackFor?: string /** Earliest handbook year with this code; equals `year` for a new unit. */ - firstYear?: string + firstYear: string } /** A single offering row as the planner needs it. */ diff --git a/packages/webapp/lib/planner/validation.test.ts b/packages/webapp/lib/planner/validation.test.ts index 66e8e01..1089b9a 100644 --- a/packages/webapp/lib/planner/validation.test.ts +++ b/packages/webapp/lib/planner/validation.test.ts @@ -41,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") @@ -62,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) @@ -71,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({ @@ -85,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") @@ -110,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) @@ -131,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) @@ -150,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) }) @@ -167,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) @@ -185,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) @@ -669,6 +675,7 @@ test("validatePlan: a semester unit in the same slot still isn't done before", ( const IT = buildTransitions([ { name: "Faculty of IT", + retrieved: "2026-10-30", units: [ { code: "FIT2014", @@ -690,6 +697,7 @@ const IT = buildTransitions([ replacedBy: [["MAT1830"]], }, { code: "FIT3235", firstOffered: { year: 2028 } }, + { code: "FIT3201", lastOffered: { year: 2028, period: "S1" } }, { code: "FIT2119", firstOffered: { year: 2028 }, @@ -705,9 +713,10 @@ test("offeringIssue: a unit is red after its final offering, with the faculty's unit: unit("FIT2014", { year: "2027" }), offerings: [offering("FIT2014", "S1")], slotKind: "S1", - slotYear: "2028", + 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") @@ -717,39 +726,58 @@ test("offeringIssue: a unit is red after its final offering, with the faculty's ) }) +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", + 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", + 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", + slotYear: 2028, transitions: IT, }) + assert.equal(after?.severity, "warning") assert.match(after?.issue.message ?? "", /teaching periods aren't published/) }) @@ -758,9 +786,10 @@ test("offeringIssue: a new unit's stated periods stand in for offerings", () => const args = { unit: unit("FIT2119", { year: "2027", firstYear: "2027" }), offerings: [], - slotYear: "2028", + slotYear: 2028, transitions: IT, } + assert.equal(offeringIssue({ ...args, slotKind: "S2" }), null) const winter = offeringIssue({ ...args, slotKind: "WINTER" }) assert.equal(winter?.severity, "warning") @@ -774,19 +803,23 @@ test("offeringIssue: a new unit with no offerings is amber, an old one stays red const args = { offerings: [], slotKind: "S1" as const, - slotYear: "2027", + 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") }) @@ -797,6 +830,7 @@ test("validatePlan: replaced units meet a new unit's prerequisites", () => { { S1: [], S2: [] }, { S1: ["FIT3235"], S2: [] }, ]) + const out = validatePlan( state, new Map([ @@ -808,6 +842,7 @@ test("validatePlan: replaced units meet a new unit's prerequisites", () => { new Map([["FIT3235", [prereq("FIT2115", "MAT1830")]]]), IT ) + const v = out.get(keyFor(2, 0, "FIT3235")) assert.ok(v) assert.equal(v.errors.length, 0) @@ -816,9 +851,11 @@ test("validatePlan: replaced units meet a new unit's prerequisites", () => { 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, diff --git a/packages/webapp/lib/planner/validation.ts b/packages/webapp/lib/planner/validation.ts index 9a79604..2e80787 100644 --- a/packages/webapp/lib/planner/validation.ts +++ b/packages/webapp/lib/planner/validation.ts @@ -76,10 +76,9 @@ 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 } @@ -91,9 +90,10 @@ export function validateUnitInSlot(input: ValidationInput): SlotUnitValidation { unit: input.unit, offerings: input.offerings, slotKind: input.slotKind, - slotYear: input.expectedHandbookYear, + slotYear: input.slotYear, transitions: input.transitions, }) + if (offering?.severity === "error") errors.push(offering.issue) else if (offering) warnings.push(offering.issue) @@ -194,24 +194,30 @@ export function offeringIssue(args: { unit: Pick offerings: readonly PlannerOffering[] slotKind: PeriodKind - slotYear?: string + slotYear: number transitions?: Transitions }): OfferingIssue | null { - const { unit, offerings, slotKind } = args - const slotYear = args.slotYear ?? unit.year + const { unit, offerings, slotKind, slotYear } = args const offered = isOfferedInPeriod(offerings, slotKind) - if (unit.year === slotYear && offered) return null + + 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, Number(slotYear), listed) + 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 null + + if (offered) + return entry ? unorderedIssue(entry.unit, slotKind, slotYear) : null + return handbookIssue(unit, offerings, slotKind, slotYear) } @@ -223,17 +229,38 @@ function outsideRun( 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, @@ -241,13 +268,16 @@ function statedRunIssue( ): 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}.` @@ -258,11 +288,12 @@ function handbookIssue( unit: Pick, offerings: readonly PlannerOffering[], slotKind: PeriodKind, - slotYear: string + 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 @@ -274,16 +305,19 @@ function handbookIssue( "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 (unit.year !== slotYear) + + 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, @@ -308,9 +342,11 @@ function noLongerOffered( ): 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", @@ -323,6 +359,7 @@ function noLongerOffered( function notYetOffered(code: string, first: TransitionTerm): OfferingIssue { const when = termLabel(first) + return { severity: "error", label: `New from ${when}`, @@ -337,19 +374,22 @@ function notYetOffered(code: string, first: TransitionTerm): OfferingIssue { function missingUnitIssue( code: string, slotKind: PeriodKind, - slotYear: string | undefined, + slotYear: number, transitions: Transitions ): OfferingIssue { const entry = transitions.byCode.get(code) - const outside = entry && outsideRun(entry, slotKind, Number(slotYear), false) + 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", @@ -412,21 +452,14 @@ export function validatePlan( // slot, which would walk the plan again for every slot. const completed = completedAtStart(state, unitsByCode, transitions) - // 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) - 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 @@ -452,9 +485,10 @@ export function validatePlan( const { severity, issue } = missingUnitIssue( code, slot.kind, - expectedHandbookYear, + slotYear, transitions ) + out.set(keyFor(y, s, code), { code, errors: severity === "error" ? [issue] : [], @@ -492,7 +526,7 @@ 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) @@ -573,6 +607,7 @@ export function completedBefore( 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, @@ -610,6 +645,7 @@ export function withEquivalents( 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)