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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/app/api/courses/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/plan/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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";

export default async function PlanPage() {
let catalogue;
try {
catalogue = await loadCurrentUserPlanCatalogue();
catalogue = await withRequirementCourses(
await loadCurrentUserPlanCatalogue(),
);
} catch {
return <PlanningCatalogueError pageTitle="Planner" retryHref="/plan" />;
}
Expand Down
71 changes: 67 additions & 4 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ import {
removePlanCourse,
saveProfileAndPlan,
setCurrentUserPlanExtensionYears,
setCourseStar,
setRequirementPlacement,
type CoursemapActionResult,
} from "@/lib/coursemap/actions";
Expand Down Expand Up @@ -58,6 +60,8 @@ type AppContextValue = {
courseCode: string,
placement: { structureCode: string; requirementKey: string } | null,
) => Promise<CoursemapActionResult>;
/** Stars a course to consider later, or unstars it. */
toggleStar: (courseCode: string) => Promise<CoursemapActionResult>;
togglePermission: (attemptId: string) => void;
toggleOverloadApproval: (attemptId: string) => void;
notify: (message: string, tone?: ToastTone) => void;
Expand Down Expand Up @@ -85,6 +89,18 @@ function createInitialState(viewer: AuthViewer | null) {

const AppContext = createContext<AppContextValue | null>(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<T extends number | undefined>(
termId: string,
fallback: T,
) {
const match = /^(\d{4})-/.exec(termId);
return match ? Number(match[1]) : fallback;
}

export function AppProvider({
children,
viewer,
Expand Down Expand Up @@ -164,7 +180,7 @@ export function AppProvider({
...current.attempts,
{
id: result.id!,
academicYear,
academicYear: courseYearForTerm(termId, academicYear),
courseCode,
termId,
status: "planned",
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -386,6 +447,7 @@ export function AppProvider({
updateAttempt,
removeAttempt,
setPlacement,
toggleStar,
togglePermission,
toggleOverloadApproval,
notify,
Expand All @@ -401,6 +463,7 @@ export function AppProvider({
updateAttempt,
removeAttempt,
setPlacement,
toggleStar,
togglePermission,
toggleOverloadApproval,
notify,
Expand Down
26 changes: 2 additions & 24 deletions apps/web/app/requirements/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down
104 changes: 16 additions & 88 deletions apps/web/app/requirements/requirements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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 (
<div key={code} className="space-y-5">
{kind !== "programme" && (
Expand All @@ -338,15 +274,7 @@ export function Requirements({
{requirements?.root ? (
<RequirementGroupView
group={requirements.root}
context={{
catalogue,
progress: treeProgress,
placement,
attemptStatusByCode: attemptStatuses,
selectedStructureCodes: selectedCodes,
unitTarget: target,
onAddCourse: setAddingCourse,
}}
context={context}
/>
) : (
<div className="rounded-xl border border-border p-8 text-center text-sm text-muted-foreground">
Expand Down
21 changes: 21 additions & 0 deletions apps/web/lib/coursemap/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoursemapActionResult> {
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.
Expand Down
Loading
Loading