(
+ termId: string,
+ fallback: T,
+) {
+ const match = /^(\d{4})-/.exec(termId);
+ return match ? Number(match[1]) : fallback;
+}
+
export function AppProvider({
children,
viewer,
@@ -164,7 +180,7 @@ export function AppProvider({
...current.attempts,
{
id: result.id!,
- academicYear,
+ academicYear: courseYearForTerm(termId, academicYear),
courseCode,
termId,
status: "planned",
@@ -210,7 +226,11 @@ export function AppProvider({
const remaining = current.attempts.filter(
(attempt) => attempt.id !== attemptId,
);
- const next = { ...moving, termId };
+ const next = {
+ ...moving,
+ termId,
+ academicYear: courseYearForTerm(termId, moving.academicYear),
+ };
const beforeIndex = beforeAttemptId
? remaining.findIndex((attempt) => attempt.id === beforeAttemptId)
: -1;
@@ -234,10 +254,21 @@ export function AppProvider({
const result = await movePlanCourse(attemptId, termId, beforeAttemptId);
if (!result.ok) {
setState((current) => ({ ...current, attempts: previousAttempts }));
+ return result;
+ }
+ const moved = previousAttempts.find(
+ (attempt) => attempt.id === attemptId,
+ );
+ // Another year's version may not be in the loaded catalogue yet.
+ if (
+ moved &&
+ moved.academicYear !== courseYearForTerm(termId, moved.academicYear)
+ ) {
+ router.refresh();
}
return result;
},
- [state.attempts],
+ [router, state.attempts],
);
const updateAttempt = useCallback(
@@ -316,7 +347,17 @@ export function AppProvider({
}
if (!attempt)
return { ok: false, message: "That course is no longer in your plan" };
- const result = await removePlanCourse(attemptId);
+ // An enrolment is a recorded attempt rather than a plan item, and has
+ // no result yet, so it is removed from the academic record instead.
+ const result =
+ attempt.status === "enrolled"
+ ? await saveAcademicResult(attemptId, "remove").then((response) => ({
+ ok: response.ok,
+ message: response.ok
+ ? "Course removed from the plan"
+ : response.message,
+ }))
+ : await removePlanCourse(attemptId);
if (!result.ok) return result;
setState((current) => ({
...current,
@@ -363,6 +404,26 @@ export function AppProvider({
[state.placements],
);
+ const toggleStar = useCallback(
+ async (courseCode: string) => {
+ const previous = state.starredCourses ?? [];
+ const starred = !previous.includes(courseCode);
+ // The star shows at once; a failed save takes it back.
+ setState((current) => ({
+ ...current,
+ starredCourses: starred
+ ? [...previous, courseCode]
+ : previous.filter((code) => code !== courseCode),
+ }));
+ const result = await setCourseStar(courseCode, starred);
+ if (!result.ok) {
+ setState((current) => ({ ...current, starredCourses: previous }));
+ }
+ return result;
+ },
+ [state.starredCourses],
+ );
+
const toggleOverloadApproval = useCallback((attemptId: string) => {
setState((current) => ({
...current,
@@ -386,6 +447,7 @@ export function AppProvider({
updateAttempt,
removeAttempt,
setPlacement,
+ toggleStar,
togglePermission,
toggleOverloadApproval,
notify,
@@ -401,6 +463,7 @@ export function AppProvider({
updateAttempt,
removeAttempt,
setPlacement,
+ toggleStar,
togglePermission,
toggleOverloadApproval,
notify,
diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx
index 19aa9faa..f5af77fd 100644
--- a/apps/web/app/requirements/page.tsx
+++ b/apps/web/app/requirements/page.tsx
@@ -2,11 +2,9 @@ import { PlanningCatalogueError } from "@/ui/plan/planning-catalogue-error";
import {
isPlanStructureKind,
loadCurrentUserPlanCatalogue,
- planCourseFromDetails,
} from "@/lib/coursemap/plan-catalogue";
import { loadOnboardingCatalogue } from "@/lib/coursemap/onboarding-catalogue";
-import { loadPublishedCoursesByCodes } from "@/lib/coursemap/published-courses";
-import { requirementCourseCodes } from "@/lib/coursemap/requirement-display";
+import { withRequirementCourses } from "@/lib/coursemap/requirement-courses";
import { Requirements } from "./requirements";
export const dynamic = "force-dynamic";
@@ -23,28 +21,8 @@ export default async function RequirementsPage({
loadCurrentUserPlanCatalogue(),
loadOnboardingCatalogue(),
]);
- const codes = [
- ...new Set(
- catalogue.structureRequirements.flatMap((item) =>
- requirementCourseCodes(item.root),
- ),
- ),
- ].filter(
- (code) =>
- !catalogue.courses.some(
- (course) =>
- course.code === code && course.year === catalogue.academicYear,
- ),
- );
- const courses =
- catalogue.academicYear !== null
- ? await loadPublishedCoursesByCodes(codes, catalogue.academicYear)
- : [];
data = {
- catalogue: {
- ...catalogue,
- courses: [...catalogue.courses, ...courses.map(planCourseFromDetails)],
- },
+ catalogue: await withRequirementCourses(catalogue),
choices,
};
} catch {
diff --git a/apps/web/app/requirements/requirements.tsx b/apps/web/app/requirements/requirements.tsx
index 013650d5..3f4a23ed 100644
--- a/apps/web/app/requirements/requirements.tsx
+++ b/apps/web/app/requirements/requirements.tsx
@@ -20,16 +20,9 @@ import type {
import type { SelectableStructureKind } from "@/lib/coursemap/programme-structure-options";
import type { Course } from "@/lib/coursemap/types";
import {
- allocateRequirements,
- placementOptions,
- requirementConditionsByKey,
- requirementTreeProgress,
-} from "@/lib/coursemap/requirement-progress";
-import {
- conditionHeading,
- type RequirementTreeCondition,
-} from "@/ui/requirements/requirement-presentation";
-import { requirementCourseStatus } from "@/lib/coursemap/requirement-display";
+ attemptStatusByCode,
+ planTreeContext,
+} from "@/ui/requirements/plan-tree-context";
import {
degreeUnitProgress,
planningCourseForAttempt,
@@ -79,14 +72,7 @@ export function Requirements({
specialisation: state.profile.specialisationCodes,
};
const selectedCodes = new Set(Object.values(selected).flat());
- const attemptStatuses = new Map(
- [...new Set(state.attempts.map((attempt) => attempt.courseCode))].flatMap(
- (code) => {
- const status = requirementCourseStatus(code, state.attempts);
- return status ? [[code, status] as const] : [];
- },
- ),
- );
+ const attemptStatuses = attemptStatusByCode(state.attempts);
const options = {
major: choices.majors.filter(
(item) =>
@@ -257,72 +243,22 @@ export function Requirements({
kind === "programme"
? (degree?.units ?? null)
: (option?.units ?? null);
- const root = requirements?.root ?? null;
- const conditions = requirementConditionsByKey(root);
- const nodeKeyFor = (projectionKey: string) =>
- [...conditions].find(
- ([, condition]) => condition.projectionKey === projectionKey,
- )?.[0];
- // A student's choices name rules by their stable key; a choice
- // for a rule this version no longer has is simply not applied.
- const pins = new Map(
- (state.placements ?? []).flatMap((choice) => {
- const nodeKey =
- choice.structureCode === code
- ? nodeKeyFor(choice.requirementKey)
- : undefined;
- return nodeKey ? [[choice.courseCode, nodeKey] as const] : [];
- }),
- );
- const allocation = allocateRequirements({
- root,
- attempts: state.attempts,
+ const context = planTreeContext({
+ structureCode: code,
+ root: requirements?.root ?? null,
catalogue,
- pins,
- });
- const treeProgress = requirementTreeProgress({
- root,
attempts: state.attempts,
- catalogue,
- allocation,
- });
- const labelFor = (nodeKey: string) => {
- const condition = conditions.get(nodeKey);
- return condition
- ? conditionHeading(condition as RequirementTreeCondition)
- : "another requirement";
- };
- const placement = {
- allocation,
- labelFor,
- optionsFor: (courseCode: string) => {
- const attempt = state.attempts.find(
- (candidate) => candidate.courseCode === courseCode,
- );
- const course = attempt
- ? planningCourseForAttempt(attempt, catalogue)
- : undefined;
- return course
- ? placementOptions({ root, course }).map((nodeKey) => ({
- nodeKey,
- label: labelFor(nodeKey),
- }))
- : [];
- },
- onPlace: (courseCode: string, nodeKey: string | null) => {
- const projectionKey = nodeKey
- ? conditions.get(nodeKey)?.projectionKey
- : undefined;
- void setPlacement(
- courseCode,
- projectionKey
- ? { structureCode: code, requirementKey: projectionKey }
- : null,
- ).then((result) => {
+ placements: state.placements ?? [],
+ statuses: attemptStatuses,
+ selectedStructureCodes: selectedCodes,
+ unitTarget: target,
+ onPlace: (courseCode, placement) => {
+ void setPlacement(courseCode, placement).then((result) => {
if (!result.ok) notify(result.message, "warning");
});
},
- };
+ onAddCourse: setAddingCourse,
+ });
return (
{kind !== "programme" && (
@@ -338,15 +274,7 @@ export function Requirements({
{requirements?.root ? (
) : (
diff --git a/apps/web/lib/coursemap/actions.ts b/apps/web/lib/coursemap/actions.ts
index 97ee7d45..adfa14ad 100644
--- a/apps/web/lib/coursemap/actions.ts
+++ b/apps/web/lib/coursemap/actions.ts
@@ -192,6 +192,27 @@ export async function recordCourseAttempt(
}
}
+/** Stars a course in the student's plan to consider later, or unstars it. */
+export async function setCourseStar(
+ courseCode: string,
+ starred: boolean,
+): Promise
{
+ try {
+ const supabase = await createClient();
+ const { error } = await supabase.rpc("set_current_user_course_star", {
+ p_course_code: courseCode,
+ p_starred: starred,
+ });
+ if (error) throw error;
+ return {
+ ok: true,
+ message: starred ? `${courseCode} starred` : `${courseCode} unstarred`,
+ };
+ } catch (error) {
+ return failure(error);
+ }
+}
+
/**
* Counts a course towards a chosen part of the student's degree, or hands the
* choice back to Coursemap when no requirement is given.
diff --git a/apps/web/lib/coursemap/requirement-courses.ts b/apps/web/lib/coursemap/requirement-courses.ts
new file mode 100644
index 00000000..93f5110c
--- /dev/null
+++ b/apps/web/lib/coursemap/requirement-courses.ts
@@ -0,0 +1,102 @@
+import "server-only";
+import {
+ planCourseFromDetails,
+ type PlanCatalogue,
+ type PlanRequirementCondition,
+ type PlanRequirementNode,
+} from "@/lib/coursemap/plan-catalogue";
+import {
+ loadPublishedCoursePage,
+ loadPublishedCoursesByCodes,
+} from "@/lib/coursemap/published-courses";
+import { requirementCourseCodes } from "@/lib/coursemap/requirement-display";
+
+/** Candidate courses fetched for each rule that counts units by filter. */
+const CANDIDATES_PER_RULE = 12;
+const RULES_SEARCHED = 12;
+
+function unitConditions(
+ node: PlanRequirementNode | null,
+): PlanRequirementCondition[] {
+ if (!node) return [];
+ if (node.type === "group") return node.children.flatMap(unitConditions);
+ return node.minimumUnits !== null ? [node] : [];
+}
+
+/**
+ * The course search filters that list what a units rule would count: its
+ * subject or tag, and its levels as the leading digit ("3+" for 3000 level
+ * or above). Rules any course meets have no useful filter.
+ */
+function searchFilters(condition: PlanRequirementCondition) {
+ const digit = (level: number) => String(level < 10 ? level : level / 1000);
+ const { minimumLevel, maximumLevel } = condition;
+ const level =
+ minimumLevel !== null
+ ? minimumLevel === maximumLevel
+ ? digit(minimumLevel)
+ : `${digit(minimumLevel)}+`
+ : maximumLevel !== null
+ ? digit(maximumLevel)
+ : undefined;
+ if (condition.conditionKind === "subject_units" && condition.subjectCode)
+ return { subject: condition.subjectCode, level };
+ if (condition.conditionKind === "tagged_units" && condition.tag)
+ return { tag: condition.tag, level };
+ return null;
+}
+
+/**
+ * The plan catalogue with the courses the student's structures list, and a
+ * page of the courses each subject or tag rule would count, so requirements
+ * can name and suggest courses that are not in the plan yet.
+ */
+export async function withRequirementCourses(
+ catalogue: PlanCatalogue,
+): Promise {
+ const academicYear = catalogue.academicYear;
+ if (academicYear === null) return catalogue;
+ const known = (code: string) =>
+ catalogue.courses.some(
+ (course) => course.code === code && course.year === academicYear,
+ );
+ const codes = [
+ ...new Set(
+ catalogue.structureRequirements.flatMap((item) =>
+ requirementCourseCodes(item.root),
+ ),
+ ),
+ ].filter((code) => !known(code));
+ const filters = catalogue.structureRequirements
+ .flatMap((item) => unitConditions(item.root))
+ .flatMap((condition) => {
+ const filter = searchFilters(condition);
+ return filter ? [filter] : [];
+ })
+ .filter(
+ (filter, index, all) =>
+ all.findIndex(
+ (other) => JSON.stringify(other) === JSON.stringify(filter),
+ ) === index,
+ )
+ .slice(0, RULES_SEARCHED);
+ const [listed, ...pages] = await Promise.all([
+ loadPublishedCoursesByCodes(codes, academicYear),
+ ...filters.map((filter) =>
+ loadPublishedCoursePage({
+ academicYear,
+ filters: filter,
+ pageSize: CANDIDATES_PER_RULE,
+ }).catch(() => null),
+ ),
+ ]);
+ const added = new Map(
+ [...listed, ...pages.flatMap((page) => page?.courses ?? [])]
+ .filter((course) => !known(course.code))
+ .map((course) => [course.code, planCourseFromDetails(course)]),
+ );
+ return {
+ ...catalogue,
+ courses: [...catalogue.courses, ...added.values()],
+ };
+}
diff --git a/apps/web/lib/coursemap/state.ts b/apps/web/lib/coursemap/state.ts
index e1057642..2efb5c67 100644
--- a/apps/web/lib/coursemap/state.ts
+++ b/apps/web/lib/coursemap/state.ts
@@ -128,6 +128,7 @@ export async function loadCoursemapState(
itemsResult,
attemptsResult,
placementsResult,
+ starsResult,
] = await Promise.all([
supabase
.from("academic_years")
@@ -157,6 +158,11 @@ export async function loadCoursemapState(
.from("plan_requirement_placements")
.select("course_code,structure_code,requirement_key")
.eq("plan_id", plan.id),
+ supabase
+ .from("plan_starred_courses")
+ .select("course_code")
+ .eq("plan_id", plan.id)
+ .order("created_at"),
]);
const structures = structuresResult.data ?? [];
@@ -321,6 +327,7 @@ export async function loadCoursemapState(
structureCode: row.structure_code,
requirementKey: row.requirement_key,
})),
+ starredCourses: (starsResult.data ?? []).map((row) => row.course_code),
};
} catch {
return fallback;
diff --git a/apps/web/lib/coursemap/types.ts b/apps/web/lib/coursemap/types.ts
index b804ba01..9078ddc8 100644
--- a/apps/web/lib/coursemap/types.ts
+++ b/apps/web/lib/coursemap/types.ts
@@ -126,4 +126,6 @@ export type AppState = {
attempts: Attempt[];
/** Courses the student moved to a part of their degree themselves. */
placements?: RequirementPlacementChoice[];
+ /** Codes of courses the student starred to consider later. */
+ starredCourses?: string[];
};
diff --git a/apps/web/playwright/authenticated.spec.ts b/apps/web/playwright/authenticated.spec.ts
index 411c2850..36ebc38e 100644
--- a/apps/web/playwright/authenticated.spec.ts
+++ b/apps/web/playwright/authenticated.spec.ts
@@ -73,6 +73,7 @@ test("course selection persists in an independent student plan", async ({
}) => {
await login(page, planner);
await page.goto("/plan");
+ await page.getByRole("tab", { name: /Later/ }).click();
await page.getByRole("button", { name: "Add an unscheduled course" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
@@ -87,6 +88,7 @@ test("course selection persists in an independent student plan", async ({
await dialog.getByRole("button", { name: /Add to/ }).click();
await expect(dialog).not.toBeVisible();
await page.reload();
+ await page.getByRole("tab", { name: /Later/ }).click();
await expect(page.getByRole("main")).toContainText("COMP1100");
await page.goto("/courses/2026/comp1110");
await page.getByRole("tab", { name: "Requisites", exact: true }).click();
diff --git a/apps/web/tests/plan-board-diagnostics.test.tsx b/apps/web/tests/plan-board-diagnostics.test.tsx
index 228b5b66..07bbe283 100644
--- a/apps/web/tests/plan-board-diagnostics.test.tsx
+++ b/apps/web/tests/plan-board-diagnostics.test.tsx
@@ -26,7 +26,7 @@ vi.mock("@/ui/shell", () => ({
AppShell: ({ children }: { children: ReactNode }) => {children},
}));
vi.mock("@/ui/overlays", () => ({
- CourseDrawer: () => null,
+ CourseDialog: () => null,
CoursePicker: () => null,
}));
const catalogue: PlanCatalogue = {
@@ -52,7 +52,7 @@ const catalogue: PlanCatalogue = {
test("a programme with units but no duration still offers three planning years without a data warning", () => {
fixtures.attempts = [];
render();
- expect(screen.getByRole("heading", { name: "Year 3" })).toBeVisible();
+ expect(screen.getByRole("tab", { name: /Year 3/ })).toBeVisible();
expect(
screen.getAllByRole("button", { name: /Add course/ }).length,
).toBeGreaterThan(0);
diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts
index 2f4efb14..429374be 100644
--- a/apps/web/types/database.ts
+++ b/apps/web/types/database.ts
@@ -3207,6 +3207,38 @@ export type Database = {
},
]
}
+ plan_starred_courses: {
+ Row: {
+ course_code: string
+ created_at: string
+ id: string
+ owner_id: string
+ plan_id: string
+ }
+ Insert: {
+ course_code: string
+ created_at?: string
+ id?: string
+ owner_id: string
+ plan_id: string
+ }
+ Update: {
+ course_code?: string
+ created_at?: string
+ id?: string
+ owner_id?: string
+ plan_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "plan_starred_courses_plan_id_fkey"
+ columns: ["plan_id"]
+ isOneToOne: false
+ referencedRelation: "plans"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
plan_structures: {
Row: {
catalogue_record_id: number
@@ -4418,6 +4450,10 @@ export type Database = {
}
Returns: number
}
+ set_current_user_course_star: {
+ Args: { p_course_code: string; p_starred: boolean }
+ Returns: undefined
+ }
set_current_user_plan_extension_years: {
Args: { p_extension_years: number }
Returns: undefined
diff --git a/apps/web/ui/common/star-button.tsx b/apps/web/ui/common/star-button.tsx
new file mode 100644
index 00000000..81d7afa4
--- /dev/null
+++ b/apps/web/ui/common/star-button.tsx
@@ -0,0 +1,38 @@
+"use client";
+import { Star } from "lucide-react";
+import { Button } from "@coursemap/ui/primitives/button";
+import { cn } from "@/lib/cn";
+import { useCoursemap } from "@/app/providers";
+
+/** Stars a course to come back to while planning, or unstars it. */
+export function StarButton({
+ courseCode,
+ className,
+}: {
+ courseCode: string;
+ className?: string;
+}) {
+ const { state, toggleStar, notify } = useCoursemap();
+ const starred = (state.starredCourses ?? []).includes(courseCode);
+ return (
+
+ );
+}
diff --git a/apps/web/ui/overlays/course-dialog.tsx b/apps/web/ui/overlays/course-dialog.tsx
new file mode 100644
index 00000000..078139c3
--- /dev/null
+++ b/apps/web/ui/overlays/course-dialog.tsx
@@ -0,0 +1,594 @@
+"use client";
+import { useReturnFocus } from "@/hooks/use-return-focus";
+import { Alert, AlertDescription } from "@coursemap/ui/components/alert";
+import { Button } from "@coursemap/ui/primitives/button";
+import {
+ Field,
+ FieldError,
+ FieldDescription,
+} from "@coursemap/ui/primitives/field";
+import { Input } from "@coursemap/ui/primitives/input";
+import { OptionPicker } from "@/ui/common/option-picker";
+import ReuiLink from "next/link";
+import { cn } from "@/lib/cn";
+
+import {
+ AlertTriangle,
+ Check,
+ ExternalLink,
+ ShieldCheck,
+ Trash2,
+ X,
+} from "lucide-react";
+import { useState } from "react";
+
+import { useCoursemap } from "@/app/providers";
+import type { PlanCatalogue } from "@/lib/coursemap/plan-catalogue";
+import type { Attempt } from "@/lib/coursemap/types";
+import {
+ attemptedUnitsError,
+ attemptedUnitsFromInput,
+ attemptUnitRequirement,
+} from "@/lib/coursemap/attempt-units";
+import {
+ effectiveStatus,
+ isActiveAttempt,
+ missingPrereqs,
+ planningCourseForAttempt,
+ termIndex,
+ unitsForAttempt,
+} from "@/lib/planner";
+import type { StudentRecord } from "@/lib/coursemap/requisite-evaluation";
+import { EnrolmentSteps } from "@/ui/courses/enrolment-steps";
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+} from "@coursemap/ui/primitives/dialog";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@coursemap/ui/primitives/tabs";
+
+import { StatusPill } from "@/ui/common/status-pill";
+import { StarButton } from "@/ui/common/star-button";
+import { FixIssueButton } from "@/ui/plan/fix-issue-button";
+
+const AUD = new Intl.NumberFormat("en-AU", {
+ style: "currency",
+ currency: "AUD",
+ maximumFractionDigits: 0,
+});
+/** "First Semester" as S1, so the header stays one line. */
+function shortSession(session: string) {
+ if (/first|semester 1\b/i.test(session)) return "S1";
+ if (/second|semester 2\b/i.test(session)) return "S2";
+ return session;
+}
+const COURSE_CODE = /\b[A-Z]{4}\d{4}\b/g;
+
+/**
+ * The plan as a student record for one course's requisites: courses planned
+ * or done before its semester count as completed, and ones beside it as
+ * taken at the same time.
+ */
+function planStudentRecord(
+ attempt: Attempt,
+ attempts: Attempt[],
+ degreeCode: string,
+ catalogue?: PlanCatalogue,
+): StudentRecord {
+ const order = termIndex(attempt.termId, catalogue);
+ const others = attempts.filter(
+ (other) => other.id !== attempt.id && isActiveAttempt(other),
+ );
+ return {
+ completed: new Map(
+ others
+ .filter((other) => termIndex(other.termId, catalogue) < order)
+ .map((other) => {
+ const course = planningCourseForAttempt(other, catalogue);
+ return [
+ other.courseCode.toUpperCase(),
+ {
+ units: unitsForAttempt(other, course),
+ mark: other.mark ?? null,
+ tags: course?.tags ?? [],
+ },
+ ];
+ }),
+ ),
+ enrolled: new Set(
+ others
+ .filter((other) => termIndex(other.termId, catalogue) === order)
+ .map((other) => other.courseCode.toUpperCase()),
+ ),
+ programmeCodes: degreeCode ? [degreeCode] : [],
+ wam: null,
+ gpa: null,
+ studyYear: null,
+ };
+}
+
+/**
+ * A planned or recorded course: the key facts up top, its description and
+ * requisites a tab apart, and the result actions along the bottom. A missing
+ * prerequisite shows above the tabs so it is seen without looking for it.
+ */
+export function CourseDialog({
+ attemptId,
+ catalogue,
+ onClose,
+}: {
+ attemptId: string;
+ catalogue?: PlanCatalogue;
+ onClose: () => void;
+}) {
+ const restoreFocus = useReturnFocus();
+ const { state, updateAttempt, removeAttempt, togglePermission, notify } =
+ useCoursemap();
+ const attempt = state.attempts.find((item) => item.id === attemptId);
+ const course = attempt
+ ? planningCourseForAttempt(attempt, catalogue)
+ : undefined;
+ const [attemptedUnitsInput, setAttemptedUnitsInput] = useState(() =>
+ attempt?.unitsAttempted === undefined ? "" : String(attempt.unitsAttempted),
+ );
+ const [showFullDescription, setShowFullDescription] = useState(false);
+ const unitRequirement = course ? attemptUnitRequirement(course) : null;
+ const status = attempt
+ ? effectiveStatus(attempt, state.attempts, catalogue)
+ : "planned";
+
+ if (!attempt || !course || !unitRequirement) return null;
+
+ const missing = new Set(missingPrereqs(attempt, state.attempts, catalogue));
+ const prereqsMet = missing.size === 0;
+ const recorded = attempt.status !== "planned";
+ const enrolled = attempt.status === "enrolled";
+ /** A result or withdrawal is history and stays put; an enrolment can go. */
+ const final = recorded && !enrolled;
+ const selectedAttemptedUnits = attemptedUnitsFromInput(
+ unitRequirement,
+ attemptedUnitsInput,
+ );
+ const unitError = attemptedUnitsError(unitRequirement, attemptedUnitsInput);
+ const unitSelectionRequired = unitRequirement.kind !== "fixed";
+ const unitSelectionMissing =
+ unitSelectionRequired && selectedAttemptedUnits === null;
+ const submittedAttemptedUnits = unitSelectionRequired
+ ? (selectedAttemptedUnits ?? undefined)
+ : undefined;
+ const showUnits = !recorded && unitSelectionRequired;
+ const facts = [
+ ["Units", String(unitsForAttempt(attempt, course))],
+ ["Level", String(course.level)],
+ [
+ "Offered",
+ [...new Set(course.sessions)].join(", ") || "Not listed this year",
+ ],
+ ["Delivery", course.delivery],
+ [
+ "Cost",
+ course.domesticFee != null
+ ? `${AUD.format(course.domesticFee)} domestic`
+ : "Not listed",
+ ],
+ ["Convener", course.convener],
+ ["Counts towards", course.countsTowards.join(", ")],
+ ].filter(([, value]) => value);
+ const incompatibleCodes = [
+ ...new Set(course.incompatibilities.join(" ").match(COURSE_CODE) ?? []),
+ ];
+ const availableCodes = new Set(
+ (catalogue?.courses ?? []).map((item) => item.code),
+ );
+ const rule = course.prerequisiteRule?.relationalExpression ?? null;
+ const student = rule
+ ? planStudentRecord(
+ attempt,
+ state.attempts,
+ state.profile.degreeCode,
+ catalogue,
+ )
+ : null;
+ const remove = async () => {
+ const result = await removeAttempt(attempt.id);
+ notify(result.message, result.ok ? "success" : "error");
+ if (result.ok) onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/ui/overlays/course-drawer.tsx b/apps/web/ui/overlays/course-drawer.tsx
deleted file mode 100644
index fdf8941f..00000000
--- a/apps/web/ui/overlays/course-drawer.tsx
+++ /dev/null
@@ -1,498 +0,0 @@
-"use client";
-import { useReturnFocus } from "@/hooks/use-return-focus";
-import { Alert, AlertDescription } from "@coursemap/ui/components/alert";
-import { Button } from "@coursemap/ui/primitives/button";
-import {
- Field,
- FieldError,
- FieldDescription,
-} from "@coursemap/ui/primitives/field";
-import { Input } from "@coursemap/ui/primitives/input";
-import { OptionPicker } from "@/ui/common/option-picker";
-import ReuiLink from "next/link";
-import { cn } from "@/lib/cn";
-
-import {
- AlertTriangle,
- BookMarked,
- CalendarDays,
- Check,
- ExternalLink,
- Info,
- ShieldCheck,
- Trash2,
- UserRound,
- X,
-} from "lucide-react";
-import { useState } from "react";
-
-import { useCoursemap } from "@/app/providers";
-import type { PlanCatalogue } from "@/lib/coursemap/plan-catalogue";
-import {
- attemptedUnitsError,
- attemptedUnitsFromInput,
- attemptUnitRequirement,
-} from "@/lib/coursemap/attempt-units";
-import {
- effectiveStatus,
- missingPrereqs,
- planningCourseForAttempt,
- unitsForAttempt,
-} from "@/lib/planner";
-import {
- Sheet,
- SheetContent,
- SheetTitle,
-} from "@coursemap/ui/primitives/sheet";
-
-import { StatusPill } from "@/ui/common/status-pill";
-import { FixIssueButton } from "@/ui/plan/fix-issue-button";
-
-export function CourseDrawer({
- attemptId,
- catalogue,
- onClose,
-}: {
- attemptId: string;
- catalogue?: PlanCatalogue;
- onClose: () => void;
-}) {
- const restoreFocus = useReturnFocus();
- const { state, updateAttempt, removeAttempt, togglePermission, notify } =
- useCoursemap();
- const attempt = state.attempts.find((item) => item.id === attemptId);
- const course = attempt
- ? planningCourseForAttempt(attempt, catalogue)
- : undefined;
- const [attemptedUnitsInput, setAttemptedUnitsInput] = useState(() =>
- attempt?.unitsAttempted === undefined ? "" : String(attempt.unitsAttempted),
- );
- const unitRequirement = course ? attemptUnitRequirement(course) : null;
- const status = attempt
- ? effectiveStatus(attempt, state.attempts, catalogue)
- : "planned";
-
- if (!attempt || !course || !unitRequirement) return null;
-
- const missing = new Set(missingPrereqs(attempt, state.attempts, catalogue));
- const prereqsMet = missing.size === 0;
- const recorded = attempt.status !== "planned";
- const selectedAttemptedUnits = attemptedUnitsFromInput(
- unitRequirement,
- attemptedUnitsInput,
- );
- const unitError = attemptedUnitsError(unitRequirement, attemptedUnitsInput);
- const unitSelectionRequired = unitRequirement.kind !== "fixed";
- const unitSelectionMissing =
- unitSelectionRequired && selectedAttemptedUnits === null;
- const submittedAttemptedUnits = unitSelectionRequired
- ? (selectedAttemptedUnits ?? undefined)
- : undefined;
- const remove = async () => {
- const result = await removeAttempt(attempt.id);
- notify(result.message, result.ok ? "success" : "error");
- if (result.ok) onClose();
- };
-
- return (
- {
- if (!open) onClose();
- }}
- >
-
-
-
-
-
-
-
- {course.name}
-
-
-
-
- {course.description}
-
-
-
- {[
- ["Units", String(unitsForAttempt(attempt, course))],
- ["Level", String(course.level)],
- [
- "Offered",
- course.sessions
- .map((item) => item.replace("Semester ", "S"))
- .join(" · "),
- ],
- ].map(([label, value]) => (
-
-
- {label}
-
-
- {value}
-
-
- ))}
-
-
-
- {[
- [
, "Convener", course.convener],
- [
, "Delivery", course.delivery],
- [
-
,
- "Counts towards",
- course.countsTowards.join(", "),
- ],
- ].map(([icon, label, value], index) => (
-
- {icon}
-
- {label}
-
-
- {value}
-
-
- ))}
-
-
-
-
- {!recorded && unitRequirement.kind === "unavailable" ? (
-
-
- This course cannot be added to your plan yet.
-
-
- ) : null}
-
- {!recorded &&
- unitSelectionRequired &&
- unitRequirement.kind !== "unavailable" ? (
-
- ) : null}
-
-
-
- Requisites
-
-
-
-
-
- {prereqsMet ? (
-
- ) : (
-
- )}
-
-
-
- Prerequisite
-
-
- {course.prerequisiteText}
-
- {course.prerequisiteCodes.length > 0 && (
-
- {course.prerequisiteCodes.map((code) => (
-
- {code}
-
- ))}
-
- )}
- {!prereqsMet && (
-
-
-
-
- {[...missing].join(" + ")} must be completed or
- planned earlier
-
-
-
-
- )}
-
-
-
- {course.incompatibilities.length > 0 && (
-
-
-
-
-
-
- Incompatible with
-
-
- {course.incompatibilities.join(", ")}
-
-
-
- )}
-
- {course.permissionText && (
-
-
-
-
-
-
- Permission code
-
-
- {course.permissionText}
-
-
-
-
- )}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/ui/overlays/course-preview.tsx b/apps/web/ui/overlays/course-preview.tsx
index 939d8ac9..a911afd1 100644
--- a/apps/web/ui/overlays/course-preview.tsx
+++ b/apps/web/ui/overlays/course-preview.tsx
@@ -1,4 +1,5 @@
"use client";
+import { StarButton } from "@/ui/common/star-button";
import { Button } from "@coursemap/ui/primitives/button";
import {
Empty,
@@ -77,6 +78,7 @@ export function CoursePreview({
+
diff --git a/apps/web/ui/overlays/index.ts b/apps/web/ui/overlays/index.ts
index 2696d2e4..2e7688d1 100644
--- a/apps/web/ui/overlays/index.ts
+++ b/apps/web/ui/overlays/index.ts
@@ -1,3 +1,3 @@
export { CoursePicker } from "./course-picker";
-export { CourseDrawer } from "./course-drawer";
+export { CourseDialog } from "./course-dialog";
export { TermChooser } from "./term-chooser";
diff --git a/apps/web/ui/plan/courses-to-plan.tsx b/apps/web/ui/plan/courses-to-plan.tsx
new file mode 100644
index 00000000..927d4bf1
--- /dev/null
+++ b/apps/web/ui/plan/courses-to-plan.tsx
@@ -0,0 +1,269 @@
+"use client";
+import { useState, type PointerEvent as ReactPointerEvent } from "react";
+import { Check, GripVertical, Plus } from "lucide-react";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@coursemap/ui/primitives/tabs";
+import type { Course } from "@/lib/coursemap/types";
+import type { PlanStructureKind } from "@/lib/coursemap/plan-catalogue";
+import type {
+ CourseToPlan,
+ RuleToPlan,
+ StructureToPlan,
+} from "@/ui/plan/plan-suggestions";
+import { StarButton } from "@/ui/common/star-button";
+
+type DragStart = (
+ event: ReactPointerEvent,
+ item: CourseToPlan,
+) => void;
+
+/** "First Semester" as S1, so a row can show every session it runs in. */
+function sessionLabel(session: string) {
+ if (/first|semester 1\b/i.test(session)) return "S1";
+ if (/second|semester 2\b/i.test(session)) return "S2";
+ return session.split(" ")[0];
+}
+
+function CourseRow({
+ item,
+ onAdd,
+ onDragStart,
+}: {
+ item: CourseToPlan;
+ onAdd: (course: Course) => void;
+ onDragStart: DragStart;
+}) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+const SHOWN = 3;
+
+function RuleSection({
+ rule,
+ onAdd,
+ onDragStart,
+}: {
+ rule: RuleToPlan;
+ onAdd: (course: Course) => void;
+ onDragStart: DragStart;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const shown =
+ rule.compulsory || expanded ? rule.courses : rule.courses.slice(0, SHOWN);
+ const hidden = rule.courses.length - shown.length;
+ return (
+
+
+
+
+ {rule.heading}
+
+
+ {rule.left}
+
+
+
+
+ {shown.length > 0 ? (
+
+ {shown.map((item) => (
+
+ ))}
+
+ ) : (
+
+ Search for a course that fits.
+
+ )}
+ {hidden > 0 ? (
+
+ ) : null}
+
+ );
+}
+
+const KIND_LABELS: Record = {
+ programme: "Degree",
+ major: "Major",
+ minor: "Minor",
+ specialisation: "Specialisation",
+};
+
+/**
+ * What the plan still needs, one tab per part of the degree: each open rule
+ * in a few words with courses under it to drag into a semester, and the
+ * rules already covered folded into one line.
+ */
+export function CoursesToPlan({
+ structures,
+ starred,
+ onAdd,
+ onDragStart,
+}: {
+ structures: StructureToPlan[];
+ starred: CourseToPlan[];
+ onAdd: (course: Course) => void;
+ onDragStart: DragStart;
+}) {
+ const kindCount = (kind: PlanStructureKind) =>
+ structures.filter((item) => item.structure.kind === kind).length;
+ const tabs = structures.map((item) => ({
+ value: item.structure.code,
+ label:
+ kindCount(item.structure.kind) > 1
+ ? item.structure.name
+ : KIND_LABELS[item.structure.kind],
+ item,
+ }));
+ const [tab, setTab] = useState(() => tabs[0]?.value ?? "starred");
+ return (
+
+
+
+
+ {tabs.map(({ value, label, item }) => (
+
+ {label}
+ {item.rules.length > 0 ? (
+
+ {item.rules.length}
+
+ ) : (
+
+ )}
+
+ ))}
+
+ Starred
+ {starred.length > 0 ? (
+
+ {starred.length}
+
+ ) : null}
+
+
+
+
+ {tabs.map(({ value, item }) => (
+
+ {item.rules.length === 0 ? (
+
+ Everything for the {item.structure.name} is in your plan.
+
+ ) : (
+ item.rules.map((rule) => (
+
+ ))
+ )}
+ {item.doneCount > 0 ? (
+
+
+ {item.doneCount} {item.doneCount === 1 ? "rule" : "rules"}{" "}
+ covered
+
+ ) : null}
+
+ ))}
+
+ {starred.length === 0 ? (
+
+ Star a course and it waits here.
+
+ ) : (
+
+ {starred.map((item) => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/ui/plan/plan-board.tsx b/apps/web/ui/plan/plan-board.tsx
index f7153eed..52b144be 100644
--- a/apps/web/ui/plan/plan-board.tsx
+++ b/apps/web/ui/plan/plan-board.tsx
@@ -19,7 +19,7 @@ import { cn } from "@/lib/cn";
import { useCoursemap } from "@/app/providers";
import { AppShell } from "@/ui/shell";
import { OnboardingPrompt } from "@/ui/common/onboarding-prompt";
-import { CourseDrawer, CoursePicker } from "@/ui/overlays";
+import { CourseDialog, CoursePicker } from "@/ui/overlays";
import { Button } from "@coursemap/ui/primitives/button";
import { FixIssueButton } from "@/ui/plan/fix-issue-button";
import {
@@ -33,6 +33,19 @@ import {
TooltipTrigger,
} from "@coursemap/ui/primitives/tooltip";
import type { Attempt, Course, Term } from "@/lib/coursemap/types";
+import { YearTabs, type YearTab } from "@/ui/plan/year-tabs";
+import { CoursesToPlan } from "@/ui/plan/courses-to-plan";
+import {
+ courseForTerm,
+ ruleSearches,
+ structuresToPlan,
+ type CourseToPlan,
+ type PlannedStructure,
+} from "@/ui/plan/plan-suggestions";
+import {
+ attemptStatusByCode,
+ planTreeContext,
+} from "@/ui/requirements/plan-tree-context";
import type { PlanCatalogue } from "@/lib/coursemap/plan-catalogue";
import { recommendedCourseCodes } from "@/lib/coursemap/requirement-display";
import {
@@ -41,6 +54,8 @@ import {
} from "@/lib/coursemap/plan-timeline";
import {
STANDARD_COURSE_SLOTS,
+ STANDARD_TERM_UNITS,
+ courseIsAvailable,
effectiveStatus,
missingPrereqs,
planningCourseForAttempt,
@@ -67,6 +82,11 @@ export type DragPointer = {
rowHeight: number;
};
export type PickerState = { termId: string; intent: "all" | "recommended" };
+
+/** Drag ids for suggested courses, which have no attempt yet. */
+const SUGGESTION_DRAG = "suggestion:";
+/** Where a planned course dropped on the courses to plan box goes: out of the plan. */
+const REMOVE_DROP = "remove";
export /** Single muted status mark - the only colour on the board. */
function StatusMark({
status,
@@ -85,7 +105,21 @@ function StatusMark({
}
export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
const overloadFocus = useReturnFocus();
- const { state, reorderAttempt, notify } = useCoursemap();
+ const {
+ state,
+ reorderAttempt,
+ addCourse,
+ removeAttempt,
+ setPlacement,
+ notify,
+ } = useCoursemap();
+ const [selectedYearKey, setSelectedYearKey] = useState(null);
+ const [fetchedCourses, setFetchedCourses] = useState([]);
+ const [searched, setSearched] = useState>(
+ () => new Map(),
+ );
+ const [draggedSuggestion, setDraggedSuggestion] =
+ useState(null);
const [picker, setPicker] = useState(null);
const [overloadTerm, setOverloadTerm] = useState(null);
const [pendingDrop, setPendingDrop] = useState(null);
@@ -94,8 +128,11 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
const [dragPreview, setDragPreview] = useState(null);
const [dragPointer, setDragPointer] = useState(null);
const dragPreviewRef = useRef(null);
- const boardRef = useRef(null);
+ const boardRef = useRef(null);
const floatingCardRef = useRef(null);
+ const draggedSuggestionRef = useRef(null);
+ const hoverTabRef = useRef(undefined);
+ const hoverTimerRef = useRef | null>(null);
const pointerCleanupRef = useRef<(() => void) | null>(null);
const degree = catalogue.degrees.find(
@@ -123,23 +160,6 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
() => ({ ...catalogue, terms: timelineTerms }),
[catalogue, timelineTerms],
);
- const scheduledYears = useMemo(
- () =>
- [
- ...new Set(
- timelineTerms
- .filter((term) => term.id !== "unscheduled")
- .map((term) => term.year),
- ),
- ].map((year) => ({
- year,
- terms: timelineTerms.filter(
- (term) => term.year === year && term.id !== "unscheduled",
- ),
- })),
- [timelineTerms],
- );
- const unscheduled = timelineTerms.find((term) => term.id === "unscheduled");
const recommendedCodes = useMemo(
() => recommendedCourseCodes(catalogue, state.profile, state.attempts),
[catalogue, state.profile, state.attempts],
@@ -186,6 +206,261 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
0,
);
+ const hasRoom = (term: Term) =>
+ term.id === "unscheduled" ||
+ entriesFor(term.id).length < STANDARD_COURSE_SLOTS;
+ const termsFor = (key: string) =>
+ timelineTerms.filter((term) =>
+ key === "later"
+ ? term.id === "unscheduled"
+ : term.id !== "unscheduled" && String(term.year) === key,
+ );
+ const yearTabs: YearTab[] = [
+ ...[
+ ...new Set(
+ timelineTerms
+ .filter((term) => term.id !== "unscheduled")
+ .map((term) => term.year),
+ ),
+ ].map((year) => {
+ const terms = termsFor(String(year));
+ const entries = terms.flatMap((term) => entriesFor(term.id));
+ return {
+ key: String(year),
+ label: `Year ${Math.max(1, year - state.profile.commencementYear + 1)}`,
+ detail: String(year),
+ units: unitsOf(entries),
+ target: terms.length * STANDARD_TERM_UNITS,
+ finished:
+ entries.length > 0 &&
+ entries.every((entry) => entry.attempt.status === "completed"),
+ };
+ }),
+ ...(timelineTerms.some((term) => term.id === "unscheduled")
+ ? [
+ {
+ key: "later",
+ label: "Later",
+ detail: "Not scheduled",
+ units: unitsOf(entriesFor("unscheduled")),
+ target: 0,
+ finished: false,
+ },
+ ]
+ : []),
+ ];
+ // Opens on the first year with room that is not already behind the
+ // student, so the page starts where there is planning to do.
+ const selectedYear =
+ yearTabs.find((year) => year.key === selectedYearKey) ??
+ yearTabs.find(
+ (year) =>
+ year.key !== "later" &&
+ !year.finished &&
+ termsFor(year.key).some(hasRoom),
+ ) ??
+ yearTabs.find((year) => !year.finished) ??
+ yearTabs[0];
+ const selectedTerms = selectedYear ? termsFor(selectedYear.key) : [];
+
+ const requestAddSuggested = async (picked: Course, term: Term) => {
+ const course =
+ courseForTerm(picked.code, term, planningCatalogue) ?? picked;
+ if (
+ term.id !== "unscheduled" &&
+ course.sessions.length > 0 &&
+ !courseIsAvailable(course, term.name)
+ ) {
+ notify(
+ `${course.code} is not offered in ${term.name}. It runs in ${course.sessions.join(" and ")}.`,
+ "warning",
+ );
+ return;
+ }
+ const entries = entriesFor(term.id);
+ if (
+ term.id !== "unscheduled" &&
+ (entries.length >= STANDARD_COURSE_SLOTS ||
+ unitsOf(entries) + course.units > 24)
+ ) {
+ notify(
+ `${term.name} ${term.year} is full. Use Add course on the semester to overload it.`,
+ "warning",
+ );
+ return;
+ }
+ const result = await addCourse(course.code, term.id, course.year);
+ notify(
+ result.ok
+ ? `${course.code} added to ${term.id === "unscheduled" ? "Later" : `${term.name} ${term.year}`}`
+ : result.message,
+ result.ok ? "success" : "warning",
+ );
+ };
+
+ const offeredIn = (picked: Course, term: Term) => {
+ const course =
+ courseForTerm(picked.code, term, planningCatalogue) ?? picked;
+ return (
+ term.id === "unscheduled" ||
+ course.sessions.length === 0 ||
+ courseIsAvailable(course, term.name)
+ );
+ };
+ const addToSelectedYear = (course: Course) => {
+ const term =
+ selectedTerms.find((item) => hasRoom(item) && offeredIn(course, item)) ??
+ selectedTerms.find((item) => offeredIn(course, item));
+ if (!term) {
+ notify(
+ `${course.code} does not run in ${selectedYear?.label ?? "this year"}'s semesters.`,
+ "warning",
+ );
+ return;
+ }
+ void requestAddSuggested(course, term);
+ };
+
+ const statuses = attemptStatusByCode(state.attempts);
+ const selectedStructures = [
+ { code: state.profile.degreeCode, kind: "programme" as const },
+ { code: state.profile.majorCode, kind: "major" as const },
+ ...(state.profile.minorCodes ?? []).map((code) => ({
+ code,
+ kind: "minor" as const,
+ })),
+ ...(state.profile.specialisationCodes ?? []).map((code) => ({
+ code,
+ kind: "specialisation" as const,
+ })),
+ ].filter((item): item is { code: string; kind: typeof item.kind } =>
+ Boolean(item.code),
+ );
+ const selectedStructureCodes = new Set(
+ selectedStructures.map((item) => item.code),
+ );
+ const structures: PlannedStructure[] = selectedStructures.flatMap(
+ ({ code, kind }) => {
+ const requirements = catalogue.structureRequirements.find(
+ (item) => item.structureCode === code && item.structureKind === kind,
+ );
+ if (!requirements?.root) return [];
+ return [
+ {
+ code,
+ kind,
+ name:
+ kind === "programme"
+ ? (degree?.name ?? requirements.structureName)
+ : requirements.structureName,
+ root: requirements.root,
+ context: planTreeContext({
+ structureCode: code,
+ root: requirements.root,
+ catalogue: planningCatalogue,
+ attempts: state.attempts,
+ placements: state.placements ?? [],
+ statuses,
+ selectedStructureCodes,
+ unitTarget: kind === "programme" ? (degree?.units ?? null) : null,
+ onPlace: (courseCode, placement) => {
+ void setPlacement(courseCode, placement).then((result) => {
+ if (!result.ok) notify(result.message, "warning");
+ });
+ },
+ onAddCourse: addToSelectedYear,
+ }),
+ },
+ ];
+ },
+ );
+ // Each open rule's courses come from the course search, so every rule has
+ // something to drag in even when the catalogue has not loaded its courses.
+ const searches = ruleSearches(structures)
+ .filter((params) => !searched.has(params))
+ .join("|");
+ useEffect(() => {
+ if (!searches || catalogue.academicYear === null) return;
+ const controller = new AbortController();
+ const year = String(catalogue.academicYear);
+ void Promise.all(
+ searches.split("|").map((params) =>
+ fetch(
+ `/api/courses/search?browse=1&pageSize=12&year=${year}&${params}`,
+ { signal: controller.signal },
+ )
+ .then((response) => (response.ok ? response.json() : null))
+ .then(
+ (payload: { courses?: Course[] } | null) =>
+ [params, payload?.courses ?? []] as const,
+ )
+ .catch(() => [params, [] as Course[]] as const),
+ ),
+ ).then((results) => {
+ if (controller.signal.aborted) return;
+ setSearched((current) => new Map([...current, ...results]));
+ });
+ return () => controller.abort();
+ }, [searches, catalogue.academicYear]);
+ const toPlan = structuresToPlan({
+ structures,
+ attempts: state.attempts,
+ catalogue: planningCatalogue,
+ searched,
+ });
+
+ // Starred courses the planner has not loaded are fetched by code.
+ const findCourse = (code: string) =>
+ planningCatalogue.courses.find(
+ (course) =>
+ course.code === code && course.year === catalogue.academicYear,
+ ) ??
+ planningCatalogue.courses.find((course) => course.code === code) ??
+ fetchedCourses.find((course) => course.code === code);
+ const starredCodes = state.starredCourses ?? [];
+ const missingStarred = starredCodes
+ .filter((code) => !findCourse(code))
+ .join(",");
+ useEffect(() => {
+ if (!missingStarred || catalogue.academicYear === null) return;
+ const controller = new AbortController();
+ const params = new URLSearchParams({
+ codes: missingStarred,
+ year: String(catalogue.academicYear),
+ });
+ fetch(`/api/courses/search?${params}`, { signal: controller.signal })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((payload: { courses?: Course[] } | null) => {
+ const loaded = payload?.courses ?? [];
+ setFetchedCourses((current) => [
+ ...current,
+ ...loaded.filter(
+ (course) => !current.some((item) => item.code === course.code),
+ ),
+ ]);
+ })
+ .catch(() => undefined);
+ return () => controller.abort();
+ }, [missingStarred, catalogue.academicYear]);
+ const inPlan = new Set(
+ state.attempts
+ .filter((attempt) => attempt.status !== "withdrawn")
+ .map((attempt) => attempt.courseCode),
+ );
+ const starred: CourseToPlan[] = starredCodes.flatMap((code) => {
+ const course = inPlan.has(code) ? undefined : findCourse(code);
+ return course
+ ? [
+ {
+ course,
+ required: false,
+ tag: "Starred",
+ structureKind: "programme" as const,
+ },
+ ]
+ : [];
+ });
+
const issueNote = (entry: Entry) => {
if (entry.status === "blocked") {
const missing = missingPrereqs(
@@ -242,21 +517,6 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
return;
}
- const destinationTerm = timelineTerms.find(
- (term) => term.id === drop.termId,
- );
- if (
- destinationTerm &&
- destinationTerm.id !== "unscheduled" &&
- course.year !== destinationTerm.year
- ) {
- notify(
- `${attempt.courseCode} is a ${course.year} course. Remove it and add the ${destinationTerm.year} version instead.`,
- "warning",
- );
- return;
- }
-
const destination = entriesFor(drop.termId).filter(
(entry) => entry.attempt.id !== drop.attemptId,
);
@@ -308,28 +568,45 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
setDragPointer(null);
setDragging(null);
setDragPreview(null);
- if (!cancelled && drop) requestDrop(drop);
+ const suggestion = draggedSuggestionRef.current;
+ draggedSuggestionRef.current = null;
+ setDraggedSuggestion(null);
+ if (cancelled || !drop) return;
+ if (drop.termId === REMOVE_DROP) {
+ void removeAttempt(drop.attemptId).then((result) =>
+ notify(result.message, result.ok ? "success" : "error"),
+ );
+ return;
+ }
+ if (suggestion && drop.attemptId.startsWith(SUGGESTION_DRAG)) {
+ const term = timelineTerms.find((item) => item.id === drop.termId);
+ if (term) void requestAddSuggested(suggestion.course, term);
+ return;
+ }
+ requestDrop(drop);
};
const startPointerDrag = (
event: ReactPointerEvent,
- entry: Entry,
- term: Term,
+ dragId: string,
+ /** Where the dragged course sits now; none for a suggestion. */
+ termId: string | null,
) => {
if (event.button !== 0 || pointerCleanupRef.current) return;
event.preventDefault();
event.stopPropagation();
- const row = event.currentTarget.closest("[data-attempt-id]");
+ const row = event.currentTarget.closest("[data-drag-row]");
if (!row) return;
const rect = row.getBoundingClientRect();
- const initialDrop = {
- attemptId: entry.attempt.id,
- termId: term.id,
- };
+ const status = state.attempts.find((item) => item.id === dragId)?.status;
+ // Recorded attempts stay in the academic history, so only planned ones
+ // can be dragged back out of the plan.
+ const removable =
+ status !== "completed" && status !== "failed" && status !== "withdrawn";
- setDragging(entry.attempt.id);
- previewDrop(initialDrop);
+ setDragging(dragId);
+ if (termId) previewDrop({ attemptId: dragId, termId });
setDragPointer({
initialX: event.clientX,
initialY: event.clientY,
@@ -340,6 +617,9 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
});
const cleanup = () => {
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
+ hoverTimerRef.current = null;
+ hoverTabRef.current = undefined;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
@@ -368,11 +648,31 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
moveEvent.clientX,
moveEvent.clientY,
);
- const lane = target?.closest("[data-drop-term]");
- const termId = lane?.dataset.dropTerm;
- if (!lane || !termId) return;
+ // Holding a course over a year tab opens that year to drop it in.
+ const tab =
+ target?.closest("[data-year-tab]")?.dataset.yearTab;
+ if (tab !== hoverTabRef.current) {
+ hoverTabRef.current = tab;
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
+ hoverTimerRef.current = tab
+ ? setTimeout(() => setSelectedYearKey(tab), 450)
+ : null;
+ }
- previewDrop({ attemptId: entry.attempt.id, termId });
+ // A course lands only where it is let go: off every lane, a planned
+ // course falls back to where it was and a suggestion to nowhere.
+ const lane = target?.closest("[data-drop-term]");
+ const over = lane?.dataset.dropTerm;
+ if (over) {
+ previewDrop({ attemptId: dragId, termId: over });
+ } else if (termId && removable && target?.closest("[data-drop-remove]")) {
+ previewDrop({ attemptId: dragId, termId: REMOVE_DROP });
+ } else if (termId) {
+ previewDrop({ attemptId: dragId, termId });
+ } else {
+ dragPreviewRef.current = null;
+ setDragPreview(null);
+ }
};
const onPointerUp = (upEvent: PointerEvent) => {
@@ -392,27 +692,24 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {
window.addEventListener("keydown", onKeyDown);
};
+ const startSuggestionDrag = (
+ event: ReactPointerEvent,
+ suggestion: CourseToPlan,
+ ) => {
+ if (event.button !== 0 || pointerCleanupRef.current) return;
+ draggedSuggestionRef.current = suggestion;
+ setDraggedSuggestion(suggestion);
+ startPointerDrag(
+ event,
+ `${SUGGESTION_DRAG}${suggestion.course.code}`,
+ null,
+ );
+ };
+
const renderLane = (term: Term) => {
const entries = entriesFor(term.id);
const units = unitsOf(entries);
- const previewAttempt = dragging
- ? state.attempts.find((attempt) => attempt.id === dragging)
- : undefined;
- const previewCourse = previewAttempt
- ? planningCourseForAttempt(previewAttempt, planningCatalogue)
- : undefined;
- const previewEntry =
- previewAttempt && previewCourse
- ? {
- attempt: previewAttempt,
- course: previewCourse,
- status: effectiveStatus(
- previewAttempt,
- state.attempts,
- planningCatalogue,
- ),
- }
- : undefined;
+ const previewEntry = draggedEntry;
const previewApplies = Boolean(
previewEntry && dragPreview?.termId === term.id,
);
@@ -523,13 +820,14 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) {