refactor: consolidate query logic and eliminate N+1 instructor loads

This commit is contained in:
2026-01-29 12:03:06 -06:00
parent 61f8bd9de7
commit c90bd740de
22 changed files with 414 additions and 398 deletions
+1 -18
View File
@@ -11,23 +11,6 @@ describe("BannerApiClient", () => {
vi.clearAllMocks();
});
it("should fetch health data", async () => {
const mockHealth = {
status: "healthy",
timestamp: "2024-01-01T00:00:00Z",
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHealth),
} as Response);
const result = await apiClient.getHealth();
expect(fetch).toHaveBeenCalledWith("/api/health");
expect(result).toEqual(mockHealth);
});
it("should fetch status data", async () => {
const mockStatus = {
status: "active" as const,
@@ -57,7 +40,7 @@ describe("BannerApiClient", () => {
statusText: "Internal Server Error",
} as Response);
await expect(apiClient.getHealth()).rejects.toThrow(
await expect(apiClient.getStatus()).rejects.toThrow(
"API request failed: 500 Internal Server Error"
);
});
-21
View File
@@ -30,19 +30,6 @@ export type ReferenceEntry = CodeDescription;
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
export type SearchResponse = SearchResponseGenerated;
// Health/metrics endpoints return ad-hoc JSON — keep manual types
export interface HealthResponse {
status: string;
timestamp: string;
}
export interface MetricsResponse {
banner_api: {
status: string;
};
timestamp: string;
}
// Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
@@ -77,18 +64,10 @@ export class BannerApiClient {
return (await response.json()) as T;
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>("/health");
}
async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/status");
}
async getMetrics(): Promise<MetricsResponse> {
return this.request<MetricsResponse>("/metrics");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
+9 -19
View File
@@ -7,27 +7,17 @@ import {
formatMeetingDaysLong,
isMeetingTimeTBA,
isTimeTBA,
ratingColor,
} from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props();
let copiedEmail: string | null = $state(null);
async function copyEmail(email: string, event: MouseEvent) {
event.stopPropagation();
try {
await navigator.clipboard.writeText(email);
copiedEmail = email;
setTimeout(() => {
copiedEmail = null;
}, 2000);
} catch (err) {
console.error("Failed to copy email:", err);
}
}
const clipboard = useClipboard();
</script>
<div class="bg-muted/60 p-5 text-sm border-b border-border">
@@ -49,14 +39,14 @@ async function copyEmail(email: string, event: MouseEvent) {
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
<span
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}"
class="text-[10px] font-semibold {ratingColor(rating)}"
>{rating.toFixed(1)}</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div>
@@ -70,10 +60,10 @@ async function copyEmail(email: string, event: MouseEvent) {
{/if}
{#if instructor.email}
<button
onclick={(e) => copyEmail(instructor.email!, e)}
onclick={(e) => clipboard.copy(instructor.email!, e)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if copiedEmail === instructor.email}
{#if clipboard.copiedValue === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else}
@@ -212,7 +202,7 @@ async function copyEmail(email: string, event: MouseEvent) {
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
class={tooltipContentClass}
>
Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
+18 -87
View File
@@ -12,13 +12,16 @@ import {
getPrimaryInstructor,
isMeetingTimeTBA,
isTimeTBA,
openSeats,
seatsColor,
seatsDotColor,
ratingColor,
} from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition";
import { flip } from "svelte/animate";
import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import {
getCoreRowModel,
@@ -50,8 +53,7 @@ let {
let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = undefined!;
let copiedCrn: string | null = $state(null);
let copyTimeoutId: number | undefined;
const clipboard = useClipboard(1000);
// Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content
@@ -60,30 +62,9 @@ $effect(() => {
expandedCrn = null;
});
onMount(() => {
const osInstance = OverlayScrollbars(tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: {
autoHide: "never",
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
// React to theme changes
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
useOverlayScrollbars(() => tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: { autoHide: "never" },
});
// Column visibility state
@@ -104,63 +85,12 @@ function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
async function handleCopyCrn(event: MouseEvent | KeyboardEvent, crn: string) {
event.stopPropagation();
try {
await navigator.clipboard.writeText(crn);
if (copyTimeoutId !== undefined) {
clearTimeout(copyTimeoutId);
}
copiedCrn = crn;
copyTimeoutId = window.setTimeout(() => {
copiedCrn = null;
copyTimeoutId = undefined;
}, 1000);
} catch (err) {
console.error("Failed to copy CRN:", err);
}
}
function handleCrnKeydown(event: KeyboardEvent, crn: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleCopyCrn(event, crn);
}
}
function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName);
}
function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
@@ -470,19 +400,20 @@ const table = createSvelteTable({
<button
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
onclick={(e) =>
handleCopyCrn(
e,
clipboard.copy(
course.crn,
)}
onkeydown={(e) =>
handleCrnKeydown(
e,
course.crn,
)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
clipboard.copy(course.crn, e);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
>
{course.crn}
{#if copiedCrn === course.crn}
{#if clipboard.copiedValue === course.crn}
<span
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
in:fade={{
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from "$lib/utils";
let {
commitHash,
showStatusLink = true,
class: className,
}: {
commitHash?: string | null;
showStatusLink?: boolean;
class?: string;
} = $props();
</script>
<div class={cn("flex justify-center items-center gap-2 mt-auto pt-6 pb-4", className)}>
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href={commitHash
? `https://github.com/Xevion/banner/commit/${commitHash}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
{#if showStatusLink}
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
{/if}
</div>
@@ -0,0 +1,32 @@
/**
* Reactive clipboard copy with automatic "copied" state reset.
*
* Returns a `copiedValue` that is non-null while the copied feedback
* should be displayed, and a `copy()` function to trigger a copy.
*/
export function useClipboard(resetMs = 2000) {
let copiedValue = $state<string | null>(null);
let timeoutId: number | undefined;
async function copy(text: string, event?: MouseEvent | KeyboardEvent) {
event?.stopPropagation();
try {
await navigator.clipboard.writeText(text);
clearTimeout(timeoutId);
copiedValue = text;
timeoutId = window.setTimeout(() => {
copiedValue = null;
timeoutId = undefined;
}, resetMs);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
}
return {
get copiedValue() {
return copiedValue;
},
copy,
};
}
@@ -0,0 +1,37 @@
import { onMount } from "svelte";
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
/**
* Set up OverlayScrollbars on an element with automatic theme reactivity.
*
* Must be called during component initialization (uses `onMount` internally).
* The scrollbar theme automatically syncs with `themeStore.isDark`.
*/
export function useOverlayScrollbars(getElement: () => HTMLElement, options: PartialOptions = {}) {
onMount(() => {
const element = getElement();
const osInstance = OverlayScrollbars(element, {
...options,
scrollbars: {
...options.scrollbars,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
});
}
+28
View File
@@ -341,6 +341,34 @@ export function formatLocationTooltip(course: CourseResponse): string | null {
return null;
}
/** Number of open seats in a course section */
export function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
/** Text color class for seat availability: red (full), yellow (low), green (open) */
export function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
/** Background dot color class for seat availability */
export function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
/** Text color class for a RateMyProfessors rating */
export function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
/** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours);
+4
View File
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared tooltip content styling for bits-ui Tooltip.Content */
export const tooltipContentClass =
"z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72";
+8 -26
View File
@@ -2,44 +2,26 @@
import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars";
import { Tooltip } from "bits-ui";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
let { children } = $props();
useOverlayScrollbars(() => document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
},
});
onMount(() => {
themeStore.init();
// Enable theme transitions now that the page has rendered with the correct theme.
// Without this delay, the initial paint would animate from light to dark colors.
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition");
});
const osInstance = OverlayScrollbars(document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
});
</script>
+2 -18
View File
@@ -12,6 +12,7 @@ import type { SortingState } from "@tanstack/table-core";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import Footer from "$lib/components/Footer.svelte";
let { data } = $props();
@@ -240,23 +241,6 @@ function handlePageChange(newOffset: number) {
{/if}
<!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href="https://github.com/Xevion/banner"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
</div>
<Footer />
</div>
</div>
+6 -17
View File
@@ -13,6 +13,7 @@ import {
XCircle,
} from "@lucide/svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import Footer from "$lib/components/Footer.svelte";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
@@ -61,7 +62,6 @@ let statusState = $state({ mode: "loading" } as StatusState);
let now = $state(new Date());
const isLoading = $derived(statusState.mode === "loading");
const hasResponse = $derived(statusState.mode === "response");
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
const overallHealth: ServiceStatus | "Unreachable" = $derived(
@@ -304,20 +304,9 @@ onMount(() => {
</div>
<!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-3">
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href={hasResponse && statusState.mode === "response" && statusState.status.commit
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
</div>
<Footer
commitHash={statusState.mode === "response" ? statusState.status.commit : undefined}
showStatusLink={false}
class="mt-3 pt-0 pb-0"
/>
</div>