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
14 changes: 14 additions & 0 deletions packages/db/src/planner-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export interface PlannerState {
* away!!!").
*/
credit?: PlannerCreditEntry[]
/**
* Prerequisites the student has waived, per unit. Cleared when the
* course or start year changes, since they belong to this course map.
*/
waivers?: PlannerPrerequisiteWaiver[]
/**
* Campus the student is planning at, e.g. "Clayton" or "Malaysia".
* Filters campus-scoped area-of-study options and requirement groups
Expand All @@ -120,6 +125,15 @@ export interface PlannerState {
campus?: string
}

/**
* `prerequisiteCode` counts as met for `unitCode`'s prerequisites only.
* It earns no credit and doesn't touch any other rule or unit.
*/
export interface PlannerPrerequisiteWaiver {
unitCode: string
prerequisiteCode: string
}

/**
* One piece of advanced standing. Two flavours in one shape:
*
Expand Down
42 changes: 34 additions & 8 deletions packages/webapp/components/planner/left-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PrinterIcon,
Redo2Icon,
RotateCcwIcon,
ShieldCheckIcon,
Undo2Icon,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
Expand All @@ -24,16 +25,19 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { buildCsv, downloadBlob, planFileName } from "@/lib/planner/plan-export"
import { cn } from "@/lib/utils"

import { CreditDialog } from "./credit-dialog"
import { PlanStartControl } from "./plan-basics"
import { usePlanner, usePlannerSync } from "./planner-context"
import { TOUCH_HIT_ROW } from "./touch-target"
import { useWam } from "./wam-context"
import { WaiverDialog } from "./waiver-dialog"

/**
* Plan header above the grid: the plan's name (click to rename) and
* its start ("Starts Semester 1, 2027") and validation status on the
* left; undo/redo, Results, Credit and Print on the right, with
* left; undo/redo, Results, Credit, Waivers and Print on the right, with
* Export to CSV and Reset under More. State-only operations
* (no server round-trip).
*/
Expand Down Expand Up @@ -80,15 +84,29 @@ export function LeftSidebar() {
const { showResults, toggleShowResults, grades } = useWam()
const [creditOpen, setCreditOpen] = useState(false)
const creditCount = state.credit?.length ?? 0
const [waiversOpen, setWaiversOpen] = useState(false)
const waiverCount = state.waivers?.length ?? 0

const errorCount = useMemo(() => {
let n = 0
for (const v of validations.values()) n += v.errors.length
return n
}, [validations])

const validText = useMemo(
() =>
[...validations.values()].some((v) => v.reliesOnWaiver)
? "Every unit meets its prereqs or has them waived, and is offered in its slot"
: "Every unit meets its prereqs and is offered in its slot",
[validations]
)

const onReset = useCallback(() => {
if (!confirm("Reset the whole plan? This clears every unit you've placed."))
if (
!confirm(
"Reset the whole plan? This clears every unit you've placed and any waivers."
)
)
return
dispatch({ type: "reset" })
}, [dispatch])
Expand Down Expand Up @@ -149,17 +167,14 @@ export function LeftSidebar() {
onClick={() => {
if (errorCount === 0) {
toast.success("Plan validates cleanly", {
description:
"Every unit meets its prereqs and is offered in its slot.",
description: `${validText}.`,
})
return
}
flashErrors()
}}
title={
errorCount === 0
? "Every unit meets its prereqs and is offered in its slot"
: "Highlight the units with problems"
errorCount === 0 ? validText : "Highlight the units with problems"
}
className="ml-1.5 shrink-0 self-center"
>
Expand Down Expand Up @@ -211,6 +226,14 @@ export function LeftSidebar() {
onClick={() => setCreditOpen(true)}
count={creditCount}
/>
<ToolbarButton
icon={<ShieldCheckIcon />}
label="Waivers"
title="Prerequisites waived on this plan"
onClick={() => setWaiversOpen(true)}
count={waiverCount}
className={TOUCH_HIT_ROW}
/>
<ToolbarButton
icon={<PrinterIcon />}
label="Print"
Expand Down Expand Up @@ -248,6 +271,7 @@ export function LeftSidebar() {
</div>

<CreditDialog open={creditOpen} onOpenChangeAction={setCreditOpen} />
<WaiverDialog open={waiversOpen} onOpenChangeAction={setWaiversOpen} />
</div>
)
}
Expand All @@ -262,12 +286,14 @@ function ToolbarButton({
title,
onClick,
count = 0,
className,
}: {
icon: React.ReactNode
label: string
title: string
onClick: () => void
count?: number
className?: string
}) {
return (
<Button
Expand All @@ -276,7 +302,7 @@ function ToolbarButton({
onClick={onClick}
title={title}
aria-label={count > 0 ? `${label} (${count})` : label}
className="text-muted-foreground"
className={cn("text-muted-foreground", className)}
>
{icon}
<span className="hidden lg:inline">{label}</span>
Expand Down
15 changes: 11 additions & 4 deletions packages/webapp/components/planner/plan-basics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@ import { usePlanner } from "./planner-context"
* When the student starts: intake (Semester 1 or 2) and starting year.
* One set of controls, used by the first-run setup card and by the
* "Starts …" control beside the plan title. Changing the year on a plan
* with units asks first, because each year has its own handbook.
* with units or waivers asks first, because each year has its own handbook.
*/
function StartFields() {
const { state, dispatch, availableYears, switchYear } = usePlanner()
const [pendingYear, setPendingYear] = useState<string | null>(null)
const hasUnits = state.years.some((y) =>
y.slots.some((s) => s.unitCodes.length > 0)
)
const hasWaivers = (state.waivers?.length ?? 0) > 0

function chooseYear(y: string) {
if (y === state.courseYear) return
if (hasUnits) setPendingYear(y)

if (hasUnits || hasWaivers) setPendingYear(y)
else void switchYear(y)
}

Expand All @@ -68,8 +70,13 @@ function StartFields() {
<AlertDialogHeader>
<AlertDialogTitle>Start in {pendingYear}?</AlertDialogTitle>
<AlertDialogDescription>
Each starting year has its own handbook, so changing it clears the
units in your plan. You can undo this.
Each starting year has its own handbook, so changing it clears the{" "}
{hasUnits && hasWaivers
? "units and waivers"
: hasWaivers
? "waivers"
: "units"}{" "}
in your plan. You can undo this.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
27 changes: 13 additions & 14 deletions packages/webapp/components/planner/planner-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
defaultState,
historyReducer,
initialHistory,
withKnownYear,
type PlannerAction,
} from "@/lib/planner/state"
import type {
Expand Down Expand Up @@ -255,19 +256,6 @@ export function UnitDataOverlay({
return <PlannerCtx.Provider value={value}>{children}</PlannerCtx.Provider>
}

/** The plan with its year moved to `fallback` when the database lacks it. */
function withKnownYear(
plan: PlannerState,
availableYears: readonly string[],
fallback: string
): PlannerState {
const year =
plan.courseYear && availableYears.includes(plan.courseYear)
? plan.courseYear
: fallback
return { ...plan, courseYear: year }
}

export function PlannerProvider({
children,
initialYear,
Expand Down Expand Up @@ -763,6 +751,12 @@ export function PlannerProvider({
try {
// Same year, so the units already cached stay valid: merge.
const c = await loadYearData(year, code, false)

const { courseCode, waivers } = lastSnapshotRef.current

const clearsWaivers =
courseCode !== code && (waivers?.length ?? 0) > 0

// One undo step reverts the whole switch.
dispatch({
type: "batch",
Expand All @@ -778,14 +772,19 @@ export function PlannerProvider({
: []),
],
})

if (clearsWaivers)
toast("Waivers cleared", {
action: { label: "Undo", onClick: undo },
})
} catch (err) {
toast.error("Couldn't load course", {
description: err instanceof Error ? err.message : "Unknown error",
})
}
})
},
[state.courseYear, loadYearData]
[state.courseYear, loadYearData, undo]
)

// A "Plan this course" link landed on a saved plan for another
Expand Down
11 changes: 11 additions & 0 deletions packages/webapp/components/planner/print-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type PlannerState,
type PlannerUnit,
} from "@/lib/planner/types"
import { placedWaivers } from "@/lib/planner/waivers"
import { cn } from "@/lib/utils"

import { usePlanner, usePlannerSync } from "./planner-context"
Expand Down Expand Up @@ -145,6 +146,7 @@ export function PrintSheetView({
)

const credit = state.credit ?? []
const waivers = placedWaivers(state)
// A code credited twice, or credited and placed, counts once.
const creditTotal = creditPointsFromCredit(state, placedUnitCodes(state))

Expand Down Expand Up @@ -260,6 +262,15 @@ export function PrintSheetView({
{summary.duplicateUnitCodes.join(", ")}.
</p>
) : null}
{waivers.length > 0 ? (
<p>
Prerequisites waived:{" "}
{waivers
.map((w) => `${w.prerequisiteCode} for ${w.unitCode}`)
.join(", ")}
.
</p>
) : null}
<p>
Made with MonMap (monmap.monashcoding.com) by the Monash Association
of Coding. This is not an official Monash document: check your plan
Expand Down
Loading