diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d0d0670 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +TS_RS_EXPORT_DIR = { value = "web/src/lib/bindings/", relative = true } diff --git a/.gitignore b/.gitignore index 07b554b..7c6cdc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env /target -/go/ -.cargo/config.toml -src/scraper/README.md \ No newline at end of file + +# ts-rs bindings +web/src/lib/bindings/*.ts +!web/src/lib/bindings/index.ts diff --git a/Cargo.lock b/Cargo.lock index b4b9f54..24d40f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,7 @@ dependencies = [ "fundu", "futures", "governor", + "html-escape", "http 1.3.1", "mime_guess", "num-format", @@ -257,6 +258,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ts-rs", "url", "yansi", ] @@ -1227,6 +1229,15 @@ dependencies = [ "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]] name = "http" version = "0.2.12" @@ -3256,6 +3267,15 @@ dependencies = [ "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]] name = "thiserror" version = "1.0.69" @@ -3648,6 +3668,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "tungstenite" version = "0.21.0" @@ -3776,6 +3819,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index d276d84..161f144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,8 @@ clap = { version = "4.5", features = ["derive"] } rapidhash = "4.1.0" yansi = "1.0.1" extension-traits = "2" +ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] } +html-escape = "0.2.13" [dev-dependencies] diff --git a/Justfile b/Justfile index 1f877b4..0ff4c76 100644 --- a/Justfile +++ b/Justfile @@ -8,16 +8,20 @@ default: check: cargo fmt --all -- --check 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 test +# Generate TypeScript bindings from Rust types (ts-rs) +bindings: + cargo test export_bindings + # Run all tests (Rust + frontend) test: test-rust test-web -# Run only Rust tests +# Run only Rust tests (excludes ts-rs bindings generation) test-rust *ARGS: - cargo nextest run {{ARGS}} + cargo nextest run -E 'not test(export_bindings)' {{ARGS}} # Run only frontend tests test-web: @@ -26,7 +30,7 @@ test-web: # Quick check: clippy + tests + typecheck (skips formatting) check-quick: cargo clippy --all-features -- --deny warnings - cargo nextest run + cargo nextest run -E 'not test(export_bindings)' bun run --cwd web check # Run the Banner API search demo (hits live UTSA API, ~20s) diff --git a/src/data/models.rs b/src/data/models.rs index e4b096d..0a74d1a 100644 --- a/src/data/models.rs +++ b/src/data/models.rs @@ -3,9 +3,11 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use ts_rs::TS; /// 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 begin_time: Option, pub end_time: Option, diff --git a/src/data/reference.rs b/src/data/reference.rs index 607fd99..e004ed8 100644 --- a/src/data/reference.rs +++ b/src/data/reference.rs @@ -2,6 +2,7 @@ use crate::data::models::ReferenceData; use crate::error::Result; +use html_escape::decode_html_entities; use sqlx::PgPool; /// 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 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 = entries + .iter() + .map(|e| decode_html_entities(&e.description).into_owned()) + .collect(); sqlx::query( r#" diff --git a/src/scraper/scheduler.rs b/src/scraper/scheduler.rs index 1649570..78ce472 100644 --- a/src/scraper/scheduler.rs +++ b/src/scraper/scheduler.rs @@ -206,6 +206,19 @@ impl Scheduler { 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 match banner_api.get_subjects("", &term, 1, 500).await { Ok(pairs) => { diff --git a/src/status.rs b/src/status.rs index a6b191a..eee1137 100644 --- a/src/status.rs +++ b/src/status.rs @@ -3,10 +3,12 @@ use std::time::Instant; use dashmap::DashMap; use serde::Serialize; +use ts_rs::TS; /// Health status of a service. -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq, TS)] #[serde(rename_all = "lowercase")] +#[ts(export)] pub enum ServiceStatus { Starting, Active, diff --git a/src/web/routes.rs b/src/web/routes.rs index d524258..9b596e9 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -18,6 +18,7 @@ use http::header; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, time::Duration}; +use ts_rs::TS; use crate::state::AppState; use crate::status::ServiceStatus; @@ -227,14 +228,16 @@ async fn health() -> Json { })) } -#[derive(Serialize)] -struct ServiceInfo { +#[derive(Serialize, TS)] +#[ts(export)] +pub struct ServiceInfo { name: String, status: ServiceStatus, } -#[derive(Serialize)] -struct StatusResponse { +#[derive(Serialize, TS)] +#[ts(export)] +pub struct StatusResponse { status: ServiceStatus, version: String, commit: String, @@ -316,9 +319,10 @@ fn default_limit() -> i32 { 25 } -#[derive(Serialize)] +#[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] -struct CourseResponse { +#[ts(export)] +pub struct CourseResponse { crn: String, subject: String, course_number: String, @@ -340,32 +344,35 @@ struct CourseResponse { link_identifier: Option, is_section_linked: Option, part_of_term: Option, - meeting_times: Value, - attributes: Value, + meeting_times: Vec, + attributes: Vec, instructors: Vec, } -#[derive(Serialize)] +#[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] -struct InstructorResponse { +#[ts(export)] +pub struct InstructorResponse { banner_id: String, display_name: String, email: Option, is_primary: bool, } -#[derive(Serialize)] +#[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] -struct SearchResponse { +#[ts(export)] +pub struct SearchResponse { courses: Vec, - total_count: i64, + total_count: i32, offset: i32, limit: i32, } -#[derive(Serialize)] +#[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] -struct CodeDescription { +#[ts(export)] +pub struct CodeDescription { code: String, description: String, } @@ -411,8 +418,8 @@ async fn build_course_response( link_identifier: course.link_identifier.clone(), is_section_linked: course.is_section_linked, part_of_term: course.part_of_term.clone(), - meeting_times: course.meeting_times.clone(), - attributes: course.attributes.clone(), + meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(), + attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(), instructors, } } @@ -454,7 +461,7 @@ async fn search_courses( Ok(Json(SearchResponse { courses: course_responses, - total_count, + total_count: total_count as i32, offset, limit, })) diff --git a/web/src/app.html b/web/src/app.html index cd3d24d..e4ce37a 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -12,6 +12,18 @@ Banner + %sveltekit.head% diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 2e4ca2a..60f97b8 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -61,4 +61,101 @@ describe("BannerApiClient", () => { "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); + }); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0affcd2..d4bfc42 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,24 +1,41 @@ +import type { + CodeDescription, + CourseResponse, + DbMeetingTime, + InstructorResponse, + SearchResponse as SearchResponseGenerated, + ServiceInfo, + ServiceStatus, + StatusResponse, +} from "$lib/bindings"; + 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 { status: 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; -} - export interface MetricsResponse { banner_api: { status: string; @@ -26,15 +43,27 @@ export interface MetricsResponse { 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 { 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.fetchFn = fetchFn; } private async request(endpoint: string): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`); + const response = await this.fetchFn(`${this.baseUrl}${endpoint}`); if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); @@ -54,6 +83,29 @@ export class BannerApiClient { async getMetrics(): Promise { return this.request("/metrics"); } + + async searchCourses(params: SearchParams): Promise { + 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(`/courses/search?${query.toString()}`); + } + + async getTerms(): Promise { + return this.request("/terms"); + } + + async getSubjects(termCode: string): Promise { + return this.request(`/subjects?term=${encodeURIComponent(termCode)}`); + } + + async getReference(category: string): Promise { + return this.request(`/reference/${encodeURIComponent(category)}`); + } } export const client = new BannerApiClient(); diff --git a/web/src/lib/components/CourseDetail.svelte b/web/src/lib/components/CourseDetail.svelte new file mode 100644 index 0000000..d5b401f --- /dev/null +++ b/web/src/lib/components/CourseDetail.svelte @@ -0,0 +1,111 @@ + + +
+
+ +
+

