@@ -100,8 +100,19 @@ const SNAPSHOT_LIST_SELECT =
100100export 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
107118export 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+
9511028export 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+
9731084async 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