mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 04:23:34 -06:00
feat: add delivery mode indicators and tooltips to location column
This commit is contained in:
+5
-1
@@ -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,
|
||||||
¶ms.term,
|
¶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.q.as_deref(),
|
||||||
params.course_number_low,
|
params.course_number_low,
|
||||||
params.course_number_high,
|
params.course_number_high,
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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(); }}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user