feat: add delivery mode indicators and tooltips to location column

This commit is contained in:
2026-01-29 11:32:35 -06:00
parent 58475c8673
commit b5eaedc9bc
5 changed files with 163 additions and 26 deletions
+5 -1
View File
@@ -500,7 +500,11 @@ async fn search_courses(
let (courses, total_count) = crate::data::courses::search_courses( let (courses, total_count) = crate::data::courses::search_courses(
&state.db_pool, &state.db_pool,
&params.term, &params.term,
if params.subject.is_empty() { None } else { Some(&params.subject) }, if params.subject.is_empty() {
None
} else {
Some(&params.subject)
},
params.q.as_deref(), params.q.as_deref(),
params.course_number_low, params.course_number_low,
params.course_number_high, params.course_number_high,
+28 -13
View File
@@ -2,13 +2,16 @@
import type { CourseResponse } from "$lib/api"; import type { CourseResponse } from "$lib/api";
import { import {
abbreviateInstructor, abbreviateInstructor,
concernAccentColor,
formatLocationDisplay,
formatLocationTooltip,
formatMeetingDays, formatMeetingDays,
formatMeetingTimesTooltip,
formatTimeRange, formatTimeRange,
formatLocation, getDeliveryConcern,
getPrimaryInstructor, getPrimaryInstructor,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
formatMeetingTimesTooltip,
} from "$lib/course"; } from "$lib/course";
import CourseDetail from "./CourseDetail.svelte"; import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition"; import { fade, fly, slide } from "svelte/transition";
@@ -208,7 +211,7 @@ const columns: ColumnDef<CourseResponse, unknown>[] = [
}, },
{ {
id: "location", id: "location",
accessorFn: (row) => formatLocation(row) ?? "", accessorFn: (row) => formatLocationDisplay(row) ?? "",
header: "Location", header: "Location",
enableSorting: false, enableSorting: false,
}, },
@@ -668,19 +671,31 @@ const table = createSvelteTable({
</SimpleTooltip> </SimpleTooltip>
</td> </td>
{:else if colId === "location"} {:else if colId === "location"}
{@const concern = getDeliveryConcern(course)}
{@const accentColor = concernAccentColor(concern)}
{@const locTooltip = formatLocationTooltip(course)}
{@const locDisplay = formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if formatLocation(course)} {#if locTooltip}
<span <SimpleTooltip
class="text-muted-foreground" text={locTooltip}
>{formatLocation( delay={200}
course, passthrough
)}</span
> >
<span
class="text-muted-foreground"
class:pl-2={accentColor !== null}
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span class="text-muted-foreground">
{locDisplay}
</span>
{:else} {:else}
<span <span class="text-xs text-muted-foreground/50">—</span>
class="text-xs text-muted-foreground/50"
>—</span
>
{/if} {/if}
</td> </td>
{:else if colId === "seats"} {:else if colId === "seats"}
+1 -1
View File
@@ -60,7 +60,7 @@ $effect(() => {
> >
<div <div
class="relative h-9 rounded-md border border-border bg-card class="relative h-9 rounded-md border border-border bg-card
flex items-center w-56 cursor-pointer flex items-center w-40 cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background" has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
bind:this={containerEl} bind:this={containerEl}
onclick={() => { containerEl?.querySelector('input')?.focus(); }} onclick={() => { containerEl?.querySelector('input')?.focus(); }}
+80
View File
@@ -261,6 +261,86 @@ export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string
return meetingTimes.map(formatMeetingTimeTooltip).join("\n\n"); 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 */ /** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string { export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours); if (course.creditHours != null) return String(course.creditHours);
+49 -11
View File
@@ -69,21 +69,59 @@ $effect(() => {
}); });
}); });
// Debounced search // Centralized throttle configuration - maps trigger source to throttle delay (ms)
let searchTimeout: ReturnType<typeof setTimeout> | undefined; const THROTTLE_MS = {
$effect(() => { term: 0, // Immediate
const term = selectedTerm; subjects: 100, // Short delay for combobox selection
const subs = selectedSubjects; query: 300, // Standard input debounce
const q = query; openOnly: 0, // Immediate
const open = openOnly; offset: 0, // Immediate (pagination)
const off = offset; sorting: 0, // Immediate (column sort)
const sort = sorting; } as const;
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function scheduleSearch(source: keyof typeof THROTTLE_MS) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
performSearch(term, subs, q, open, off, sort); performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting);
}, 300); }, 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); return () => clearTimeout(searchTimeout);
}); });