From bbff2b7f36744808b62ec130be2cfbdc96f87b69 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sun, 1 Feb 2026 01:43:58 -0600 Subject: [PATCH] refactor(web): split CourseTable into modular component structure Decompose monolithic CourseTable.svelte into separate desktop/mobile views with dedicated cell components and extracted state management for improved maintainability and code organization. --- web/src/lib/components/CourseTable.svelte | 801 ------------------ .../course-table/CourseTable.svelte | 56 ++ .../course-table/CourseTableDesktop.svelte | 308 +++++++ .../course-table/CourseTableMobile.svelte | 38 + .../components/course-table/EmptyState.svelte | 7 + .../course-table/cells/CourseCodeCell.svelte | 23 + .../course-table/cells/CrnCell.svelte | 34 + .../course-table/cells/InstructorCell.svelte | 91 ++ .../course-table/cells/LocationCell.svelte | 32 + .../course-table/cells/SeatsCell.svelte | 30 + .../course-table/cells/TimeCell.svelte | 50 ++ .../course-table/cells/TitleCell.svelte | 14 + .../lib/components/course-table/columns.ts | 85 ++ .../lib/components/course-table/context.ts | 15 + web/src/lib/components/course-table/index.ts | 1 + .../lib/components/course-table/skeletons.ts | 25 + .../useCourseTableState.svelte.ts | 60 ++ web/src/routes/+page.svelte | 2 +- 18 files changed, 870 insertions(+), 802 deletions(-) delete mode 100644 web/src/lib/components/CourseTable.svelte create mode 100644 web/src/lib/components/course-table/CourseTable.svelte create mode 100644 web/src/lib/components/course-table/CourseTableDesktop.svelte create mode 100644 web/src/lib/components/course-table/CourseTableMobile.svelte create mode 100644 web/src/lib/components/course-table/EmptyState.svelte create mode 100644 web/src/lib/components/course-table/cells/CourseCodeCell.svelte create mode 100644 web/src/lib/components/course-table/cells/CrnCell.svelte create mode 100644 web/src/lib/components/course-table/cells/InstructorCell.svelte create mode 100644 web/src/lib/components/course-table/cells/LocationCell.svelte create mode 100644 web/src/lib/components/course-table/cells/SeatsCell.svelte create mode 100644 web/src/lib/components/course-table/cells/TimeCell.svelte create mode 100644 web/src/lib/components/course-table/cells/TitleCell.svelte create mode 100644 web/src/lib/components/course-table/columns.ts create mode 100644 web/src/lib/components/course-table/context.ts create mode 100644 web/src/lib/components/course-table/index.ts create mode 100644 web/src/lib/components/course-table/skeletons.ts create mode 100644 web/src/lib/components/course-table/useCourseTableState.svelte.ts diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte deleted file mode 100644 index 8566d35..0000000 --- a/web/src/lib/components/CourseTable.svelte +++ /dev/null @@ -1,801 +0,0 @@ - - -{#snippet emptyState()} -
- No courses found. Try adjusting your filters. -
-{/snippet} - -{#snippet columnVisibilityGroup( - Group: typeof DropdownMenu.Group, - GroupHeading: typeof DropdownMenu.GroupHeading, - CheckboxItem: typeof DropdownMenu.CheckboxItem, - Separator: typeof DropdownMenu.Separator, - Item: typeof DropdownMenu.Item, -)} - - - Toggle columns - - {#each columns as col} - {@const id = col.id!} - {@const label = typeof col.header === "string" ? col.header : id} - { - columnVisibility = { - ...columnVisibility, - [id]: checked, - }; - }} - class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" - > - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {label} - {/snippet} - - {/each} - - {#if hasCustomVisibility} - - - - Reset to default - - {/if} -{/snippet} - - -
- {#if loading && courses.length === 0} - {@html buildCardSkeletonHtml(skeletonRowCount)} - {:else if courses.length === 0 && !loading} - {@render emptyState()} - {:else} - {#each courses as course (course.crn)} -
- toggleRow(course.crn)} - /> -
- {/each} - {/if} -
- - - - -
- - - - - {#each table.getHeaderGroups() as headerGroup} - - {#each headerGroup.headers as header} - {#if header.column.getIsVisible()} - - {/if} - {/each} - - {/each} - - {#if loading && courses.length === 0} - - {@html buildSkeletonHtml(visibleColumnIds, skeletonRowCount)} - - {:else if courses.length === 0 && !loading} - - - - - - {:else} - - {#each table.getRowModel().rows as row, i (row.id)} - {@const course = row.original} - - toggleRow(course.crn)} - > - {#each visibleColumnIds as colId (colId)} - {#if colId === "crn"} - - {:else if colId === "course_code"} - {@const subjectDesc = - subjectMap[course.subject]} - {@const paddedSubject = - course.subject.padStart( - maxSubjectLength, - " ", - )} - - {:else if colId === "title"} - - {:else if colId === "instructor"} - {@const primary = getPrimaryInstructor( - course.instructors, - )} - {@const display = primary - ? abbreviateInstructor(primary.displayName) - : "Staff"} - {@const commaIdx = - display.indexOf(", ")} - {@const ratingData = primary?.rmpRating != null - ? { - rating: primary.rmpRating, - count: primary.rmpNumRatings ?? 0, - legacyId: primary.rmpLegacyId ?? null, - } - : null} - - {:else if colId === "time"} - - {:else if colId === "location"} - {@const concern = - getDeliveryConcern(course)} - {@const accentColor = - concernAccentColor(concern)} - {@const locTooltip = - formatLocationTooltip(course, concern)} - {@const locDisplay = - formatLocationDisplay(course, concern)} - - {:else if colId === "seats"} - {@const open = openSeats(course)} - {@const seatsTip = `${formatNumber(open)} of ${formatNumber(course.maxEnrollment)} seats open, ${formatNumber(course.enrollment)} enrolled${course.waitCount > 0 ? `, ${formatNumber(course.waitCount)} waitlisted` : ""}`} - - {/if} - {/each} - - {#if expandedCrn === course.crn} - - - - {/if} - - {/each} - {/if} -
- {#if header.column.getCanSort()} - - {#if typeof header.column.columnDef.header === "string"} - {header.column.columnDef - .header} - {:else} - - {/if} - {#if header.column.getIsSorted() === "asc"} - - {:else if header.column.getIsSorted() === "desc"} - - {:else} - - {/if} - - {:else if typeof header.column.columnDef.header === "string"} - {header.column.columnDef.header} - {:else} - - {/if} -
- {@render emptyState()} -
- - - - {paddedSubject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} - - - {course.title} - - {#if display === "Staff"} - Staff - {:else} - - {#if commaIdx !== -1} - {display.slice( - 0, - commaIdx, - )}, - {display.slice( - commaIdx + - 1, - )} - {:else} - {display} - {/if} - - {/if} - {#if ratingData} - {@const lowConfidence = - ratingData.count < - RMP_CONFIDENCE_THRESHOLD} - - {#snippet children()} - - {ratingData.rating.toFixed( - 1, - )} - {#if lowConfidence} - - {:else} - - {/if} - - {/snippet} - {#snippet content()} - - {ratingData.rating.toFixed( - 1, - )}/5 · {formatNumber(ratingData.count)} - ratings - {#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD} - (low) - {/if} - {#if ratingData.legacyId != null} - · - - RMP - - - {/if} - - {/snippet} - - {/if} - - {#if isAsyncOnline(course)} - Async - {:else if timeIsTBA(course)} - TBA - {:else} - {@const mt = - course.meetingTimes[0]} - - {#if !isMeetingTimeTBA(mt)} - {formatMeetingDays( - mt, - )} - {" "} - {/if} - {#if !isTimeTBA(mt)} - {formatTimeRange( - mt.begin_time, - mt.end_time, - )} - {:else} - TBA - {/if} - {#if course.meetingTimes.length > 1} - +{course - .meetingTimes - .length - - 1} - {/if} - - {/if} - - {#if locDisplay} - - {locDisplay} - - {:else} - - {/if} - - - - {#if open === 0}Full{:else}{open} open{/if} - {formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0} - · WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if} - -
-
- -
-
-
- - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- {@render columnVisibilityGroup( - ContextMenu.Group, - ContextMenu.GroupHeading, - ContextMenu.CheckboxItem, - ContextMenu.Separator, - ContextMenu.Item, - )} -
-
- {/if} - {/snippet} -
-
-
-
diff --git a/web/src/lib/components/course-table/CourseTable.svelte b/web/src/lib/components/course-table/CourseTable.svelte new file mode 100644 index 0000000..8ee788c --- /dev/null +++ b/web/src/lib/components/course-table/CourseTable.svelte @@ -0,0 +1,56 @@ + + + + + diff --git a/web/src/lib/components/course-table/CourseTableDesktop.svelte b/web/src/lib/components/course-table/CourseTableDesktop.svelte new file mode 100644 index 0000000..e72bd1f --- /dev/null +++ b/web/src/lib/components/course-table/CourseTableDesktop.svelte @@ -0,0 +1,308 @@ + + + +
+ + + + + {#each table.getHeaderGroups() as headerGroup} + + {#each headerGroup.headers as header} + {#if header.column.getIsVisible()} + + {/if} + {/each} + + {/each} + + {#if loading && courses.length === 0} + + {@html buildSkeletonHtml(visibleColumnIds, skeletonRowCount)} + + {:else if courses.length === 0 && !loading} + + + + + + {:else} + {#each table.getRowModel().rows as row, i (row.id)} + {@const course = row.original} + + onToggle(course.crn)} + > + {#each visibleColumnIds as colId (colId)} + {@const CellComponent = CELL_COMPONENTS[colId]} + {#if CellComponent} + + {:else} + + {/if} + {/each} + + {#if expandedCrn === course.crn} + + + + {/if} + + {/each} + {/if} +
+ {#if header.column.getCanSort()} + + {#if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} + {#if header.column.getIsSorted() === "asc"} + + {:else if header.column.getIsSorted() === "desc"} + + {:else} + + {/if} + + {:else if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} +
+ +
+
+ +
+
+
+ + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ + + Toggle columns + + {#each COLUMN_DEFS as col} + {@const id = col.id!} + {@const label = typeof col.header === "string" ? col.header : id} + { + columnVisibility = { + ...columnVisibility, + [id]: checked, + }; + }} + class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {label} + {/snippet} + + {/each} + + {#if hasCustomVisibility} + + + + Reset to default + + {/if} +
+
+ {/if} + {/snippet} +
+
+
+
diff --git a/web/src/lib/components/course-table/CourseTableMobile.svelte b/web/src/lib/components/course-table/CourseTableMobile.svelte new file mode 100644 index 0000000..80fbc0c --- /dev/null +++ b/web/src/lib/components/course-table/CourseTableMobile.svelte @@ -0,0 +1,38 @@ + + +
+ {#if loading && courses.length === 0} + {@html buildCardSkeletonHtml(skeletonRowCount)} + {:else if courses.length === 0 && !loading} + + {:else} + {#each courses as course (course.crn)} +
+ onToggle(course.crn)} + /> +
+ {/each} + {/if} +
diff --git a/web/src/lib/components/course-table/EmptyState.svelte b/web/src/lib/components/course-table/EmptyState.svelte new file mode 100644 index 0000000..d24c0ee --- /dev/null +++ b/web/src/lib/components/course-table/EmptyState.svelte @@ -0,0 +1,7 @@ + + +
+ No courses found. Try adjusting your filters. +
diff --git a/web/src/lib/components/course-table/cells/CourseCodeCell.svelte b/web/src/lib/components/course-table/cells/CourseCodeCell.svelte new file mode 100644 index 0000000..a943f1e --- /dev/null +++ b/web/src/lib/components/course-table/cells/CourseCodeCell.svelte @@ -0,0 +1,23 @@ + + + + + {paddedSubject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} + + diff --git a/web/src/lib/components/course-table/cells/CrnCell.svelte b/web/src/lib/components/course-table/cells/CrnCell.svelte new file mode 100644 index 0000000..a554155 --- /dev/null +++ b/web/src/lib/components/course-table/cells/CrnCell.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/web/src/lib/components/course-table/cells/InstructorCell.svelte b/web/src/lib/components/course-table/cells/InstructorCell.svelte new file mode 100644 index 0000000..9322b32 --- /dev/null +++ b/web/src/lib/components/course-table/cells/InstructorCell.svelte @@ -0,0 +1,91 @@ + + + + {#if display === "Staff"} + Staff + {:else} + + {#if commaIdx !== -1} + {display.slice(0, commaIdx)}, + {display.slice(commaIdx + 1)} + {:else} + {display} + {/if} + + {/if} + {#if ratingData} + {@const lowConfidence = ratingData.count < RMP_CONFIDENCE_THRESHOLD} + + {#snippet children()} + + {ratingData.rating.toFixed(1)} + {#if lowConfidence} + + {:else} + + {/if} + + {/snippet} + {#snippet content()} + + {ratingData.rating.toFixed(1)}/5 · {formatNumber(ratingData.count)} + ratings + {#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD} + (low) + {/if} + {#if ratingData.legacyId != null} + · + + RMP + + + {/if} + + {/snippet} + + {/if} + diff --git a/web/src/lib/components/course-table/cells/LocationCell.svelte b/web/src/lib/components/course-table/cells/LocationCell.svelte new file mode 100644 index 0000000..007b770 --- /dev/null +++ b/web/src/lib/components/course-table/cells/LocationCell.svelte @@ -0,0 +1,32 @@ + + + + {#if locDisplay} + + {locDisplay} + + {:else} + + {/if} + diff --git a/web/src/lib/components/course-table/cells/SeatsCell.svelte b/web/src/lib/components/course-table/cells/SeatsCell.svelte new file mode 100644 index 0000000..0501ad7 --- /dev/null +++ b/web/src/lib/components/course-table/cells/SeatsCell.svelte @@ -0,0 +1,30 @@ + + + + + + {#if open === 0}Full{:else}{open} open{/if} + {formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0} + · WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if} + + diff --git a/web/src/lib/components/course-table/cells/TimeCell.svelte b/web/src/lib/components/course-table/cells/TimeCell.svelte new file mode 100644 index 0000000..c147694 --- /dev/null +++ b/web/src/lib/components/course-table/cells/TimeCell.svelte @@ -0,0 +1,50 @@ + + + + {#if isAsyncOnline(course)} + Async + {:else if timeIsTBA(course)} + TBA + {:else} + {@const mt = course.meetingTimes[0]} + + {#if !isMeetingTimeTBA(mt)} + {formatMeetingDays(mt)} + {" "} + {/if} + {#if !isTimeTBA(mt)} + {formatTimeRange(mt.begin_time, mt.end_time)} + {:else} + TBA + {/if} + {#if course.meetingTimes.length > 1} + +{course.meetingTimes.length - 1} + {/if} + + {/if} + diff --git a/web/src/lib/components/course-table/cells/TitleCell.svelte b/web/src/lib/components/course-table/cells/TitleCell.svelte new file mode 100644 index 0000000..8bf2e12 --- /dev/null +++ b/web/src/lib/components/course-table/cells/TitleCell.svelte @@ -0,0 +1,14 @@ + + + + {course.title} + diff --git a/web/src/lib/components/course-table/columns.ts b/web/src/lib/components/course-table/columns.ts new file mode 100644 index 0000000..0834720 --- /dev/null +++ b/web/src/lib/components/course-table/columns.ts @@ -0,0 +1,85 @@ +// columns.ts +import type { CourseResponse } from "$lib/api"; +import type { ColumnDef } from "@tanstack/table-core"; +import type { Component } from "svelte"; +import { + abbreviateInstructor, + formatLocationDisplay, + formatMeetingDays, + formatTimeRange, + getPrimaryInstructor, + openSeats, +} from "$lib/course"; + +import CrnCell from "./cells/CrnCell.svelte"; +import CourseCodeCell from "./cells/CourseCodeCell.svelte"; +import TitleCell from "./cells/TitleCell.svelte"; +import InstructorCell from "./cells/InstructorCell.svelte"; +import TimeCell from "./cells/TimeCell.svelte"; +import LocationCell from "./cells/LocationCell.svelte"; +import SeatsCell from "./cells/SeatsCell.svelte"; + +export const COLUMN_DEFS: ColumnDef[] = [ + { + id: "crn", + accessorKey: "crn", + header: "CRN", + enableSorting: false, + }, + { + id: "course_code", + accessorFn: (row) => `${row.subject} ${row.courseNumber}`, + header: "Course", + enableSorting: true, + }, + { + id: "title", + accessorKey: "title", + header: "Title", + enableSorting: true, + }, + { + id: "instructor", + accessorFn: (row) => { + const primary = getPrimaryInstructor(row.instructors); + if (!primary) return "Staff"; + return abbreviateInstructor(primary.displayName); + }, + header: "Instructor", + enableSorting: true, + }, + { + id: "time", + accessorFn: (row) => { + if (row.meetingTimes.length === 0) return ""; + const mt = row.meetingTimes[0]; + return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`; + }, + header: "Time", + enableSorting: true, + }, + { + id: "location", + accessorFn: (row) => formatLocationDisplay(row) ?? "", + header: "Location", + enableSorting: false, + }, + { + id: "seats", + accessorFn: (row) => openSeats(row), + header: "Seats", + enableSorting: true, + }, +]; + +/** Column ID to Svelte cell component. Used by the row renderer. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const CELL_COMPONENTS: Record> = { + crn: CrnCell, + course_code: CourseCodeCell, + title: TitleCell, + instructor: InstructorCell, + time: TimeCell, + location: LocationCell, + seats: SeatsCell, +}; diff --git a/web/src/lib/components/course-table/context.ts b/web/src/lib/components/course-table/context.ts new file mode 100644 index 0000000..f4e72f1 --- /dev/null +++ b/web/src/lib/components/course-table/context.ts @@ -0,0 +1,15 @@ +import type { useClipboard } from "$lib/composables/useClipboard.svelte"; +import { getContext } from "svelte"; + +export const TABLE_CONTEXT_KEY = Symbol("table-context"); + +export type TableContext = { + clipboard: ReturnType; + subjectMap: Record; + maxSubjectLength: number; +}; + +/** Type-safe utility for accessing table context in cell components */ +export function getTableContext(): TableContext { + return getContext(TABLE_CONTEXT_KEY); +} diff --git a/web/src/lib/components/course-table/index.ts b/web/src/lib/components/course-table/index.ts new file mode 100644 index 0000000..3433c2a --- /dev/null +++ b/web/src/lib/components/course-table/index.ts @@ -0,0 +1 @@ +export { default as CourseTable } from "./CourseTable.svelte"; diff --git a/web/src/lib/components/course-table/skeletons.ts b/web/src/lib/components/course-table/skeletons.ts new file mode 100644 index 0000000..d3ace6c --- /dev/null +++ b/web/src/lib/components/course-table/skeletons.ts @@ -0,0 +1,25 @@ +export const SKELETON_WIDTHS: Record = { + crn: "w-10", + course_code: "w-20", + title: "w-40", + instructor: "w-20", + time: "w-20", + location: "w-20", + seats: "w-14 ml-auto", +}; + +export function buildSkeletonHtml(colIds: string[], rowCount: number): string { + const cells = colIds + .map((id) => { + const w = SKELETON_WIDTHS[id] ?? "w-20"; + return `
`; + }) + .join(""); + const row = `${cells}`; + return row.repeat(rowCount); +} + +export function buildCardSkeletonHtml(count: number): string { + const card = `
`; + return card.repeat(count); +} diff --git a/web/src/lib/components/course-table/useCourseTableState.svelte.ts b/web/src/lib/components/course-table/useCourseTableState.svelte.ts new file mode 100644 index 0000000..27ba83b --- /dev/null +++ b/web/src/lib/components/course-table/useCourseTableState.svelte.ts @@ -0,0 +1,60 @@ +// useCourseTableState.svelte.ts +import type { CourseResponse } from "$lib/api"; + +export function useCourseTableState(getCourses: () => CourseResponse[], getLimit: () => number) { + let expandedCrn: string | null = $state(null); + let previousRowCount = $state(0); + let hadResults = $state(false); + let contentHeight = $state(null); + + // Track previous row count so skeleton matches expected result size + $effect(() => { + const courses = getCourses(); + if (courses.length > 0) { + previousRowCount = courses.length; + } + }); + + let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : getLimit()); + + // Collapse expanded row when dataset changes + $effect(() => { + getCourses(); // track dependency + expandedCrn = null; + }); + + // Skip FLIP on initial load + $effect(() => { + if (getCourses().length > 0) hadResults = true; + }); + + function toggleRow(crn: string) { + expandedCrn = expandedCrn === crn ? null : crn; + } + + /** Bind to the table element to track content height via ResizeObserver */ + function observeHeight(tableElement: HTMLTableElement) { + const observer = new ResizeObserver(([entry]) => { + contentHeight = entry.contentRect.height; + }); + observer.observe(tableElement); + return () => observer.disconnect(); + } + + return { + get expandedCrn() { + return expandedCrn; + }, + get skeletonRowCount() { + return skeletonRowCount; + }, + get hadResults() { + return hadResults; + }, + get contentHeight() { + return contentHeight; + }, + toggleRow, + observeHeight, + }; +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index e78a403..20bcb55 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -9,7 +9,7 @@ import { type Subject, client, } from "$lib/api"; -import CourseTable from "$lib/components/CourseTable.svelte"; +import { CourseTable } from "$lib/components/course-table"; import FilterChip from "$lib/components/FilterChip.svelte"; import Footer from "$lib/components/Footer.svelte"; import Pagination from "$lib/components/Pagination.svelte";