Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/handbook-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions packages/webapp/components/planner/requirements-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -427,18 +428,21 @@ function GroupList({
</div>
<ul className="flex flex-wrap gap-1">
{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 (
<li key={`${g.grouping}:${code}`}>
<UnitDetailPopover
code={code}
code={shown}
yearIndex={placement?.yearIndex}
slotIndex={placement?.slotIndex}
>
<button
type="button"
aria-label={`Details for ${code}`}
aria-label={`Details for ${shown}`}
className={cn(
"inline-flex cursor-pointer items-center gap-0.5 rounded-control border px-1 py-0.5 text-[9px] tabular-nums transition-colors hover:bg-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
placed
Expand All @@ -454,6 +458,11 @@ function GroupList({
<CircleIcon className="size-2" />
)}
{code}
{via ? (
<span className="opacity-70">
via {via.join(" + ")}
</span>
) : null}
</button>
</UnitDetailPopover>
</li>
Expand Down
74 changes: 58 additions & 16 deletions packages/webapp/components/planner/unit-search-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -461,6 +477,8 @@ function UnitSearchView({
<UnitRow
unit={u}
offerings={offs}
offeringsLoaded={offerings.has(u.code)}
offering={offeringFor(u.code)}
slotKind={slotKind}
placed={inSlot}
onPlanElsewhere={!inSlot && plannedCodes.has(u.code)}
Expand Down Expand Up @@ -498,11 +516,7 @@ function UnitSearchView({
key={detailCode}
code={detailCode}
slotKind={slotKind}
fits={
slotKind
? isOfferedInPeriod(offerings.get(detailCode) ?? [], slotKind)
: true
}
offering={offeringFor(detailCode)}
hasOfferingData={
offerings.has(detailCode) ||
(offerings.get(detailCode) ?? []).length > 0
Expand Down Expand Up @@ -534,6 +548,8 @@ function GroupHeading({ children }: { children: React.ReactNode }) {
function UnitRow({
unit,
offerings,
offeringsLoaded,
offering,
slotKind,
placed,
onPlanElsewhere,
Expand All @@ -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
Expand All @@ -553,10 +571,12 @@ function UnitRow({
onClick: () => void
}) {
const periods = useMemo(() => {
const s = new Set<PeriodKind>()
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 (
<button
Expand Down Expand Up @@ -602,14 +622,14 @@ function UnitRow({
<span className="ml-0.5">On plan</span>
</PeriodChip>
) : null}
{periods.length === 0 ? (
{note ? (
<span
className={cn(
"text-[10px] italic",
focused ? "text-accent-foreground/70" : "text-muted-foreground"
)}
>
{offerings.length === 0 ? "Checking offerings…" : "Off-cycle"}
{note}
</span>
) : (
periods.map((p) => (
Expand All @@ -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,
Expand Down Expand Up @@ -650,7 +692,7 @@ function PeriodChip({
function FocusedDetails({
code,
slotKind,
fits,
offering,
hasOfferingData,
placed,
settled,
Expand All @@ -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. */
Expand All @@ -679,14 +721,14 @@ function FocusedDetails({
<span className="inline-flex items-center gap-1 rounded-control border border-border bg-muted px-2 py-0.5 font-medium text-muted-foreground">
Checking fit for {PERIOD_KIND_LABEL[slotKind]}…
</span>
) : fits ? (
) : !offering ? (
<span className="inline-flex items-center gap-1 rounded-control border border-success/30 bg-success-soft px-2 py-0.5 font-medium text-success-foreground">
<CheckIcon className="size-3" />
Fits {PERIOD_KIND_LABEL[slotKind]}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-control border border-warning/40 bg-warning-soft px-2 py-0.5 font-medium text-warning-foreground">
Not offered in {PERIOD_KIND_LABEL[slotKind]}
{offering.label}
</span>
)}
</div>
Expand Down
23 changes: 22 additions & 1 deletion packages/webapp/lib/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>`(${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.
Expand All @@ -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])))
Expand All @@ -870,6 +883,7 @@ async function _fetchUnitsByCode(
creditPoints: r.creditPoints ?? 0,
level: r.level,
school: r.school,
firstYear: r.firstYear,
}))
}
export async function fetchUnitsByCode(
Expand Down Expand Up @@ -898,6 +912,7 @@ async function _searchUnits(
creditPoints: units.creditPoints,
level: units.level,
school: units.school,
firstYear: unitFirstYear(),
})
.from(units)
.where(
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
}

Expand Down
37 changes: 32 additions & 5 deletions packages/webapp/lib/planner/personalize-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,15 +131,15 @@ 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",
"MAT1830",
])
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", () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
})
Loading