Skip to content

Commit 9f0b0b7

Browse files
claudeHarryRandall
authored andcommitted
feat: filter the course explorer by subject, level, college, area and tag
1 parent ae12675 commit 9f0b0b7

2 files changed

Lines changed: 208 additions & 26 deletions

File tree

‎apps/web/app/courses/(directory)/page.tsx‎

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@ import { DataTableShell } from "@/ui/admin/catalogue-table/catalogue-table";
77
import { FilterBar } from "@/ui/common/filter-bar";
88
import {
99
loadAcademicYearOptions,
10+
loadCourseFilterOptions,
1011
loadPublishedCoursePage,
12+
type CourseFilterOptions,
1113
type PublishedCoursePage,
1214
} from "@/lib/coursemap/published-courses";
1315
import { CourseDirectory } from "../course-directory";
1416

1517
type CoursesSearchParams = {
1618
q?: string | string[];
19+
subject?: string | string[];
1720
level?: string | string[];
1821
session?: string | string[];
22+
college?: string | string[];
23+
area?: string | string[];
24+
tag?: string | string[];
1925
year?: string | string[];
2026
page?: string | string[];
2127
};
@@ -33,12 +39,25 @@ export default async function CoursesPage({
3339
const page = Math.max(1, Number(firstParam(params.page)) || 1);
3440
const query = firstParam(params.q).slice(0, 100);
3541
const levelParam = firstParam(params.level);
36-
const level = ["1", "2", "3", "4"].includes(levelParam) ? levelParam : "";
42+
// A digit is that level; a digit and + is that level or higher, which is
43+
// how degree rules ask for units ("2000-level or higher").
44+
const level = /^[1-9]\+?$/u.test(levelParam) ? levelParam : "";
3745
const sessionParam = firstParam(params.session);
3846
const session = ["Semester 1", "Semester 2"].includes(sessionParam)
3947
? sessionParam
4048
: "";
41-
const filters = { query, level, session };
49+
const subjectParam = firstParam(params.subject).toUpperCase();
50+
const subject = /^[A-Z]{4}$/u.test(subjectParam) ? subjectParam : "";
51+
const college = firstParam(params.college).slice(0, 120);
52+
const area = firstParam(params.area).slice(0, 120);
53+
const tag = firstParam(params.tag).slice(0, 120);
54+
const filters = { query, subject, level, session, college, area, tag };
55+
let filterOptions: CourseFilterOptions = {
56+
subjects: [],
57+
colleges: [],
58+
areas: [],
59+
tags: [],
60+
};
4261
let yearOptions: Awaited<ReturnType<typeof loadAcademicYearOptions>> = [];
4362
let selectedAcademicYear = new Date().getFullYear();
4463
let result: PublishedCoursePage = {
@@ -58,19 +77,26 @@ export default async function CoursesPage({
5877
: availableYears.has(currentYear)
5978
? currentYear
6079
: (yearOptions[0]?.year ?? currentYear);
61-
result = await loadPublishedCoursePage({
62-
academicYear: selectedAcademicYear,
63-
page,
64-
filters,
65-
});
80+
[result, filterOptions] = await Promise.all([
81+
loadPublishedCoursePage({
82+
academicYear: selectedAcademicYear,
83+
page,
84+
filters,
85+
}),
86+
loadCourseFilterOptions(selectedAcademicYear),
87+
]);
6688
} catch {
6789
// Show an explicit outage state rather than an empty catalogue.
6890
catalogueUnavailable = true;
6991
}
7092
const paginationSearchParams = {
7193
q: query || undefined,
94+
subject: subject || undefined,
7295
level: level || undefined,
7396
session: session || undefined,
97+
college: college || undefined,
98+
area: area || undefined,
99+
tag: tag || undefined,
74100
year: String(selectedAcademicYear),
75101
};
76102

@@ -124,6 +150,16 @@ export default async function CoursesPage({
124150
: `${option.year} · No published courses`,
125151
})),
126152
},
153+
{
154+
key: "subject",
155+
label: "Subject",
156+
options: filterOptions.subjects.map((option) => ({
157+
value: option.code,
158+
label: option.name
159+
? `${option.code} · ${option.name}`
160+
: option.code,
161+
})),
162+
},
127163
{
128164
key: "level",
129165
label: "Level",
@@ -132,6 +168,8 @@ export default async function CoursesPage({
132168
{ value: "2", label: "2000 level" },
133169
{ value: "3", label: "3000 level" },
134170
{ value: "4", label: "4000 level" },
171+
{ value: "2+", label: "2000 level or higher" },
172+
{ value: "3+", label: "3000 level or higher" },
135173
],
136174
},
137175
{
@@ -142,14 +180,32 @@ export default async function CoursesPage({
142180
{ value: "Semester 2", label: "Semester 2" },
143181
],
144182
},
183+
...(
184+
[
185+
["college", "College", filterOptions.colleges],
186+
["area", "Area of interest", filterOptions.areas],
187+
["tag", "Tag", filterOptions.tags],
188+
] as const
189+
).flatMap(([key, label, values]) =>
190+
// A filter with nothing to choose would only ever empty the list.
191+
values.length
192+
? [
193+
{
194+
key,
195+
label,
196+
options: values.map((value) => ({ value, label: value })),
197+
},
198+
]
199+
: [],
200+
),
145201
]}
146202
/>
147203
<CourseDirectory
148204
courses={result.courses}
149205
page={result.page}
150206
pageSize={result.pageSize}
151207
total={result.total}
152-
filtered={Boolean(query || level || session)}
208+
filtered={Object.values(filters).some(Boolean)}
153209
academicYear={selectedAcademicYear}
154210
searchParams={paginationSearchParams}
155211
/>

‎apps/web/lib/coursemap/published-courses.ts‎

Lines changed: 144 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,19 @@ const SNAPSHOT_LIST_SELECT =
100100
export type PublishedCourseFilters = {
101101
query?: string;
102102
subject?: string;
103+
/** A level digit, such as 2, or that level or higher, such as 2+. */
103104
level?: string;
104105
session?: string;
106+
college?: string;
107+
area?: string;
108+
tag?: string;
109+
};
110+
111+
export type CourseFilterOptions = {
112+
subjects: Array<{ code: string; name: string | null }>;
113+
colleges: string[];
114+
areas: string[];
115+
tags: string[];
105116
};
106117

107118
export type PublishedCoursePage = {
@@ -784,6 +795,9 @@ function detailAsCourseDetails(value: Json): CourseDetails | null {
784795
const areasOfInterest = readArray(value.areasOfInterest).flatMap((item) =>
785796
isRecord(item) && readString(item.name) ? [readString(item.name)] : [],
786797
);
798+
const tags = readArray(value.tags).flatMap((item) =>
799+
isRecord(item) && readString(item.name) ? [readString(item.name)] : [],
800+
);
787801
const attributes = readArray(value.attributes).flatMap<CourseAttribute>(
788802
(item) =>
789803
isRecord(item) && readString(item.value)
@@ -837,6 +851,7 @@ function detailAsCourseDetails(value: Json): CourseDetails | null {
837851
academicCareer: readNullableString(snapshot.academicCareer),
838852
accent: accentFor(code),
839853
areasOfInterest,
854+
tags,
840855
assessments: readAssessments(value),
841856
assumedKnowledgeText: ruleText(value, "assumed_knowledge"),
842857
attributes,
@@ -948,6 +963,68 @@ async function loadAcademicYearOptionsUncached(): Promise<
948963
);
949964
}
950965

966+
async function loadCourseFilterOptionsUncached(
967+
academicYear: number,
968+
): Promise<CourseFilterOptions> {
969+
const empty = { subjects: [], colleges: [], areas: [], tags: [] };
970+
const supabase = createPublicClient();
971+
const year = await academicYearRecord(supabase, academicYear);
972+
if (!year) return empty;
973+
const [summaries, areas, tags] = await Promise.all([
974+
supabase
975+
.from("published_course_summaries")
976+
.select("version_id,subject_code,subject_name,college")
977+
.eq("academic_year_id", year.id),
978+
// Only published versions are readable here, so every row belongs to a
979+
// published course of some year; the year's version set narrows them.
980+
supabase.from("course_areas_of_interest").select("version_id,name"),
981+
supabase.from("course_tags").select("version_id,name"),
982+
]);
983+
for (const result of [summaries, areas, tags]) {
984+
if (result.error) throw result.error;
985+
}
986+
const versions = new Set((summaries.data ?? []).map((row) => row.version_id));
987+
const distinctNames = (rows: Array<{ version_id: number; name: string }>) => {
988+
const byKey = new Map<string, string>();
989+
for (const row of rows) {
990+
if (!versions.has(row.version_id)) continue;
991+
const key = row.name.toLowerCase();
992+
if (!byKey.has(key)) byKey.set(key, row.name);
993+
}
994+
return [...byKey.values()].sort((left, right) => left.localeCompare(right));
995+
};
996+
const subjects = new Map<string, string | null>();
997+
const colleges = new Set<string>();
998+
for (const row of summaries.data ?? []) {
999+
if (row.subject_code && !subjects.has(row.subject_code)) {
1000+
subjects.set(row.subject_code, row.subject_name);
1001+
}
1002+
if (row.college) colleges.add(row.college);
1003+
}
1004+
return {
1005+
subjects: [...subjects]
1006+
.map(([code, name]) => ({ code, name }))
1007+
.sort((left, right) => left.code.localeCompare(right.code)),
1008+
colleges: [...colleges].sort((left, right) => left.localeCompare(right)),
1009+
areas: distinctNames(areas.data ?? []),
1010+
tags: distinctNames(tags.data ?? []),
1011+
};
1012+
}
1013+
1014+
/** The values the course explorer can filter a year's courses by. */
1015+
export async function loadCourseFilterOptions(
1016+
academicYear: number,
1017+
): Promise<CourseFilterOptions> {
1018+
return unstable_cache(
1019+
() => loadCourseFilterOptionsUncached(academicYear),
1020+
["published-course-filter-options", String(academicYear)],
1021+
{
1022+
revalidate: 300,
1023+
tags: [PUBLISHED_COURSE_PAGE_TAG, publishedCourseYearTag(academicYear)],
1024+
},
1025+
)();
1026+
}
1027+
9511028
export async function loadAcademicYearOptions(): Promise<AcademicYearOption[]> {
9521029
return unstable_cache(
9531030
loadAcademicYearOptionsUncached,
@@ -970,6 +1047,40 @@ function searchPattern(value: string) {
9701047
.trim();
9711048
}
9721049

1050+
/** Parses a level filter: `2` is 2000 level, `2+` is 2000 level or higher. */
1051+
function levelFilter(value: string) {
1052+
const match = /^([1-9])(\+?)$/u.exec(value);
1053+
return match
1054+
? { level: Number(match[1]) * 1000, orHigher: match[2] === "+" }
1055+
: null;
1056+
}
1057+
1058+
/** Versions carrying an area of interest or tag, matched however it is cased. */
1059+
async function snapshotIdsNamed(
1060+
supabase: SupabaseClient<Database>,
1061+
table: "course_areas_of_interest" | "course_tags",
1062+
name: string,
1063+
) {
1064+
const { data, error } = await supabase
1065+
.from(table)
1066+
.select("version_id")
1067+
.ilike(
1068+
"name",
1069+
name.replace(/[\\%_]/gu, (character) => `\\${character}`),
1070+
);
1071+
if (error) throw error;
1072+
return [...new Set((data ?? []).map((row) => row.version_id))];
1073+
}
1074+
1075+
function intersect(lists: Array<number[] | null>) {
1076+
const present = lists.filter((list): list is number[] => list !== null);
1077+
if (present.length === 0) return null;
1078+
return present.reduce((kept, list) => {
1079+
const allowed = new Set(list);
1080+
return kept.filter((id) => allowed.has(id));
1081+
});
1082+
}
1083+
9731084
async function snapshotIdsForSession(
9741085
supabase: SupabaseClient<Database>,
9751086
yearId: number,
@@ -1167,6 +1278,7 @@ async function loadListRelationships(
11671278
academicCareer: snapshot.academic_career,
11681279
accent: accentFor(code),
11691280
areasOfInterest: [],
1281+
tags: [],
11701282
assessments: [],
11711283
assumedKnowledgeText: "",
11721284
attributes: [],
@@ -1260,19 +1372,28 @@ async function loadPublishedCoursePageUncached({
12601372
const safePageSize = Math.min(100, Math.max(1, Math.floor(pageSize)));
12611373
const query = firstFilterValue(filters.query);
12621374
const subject = firstFilterValue(filters.subject).toUpperCase();
1263-
const level = Number(firstFilterValue(filters.level));
1375+
const level = levelFilter(firstFilterValue(filters.level));
12641376
const session = firstFilterValue(filters.session);
1377+
const college = firstFilterValue(filters.college);
1378+
const area = firstFilterValue(filters.area);
1379+
const tag = firstFilterValue(filters.tag);
12651380

12661381
const supabase = createPublicClient();
12671382
const year = await academicYearRecord(supabase, academicYear);
12681383
if (!year) {
12691384
return { courses: [], page: safePage, pageSize: safePageSize, total: 0 };
12701385
}
12711386
const cleanedQuery = searchPattern(query);
1272-
const sessionSnapshotIds = session
1273-
? await snapshotIdsForSession(supabase, year.id, session)
1274-
: null;
1275-
if (sessionSnapshotIds?.length === 0) {
1387+
const snapshotIds = intersect(
1388+
await Promise.all([
1389+
session ? snapshotIdsForSession(supabase, year.id, session) : null,
1390+
area
1391+
? snapshotIdsNamed(supabase, "course_areas_of_interest", area)
1392+
: null,
1393+
tag ? snapshotIdsNamed(supabase, "course_tags", tag) : null,
1394+
]),
1395+
);
1396+
if (snapshotIds?.length === 0) {
12761397
return { courses: [], page: safePage, pageSize: safePageSize, total: 0 };
12771398
}
12781399

@@ -1281,11 +1402,14 @@ async function loadPublishedCoursePageUncached({
12811402
.select(SNAPSHOT_LIST_SELECT, { count: "exact" })
12821403
.eq("academic_year_id", year.id);
12831404
if (subject) snapshotsQuery = snapshotsQuery.eq("subject_code", subject);
1284-
if (Number.isInteger(level) && level > 0) {
1285-
snapshotsQuery = snapshotsQuery.eq("level", level * 1000);
1405+
if (level) {
1406+
snapshotsQuery = level.orHigher
1407+
? snapshotsQuery.gte("level", level.level)
1408+
: snapshotsQuery.eq("level", level.level);
12861409
}
1287-
if (sessionSnapshotIds) {
1288-
snapshotsQuery = snapshotsQuery.in("version_id", sessionSnapshotIds);
1410+
if (college) snapshotsQuery = snapshotsQuery.eq("college", college);
1411+
if (snapshotIds) {
1412+
snapshotsQuery = snapshotsQuery.in("version_id", snapshotIds);
12891413
}
12901414
if (cleanedQuery) {
12911415
const pattern = `*${cleanedQuery}*`;
@@ -1318,16 +1442,21 @@ export async function loadPublishedCoursePage(args: {
13181442
100,
13191443
Math.max(1, Math.floor(args.pageSize ?? 24)),
13201444
);
1321-
const query = firstFilterValue(args.filters?.query);
1322-
const subject = firstFilterValue(args.filters?.subject).toUpperCase();
1323-
const level = Number(firstFilterValue(args.filters?.level));
1324-
const session = firstFilterValue(args.filters?.session);
1445+
const filters = {
1446+
query: firstFilterValue(args.filters?.query),
1447+
subject: firstFilterValue(args.filters?.subject).toUpperCase(),
1448+
level: firstFilterValue(args.filters?.level),
1449+
session: firstFilterValue(args.filters?.session),
1450+
college: firstFilterValue(args.filters?.college),
1451+
area: firstFilterValue(args.filters?.area),
1452+
tag: firstFilterValue(args.filters?.tag),
1453+
};
13251454

13261455
return unstable_cache(
13271456
() =>
13281457
loadPublishedCoursePageUncached({
13291458
academicYear: args.academicYear,
1330-
filters: { query, subject, level: String(level), session },
1459+
filters,
13311460
page: safePage,
13321461
pageSize: safePageSize,
13331462
}),
@@ -1336,10 +1465,7 @@ export async function loadPublishedCoursePage(args: {
13361465
String(args.academicYear),
13371466
String(safePage),
13381467
String(safePageSize),
1339-
query,
1340-
subject,
1341-
String(level),
1342-
session,
1468+
...Object.values(filters),
13431469
],
13441470
{
13451471
revalidate: 300,

0 commit comments

Comments
 (0)