Instructors

+ {#if course.instructors.length > 0} +
    + {#each course.instructors as instructor} +
  • + {instructor.displayName} + {#if instructor.isPrimary} + primary + {/if} + {#if instructor.email} + — {instructor.email} + {/if} +
  • + {/each} +
+ {:else} + Staff + {/if} +
+ + +
+

Meeting Times

+ {#if course.meetingTimes.length > 0} +
    + {#each course.meetingTimes as mt} +
  • + {formatMeetingDays(mt) || "TBA"} + {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} + {#if mt.building || mt.room} + + ({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}) + + {/if} +
    {mt.start_date} – {mt.end_date}
    +
  • + {/each} +
+ {:else} + TBA + {/if} +
+ + +
+

Delivery

+ + {course.instructionalMethod ?? "—"} + {#if course.campus} + · {course.campus} + {/if} + +
+ + +
+

Credits

+ {formatCreditHours(course)} +
+ + + {#if course.attributes.length > 0} +
+

Attributes

+
+ {#each course.attributes as attr} + + {attr} + + {/each} +
+
+ {/if} + + + {#if course.crossList} +
+

Cross-list

+ + {course.crossList} + {#if course.crossListCount != null && course.crossListCapacity != null} + ({course.crossListCount}/{course.crossListCapacity}) + {/if} + +
+ {/if} + + + {#if course.waitCapacity > 0} +
+

Waitlist

+ {course.waitCount} / {course.waitCapacity} +
+ {/if} +
+
diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte new file mode 100644 index 0000000..ad0c561 --- /dev/null +++ b/web/src/lib/components/CourseTable.svelte @@ -0,0 +1,91 @@ + + +
+ + + + + + + + + + + + + {#if loading && courses.length === 0} + {#each Array(5) as _} + + + + + + + + + {/each} + {:else if courses.length === 0} + + + + {:else} + {#each courses as course (course.crn)} + toggleRow(course.crn)} + > + + + + + + + + {#if expandedCrn === course.crn} + + + + {/if} + {/each} + {/if} + +
CRNCourseTitleInstructorTimeSeats
+ No courses found. Try adjusting your filters. +
{course.crn} + {course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""} + {course.title}{primaryInstructorDisplay(course)}{timeDisplay(course)} + {course.enrollment}/{course.maxEnrollment} + {#if course.waitCount > 0} +
WL: {course.waitCount}/{course.waitCapacity}
+ {/if} +
+ +
+
diff --git a/web/src/lib/components/Pagination.svelte b/web/src/lib/components/Pagination.svelte new file mode 100644 index 0000000..d252ed5 --- /dev/null +++ b/web/src/lib/components/Pagination.svelte @@ -0,0 +1,37 @@ + + +{#if totalCount > 0} +
+ + Showing {start}–{end} of {totalCount} courses + +
+ + +
+
+{/if} diff --git a/web/src/lib/components/SearchFilters.svelte b/web/src/lib/components/SearchFilters.svelte new file mode 100644 index 0000000..099a035 --- /dev/null +++ b/web/src/lib/components/SearchFilters.svelte @@ -0,0 +1,52 @@ + + +
+ + + + + + + +
diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte index 7ecfed8..8cd7a60 100644 --- a/web/src/lib/components/ThemeToggle.svelte +++ b/web/src/lib/components/ThemeToggle.svelte @@ -1,53 +1,66 @@ diff --git a/web/src/lib/course.test.ts b/web/src/lib/course.test.ts new file mode 100644 index 0000000..2efda50 --- /dev/null +++ b/web/src/lib/course.test.ts @@ -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 { + 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 AM–9: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("1–3"); + }); + it("returns dash when no credit info", () => { + expect( + formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse) + ).toBe("—"); + }); +}); diff --git a/web/src/lib/course.ts b/web/src/lib/course.ts new file mode 100644 index 0000000..0654626 --- /dev/null +++ b/web/src/lib/course.ts @@ -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 AM–9: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 "—"; +} diff --git a/web/src/lib/stores/theme.svelte.ts b/web/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..690c3cd --- /dev/null +++ b/web/src/lib/stores/theme.svelte.ts @@ -0,0 +1,36 @@ +class ThemeStore { + isDark = $state(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(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1139bf8..5dd96e7 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,22 +1,16 @@ - - {@html ``} - +onMount(() => { + themeStore.init(); +}); +
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 987d0f9..4e94e53 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,327 +1,169 @@ -
-
-
- -
-
- - System Status -
- {#if isLoading} -
- {:else} - {#if overallIcon} - {@const OverallIconComponent = overallIcon.icon} -
- - {overallHealth} - - -
- {/if} - {/if} -
+
+
+ +
+

UTSA Course Search

+
- -
- {#if shouldShowSkeleton} - {#each Array(3) as _} -
-
-
-
-
-
-
-
-
-
- {/each} - {:else} - {#each services as service (service.name)} - {@const statusInfo = STATUS_ICONS[service.status]} - {@const ServiceIcon = service.icon} - {@const StatusIconComponent = statusInfo.icon} -
-
- - {service.name} -
-
- - {service.status} - - -
-
- {/each} - {/if} -
+ + - -
- {#if isLoading} -
-
- - Response Time -
-
-
- {:else if shouldShowTiming && statusState.mode === "response"} -
-
- - Response Time -
- - {formatNumber(statusState.timing.health!)}ms - -
- {/if} - - {#if isLoading} -
-
- - Last Updated -
- Loading... -
- {:else if shouldShowLastFetch && lastFetch} -
-
- - Last Updated -
- - - - {relativeLastFetch} - - - - as of {lastFetch.toLocaleTimeString()} - - -
- {/if} + + {#if error} +
+

{error}

+
+ {:else} + + + {#if searchResult} + + {/if} + {/if} + + +
+ {#if __APP_VERSION__} + v{__APP_VERSION__} +
+ {/if} + + GitHub + +
+ + Status +
- - -
- {#if __APP_VERSION__} - v{__APP_VERSION__} -
- {/if} - - GitHub - -
diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts new file mode 100644 index 0000000..ab7fbd8 --- /dev/null +++ b/web/src/routes/+page.ts @@ -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 }; +}; diff --git a/web/src/routes/health/+page.svelte b/web/src/routes/health/+page.svelte new file mode 100644 index 0000000..3a759ed --- /dev/null +++ b/web/src/routes/health/+page.svelte @@ -0,0 +1,327 @@ + + +
+
+
+ +
+
+ + System Status +
+ {#if isLoading} +
+ {:else} + {#if overallIcon} + {@const OverallIconComponent = overallIcon.icon} +
+ + {overallHealth} + + +
+ {/if} + {/if} +
+ + +
+ {#if shouldShowSkeleton} + {#each Array(3) as _} +
+
+
+
+
+
+
+
+
+
+ {/each} + {:else} + {#each services as service (service.name)} + {@const statusInfo = STATUS_ICONS[service.status]} + {@const ServiceIcon = service.icon} + {@const StatusIconComponent = statusInfo.icon} +
+
+ + {service.name} +
+
+ + {service.status} + + +
+
+ {/each} + {/if} +
+ + +
+ {#if isLoading} +
+
+ + Response Time +
+
+
+ {:else if shouldShowTiming && statusState.mode === "response"} +
+
+ + Response Time +
+ + {formatNumber(statusState.timing.health!)}ms + +
+ {/if} + + {#if isLoading} +
+
+ + Last Updated +
+ Loading... +
+ {:else if shouldShowLastFetch && lastFetch} +
+
+ + Last Updated +
+ + + + {relativeLastFetch} + + + + as of {lastFetch.toLocaleTimeString()} + + +
+ {/if} +
+
+
+ + +
+ {#if __APP_VERSION__} + v{__APP_VERSION__} +
+ {/if} + + GitHub + +
+
diff --git a/web/src/routes/layout.css b/web/src/routes/layout.css index a6ed126..af79e61 100644 --- a/web/src/routes/layout.css +++ b/web/src/routes/layout.css @@ -69,6 +69,13 @@ body * { 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 { 0%, 100% {