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
16 changes: 1 addition & 15 deletions apps/web/app/courses/[code]/course-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,6 @@ export function CourseDetailClient({
courseTabFromSearch(searchParams.get("tab")),
);
const [planOpen, setPlanOpen] = useState(false);
const completedCodes = new Set(
state.attempts
.filter((attempt) => attempt.status === "completed")
.map((attempt) => attempt.courseCode),
);
const plannedCodes = new Set(
state.attempts
.filter(
(attempt) =>
attempt.status === "planned" || attempt.status === "enrolled",
)
.map((attempt) => attempt.courseCode),
);

useEffect(() => {
const syncTabFromHistory = () => {
Expand Down Expand Up @@ -75,10 +62,9 @@ export function CourseDetailClient({
>
<AppShell tabs={<CourseDetailTabsList />}>
<CourseDetailView
completedCodes={completedCodes}
attempts={state.attempts}
course={course}
onAddToPlan={() => setPlanOpen(true)}
plannedCodes={plannedCodes}
requisiteCompletion={requisiteCompletion}
/>
{planOpen ? (
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/structures/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error";

export default function StructureError({ reset }: { reset: () => void }) {
return <StructureCatalogueError onRetry={reset} />;
}
39 changes: 39 additions & 0 deletions apps/web/app/structures/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Card } from "@coursemap/ui/primitives/card";
import { Skeleton } from "@coursemap/ui/primitives/skeleton";
import { TabsLoading } from "@/ui/common/tabs-loading";
import { AppShell } from "@/ui/shell";

export default function StructureLoading() {
return (
<AppShell
loading
tabs={<TabsLoading widths={["w-16", "w-24", "w-20"]} className="gap-4" />}
>
<div aria-busy="true" className="space-y-4">
<span className="sr-only">Loading programme</span>
<div className="space-y-3 pb-5">
<Skeleton className="h-8 w-80 max-w-full" />
<Skeleton className="h-5 w-64 max-w-full" />
</div>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
<div className="space-y-4">
{[0, 1].map((index) => (
<Card key={index} className="min-w-0 gap-5 p-5">
<Skeleton className="h-4 w-36" />
<div className="space-y-3">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
</Card>
))}
</div>
<Card className="min-w-0 gap-5 p-5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-32 w-full" />
</Card>
</div>
</div>
</AppShell>
);
}
62 changes: 62 additions & 0 deletions apps/web/app/structures/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { notFound } from "next/navigation";

import { requirementCourseCodes } from "@/lib/coursemap/requirement-display";
import { planCourseFromDetails } from "@/lib/coursemap/plan-catalogue";
import { loadPublishedCoursesByCodes } from "@/lib/coursemap/published-courses";
import {
loadPublishedStructure,
loadPublishedStructureYears,
} from "@/lib/coursemap/published-structures";
import type { Course } from "@/lib/coursemap/types";
import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error";
import { StructureDetailClient } from "./structure-detail-client";

export default async function StructurePage({
params,
searchParams,
}: {
params: Promise<{ code: string }>;
searchParams: Promise<{ year?: string | string[] }>;
}) {
const { code } = await params;
const requestedYearParam = (await searchParams).year;
const requestedYear = Number(
Array.isArray(requestedYearParam)
? requestedYearParam[0]
: requestedYearParam,
);

let structure = null;
let courses: Course[] = [];
try {
const years = await loadPublishedStructureYears(code);
if (years.length === 0) notFound();
const thisYear = new Date().getFullYear();
const academicYear = years.includes(requestedYear)
? requestedYear
: years.includes(thisYear)
? thisYear
: years[0];
structure = await loadPublishedStructure(code, academicYear);
if (structure) {
// The option cards read better with a title and a unit value, so the
// courses the tree names are resolved once here rather than per card.
const details = await loadPublishedCoursesByCodes(
requirementCourseCodes(structure.requirements),
academicYear,
);
courses = details.map(planCourseFromDetails);
}
} catch {
return (
<StructureCatalogueError
retryHref={`/structures/${encodeURIComponent(code)}`}
/>
);
}

if (!structure) notFound();
return <StructureDetailClient structure={structure} courses={courses} />;
}

export const dynamic = "force-dynamic";
72 changes: 72 additions & 0 deletions apps/web/app/structures/[code]/structure-detail-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { Tabs } from "@coursemap/ui/primitives/tabs";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";

import type { Course } from "@/lib/coursemap/types";
import type { StructureDetails } from "@/lib/coursemap/structure-types";
import { readingTreeContext } from "@/ui/requirements/requirement-presentation";
import {
StructureDetailTabsList,
StructureDetailView,
structureTabFromSearch,
type StructureTab,
} from "@/ui/requirements/structure-detail-view";
import { AppShell } from "@/ui/shell";

export function StructureDetailClient({
structure,
courses,
}: {
structure: StructureDetails;
courses: Course[];
}) {
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<StructureTab>(() =>
structureTabFromSearch(searchParams.get("tab")),
);

useEffect(() => {
const syncTabFromHistory = () => {
setActiveTab(
structureTabFromSearch(
new URL(window.location.href).searchParams.get("tab"),
),
);
};
window.addEventListener("popstate", syncTabFromHistory);
return () => window.removeEventListener("popstate", syncTabFromHistory);
}, []);

const selectTab = (tab: StructureTab) => {
setActiveTab(tab);
const url = new URL(window.location.href);
if (tab === "overview") url.searchParams.delete("tab");
else url.searchParams.set("tab", tab);
window.history.pushState({}, "", `${url.pathname}${url.search}${url.hash}`);
};

return (
<Tabs
value={activeTab}
onValueChange={(value) => selectTab(value as StructureTab)}
className="gap-0"
>
<AppShell
tabs={<StructureDetailTabsList />}
breadcrumbSegmentLabels={{ structures: null }}
currentBreadcrumbLabel={structure.name}
>
<StructureDetailView
structure={structure}
treeContext={readingTreeContext({
academicYear: structure.year,
courses,
unitTarget: structure.units,
})}
/>
</AppShell>
</Tabs>
);
}
6 changes: 6 additions & 0 deletions apps/web/lib/coursemap/course-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ export type CourseDetails = {
/** Published courses which can be opened from requisite prose. */
availableCourseCodes: string[];
incompatibilityText: string;
/**
* Whether the reverse lookup for courses this one unlocks actually ran. It
* only runs over published courses, so a draft cannot tell an empty result
* from an unasked question and must say so rather than imply nothing.
*/
unlocksAreKnown: boolean;
sourceUrl: string;
sourceUpdatedAt: string | null;
publicationStatus: "published" | "draft";
Expand Down
5 changes: 5 additions & 0 deletions apps/web/lib/coursemap/published-courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,9 @@ function detailAsCourseDetails(
sessions,
sourceUpdatedAt: readNullableString(snapshot.sourceUpdatedAt),
sourceUrl: sourceUrl(academicYear, code),
// The published detail always carries a graph array, even when empty. A
// draft projection has no key at all, so the reverse lookup never ran.
unlocksAreKnown: Array.isArray(value.prerequisiteEdges),
subject: readString(snapshot.subjectCode, code.slice(0, 4)),
subjectName: readNullableString(snapshot.subjectName),
unitValue,
Expand Down Expand Up @@ -1219,6 +1222,8 @@ async function loadListRelationships(
sessions: sessionNames,
sourceUpdatedAt: snapshot.source_updated_at,
sourceUrl: sourceUrl(year.year, code),
// The list query reads prerequisite references only, never the reverse.
unlocksAreKnown: false,
subject: snapshot.subject_code ?? code.slice(0, 4),
subjectName: snapshot.subject_name,
unitValue,
Expand Down
Loading
Loading