feat: add course search UI with ts-rs type bindings

Integrate ts-rs for Rust-to-TypeScript type generation, build course
search page with filters, pagination, and expandable detail rows,
and refactor theme toggle into a reactive store with view transition
animation.
This commit is contained in:
2026-01-28 22:11:17 -06:00
parent 15256ff91c
commit 5fab8c216a
26 changed files with 1360 additions and 401 deletions
+2
View File
@@ -0,0 +1,2 @@
[env]
TS_RS_EXPORT_DIR = { value = "web/src/lib/bindings/", relative = true }
Vendored
+4 -3
View File
@@ -1,5 +1,6 @@
.env .env
/target /target
/go/
.cargo/config.toml # ts-rs bindings
src/scraper/README.md web/src/lib/bindings/*.ts
!web/src/lib/bindings/index.ts
Generated
+49
View File
@@ -235,6 +235,7 @@ dependencies = [
"fundu", "fundu",
"futures", "futures",
"governor", "governor",
"html-escape",
"http 1.3.1", "http 1.3.1",
"mime_guess", "mime_guess",
"num-format", "num-format",
@@ -257,6 +258,7 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ts-rs",
"url", "url",
"yansi", "yansi",
] ]
@@ -1227,6 +1229,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@@ -3256,6 +3267,15 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -3648,6 +3668,29 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
dependencies = [
"serde_json",
"thiserror 2.0.16",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"termcolor",
]
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.21.0" version = "0.21.0"
@@ -3776,6 +3819,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
+2
View File
@@ -55,6 +55,8 @@ clap = { version = "4.5", features = ["derive"] }
rapidhash = "4.1.0" rapidhash = "4.1.0"
yansi = "1.0.1" yansi = "1.0.1"
extension-traits = "2" extension-traits = "2"
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
html-escape = "0.2.13"
[dev-dependencies] [dev-dependencies]
+8 -4
View File
@@ -8,16 +8,20 @@ default:
check: check:
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --all-features -- --deny warnings cargo clippy --all-features -- --deny warnings
cargo nextest run cargo nextest run -E 'not test(export_bindings)'
bun run --cwd web check bun run --cwd web check
bun run --cwd web test bun run --cwd web test
# Generate TypeScript bindings from Rust types (ts-rs)
bindings:
cargo test export_bindings
# Run all tests (Rust + frontend) # Run all tests (Rust + frontend)
test: test-rust test-web test: test-rust test-web
# Run only Rust tests # Run only Rust tests (excludes ts-rs bindings generation)
test-rust *ARGS: test-rust *ARGS:
cargo nextest run {{ARGS}} cargo nextest run -E 'not test(export_bindings)' {{ARGS}}
# Run only frontend tests # Run only frontend tests
test-web: test-web:
@@ -26,7 +30,7 @@ test-web:
# Quick check: clippy + tests + typecheck (skips formatting) # Quick check: clippy + tests + typecheck (skips formatting)
check-quick: check-quick:
cargo clippy --all-features -- --deny warnings cargo clippy --all-features -- --deny warnings
cargo nextest run cargo nextest run -E 'not test(export_bindings)'
bun run --cwd web check bun run --cwd web check
# Run the Banner API search demo (hits live UTSA API, ~20s) # Run the Banner API search demo (hits live UTSA API, ~20s)
+3 -1
View File
@@ -3,9 +3,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use ts_rs::TS;
/// Represents a meeting time stored as JSONB in the courses table. /// Represents a meeting time stored as JSONB in the courses table.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct DbMeetingTime { pub struct DbMeetingTime {
pub begin_time: Option<String>, pub begin_time: Option<String>,
pub end_time: Option<String>, pub end_time: Option<String>,
+5 -1
View File
@@ -2,6 +2,7 @@
use crate::data::models::ReferenceData; use crate::data::models::ReferenceData;
use crate::error::Result; use crate::error::Result;
use html_escape::decode_html_entities;
use sqlx::PgPool; use sqlx::PgPool;
/// Batch upsert reference data entries. /// Batch upsert reference data entries.
@@ -12,7 +13,10 @@ pub async fn batch_upsert(entries: &[ReferenceData], db_pool: &PgPool) -> Result
let categories: Vec<&str> = entries.iter().map(|e| e.category.as_str()).collect(); let categories: Vec<&str> = entries.iter().map(|e| e.category.as_str()).collect();
let codes: Vec<&str> = entries.iter().map(|e| e.code.as_str()).collect(); let codes: Vec<&str> = entries.iter().map(|e| e.code.as_str()).collect();
let descriptions: Vec<&str> = entries.iter().map(|e| e.description.as_str()).collect(); let descriptions: Vec<String> = entries
.iter()
.map(|e| decode_html_entities(&e.description).into_owned())
.collect();
sqlx::query( sqlx::query(
r#" r#"
+13
View File
@@ -206,6 +206,19 @@ impl Scheduler {
let mut all_entries = Vec::new(); let mut all_entries = Vec::new();
// Terms (fetched via session pool, no active session needed)
match banner_api.sessions.get_terms("", 1, 500).await {
Ok(terms) => {
debug!(count = terms.len(), "Fetched terms");
all_entries.extend(terms.into_iter().map(|t| ReferenceData {
category: "term".to_string(),
code: t.code,
description: t.description,
}));
}
Err(e) => warn!(error = ?e, "Failed to fetch terms"),
}
// Subjects // Subjects
match banner_api.get_subjects("", &term, 1, 500).await { match banner_api.get_subjects("", &term, 1, 500).await {
Ok(pairs) => { Ok(pairs) => {
+3 -1
View File
@@ -3,10 +3,12 @@ use std::time::Instant;
use dashmap::DashMap; use dashmap::DashMap;
use serde::Serialize; use serde::Serialize;
use ts_rs::TS;
/// Health status of a service. /// Health status of a service.
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum ServiceStatus { pub enum ServiceStatus {
Starting, Starting,
Active, Active,
+25 -18
View File
@@ -18,6 +18,7 @@ use http::header;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration}; use std::{collections::BTreeMap, time::Duration};
use ts_rs::TS;
use crate::state::AppState; use crate::state::AppState;
use crate::status::ServiceStatus; use crate::status::ServiceStatus;
@@ -227,14 +228,16 @@ async fn health() -> Json<Value> {
})) }))
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
struct ServiceInfo { #[ts(export)]
pub struct ServiceInfo {
name: String, name: String,
status: ServiceStatus, status: ServiceStatus,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
struct StatusResponse { #[ts(export)]
pub struct StatusResponse {
status: ServiceStatus, status: ServiceStatus,
version: String, version: String,
commit: String, commit: String,
@@ -316,9 +319,10 @@ fn default_limit() -> i32 {
25 25
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CourseResponse { #[ts(export)]
pub struct CourseResponse {
crn: String, crn: String,
subject: String, subject: String,
course_number: String, course_number: String,
@@ -340,32 +344,35 @@ struct CourseResponse {
link_identifier: Option<String>, link_identifier: Option<String>,
is_section_linked: Option<bool>, is_section_linked: Option<bool>,
part_of_term: Option<String>, part_of_term: Option<String>,
meeting_times: Value, meeting_times: Vec<crate::data::models::DbMeetingTime>,
attributes: Value, attributes: Vec<String>,
instructors: Vec<InstructorResponse>, instructors: Vec<InstructorResponse>,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct InstructorResponse { #[ts(export)]
pub struct InstructorResponse {
banner_id: String, banner_id: String,
display_name: String, display_name: String,
email: Option<String>, email: Option<String>,
is_primary: bool, is_primary: bool,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SearchResponse { #[ts(export)]
pub struct SearchResponse {
courses: Vec<CourseResponse>, courses: Vec<CourseResponse>,
total_count: i64, total_count: i32,
offset: i32, offset: i32,
limit: i32, limit: i32,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CodeDescription { #[ts(export)]
pub struct CodeDescription {
code: String, code: String,
description: String, description: String,
} }
@@ -411,8 +418,8 @@ async fn build_course_response(
link_identifier: course.link_identifier.clone(), link_identifier: course.link_identifier.clone(),
is_section_linked: course.is_section_linked, is_section_linked: course.is_section_linked,
part_of_term: course.part_of_term.clone(), part_of_term: course.part_of_term.clone(),
meeting_times: course.meeting_times.clone(), meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
attributes: course.attributes.clone(), attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
instructors, instructors,
} }
} }
@@ -454,7 +461,7 @@ async fn search_courses(
Ok(Json(SearchResponse { Ok(Json(SearchResponse {
courses: course_responses, courses: course_responses,
total_count, total_count: total_count as i32,
offset, offset,
limit, limit,
})) }))
+12
View File
@@ -12,6 +12,18 @@
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo192.png" /> <link rel="apple-touch-icon" href="%sveltekit.assets%/logo192.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<title>Banner</title> <title>Banner</title>
<script>
(function () {
var stored = localStorage.getItem("theme");
var isDark =
stored === "dark" ||
(stored !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.classList.add("dark");
}
})();
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+97
View File
@@ -61,4 +61,101 @@ describe("BannerApiClient", () => {
"API request failed: 500 Internal Server Error" "API request failed: 500 Internal Server Error"
); );
}); });
it("should search courses with all params", async () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await apiClient.searchCourses({
term: "202420",
subject: "CS",
q: "data",
open_only: true,
limit: 25,
offset: 50,
});
expect(fetch).toHaveBeenCalledWith(
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
);
expect(result).toEqual(mockResponse);
});
it("should search courses with minimal params", async () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
await apiClient.searchCourses({ term: "202420" });
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
});
it("should fetch terms", async () => {
const mockTerms = [
{ code: "202420", description: "Fall 2024" },
{ code: "202510", description: "Spring 2025" },
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTerms),
} as Response);
const result = await apiClient.getTerms();
expect(fetch).toHaveBeenCalledWith("/api/terms");
expect(result).toEqual(mockTerms);
});
it("should fetch subjects for a term", async () => {
const mockSubjects = [
{ code: "CS", description: "Computer Science" },
{ code: "MAT", description: "Mathematics" },
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSubjects),
} as Response);
const result = await apiClient.getSubjects("202420");
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
expect(result).toEqual(mockSubjects);
});
it("should fetch reference data", async () => {
const mockRef = [
{ code: "F", description: "Face to Face" },
{ code: "OL", description: "Online" },
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockRef),
} as Response);
const result = await apiClient.getReference("instructional_methods");
expect(fetch).toHaveBeenCalledWith("/api/reference/instructional_methods");
expect(result).toEqual(mockRef);
});
}); });
+68 -16
View File
@@ -1,24 +1,41 @@
import type {
CodeDescription,
CourseResponse,
DbMeetingTime,
InstructorResponse,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
StatusResponse,
} from "$lib/bindings";
const API_BASE_URL = "/api"; const API_BASE_URL = "/api";
// Re-export generated types under their canonical names
export type {
CodeDescription,
CourseResponse,
DbMeetingTime,
InstructorResponse,
ServiceInfo,
ServiceStatus,
StatusResponse,
};
// Semantic aliases — these all share the CodeDescription shape
export type Term = CodeDescription;
export type Subject = CodeDescription;
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 { export interface HealthResponse {
status: string; status: string;
timestamp: string; timestamp: string;
} }
export type Status = "starting" | "active" | "connected" | "disabled" | "error";
export interface ServiceInfo {
name: string;
status: Status;
}
export interface StatusResponse {
status: Status;
version: string;
commit: string;
services: Record<string, ServiceInfo>;
}
export interface MetricsResponse { export interface MetricsResponse {
banner_api: { banner_api: {
status: string; status: string;
@@ -26,15 +43,27 @@ export interface MetricsResponse {
timestamp: string; timestamp: string;
} }
// Client-side only — not generated from Rust
export interface SearchParams {
term: string;
subject?: string;
q?: string;
open_only?: boolean;
limit?: number;
offset?: number;
}
export class BannerApiClient { export class BannerApiClient {
private baseUrl: string; private baseUrl: string;
private fetchFn: typeof fetch;
constructor(baseUrl: string = API_BASE_URL) { constructor(baseUrl: string = API_BASE_URL, fetchFn: typeof fetch = fetch) {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.fetchFn = fetchFn;
} }
private async request<T>(endpoint: string): Promise<T> { private async request<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`); const response = await this.fetchFn(`${this.baseUrl}${endpoint}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`); throw new Error(`API request failed: ${response.status} ${response.statusText}`);
@@ -54,6 +83,29 @@ export class BannerApiClient {
async getMetrics(): Promise<MetricsResponse> { async getMetrics(): Promise<MetricsResponse> {
return this.request<MetricsResponse>("/metrics"); return this.request<MetricsResponse>("/metrics");
} }
async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
if (params.subject) query.set("subject", params.subject);
if (params.q) query.set("q", params.q);
if (params.open_only) query.set("open_only", "true");
if (params.limit !== undefined) query.set("limit", String(params.limit));
if (params.offset !== undefined) query.set("offset", String(params.offset));
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
async getTerms(): Promise<Term[]> {
return this.request<Term[]>("/terms");
}
async getSubjects(termCode: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(termCode)}`);
}
async getReference(category: string): Promise<ReferenceEntry[]> {
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
}
} }
export const client = new BannerApiClient(); export const client = new BannerApiClient();
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
formatTime,
formatMeetingDays,
formatCreditHours,
} from "$lib/course";
let { course }: { course: CourseResponse } = $props();
</script>
<div class="bg-muted p-4 text-sm border-b border-border">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Instructors -->
<div>
<h4 class="font-medium text-foreground mb-1">Instructors</h4>
{#if course.instructors.length > 0}
<ul class="space-y-0.5">
{#each course.instructors as instructor}
<li class="text-muted-foreground">
{instructor.displayName}
{#if instructor.isPrimary}
<span class="text-xs bg-card border border-border rounded px-1 py-0.5 ml-1">primary</span>
{/if}
{#if instructor.email}
<span class="text-xs">{instructor.email}</span>
{/if}
</li>
{/each}
</ul>
{:else}
<span class="text-muted-foreground">Staff</span>
{/if}
</div>
<!-- Meeting Times -->
<div>
<h4 class="font-medium text-foreground mb-1">Meeting Times</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-1">
{#each course.meetingTimes as mt}
<li class="text-muted-foreground">
<span class="font-mono">{formatMeetingDays(mt) || "TBA"}</span>
{formatTime(mt.begin_time)}{formatTime(mt.end_time)}
{#if mt.building || mt.room}
<span class="text-xs">
({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""})
</span>
{/if}
<div class="text-xs opacity-70">{mt.start_date} {mt.end_date}</div>
</li>
{/each}
</ul>
{:else}
<span class="text-muted-foreground">TBA</span>
{/if}
</div>
<!-- Delivery -->
<div>
<h4 class="font-medium text-foreground mb-1">Delivery</h4>
<span class="text-muted-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
· {course.campus}
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="font-medium text-foreground mb-1">Credits</h4>
<span class="text-muted-foreground">{formatCreditHours(course)}</span>
</div>
<!-- Attributes -->
{#if course.attributes.length > 0}
<div>
<h4 class="font-medium text-foreground mb-1">Attributes</h4>
<div class="flex flex-wrap gap-1">
{#each course.attributes as attr}
<span class="text-xs bg-card border border-border rounded px-1.5 py-0.5 text-muted-foreground">
{attr}
</span>
{/each}
</div>
</div>
{/if}
<!-- Cross-list -->
{#if course.crossList}
<div>
<h4 class="font-medium text-foreground mb-1">Cross-list</h4>
<span class="text-muted-foreground">
{course.crossList}
{#if course.crossListCount != null && course.crossListCapacity != null}
({course.crossListCount}/{course.crossListCapacity})
{/if}
</span>
</div>
{/if}
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="font-medium text-foreground mb-1">Waitlist</h4>
<span class="text-muted-foreground">{course.waitCount} / {course.waitCapacity}</span>
</div>
{/if}
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { abbreviateInstructor, formatMeetingTime, getPrimaryInstructor } from "$lib/course";
import CourseDetail from "./CourseDetail.svelte";
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
let expandedCrn: string | null = $state(null);
function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
function seatsColor(course: CourseResponse): string {
return course.enrollment < course.maxEnrollment ? "text-status-green" : "text-status-red";
}
function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName);
}
function timeDisplay(course: CourseResponse): string {
if (course.meetingTimes.length === 0) return "TBA";
return formatMeetingTime(course.meetingTimes[0]);
}
</script>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b border-border text-left text-muted-foreground">
<th class="py-2 px-2 font-medium">CRN</th>
<th class="py-2 px-2 font-medium">Course</th>
<th class="py-2 px-2 font-medium">Title</th>
<th class="py-2 px-2 font-medium">Instructor</th>
<th class="py-2 px-2 font-medium">Time</th>
<th class="py-2 px-2 font-medium text-right">Seats</th>
</tr>
</thead>
<tbody>
{#if loading && courses.length === 0}
{#each Array(5) as _}
<tr class="border-b border-border">
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse ml-auto"></div></td>
</tr>
{/each}
{:else if courses.length === 0}
<tr>
<td colspan="6" class="py-12 text-center text-muted-foreground">
No courses found. Try adjusting your filters.
</td>
</tr>
{:else}
{#each courses as course (course.crn)}
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
onclick={() => toggleRow(course.crn)}
>
<td class="py-2 px-2 font-mono">{course.crn}</td>
<td class="py-2 px-2 whitespace-nowrap">
{course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""}
</td>
<td class="py-2 px-2">{course.title}</td>
<td class="py-2 px-2 whitespace-nowrap">{primaryInstructorDisplay(course)}</td>
<td class="py-2 px-2 whitespace-nowrap">{timeDisplay(course)}</td>
<td class="py-2 px-2 text-right whitespace-nowrap {seatsColor(course)}">
{course.enrollment}/{course.maxEnrollment}
{#if course.waitCount > 0}
<div class="text-xs text-muted-foreground">WL: {course.waitCount}/{course.waitCapacity}</div>
{/if}
</td>
</tr>
{#if expandedCrn === course.crn}
<tr>
<td colspan="6" class="p-0">
<CourseDetail {course} />
</td>
</tr>
{/if}
{/each}
{/if}
</tbody>
</table>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
let { totalCount, offset, limit, onPageChange }: {
totalCount: number;
offset: number;
limit: number;
onPageChange: (newOffset: number) => void;
} = $props();
const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount));
const hasPrev = $derived(offset > 0);
const hasNext = $derived(offset + limit < totalCount);
</script>
{#if totalCount > 0}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
Showing {start}{end} of {totalCount} courses
</span>
<div class="flex gap-2">
<button
disabled={!hasPrev}
onclick={() => onPageChange(offset - limit)}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
>
Previous
</button>
<button
disabled={!hasNext}
onclick={() => onPageChange(offset + limit)}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
>
Next
</button>
</div>
</div>
{/if}
@@ -0,0 +1,52 @@
<script lang="ts">
import type { Term, Subject } from "$lib/api";
let {
terms,
subjects,
selectedTerm = $bindable(),
selectedSubject = $bindable(),
query = $bindable(),
openOnly = $bindable(),
}: {
terms: Term[];
subjects: Subject[];
selectedTerm: string;
selectedSubject: string;
query: string;
openOnly: boolean;
} = $props();
</script>
<div class="flex flex-wrap gap-3 items-center">
<select
bind:value={selectedTerm}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
>
{#each terms as term (term.code)}
<option value={term.code}>{term.description}</option>
{/each}
</select>
<select
bind:value={selectedSubject}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
>
<option value="">All Subjects</option>
{#each subjects as subject (subject.code)}
<option value={subject.code}>{subject.description}</option>
{/each}
</select>
<input
type="text"
placeholder="Search courses..."
bind:value={query}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm flex-1 min-w-[200px]"
/>
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
<input type="checkbox" bind:checked={openOnly} />
Open only
</label>
</div>
+52 -39
View File
@@ -1,53 +1,66 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { tick } from "svelte";
import { Monitor, Moon, Sun } from "@lucide/svelte"; import { Moon, Sun } from "@lucide/svelte";
import { themeStore } from "$lib/stores/theme.svelte";
type Theme = "light" | "dark" | "system"; /**
* Theme toggle with View Transitions API circular reveal animation.
* The clip-path circle expands from the click point to cover the viewport.
*/
async function handleToggle(event: MouseEvent) {
const supportsViewTransition =
typeof document !== "undefined" &&
"startViewTransition" in document &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let theme = $state<Theme>("system"); if (!supportsViewTransition) {
themeStore.toggle();
if (browser) { return;
theme = (localStorage.getItem("theme") as Theme) ?? "system";
} }
const nextTheme = $derived<Theme>( const x = event.clientX;
theme === "light" ? "dark" : theme === "dark" ? "system" : "light" const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const transition = document.startViewTransition(async () => {
themeStore.toggle();
await tick();
});
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
},
{
duration: 500,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
pseudoElement: "::view-transition-new(root)",
}
); );
});
function applyTheme(t: Theme) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = t === "dark" || (t === "system" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
}
function toggle() {
const next = nextTheme;
const update = () => {
theme = next;
localStorage.setItem("theme", next);
applyTheme(next);
};
if (document.startViewTransition) {
document.startViewTransition(update);
} else {
update();
}
} }
</script> </script>
<button <button
onclick={toggle} type="button"
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125 class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
text-muted-foreground hover:bg-muted bg-transparent transition-colors" text-muted-foreground hover:bg-muted bg-transparent transition-colors"
aria-label="Toggle theme"
> >
{#if nextTheme === "dark"} <div class="relative size-[18px]">
<Moon size={18} /> <Sun
{:else if nextTheme === "system"} size={18}
<Monitor size={18} /> class="absolute inset-0 transition-all duration-300 {themeStore.isDark
{:else} ? 'rotate-90 scale-0 opacity-0'
<Sun size={18} /> : 'rotate-0 scale-100 opacity-100'}"
{/if} />
<Moon
size={18}
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
? 'rotate-0 scale-100 opacity-100'
: '-rotate-90 scale-0 opacity-0'}"
/>
</div>
</button> </button>
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import {
formatTime,
formatMeetingDays,
formatMeetingTime,
abbreviateInstructor,
formatCreditHours,
getPrimaryInstructor,
} from "$lib/course";
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
return {
begin_time: null,
end_time: null,
start_date: "2024-08-26",
end_date: "2024-12-12",
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
building: null,
building_description: null,
room: null,
campus: null,
meeting_type: "CLAS",
meeting_schedule_type: "LEC",
...overrides,
};
}
describe("formatTime", () => {
it("converts 0900 to 9:00 AM", () => expect(formatTime("0900")).toBe("9:00 AM"));
it("converts 1330 to 1:30 PM", () => expect(formatTime("1330")).toBe("1:30 PM"));
it("converts 0000 to 12:00 AM", () => expect(formatTime("0000")).toBe("12:00 AM"));
it("converts 1200 to 12:00 PM", () => expect(formatTime("1200")).toBe("12:00 PM"));
it("converts 2359 to 11:59 PM", () => expect(formatTime("2359")).toBe("11:59 PM"));
it("returns TBA for null", () => expect(formatTime(null)).toBe("TBA"));
it("returns TBA for empty string", () => expect(formatTime("")).toBe("TBA"));
it("returns TBA for short string", () => expect(formatTime("09")).toBe("TBA"));
});
describe("formatMeetingDays", () => {
it("returns MWF for mon/wed/fri", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))).toBe(
"MWF"
);
});
it("returns TR for tue/thu", () => {
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR");
});
it("returns empty string when no days", () => {
expect(formatMeetingDays(makeMeetingTime())).toBe("");
});
it("returns all days", () => {
expect(
formatMeetingDays(
makeMeetingTime({
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: true,
sunday: true,
})
)
).toBe("MTWRFSU");
});
});
describe("formatMeetingTime", () => {
it("formats a standard meeting time", () => {
expect(
formatMeetingTime(
makeMeetingTime({ monday: true, wednesday: true, friday: true, begin_time: "0900", end_time: "0950" })
)
).toBe("MWF 9:00 AM9:50 AM");
});
it("returns TBA when no days", () => {
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe("TBA");
});
it("returns days + TBA when no times", () => {
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
});
});
describe("abbreviateInstructor", () => {
it("abbreviates standard name", () => expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J."));
it("handles no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff"));
it("handles multiple first names", () =>
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
});
describe("getPrimaryInstructor", () => {
it("returns primary instructor", () => {
const instructors: InstructorResponse[] = [
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
{ bannerId: "2", displayName: "B", email: null, isPrimary: true },
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
});
it("returns first instructor when no primary", () => {
const instructors: InstructorResponse[] = [
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
});
it("returns undefined for empty array", () => {
expect(getPrimaryInstructor([])).toBeUndefined();
});
});
describe("formatCreditHours", () => {
it("returns creditHours when set", () => {
expect(
formatCreditHours({ creditHours: 3, creditHourLow: null, creditHourHigh: null } as CourseResponse)
).toBe("3");
});
it("returns range when variable", () => {
expect(
formatCreditHours({ creditHours: null, creditHourLow: 1, creditHourHigh: 3 } as CourseResponse)
).toBe("13");
});
it("returns dash when no credit info", () => {
expect(
formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse)
).toBe("—");
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
/** Convert "0900" to "9:00 AM" */
export function formatTime(time: string | null): string {
if (!time || time.length !== 4) return "TBA";
const hours = parseInt(time.slice(0, 2), 10);
const minutes = time.slice(2);
const period = hours >= 12 ? "PM" : "AM";
const display = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
return `${display}:${minutes} ${period}`;
}
/** Get day abbreviation string like "MWF" from a meeting time */
export function formatMeetingDays(mt: DbMeetingTime): string {
const days: [boolean, string][] = [
[mt.monday, "M"],
[mt.tuesday, "T"],
[mt.wednesday, "W"],
[mt.thursday, "R"],
[mt.friday, "F"],
[mt.saturday, "S"],
[mt.sunday, "U"],
];
return days
.filter(([active]) => active)
.map(([, abbr]) => abbr)
.join("");
}
/** Condensed meeting time: "MWF 9:00 AM9:50 AM" */
export function formatMeetingTime(mt: DbMeetingTime): string {
const days = formatMeetingDays(mt);
if (!days) return "TBA";
const begin = formatTime(mt.begin_time);
const end = formatTime(mt.end_time);
if (begin === "TBA") return `${days} TBA`;
return `${days} ${begin}${end}`;
}
/** Abbreviate instructor name: "Heaps, John" → "Heaps, J." */
export function abbreviateInstructor(name: string): string {
const commaIdx = name.indexOf(", ");
if (commaIdx === -1) return name;
const last = name.slice(0, commaIdx);
const first = name.slice(commaIdx + 2);
return `${last}, ${first.charAt(0)}.`;
}
/** Get primary instructor from a course, or first instructor */
export function getPrimaryInstructor(instructors: InstructorResponse[]): InstructorResponse | undefined {
return instructors.find((i) => i.isPrimary) ?? instructors[0];
}
/** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours);
if (course.creditHourLow != null && course.creditHourHigh != null) {
return `${course.creditHourLow}${course.creditHourHigh}`;
}
return "—";
}
+36
View File
@@ -0,0 +1,36 @@
class ThemeStore {
isDark = $state<boolean>(false);
private initialized = false;
init() {
if (this.initialized || typeof window === "undefined") return;
this.initialized = true;
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
this.isDark = stored === "dark";
} else {
this.isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
}
this.updateDOMClass();
}
toggle() {
this.isDark = !this.isDark;
localStorage.setItem("theme", this.isDark ? "dark" : "light");
this.updateDOMClass();
}
private updateDOMClass() {
if (typeof document === "undefined") return;
if (this.isDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
}
export const themeStore = new ThemeStore();
+6 -12
View File
@@ -1,22 +1,16 @@
<script lang="ts"> <script lang="ts">
import "./layout.css"; import "./layout.css";
import { onMount } from "svelte";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
let { children } = $props(); let { children } = $props();
</script>
<svelte:head> onMount(() => {
{@html `<script> themeStore.init();
(function() { });
const stored = localStorage.getItem("theme"); </script>
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (stored === "dark" || (!stored && prefersDark) || (stored === "system" && prefersDark)) {
document.documentElement.classList.add("dark");
}
})();
</script>`}
</svelte:head>
<Tooltip.Provider> <Tooltip.Provider>
<div class="fixed top-5 right-5 z-50"> <div class="fixed top-5 right-5 z-50">
+131 -289
View File
@@ -1,327 +1,169 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { untrack } from "svelte";
import { import { goto } from "$app/navigation";
Activity, import { type Subject, type SearchResponse, client } from "$lib/api";
Bot, import SearchFilters from "$lib/components/SearchFilters.svelte";
CheckCircle, import CourseTable from "$lib/components/CourseTable.svelte";
Circle, import Pagination from "$lib/components/Pagination.svelte";
Clock,
Globe,
Hourglass,
MessageCircle,
WifiOff,
XCircle,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import { type Status, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000; let { data } = $props();
const REQUEST_TIMEOUT = 10000;
const SERVICE_ICONS: Record<string, typeof Bot> = { // Read initial state from URL params (intentionally captured once)
bot: Bot, const initialParams = untrack(() => new URLSearchParams(data.url.search));
banner: Globe,
discord: MessageCircle,
database: Activity,
web: Globe,
scraper: Clock,
};
interface ResponseTiming { // Filter state
health: number | null; let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
status: number | null; let selectedSubject = $state(initialParams.get("subject") ?? "");
let query = $state(initialParams.get("q") ?? "");
let openOnly = $state(initialParams.get("open") === "true");
let offset = $state(Number(initialParams.get("offset")) || 0);
const limit = 25;
// Data state
let subjects: Subject[] = $state([]);
let searchResult: SearchResponse | null = $state(null);
let loading = $state(false);
let error = $state<string | null>(null);
// Fetch subjects when term changes
$effect(() => {
const term = selectedTerm;
if (!term) return;
client.getSubjects(term).then((s) => {
subjects = s;
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
selectedSubject = "";
} }
});
});
interface Service { // Debounced search
name: string; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
status: Status; $effect(() => {
icon: typeof Bot; const term = selectedTerm;
const subject = selectedSubject;
const q = query;
const open = openOnly;
const off = offset;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(term, subject, q, open, off);
}, 300);
return () => clearTimeout(searchTimeout);
});
// Reset offset when filters change (not offset itself)
let prevFilters = $state("");
$effect(() => {
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
if (prevFilters && key !== prevFilters) {
offset = 0;
} }
prevFilters = key;
});
type StatusState = async function performSearch(
| { mode: "loading" } term: string,
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse } subject: string,
| { mode: "error"; lastFetch: Date } q: string,
| { mode: "timeout"; lastFetch: Date }; open: boolean,
off: number,
) {
if (!term) return;
loading = true;
error = null;
const STATUS_ICONS: Record<Status | "Unreachable", { icon: typeof CheckCircle; color: string }> = { // Sync URL
active: { icon: CheckCircle, color: "var(--status-green)" }, const params = new URLSearchParams();
connected: { icon: CheckCircle, color: "var(--status-green)" }, params.set("term", term);
starting: { icon: Hourglass, color: "var(--status-orange)" }, if (subject) params.set("subject", subject);
disabled: { icon: Circle, color: "var(--status-gray)" }, if (q) params.set("q", q);
error: { icon: XCircle, color: "var(--status-red)" }, if (open) params.set("open", "true");
Unreachable: { icon: WifiOff, color: "var(--status-red)" }, if (off > 0) params.set("offset", String(off));
}; goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
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: Status | "Unreachable" = $derived(
statusState.mode === "timeout"
? "Unreachable"
: statusState.mode === "error"
? "error"
: statusState.mode === "response"
? statusState.status.status
: "error"
);
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
const services: Service[] = $derived(
statusState.mode === "response"
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
([id, info]) => ({
name: info.name,
status: info.status,
icon: SERVICE_ICONS[id] ?? Bot,
})
)
: []
);
const shouldShowTiming = $derived(
statusState.mode === "response" && statusState.timing.health !== null
);
const shouldShowLastFetch = $derived(
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
);
const lastFetch = $derived(
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
? statusState.lastFetch
: null
);
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
function formatNumber(num: number): string {
return num.toLocaleString();
}
onMount(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Adaptive tick: schedules the next `now` update based on when the
// relative time text would actually change (every ~1s for recent
// timestamps, every ~1m for minute-level, etc.)
function scheduleNowTick() {
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
nowTimeoutId = setTimeout(() => {
now = new Date();
scheduleNowTick();
}, delay);
}
scheduleNowTick();
const fetchData = async () => {
try { try {
const startTime = Date.now(); searchResult = await client.searchCourses({
term,
const timeoutPromise = new Promise<never>((_, reject) => { subject: subject || undefined,
requestTimeoutId = setTimeout(() => { q: q || undefined,
reject(new Error("Request timeout")); open_only: open || undefined,
}, REQUEST_TIMEOUT); limit,
offset: off,
}); });
} catch (e) {
const statusData = await Promise.race([client.getStatus(), timeoutPromise]); error = e instanceof Error ? e.message : "Search failed";
} finally {
if (requestTimeoutId) { loading = false;
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const responseTime = Date.now() - startTime;
statusState = {
mode: "response",
status: statusData,
timing: { health: responseTime, status: responseTime },
lastFetch: new Date(),
};
} catch (err) {
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const message = err instanceof Error ? err.message : "";
if (message === "Request timeout") {
statusState = { mode: "timeout", lastFetch: new Date() };
} else {
statusState = { mode: "error", lastFetch: new Date() };
} }
} }
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL); function handlePageChange(newOffset: number) {
}; offset = newOffset;
}
void fetchData();
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (requestTimeoutId) clearTimeout(requestTimeoutId);
if (nowTimeoutId) clearTimeout(nowTimeoutId);
};
});
</script> </script>
<div class="min-h-screen flex flex-col items-center justify-center p-5"> <div class="min-h-screen flex flex-col items-center p-5">
<div <div class="w-full max-w-4xl flex flex-col gap-6">
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm" <!-- Title -->
> <div class="text-center pt-8 pb-2">
<div class="flex flex-col gap-4"> <h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
<!-- Overall Status --> </div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <!-- Filters -->
<Activity <SearchFilters
size={18} terms={data.terms}
color={isLoading ? undefined : overallIcon.color} {subjects}
class={isLoading ? "animate-pulse" : ""} bind:selectedTerm
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;" bind:selectedSubject
bind:query
bind:openOnly
/> />
<span class="text-base font-medium text-foreground">System Status</span>
<!-- Results -->
{#if error}
<div class="text-center py-8">
<p class="text-status-red">{error}</p>
<button
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset)}
class="mt-2 text-sm text-muted-foreground hover:underline"
>
Retry
</button>
</div> </div>
{#if isLoading}
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
{:else} {:else}
{#if overallIcon} <CourseTable courses={searchResult?.courses ?? []} {loading} />
{@const OverallIconComponent = overallIcon.icon}
<div class="flex items-center gap-1.5">
<span
class="text-sm"
class:text-muted-foreground={overallHealth === "disabled"}
class:opacity-70={overallHealth === "disabled"}
>
{overallHealth}
</span>
<OverallIconComponent size={16} color={overallIcon.color} />
</div>
{/if}
{/if}
</div>
<!-- Services --> {#if searchResult}
<div class="flex flex-col gap-3 mt-4"> <Pagination
{#if shouldShowSkeleton} totalCount={searchResult.totalCount}
{#each Array(3) as _} offset={searchResult.offset}
<div class="flex items-center justify-between"> {limit}
<div class="flex items-center gap-2"> onPageChange={handlePageChange}
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div> />
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-2">
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
</div>
</div>
{/each}
{:else}
{#each services as service (service.name)}
{@const statusInfo = STATUS_ICONS[service.status]}
{@const ServiceIcon = service.icon}
{@const StatusIconComponent = statusInfo.icon}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ServiceIcon size={18} />
<span class="text-muted-foreground">{service.name}</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="text-sm"
class:text-muted-foreground={service.status === "disabled"}
class:opacity-70={service.status === "disabled"}
>
{service.status}
</span>
<StatusIconComponent size={16} color={statusInfo.color} />
</div>
</div>
{/each}
{/if} {/if}
</div>
<!-- Timing & Last Updated -->
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
{#if isLoading}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Hourglass size={13} />
<span class="text-sm text-muted-foreground">Response Time</span>
</div>
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
</div>
{:else if shouldShowTiming && statusState.mode === "response"}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Hourglass size={13} />
<span class="text-sm text-muted-foreground">Response Time</span>
</div>
<span class="text-sm text-muted-foreground">
{formatNumber(statusState.timing.health!)}ms
</span>
</div>
{/if} {/if}
{#if isLoading}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Clock size={13} />
<span class="text-sm text-muted-foreground">Last Updated</span>
</div>
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
</div>
{:else if shouldShowLastFetch && lastFetch}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Clock size={13} />
<span class="text-sm text-muted-foreground">Last Updated</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<abbr
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
>
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
</abbr>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
>
as of {lastFetch.toLocaleTimeString()}
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
</div>
</div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-3"> <div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
{#if __APP_VERSION__} {#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span> <span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div> <div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if} {/if}
<a <a
href={hasResponse && statusState.mode === "response" && statusState.status.commit href="https://github.com/Xevion/banner"
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
: "https://github.com/Xevion/banner"}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline" class="text-xs text-muted-foreground no-underline hover:underline"
> >
GitHub GitHub
</a> </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>
</div> </div>
</div> </div>
+8
View File
@@ -0,0 +1,8 @@
import type { PageLoad } from "./$types";
import { BannerApiClient } from "$lib/api";
export const load: PageLoad = async ({ url, fetch }) => {
const client = new BannerApiClient(undefined, fetch);
const terms = await client.getTerms();
return { terms, url };
};
+327
View File
@@ -0,0 +1,327 @@
<script lang="ts">
import { onMount } from "svelte";
import {
Activity,
Bot,
CheckCircle,
Circle,
Clock,
Globe,
Hourglass,
MessageCircle,
WifiOff,
XCircle,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
const REQUEST_TIMEOUT = 10000;
const SERVICE_ICONS: Record<string, typeof Bot> = {
bot: Bot,
banner: Globe,
discord: MessageCircle,
database: Activity,
web: Globe,
scraper: Clock,
};
interface ResponseTiming {
health: number | null;
status: number | null;
}
interface Service {
name: string;
status: ServiceStatus;
icon: typeof Bot;
}
type StatusState =
| { mode: "loading" }
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
| { mode: "error"; lastFetch: Date }
| { mode: "timeout"; lastFetch: Date };
const STATUS_ICONS: Record<ServiceStatus | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
active: { icon: CheckCircle, color: "var(--status-green)" },
connected: { icon: CheckCircle, color: "var(--status-green)" },
starting: { icon: Hourglass, color: "var(--status-orange)" },
disabled: { icon: Circle, color: "var(--status-gray)" },
error: { icon: XCircle, color: "var(--status-red)" },
Unreachable: { icon: WifiOff, color: "var(--status-red)" },
};
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(
statusState.mode === "timeout"
? "Unreachable"
: statusState.mode === "error"
? "error"
: statusState.mode === "response"
? statusState.status.status
: "error"
);
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
const services: Service[] = $derived(
statusState.mode === "response"
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
([id, info]) => ({
name: info.name,
status: info.status,
icon: SERVICE_ICONS[id] ?? Bot,
})
)
: []
);
const shouldShowTiming = $derived(
statusState.mode === "response" && statusState.timing.health !== null
);
const shouldShowLastFetch = $derived(
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
);
const lastFetch = $derived(
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
? statusState.lastFetch
: null
);
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
function formatNumber(num: number): string {
return num.toLocaleString();
}
onMount(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Adaptive tick: schedules the next `now` update based on when the
// relative time text would actually change (every ~1s for recent
// timestamps, every ~1m for minute-level, etc.)
function scheduleNowTick() {
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
nowTimeoutId = setTimeout(() => {
now = new Date();
scheduleNowTick();
}, delay);
}
scheduleNowTick();
const fetchData = async () => {
try {
const startTime = Date.now();
const timeoutPromise = new Promise<never>((_, reject) => {
requestTimeoutId = setTimeout(() => {
reject(new Error("Request timeout"));
}, REQUEST_TIMEOUT);
});
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const responseTime = Date.now() - startTime;
statusState = {
mode: "response",
status: statusData,
timing: { health: responseTime, status: responseTime },
lastFetch: new Date(),
};
} catch (err) {
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const message = err instanceof Error ? err.message : "";
if (message === "Request timeout") {
statusState = { mode: "timeout", lastFetch: new Date() };
} else {
statusState = { mode: "error", lastFetch: new Date() };
}
}
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
};
void fetchData();
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (requestTimeoutId) clearTimeout(requestTimeoutId);
if (nowTimeoutId) clearTimeout(nowTimeoutId);
};
});
</script>
<div class="min-h-screen flex flex-col items-center justify-center p-5">
<div
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm"
>
<div class="flex flex-col gap-4">
<!-- Overall Status -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Activity
size={18}
color={isLoading ? undefined : overallIcon.color}
class={isLoading ? "animate-pulse" : ""}
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;"
/>
<span class="text-base font-medium text-foreground">System Status</span>
</div>
{#if isLoading}
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
{:else}
{#if overallIcon}
{@const OverallIconComponent = overallIcon.icon}
<div class="flex items-center gap-1.5">
<span
class="text-sm"
class:text-muted-foreground={overallHealth === "disabled"}
class:opacity-70={overallHealth === "disabled"}
>
{overallHealth}
</span>
<OverallIconComponent size={16} color={overallIcon.color} />
</div>
{/if}
{/if}
</div>
<!-- Services -->
<div class="flex flex-col gap-3 mt-4">
{#if shouldShowSkeleton}
{#each Array(3) as _}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div>
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-2">
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
</div>
</div>
{/each}
{:else}
{#each services as service (service.name)}
{@const statusInfo = STATUS_ICONS[service.status]}
{@const ServiceIcon = service.icon}
{@const StatusIconComponent = statusInfo.icon}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ServiceIcon size={18} />
<span class="text-muted-foreground">{service.name}</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="text-sm"
class:text-muted-foreground={service.status === "disabled"}
class:opacity-70={service.status === "disabled"}
>
{service.status}
</span>
<StatusIconComponent size={16} color={statusInfo.color} />
</div>
</div>
{/each}
{/if}
</div>
<!-- Timing & Last Updated -->
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
{#if isLoading}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Hourglass size={13} />
<span class="text-sm text-muted-foreground">Response Time</span>
</div>
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
</div>
{:else if shouldShowTiming && statusState.mode === "response"}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Hourglass size={13} />
<span class="text-sm text-muted-foreground">Response Time</span>
</div>
<span class="text-sm text-muted-foreground">
{formatNumber(statusState.timing.health!)}ms
</span>
</div>
{/if}
{#if isLoading}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Clock size={13} />
<span class="text-sm text-muted-foreground">Last Updated</span>
</div>
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
</div>
{:else if shouldShowLastFetch && lastFetch}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Clock size={13} />
<span class="text-sm text-muted-foreground">Last Updated</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<abbr
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
>
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
</abbr>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
>
as of {lastFetch.toLocaleTimeString()}
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
</div>
</div>
</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>
</div>
+7
View File
@@ -69,6 +69,13 @@ body * {
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms; transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
} }
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {