diff --git a/src/web/routes.rs b/src/web/routes.rs index a329325..4cdf6cb 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -500,7 +500,11 @@ async fn search_courses( let (courses, total_count) = crate::data::courses::search_courses( &state.db_pool, ¶ms.term, - if params.subject.is_empty() { None } else { Some(¶ms.subject) }, + if params.subject.is_empty() { + None + } else { + Some(¶ms.subject) + }, params.q.as_deref(), params.course_number_low, params.course_number_high, diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index c295471..a5bcd15 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -2,13 +2,16 @@ import type { CourseResponse } from "$lib/api"; import { abbreviateInstructor, + concernAccentColor, + formatLocationDisplay, + formatLocationTooltip, formatMeetingDays, + formatMeetingTimesTooltip, formatTimeRange, - formatLocation, + getDeliveryConcern, getPrimaryInstructor, isMeetingTimeTBA, isTimeTBA, - formatMeetingTimesTooltip, } from "$lib/course"; import CourseDetail from "./CourseDetail.svelte"; import { fade, fly, slide } from "svelte/transition"; @@ -208,7 +211,7 @@ const columns: ColumnDef[] = [ }, { id: "location", - accessorFn: (row) => formatLocation(row) ?? "", + accessorFn: (row) => formatLocationDisplay(row) ?? "", header: "Location", enableSorting: false, }, @@ -668,19 +671,31 @@ const table = createSvelteTable({ {:else if colId === "location"} + {@const concern = getDeliveryConcern(course)} + {@const accentColor = concernAccentColor(concern)} + {@const locTooltip = formatLocationTooltip(course)} + {@const locDisplay = formatLocationDisplay(course)} - {#if formatLocation(course)} - {formatLocation( - course, - )} + + {locDisplay ?? "—"} + + + {:else if locDisplay} + + {locDisplay} + {:else} - + {/if} {:else if colId === "seats"} diff --git a/web/src/lib/components/TermCombobox.svelte b/web/src/lib/components/TermCombobox.svelte index 52490be..4ba426f 100644 --- a/web/src/lib/components/TermCombobox.svelte +++ b/web/src/lib/components/TermCombobox.svelte @@ -60,7 +60,7 @@ $effect(() => { >
{ containerEl?.querySelector('input')?.focus(); }} diff --git a/web/src/lib/course.ts b/web/src/lib/course.ts index ae9511d..25c963f 100644 --- a/web/src/lib/course.ts +++ b/web/src/lib/course.ts @@ -261,6 +261,86 @@ export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string return meetingTimes.map(formatMeetingTimeTooltip).join("\n\n"); } +/** + * Delivery concern category for visual accent on location cells. + * - "online": fully online with no physical location (OA, OS, OH without INT building) + * - "internet": internet campus with INT building code + * - "hybrid": mix of online and in-person (HB, H1, H2) + * - "off-campus": in-person but not on Main Campus + * - null: normal in-person on main campus (no accent) + */ +export type DeliveryConcern = "online" | "internet" | "hybrid" | "off-campus" | null; + +const ONLINE_METHODS = new Set(["OA", "OS", "OH"]); +const HYBRID_METHODS = new Set(["HB", "H1", "H2"]); +const MAIN_CAMPUS = "11"; +const ONLINE_CAMPUSES = new Set(["9", "ONL"]); + +export function getDeliveryConcern(course: CourseResponse): DeliveryConcern { + const method = course.instructionalMethod; + if (method && ONLINE_METHODS.has(method)) { + const hasIntBuilding = course.meetingTimes.some((mt: DbMeetingTime) => mt.building === "INT"); + return hasIntBuilding ? "internet" : "online"; + } + if (method && HYBRID_METHODS.has(method)) return "hybrid"; + if (course.campus && course.campus !== MAIN_CAMPUS && !ONLINE_CAMPUSES.has(course.campus)) { + return "off-campus"; + } + return null; +} + +/** Border accent color for each delivery concern type. */ +export function concernAccentColor(concern: DeliveryConcern): string | null { + switch (concern) { + case "online": + return "#3b82f6"; // blue-500 + case "internet": + return "#06b6d4"; // cyan-500 + case "hybrid": + return "#a855f7"; // purple-500 + case "off-campus": + return "#f59e0b"; // amber-500 + default: + return null; + } +} + +/** + * Location display text for the table cell. + * Falls back to "Online" for online courses instead of showing a dash. + */ +export function formatLocationDisplay(course: CourseResponse): string | null { + const loc = formatLocation(course); + if (loc) return loc; + const concern = getDeliveryConcern(course); + if (concern === "online") return "Online"; + return null; +} + +/** Tooltip text for the location column: long-form location + delivery note */ +export function formatLocationTooltip(course: CourseResponse): string | null { + const parts: string[] = []; + + for (const mt of course.meetingTimes) { + const loc = formatLocationLong(mt); + if (loc && !parts.includes(loc)) parts.push(loc); + } + + const locationLine = parts.length > 0 ? parts.join(", ") : null; + + const concern = getDeliveryConcern(course); + let deliveryNote: string | null = null; + if (concern === "online") deliveryNote = "Online"; + else if (concern === "internet") deliveryNote = "Internet"; + else if (concern === "hybrid") deliveryNote = "Hybrid"; + else if (concern === "off-campus") deliveryNote = "Off-campus"; + + if (locationLine && deliveryNote) return `${locationLine}\n${deliveryNote}`; + if (locationLine) return locationLine; + if (deliveryNote) return deliveryNote; + return null; +} + /** Format credit hours display */ export function formatCreditHours(course: CourseResponse): string { if (course.creditHours != null) return String(course.creditHours); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index c646f56..ccf7b6c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -69,21 +69,59 @@ $effect(() => { }); }); -// Debounced search -let searchTimeout: ReturnType | undefined; -$effect(() => { - const term = selectedTerm; - const subs = selectedSubjects; - const q = query; - const open = openOnly; - const off = offset; - const sort = sorting; +// Centralized throttle configuration - maps trigger source to throttle delay (ms) +const THROTTLE_MS = { + term: 0, // Immediate + subjects: 100, // Short delay for combobox selection + query: 300, // Standard input debounce + openOnly: 0, // Immediate + offset: 0, // Immediate (pagination) + sorting: 0, // Immediate (column sort) +} as const; +let searchTimeout: ReturnType | undefined; + +function scheduleSearch(source: keyof typeof THROTTLE_MS) { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - performSearch(term, subs, q, open, off, sort); - }, 300); + performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting); + }, THROTTLE_MS[source]); +} +// Separate effects for each trigger source with appropriate throttling +$effect(() => { + selectedTerm; + scheduleSearch("term"); + return () => clearTimeout(searchTimeout); +}); + +$effect(() => { + selectedSubjects; + scheduleSearch("subjects"); + return () => clearTimeout(searchTimeout); +}); + +$effect(() => { + query; + scheduleSearch("query"); + return () => clearTimeout(searchTimeout); +}); + +$effect(() => { + openOnly; + scheduleSearch("openOnly"); + return () => clearTimeout(searchTimeout); +}); + +$effect(() => { + offset; + scheduleSearch("offset"); + return () => clearTimeout(searchTimeout); +}); + +$effect(() => { + sorting; + scheduleSearch("sorting"); return () => clearTimeout(searchTimeout); });