diff --git a/apps/web/app/api/courses/search/route.ts b/apps/web/app/api/courses/search/route.ts index 06c60134..bedeb771 100644 --- a/apps/web/app/api/courses/search/route.ts +++ b/apps/web/app/api/courses/search/route.ts @@ -81,6 +81,8 @@ export async function GET(request: Request) { query, level: searchParams.get("level") ?? undefined, session: searchParams.get("session") ?? undefined, + subject: searchParams.get("subject") ?? undefined, + tag: searchParams.get("tag") ?? undefined, }, page: Number.isFinite(requestedPage) ? requestedPage : 1, pageSize: Number.isFinite(requestedPageSize) ? requestedPageSize : 8, diff --git a/apps/web/app/plan/page.tsx b/apps/web/app/plan/page.tsx index cacc41f1..b1cd040e 100644 --- a/apps/web/app/plan/page.tsx +++ b/apps/web/app/plan/page.tsx @@ -1,5 +1,6 @@ import { PlanningCatalogueError } from "@/ui/plan/planning-catalogue-error"; import { loadCurrentUserPlanCatalogue } from "@/lib/coursemap/plan-catalogue"; +import { withRequirementCourses } from "@/lib/coursemap/requirement-courses"; import { PlanClient } from "./plan-client"; export const dynamic = "force-dynamic"; @@ -7,7 +8,9 @@ export const dynamic = "force-dynamic"; export default async function PlanPage() { let catalogue; try { - catalogue = await loadCurrentUserPlanCatalogue(); + catalogue = await withRequirementCourses( + await loadCurrentUserPlanCatalogue(), + ); } catch { return ; } diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index f89c6a7f..0c0c52f3 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -14,6 +14,7 @@ import { } from "react"; import { useRouter } from "next/navigation"; import type { AuthViewer } from "@/lib/auth/viewer"; +import { saveAcademicResult } from "@/lib/academic/actions"; import type { AppState, AttemptStatus, Profile } from "@/lib/coursemap/types"; export type { AppState, Profile } from "@/lib/coursemap/types"; @@ -24,6 +25,7 @@ import { removePlanCourse, saveProfileAndPlan, setCurrentUserPlanExtensionYears, + setCourseStar, setRequirementPlacement, type CoursemapActionResult, } from "@/lib/coursemap/actions"; @@ -58,6 +60,8 @@ type AppContextValue = { courseCode: string, placement: { structureCode: string; requirementKey: string } | null, ) => Promise; + /** Stars a course to consider later, or unstars it. */ + toggleStar: (courseCode: string) => Promise; togglePermission: (attemptId: string) => void; toggleOverloadApproval: (attemptId: string) => void; notify: (message: string, tone?: ToastTone) => void; @@ -85,6 +89,18 @@ function createInitialState(viewer: AuthViewer | null) { const AppContext = createContext(null); +/** + * A scheduled course takes the version for the year it is placed in, so its + * academic year follows the term; Later keeps the year it already had. + */ +function courseYearForTerm( + 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 ( + { + if (!open) onClose(); + }} + > + +
+
+
+

+ {course.code} +

+ +
+ +

+ {course.name} +

+
+

+ {unitsForAttempt(attempt, course)} units · Level {course.level} + {course.sessions.length + ? ` · ${[...new Set(course.sessions.map(shortSession))].join(", ")}` + : ""} +

+
+ + +
+ + {!prereqsMet ? ( +
+ +

+ Prerequisites aren't planned before this yet +

+ +
+ ) : null} + + + + About + + Requisites + {!prereqsMet ? ( + + ) : null} + + +
+ +
+ {facts.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+

+ {course.description} +

+
+ {course.description.length > 220 ? ( + + ) : null} + + Full course page + +
+
+
+ + + {rule ? ( +
+ +
+ ) : ( +
+

+ Needs first +

+ {course.prerequisiteCodes.length > 0 ? ( +
+ {course.prerequisiteCodes.map((code) => ( + + {code} + + ))} +
+ ) : null} +

+ {course.prerequisiteText || "No prerequisites."} +

+
+ )} + + {!rule && incompatibleCodes.length > 0 && ( +
+

+ Can't take with +

+
+ {incompatibleCodes.map((code) => ( + + {code} + + ))} +
+
+ )} + + {course.permissionText && ( +
+

+

+

+ {course.permissionText} +

+ +
+ )} +
+
+
+ + {showUnits ? ( +
+ {!recorded && unitRequirement.kind === "unavailable" ? ( + + + This course cannot be added to your plan yet. + + + ) : null} + {!recorded && + unitSelectionRequired && + unitRequirement.kind !== "unavailable" ? ( +
+ + + +
+ ) : null} +
+ ) : null} +
+
+ + {enrolled ? ( + + ) : ( + <> + + + + )} +
+
+
+
+ ); +} 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.code} -

- -
- -

- {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 ? ( +

    +

    + ) : 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 }) {
    - {dragPointer && draggedAttempt && draggedCourse && draggedStatus && ( + {dragPointer && draggedEntry && (
    - + - {draggedCourse.code} + {draggedEntry.course.code} - {draggedCourse.name} + {draggedEntry.course.name} - {unitsForAttempt(draggedAttempt, draggedCourse)}u + {unitsForAttempt(draggedEntry.attempt, draggedEntry.course)}u
    @@ -793,7 +1096,7 @@ export function PlanBoard({ catalogue }: { catalogue: PlanCatalogue }) { )} {selectedAttempt && ( - setSelectedAttempt(null)} diff --git a/apps/web/ui/plan/plan-suggestions.ts b/apps/web/ui/plan/plan-suggestions.ts new file mode 100644 index 00000000..da194bf3 --- /dev/null +++ b/apps/web/ui/plan/plan-suggestions.ts @@ -0,0 +1,270 @@ +import type { Attempt, Course, Term } from "@/lib/coursemap/types"; +import { requirementNodeMatcher } from "@/lib/coursemap/requirement-progress"; +import type { + PlanCatalogue, + PlanStructureKind, +} from "@/lib/coursemap/plan-catalogue"; +import { + conditionHeading, + conditionSummary, + conditionTone, + hidesCondition, + listedCourseCounts, + requirementRowStatus, + suggestedCourses, + type RequirementTreeCondition, + type RequirementTreeGroup, + type RequirementTreeNode, + type TreeContext, +} from "@/ui/requirements/requirement-presentation"; + +export type PlannedStructure = { + code: string; + kind: PlanStructureKind; + name: string; + root: RequirementTreeGroup; + context: TreeContext; +}; + +export type CourseToPlan = { + course: Course; + /** A compulsory course rather than one of several that would count. */ + required: boolean; + /** A few words on the rule it counts towards, such as "COMP courses". */ + tag: string; + structureKind: PlanStructureKind; +}; + +/** + * Rules that still need courses, from the top of the tree down. The whole + * structure reads as planned once its unit total is, so the root is always + * opened; a group within it is skipped once it is planned or complete. + */ +function rulesToPlan( + node: RequirementTreeNode, + context: TreeContext, + root = true, +): RequirementTreeCondition[] { + if (node.type === "group") { + return root || requirementRowStatus(node, context).kind === "todo" + ? node.children.flatMap((child) => rulesToPlan(child, context, false)) + : []; + } + if (hidesCondition(node, context) || conditionTone(node) !== "requirement") + return []; + return requirementRowStatus(node, context).kind === "todo" ? [node] : []; +} + +/** The version of a course to plan in a semester: that year's, or the latest. */ +export function courseForTerm( + code: string, + term: Term, + catalogue: PlanCatalogue, +): Course | undefined { + const versions = catalogue.courses.filter((course) => course.code === code); + return ( + versions.find((course) => course.year === term.year) ?? + versions.find((course) => course.year === catalogue.academicYear) + ); +} + +/** One open rule with the courses that would count towards it. */ +export type RuleToPlan = { + key: string; + /** A few words, such as "COMP 3000+" or "Pick one". */ + heading: string; + /** The rule in full, for a tooltip. */ + detail: string; + /** What is left, such as "18 units to plan". */ + left: string; + /** Share of the rule completed or planned, from 0 to 1. */ + progress: number; + compulsory: boolean; + courses: CourseToPlan[]; +}; + +export type StructureToPlan = { + structure: PlannedStructure; + rules: RuleToPlan[]; + /** Rules already covered by completed or planned courses. */ + doneCount: number; +}; + +/** + * 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 none. + */ +export function ruleSearchParams(rule: RequirementTreeCondition) { + const digit = (level: number) => String(level < 10 ? level : level / 1000); + const { minimumLevel, maximumLevel } = rule; + const params = new URLSearchParams(); + if (rule.conditionKind === "subject_units" && rule.subjectCode) + params.set("subject", rule.subjectCode); + else if (rule.conditionKind === "tagged_units" && rule.tag) + params.set("tag", rule.tag); + else if (rule.conditionKind !== "level_units") return null; + if (minimumLevel !== null) + params.set( + "level", + minimumLevel === maximumLevel + ? digit(minimumLevel) + : `${digit(minimumLevel)}+`, + ); + else if (maximumLevel !== null) params.set("level", digit(maximumLevel)); + return params.size > 0 ? params.toString() : null; +} + +/** Open rules across the structures that could use a course search. */ +export function ruleSearches(structures: PlannedStructure[]) { + return [ + ...new Set( + structures.flatMap((structure) => + rulesToPlan(structure.root, structure.context).flatMap((rule) => { + const params = ruleSearchParams(rule); + return params ? [params] : []; + }), + ), + ), + ]; +} + +/** A rule named in a few words, so a list of them reads at a glance. */ +function shortHeading(rule: RequirementTreeCondition, compulsory: boolean) { + const level = rule.minimumLevel !== null ? ` ${rule.minimumLevel}+` : ""; + switch (rule.conditionKind) { + case "subject_units": + return rule.subjectCode + ? level + ? `${rule.subjectCode}${level}` + : `${rule.subjectCode} courses` + : "Subject courses"; + case "level_units": + return rule.minimumLevel !== null + ? `${rule.minimumLevel}-level courses` + : "Course level"; + case "course_set_units": { + if (compulsory) return "Compulsory"; + const count = rule.minimumCourses ?? 1; + return count === 1 ? "Pick one" : `Pick ${count}`; + } + case "elective_units": + return "Electives"; + default: + return conditionHeading(rule); + } +} + +function requirementRules( + node: RequirementTreeNode, + context: TreeContext, +): RequirementTreeCondition[] { + if (node.type === "group") + return node.children.flatMap((child) => requirementRules(child, context)); + return !hidesCondition(node, context) && conditionTone(node) === "requirement" + ? [node] + : []; +} + +/** + * Each structure's open rules with a few courses under each that would count, + * in requirement order. A course can count for more than one structure, so it + * may appear under each of them. + */ +export function structuresToPlan({ + structures, + attempts, + catalogue, + searched = new Map(), + perRule = 3, +}: { + structures: PlannedStructure[]; + attempts: Attempt[]; + catalogue: PlanCatalogue; + /** Courses the course search found for a rule, by its search params. */ + searched?: ReadonlyMap; + perRule?: number; +}): StructureToPlan[] { + const planned = new Set( + attempts + .filter((attempt) => attempt.status !== "withdrawn") + .map((attempt) => attempt.courseCode), + ); + return structures.map((structure) => { + const { context } = structure; + const open = rulesToPlan(structure.root, context); + const searchedFor = (rule: RequirementTreeCondition) => { + const params = ruleSearchParams(rule); + return params ? (searched.get(params) ?? []) : []; + }; + // Any course counts towards electives, so they borrow the courses found + // for the structure's other rules. + const pool = open.flatMap(searchedFor); + const suggestedFor = (rule: RequirementTreeCondition) => { + const local = suggestedCourses(rule, context, perRule + 6); + if (local.length > 0) return local; + const found = searchedFor(rule); + if (found.length > 0) return found; + return ruleSearchParams(rule) === null && !requirementNodeMatcher(rule) + ? pool + : []; + }; + const seen = new Set(); + const rules = open.flatMap((rule): RuleToPlan[] => { + const listed = listedCourseCounts(rule, context); + const compulsory = listed.codes.length > 0 && listed.required; + // A choice the student already made, counted under another rule, + // is theirs to move rather than a reason to suggest the alternatives. + if ( + !compulsory && + listed.codes.some((code) => context.attemptStatusByCode.has(code)) + ) + return []; + const heading = shortHeading(rule, compulsory); + const courses = ( + listed.codes.length > 0 + ? listed.codes.flatMap((code) => { + const course = catalogue.courses.find( + (item) => + item.code === code && item.year === catalogue.academicYear, + ); + return course ? [course] : []; + }) + : suggestedFor(rule) + ) + .filter((course) => !planned.has(course.code) && !seen.has(course.code)) + .map((course) => { + seen.add(course.code); + return { + course, + required: compulsory, + tag: compulsory ? "Required" : heading, + structureKind: structure.kind, + }; + }); + const status = requirementRowStatus(rule, context); + const figure = status.kind === "unmeasured" ? null : status.figure; + return [ + { + key: `${structure.code}:${rule.id}`, + heading, + detail: conditionSummary(rule), + left: status.kind === "unmeasured" ? "" : status.label, + progress: + figure && figure.target > 0 + ? Math.min(1, figure.value / figure.target) + : 0, + compulsory, + courses, + }, + ]; + }); + const measured = requirementRules(structure.root, context).filter( + (rule) => { + const kind = requirementRowStatus(rule, context).kind; + return kind === "planned" || kind === "complete"; + }, + ); + return { structure, rules, doneCount: measured.length }; + }); +} diff --git a/apps/web/ui/plan/year-tabs.tsx b/apps/web/ui/plan/year-tabs.tsx new file mode 100644 index 00000000..9c926d24 --- /dev/null +++ b/apps/web/ui/plan/year-tabs.tsx @@ -0,0 +1,93 @@ +"use client"; +import { CheckCircle2 } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type YearTab = { + /** The calendar year, or "later" for courses not scheduled yet. */ + key: string; + label: string; + detail: string; + units: number; + target: number; + /** Every course in the year is recorded as completed. */ + finished: boolean; +}; + +/** + * The plan's years as tabs with their progress. Dragging a course over a tab + * opens that year, so a course can move between years in one drag. + */ +export function YearTabs({ + years, + selectedKey, + onSelect, +}: { + years: YearTab[]; + selectedKey: string; + onSelect: (key: string) => void; +}) { + return ( +
    + {years.map((year) => { + const selected = year.key === selectedKey; + return ( + + ); + })} +
    + ); +} diff --git a/apps/web/ui/requirements/plan-tree-context.ts b/apps/web/ui/requirements/plan-tree-context.ts new file mode 100644 index 00000000..391d4ec5 --- /dev/null +++ b/apps/web/ui/requirements/plan-tree-context.ts @@ -0,0 +1,137 @@ +import type { + Attempt, + Course, + RequirementPlacementChoice, +} from "@/lib/coursemap/types"; +import type { + PlanCatalogue, + PlanRequirementGroup, +} from "@/lib/coursemap/plan-catalogue"; +import { + allocateRequirements, + placementOptions, + requirementConditionsByKey, + requirementTreeProgress, +} from "@/lib/coursemap/requirement-progress"; +import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; +import { planningCourseForAttempt } from "@/lib/planner"; +import { + conditionHeading, + type RequirementTreeCondition, + type TreeContext, +} from "@/ui/requirements/requirement-presentation"; + +/** Each course in the plan by the furthest it has come. */ +export function attemptStatusByCode(attempts: readonly Attempt[]) { + return new Map( + [...new Set(attempts.map((attempt) => attempt.courseCode))].flatMap( + (code) => { + const status = requirementCourseStatus(code, attempts); + return status ? [[code, status] as const] : []; + }, + ), + ); +} + +/** + * How one of the student's structures reads against their plan: where each + * course counts, how far each rule has come, and how to move a course + * between rules. + */ +export function planTreeContext({ + structureCode, + root, + catalogue, + attempts, + placements, + statuses, + selectedStructureCodes, + unitTarget, + onPlace, + onAddCourse, +}: { + structureCode: string; + root: PlanRequirementGroup | null; + catalogue: PlanCatalogue; + attempts: readonly Attempt[]; + placements: readonly RequirementPlacementChoice[]; + statuses: TreeContext["attemptStatusByCode"]; + selectedStructureCodes: ReadonlySet; + unitTarget: number | null; + onPlace: ( + courseCode: string, + placement: { structureCode: string; requirementKey: string } | null, + ) => void; + onAddCourse?: (course: Course) => void; +}): TreeContext { + 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( + placements.flatMap((choice) => { + const nodeKey = + choice.structureCode === structureCode + ? nodeKeyFor(choice.requirementKey) + : undefined; + return nodeKey ? [[choice.courseCode, nodeKey] as const] : []; + }), + ); + const allocation = allocateRequirements({ + root, + attempts, + catalogue, + pins, + }); + const labelFor = (nodeKey: string) => { + const condition = conditions.get(nodeKey); + return condition + ? conditionHeading(condition as RequirementTreeCondition) + : "another requirement"; + }; + return { + catalogue, + progress: requirementTreeProgress({ + root, + attempts, + catalogue, + allocation, + }), + placement: { + allocation, + labelFor, + optionsFor: (courseCode: string) => { + const attempt = 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; + onPlace( + courseCode, + projectionKey + ? { structureCode, requirementKey: projectionKey } + : null, + ); + }, + }, + attemptStatusByCode: statuses, + selectedStructureCodes, + unitTarget, + onAddCourse, + }; +} diff --git a/supabase/migrations/019_plan_starred_courses.sql b/supabase/migrations/019_plan_starred_courses.sql new file mode 100644 index 00000000..3a632127 --- /dev/null +++ b/supabase/migrations/019_plan_starred_courses.sql @@ -0,0 +1,95 @@ +-- Courses a student has starred to come back to while planning. +-- +-- A star belongs to the plan and names the course by its code, so it +-- outlives catalogue republishing and does not depend on a course version. + +create table if not exists public.plan_starred_courses ( + id uuid default gen_random_uuid() not null, + plan_id uuid not null, + owner_id uuid not null, + course_code text not null, + created_at timestamp with time zone default now() not null, + constraint plan_starred_courses_course_code_check check ((course_code ~ '^[A-Z]{4}[0-9]{4}[A-Z]?$'::text)) +); + +alter table only public.plan_starred_courses + add constraint plan_starred_courses_pkey primary key (id); + +alter table only public.plan_starred_courses + add constraint plan_starred_courses_plan_course_unique unique (plan_id, course_code); + +alter table only public.plan_starred_courses + add constraint plan_starred_courses_plan_id_fkey foreign key (plan_id) + references public.plans(id) on delete cascade; + +alter table only public.plan_starred_courses + add constraint plan_starred_courses_owner_id_fkey foreign key (owner_id) + references auth.users(id) on delete cascade; + +create index plan_starred_courses_owner_id_idx + on public.plan_starred_courses using btree (owner_id); + +alter table public.plan_starred_courses enable row level security; + +create policy plan_starred_courses_owner_select on public.plan_starred_courses + for select to authenticated + using ((( select auth.uid() as uid) = owner_id)); + +create policy plan_starred_courses_owner_insert on public.plan_starred_courses + for insert to authenticated + with check ((( select auth.uid() as uid) = owner_id)); + +create policy plan_starred_courses_owner_delete on public.plan_starred_courses + for delete to authenticated + using ((( select auth.uid() as uid) = owner_id)); + +revoke all on table public.plan_starred_courses from public, anon, authenticated, service_role; +grant all on table public.plan_starred_courses to service_role; +grant select, insert, delete on table public.plan_starred_courses to authenticated; + +comment on table public.plan_starred_courses is 'Courses a student has starred in their plan to consider later.'; + +-- Stars or unstars a course in the caller's primary plan. The plan is found +-- from the session, so a caller can only ever write their own. +create or replace function public.set_current_user_course_star( + p_course_code text, + p_starred boolean +) returns void + language plpgsql + set search_path to '' + as $$ +declare + v_user_id uuid := (select auth.uid()); + v_plan_id uuid; + v_course_code text := upper(btrim(p_course_code)); +begin + if v_user_id is null then + raise exception using + errcode = '42501', + message = 'You must be signed in to update a plan.'; + end if; + + select plans.id into v_plan_id + from public.plans + where plans.owner_id = v_user_id and plans.is_primary and plans.status = 'active'; + + if v_plan_id is null then + raise exception using + errcode = 'P0002', + message = 'Your primary plan was not found.'; + end if; + + if not p_starred then + delete from public.plan_starred_courses + where plan_id = v_plan_id and course_code = v_course_code; + return; + end if; + + insert into public.plan_starred_courses (plan_id, owner_id, course_code) + values (v_plan_id, v_user_id, v_course_code) + on conflict (plan_id, course_code) do nothing; +end; +$$; + +revoke all on function public.set_current_user_course_star(text, boolean) from public, anon, authenticated, service_role; +grant all on function public.set_current_user_course_star(text, boolean) to authenticated; diff --git a/supabase/migrations/020_plan_item_year_versions.sql b/supabase/migrations/020_plan_item_year_versions.sql new file mode 100644 index 00000000..1872b2a6 --- /dev/null +++ b/supabase/migrations/020_plan_item_year_versions.sql @@ -0,0 +1,257 @@ +-- A planned course follows the year it is placed in. +-- +-- Placing or moving a course into a period in another academic year swaps the +-- plan item to that year's version of the course, so a 2026 course dropped +-- into 2025 becomes the 2025 course. It is refused only when that year's +-- version has not been imported and published yet. Recorded attempts leave the +-- plan, so only planned courses change version here. + +create or replace function public.add_current_user_plan_item(p_course_code text, p_academic_year smallint, p_planned_calendar_year smallint default null::smallint, p_planned_period_code text default null::text) returns uuid + language plpgsql security definer + set search_path to '' + as $$ +declare + user_id uuid := (select auth.uid()); + selected_plan_id uuid; + selected_record_id bigint; + selected_period_id bigint; + next_sort_order bigint; + created_item_id uuid; + period_code text := nullif(upper(btrim(p_planned_period_code)), ''); + course_code text := upper(btrim(p_course_code)); + course_year smallint := coalesce(p_planned_calendar_year, p_academic_year); +begin + if user_id is null then + raise exception using errcode = '28000', message = 'Authentication is required.'; + end if; + + if p_academic_year is null then + raise exception using + errcode = '22023', + message = 'A course academic year is required.'; + end if; + + if (p_planned_calendar_year is null) <> (period_code is null) then + raise exception using + errcode = '22023', + message = 'Planned year and period must be supplied together.'; + end if; + + select plans.id + into selected_plan_id + from public.plans + where plans.owner_id = user_id + and plans.is_primary + and plans.status = 'active' + for update of plans; + + if selected_plan_id is null then + raise exception using + errcode = 'P0002', + message = 'Save a primary degree plan before adding courses.'; + end if; + + select item_years.id + into selected_record_id + from public.catalogue_codes as items + join public.catalogue_records as item_years on item_years.code_id = items.id + join public.academic_years + on academic_years.id = item_years.academic_year_id + where items.kind = 'course' + and items.code = course_code + and academic_years.year = course_year + and item_years.archived_at is null + and item_years.published_version_id is not null + limit 1; + + if selected_record_id is null then + raise exception using + errcode = 'P0002', + message = format('%s for %s isn''t imported yet.', course_code, course_year); + end if; + + if p_planned_calendar_year is not null then + select academic_periods.id + into selected_period_id + from public.academic_periods + where academic_periods.calendar_year = p_planned_calendar_year + and academic_periods.code = period_code + limit 1; + end if; + + select coalesce(max(plan_items.sort_order), -1) + 1 + into next_sort_order + from public.plan_items + where plan_items.plan_id = selected_plan_id + and plan_items.owner_id = user_id + and plan_items.planned_calendar_year is not distinct from p_planned_calendar_year + and plan_items.planned_period_code is not distinct from period_code; + + insert into public.plan_items ( + plan_id, + owner_id, + catalogue_record_id, + academic_period_id, + planned_calendar_year, + planned_period_code, + sort_order + ) values ( + selected_plan_id, + user_id, + selected_record_id, + selected_period_id, + p_planned_calendar_year, + period_code, + next_sort_order + ) + returning id into created_item_id; + + return created_item_id; +end; +$$; + +create or replace function public.move_current_user_plan_item(p_plan_item_id uuid, p_planned_calendar_year smallint default null::smallint, p_planned_period_code text default null::text, p_before_plan_item_id uuid default null::uuid) returns void + language plpgsql security definer + set search_path to '' + as $$ +declare + user_id uuid := (select auth.uid()); + selected_plan_id uuid; + selected_academic_year smallint; + selected_code_id bigint; + selected_course_code text; + selected_record_id bigint; + selected_period_id bigint; + destination_sort_order bigint; + period_code text := nullif(upper(btrim(p_planned_period_code)), ''); +begin + if user_id is null then + raise exception using errcode = '28000', message = 'Authentication is required.'; + end if; + + if (p_planned_calendar_year is null) <> (period_code is null) then + raise exception using + errcode = '22023', + message = 'Planned year and period must be supplied together.'; + end if; + + select + plan_items.plan_id, + plan_items.catalogue_record_id, + academic_years.year, + catalogue_codes.id, + catalogue_codes.code + into + selected_plan_id, + selected_record_id, + selected_academic_year, + selected_code_id, + selected_course_code + from public.plan_items + join public.catalogue_records + on catalogue_records.id = plan_items.catalogue_record_id + join public.catalogue_codes + on catalogue_codes.id = catalogue_records.code_id + join public.academic_years + on academic_years.id = catalogue_records.academic_year_id + where plan_items.id = p_plan_item_id + and plan_items.owner_id = user_id + for update of plan_items; + + if selected_plan_id is null then + raise exception using errcode = 'P0002', message = 'Plan item not found.'; + end if; + + if p_planned_calendar_year is not null + and p_planned_calendar_year <> selected_academic_year + then + selected_record_id := null; + + select item_years.id + into selected_record_id + from public.catalogue_records as item_years + join public.academic_years + on academic_years.id = item_years.academic_year_id + where item_years.code_id = selected_code_id + and item_years.kind = 'course' + and academic_years.year = p_planned_calendar_year + and item_years.archived_at is null + and item_years.published_version_id is not null + limit 1; + + if selected_record_id is null then + raise exception using + errcode = 'P0002', + message = format( + '%s for %s isn''t imported yet.', + selected_course_code, + p_planned_calendar_year + ); + end if; + end if; + + perform 1 + from public.plan_items + where plan_items.plan_id = selected_plan_id + and plan_items.owner_id = user_id + for update of plan_items; + + if p_planned_calendar_year is not null then + select academic_periods.id + into selected_period_id + from public.academic_periods + where academic_periods.calendar_year = p_planned_calendar_year + and academic_periods.code = period_code + limit 1; + end if; + + if p_before_plan_item_id is not null then + select plan_items.sort_order + into destination_sort_order + from public.plan_items + where plan_items.id = p_before_plan_item_id + and plan_items.owner_id = user_id + and plan_items.plan_id = selected_plan_id + and plan_items.id <> p_plan_item_id + and plan_items.planned_calendar_year is not distinct from + p_planned_calendar_year + and plan_items.planned_period_code is not distinct from period_code; + + if destination_sort_order is null then + raise exception using + errcode = 'P0002', + message = 'The requested destination item was not found.'; + end if; + + update public.plan_items + set sort_order = plan_items.sort_order + 1 + where plan_items.plan_id = selected_plan_id + and plan_items.owner_id = user_id + and plan_items.id <> p_plan_item_id + and plan_items.planned_calendar_year is not distinct from + p_planned_calendar_year + and plan_items.planned_period_code is not distinct from period_code + and plan_items.sort_order >= destination_sort_order; + else + select coalesce(max(plan_items.sort_order), -1) + 1 + into destination_sort_order + from public.plan_items + where plan_items.plan_id = selected_plan_id + and plan_items.owner_id = user_id + and plan_items.id <> p_plan_item_id + and plan_items.planned_calendar_year is not distinct from + p_planned_calendar_year + and plan_items.planned_period_code is not distinct from period_code; + end if; + + update public.plan_items + set + catalogue_record_id = selected_record_id, + academic_period_id = selected_period_id, + planned_calendar_year = p_planned_calendar_year, + planned_period_code = period_code, + sort_order = destination_sort_order + where plan_items.id = p_plan_item_id + and plan_items.owner_id = user_id; +end; +$$; diff --git a/supabase/tests/database/course_planner_write_hardening.sql b/supabase/tests/database/course_planner_write_hardening.sql index 9abf54ea..c7a59570 100644 --- a/supabase/tests/database/course_planner_write_hardening.sql +++ b/supabase/tests/database/course_planner_write_hardening.sql @@ -3,7 +3,7 @@ begin; create extension if not exists pgtap with schema extensions; -select extensions.plan(23); +select extensions.plan(26); insert into auth.users ( instance_id, id, aud, role, email, raw_app_meta_data, raw_user_meta_data, @@ -48,6 +48,10 @@ select pg_temp.publish_course( 'COMP1110', 2030::smallint, 'Structured Programming 2030', 'fixed', 6 ); +select pg_temp.publish_course( + 'COMP1110', 2029::smallint, 'Structured Programming 2029', 'fixed', 6 +); + select pg_temp.publish_course( 'COMP1100', 2026::smallint, 'Programming as Problem Solving', 'fixed', 6 ); @@ -182,9 +186,44 @@ select extensions.throws_ok( null ) $$, - '22023', - 'A planned course cannot be moved outside its selected academic year.', - 'moving a plan item cannot silently change its selected course year' + 'P0002', + 'COMP1110 for 2027 isn''t imported yet.', + 'moving a plan item into a year without that course version is refused' +); + +select extensions.lives_ok( + $$ + select public.move_current_user_plan_item( + ( + select plan_items.id + from public.plan_items + join public.catalogue_records as plan_records on plan_records.id = plan_items.catalogue_record_id + join public.catalogue_codes as courses on courses.id = plan_records.code_id + where plan_items.owner_id = (select auth.uid()) + and courses.code = 'COMP1110' + ), + 2029::smallint, + 'S1', + null + ) + $$, + 'a planned course can move into another year that has its version imported' +); + +select extensions.ok( + exists ( + select 1 + from public.plan_items + join public.catalogue_records as plan_records on plan_records.id = plan_items.catalogue_record_id + join public.catalogue_codes as courses on courses.id = plan_records.code_id + join public.academic_years on academic_years.id = plan_records.academic_year_id + where plan_items.owner_id = (select auth.uid()) + and courses.code = 'COMP1110' + and academic_years.year = 2029 + and plan_items.planned_calendar_year = 2029 + and plan_items.planned_period_code = 'S1' + ), + 'moving into another year swaps the plan item to that year''s course version' ); select extensions.throws_ok( @@ -249,6 +288,17 @@ select extensions.throws_ok( 'direct authenticated plan-item deletes are denied' ); +select extensions.throws_ok( + $$ + select public.add_current_user_plan_item( + 'COMP1100', 2026::smallint, 2027::smallint, 'S1' + ) + $$, + 'P0002', + 'COMP1100 for 2027 isn''t imported yet.', + 'adding a course into a year without that course version is refused' +); + select extensions.lives_ok( $$ select public.add_current_user_plan_item( diff --git a/supabase/tests/database/plan_starred_courses.sql b/supabase/tests/database/plan_starred_courses.sql new file mode 100644 index 00000000..066a048e --- /dev/null +++ b/supabase/tests/database/plan_starred_courses.sql @@ -0,0 +1,121 @@ +begin; + +create extension if not exists pgtap with schema extensions; + +select extensions.plan(8); + +select extensions.ok( + has_function_privilege( + 'authenticated', + 'public.set_current_user_course_star(text,boolean)', + 'execute' + ) + and not has_function_privilege( + 'anon', + 'public.set_current_user_course_star(text,boolean)', + 'execute' + ) + and exists ( + select 1 + from pg_proc as functions + where functions.oid = + 'public.set_current_user_course_star(text,boolean)'::regprocedure + and not functions.prosecdef + and functions.proconfig @> array['search_path=""']::text[] + ), + 'the star RPC is security invoker, has a fixed search path and is for signed-in users only' +); + +insert into auth.users ( + instance_id, id, aud, role, email, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at +) values + ( + '00000000-0000-0000-0000-000000000000', + '99000000-0000-4000-8000-000000000001', + 'authenticated', 'authenticated', 'star-owner@example.test', + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '00000000-0000-0000-0000-000000000000', + '99000000-0000-4000-8000-000000000002', + 'authenticated', 'authenticated', 'star-other@example.test', + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ); + +insert into public.plans ( + owner_id, academic_year_id, name, is_primary, commencement_year, study_load +) +select users.id, (select id from public.academic_years where year = 2026), + 'Star plan', true, 2026, 'full_time' +from auth.users as users +where users.id in ( + '99000000-0000-4000-8000-000000000001', + '99000000-0000-4000-8000-000000000002' +); + +set local role anon; + +select extensions.throws_ok( + $$ select public.set_current_user_course_star('COMP1100', true) $$, + '42501', + null, + 'anonymous visitors cannot star a course' +); + +reset role; + +select set_config('request.jwt.claim.sub', '99000000-0000-4000-8000-000000000001', true); +select set_config('request.jwt.claim.role', 'authenticated', true); +set local role authenticated; + +select extensions.lives_ok( + $$ select public.set_current_user_course_star(' comp1100 ', true) $$, + 'a student stars a course in their primary plan' +); + +select public.set_current_user_course_star('COMP1100', true); + +select extensions.results_eq( + $$ select course_code from public.plan_starred_courses $$, + $$ values ('COMP1100'::text) $$, + 'the star is stored once, normalised against the caller''s plan' +); + +select extensions.throws_ok( + $$ + insert into public.plan_starred_courses (plan_id, owner_id, course_code) + select plans.id, '99000000-0000-4000-8000-000000000002', 'COMP1110' + from public.plans + where plans.owner_id = '99000000-0000-4000-8000-000000000001' + $$, + '42501', + null, + 'a student cannot write a star under another owner' +); + +select extensions.throws_ok( + $$ select public.set_current_user_course_star('Not a code', true) $$, + '23514', + null, + 'stars name a course code' +); + +select set_config('request.jwt.claim.sub', '99000000-0000-4000-8000-000000000002', true); + +select extensions.is_empty( + $$ select 1 from public.plan_starred_courses $$, + 'a student cannot see another student''s stars' +); + +select set_config('request.jwt.claim.sub', '99000000-0000-4000-8000-000000000001', true); +select public.set_current_user_course_star('COMP1100', false); + +select extensions.is_empty( + $$ select 1 from public.plan_starred_courses $$, + 'unstarring removes the course' +); + +select * from extensions.finish(); + +rollback;