30 Commits

Author SHA1 Message Date
215703593b refactor: consolidate course data models into structured types
Extract DateRange, MeetingLocation, CreditHours, CrossList, Enrollment,
and RmpRating into dedicated types. Replace primitive fields across
backend models and frontend bindings with type-safe alternatives.
2026-02-01 04:07:06 -06:00
2123bcbe3e chore(master): release 0.6.2 (#2) 2026-02-01 02:42:48 -06:00
6c15f4082f refactor(api): extract toURLSearchParams helper for query param handling 2026-02-01 02:05:29 -06:00
bbff2b7f36 refactor(web): split CourseTable into modular component structure
Decompose monolithic CourseTable.svelte into separate desktop/mobile views
with dedicated cell components and extracted state management for improved
maintainability and code organization.
2026-02-01 01:43:58 -06:00
b37604f807 fix(web): skip view transitions for same-page navigations
Prevents document-level view transitions from blocking pointer events
during query param updates (e.g. filter changes). Only use transitions
when the pathname actually changes.
2026-02-01 01:08:00 -06:00
d278498daa refactor(web): replace component tooltips with delegated singleton 2026-02-01 01:06:56 -06:00
bd2acee6f4 feat(web): build responsive layout with mobile card view 2026-02-01 00:40:58 -06:00
7e7fc1df94 style(web): improve ux by preventing unwanted text selection 2026-01-31 21:04:09 -06:00
005adb8792 style(web): update spacing and color classes across components 2026-01-31 20:51:28 -06:00
dfaaa88d54 chore: add vscode workspace extension recommendations 2026-01-31 20:14:12 -06:00
f387401a41 refactor(api): rename middleware and enable database query logging 2026-01-31 20:13:54 -06:00
4e0140693b refactor(web): extract FilterPopover component and upgrade range sliders
Replace basic HTML range inputs with svelte-range-slider-pips library
for better UX. Create shared FilterPopover component to eliminate
duplicate popover structure across Attributes, Schedule, Status, and
More filter components.
2026-01-31 17:16:00 -06:00
e9209684eb feat(web): batch rapid search query changes into history entries, allow for query history 2026-01-31 16:44:26 -06:00
b562fe227e fix(web): ignore .svelte-kit/generated in vite watcher
Prevents spurious full-page reloads when svelte-kit sync runs
externally (e.g. during `just check`). The SvelteKit vite plugin
already watches source files and writes generated output itself.
2026-01-31 16:25:58 -06:00
44260422d6 refactor(web): streamline filter ui with simplified removal 2026-01-31 15:12:53 -06:00
96a8c13125 fix(data): handle alphanumeric course numbers in range filtering 2026-01-31 15:12:53 -06:00
567c4aec3c feat(web): implement aligned course codes with jetbrains mono 2026-01-31 14:16:10 -06:00
f5a639e88b feat(web): add dynamic range sliders with consolidated search options API 2026-01-31 13:50:26 -06:00
d91f7ab342 refactor(web): consolidate tooltip implementations with shared components 2026-01-31 12:26:31 -06:00
7f0f08725a fix(web): prevent interaction blocking during search transitions
Remove document-level view transition fallback that applies
visibility:hidden to the entire page. Use scoped table transitions to
keep filters and controls interactive during search result updates.
2026-01-31 12:16:36 -06:00
02b18f0c66 chore: add aliases to Justfile 2026-01-31 11:30:43 -06:00
106bf232c4 feat(web): implement multi-dimensional course filtering system
Add schedule, attribute, instructor, and credit hour filters to course
search. Extend backend query with 9 new parameters and create reusable
popover components for filter UI with active state indicators.
2026-01-31 11:29:04 -06:00
239f7ee38c refactor: standardize error responses with ApiError and ts-rs bindings 2026-01-31 10:35:04 -06:00
0ee4e8a8bc refactor: migrate API responses from manual JSON to type-safe bindings
Replace hand-written TypeScript interfaces and serde_json::Value responses
with ts-rs generated bindings across admin, metrics, timeline, and WebSocket
APIs. Eliminates manual type maintenance and ensures frontend types stay
in sync with backend definitions.
2026-01-31 10:00:36 -06:00
5729a821d5 feat(web): implement smooth view transitions for search results 2026-01-31 09:33:09 -06:00
5134ae9388 chore: add dev-build flag for embedded vite builds 2026-01-31 01:59:56 -06:00
9e825cd113 fix: re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping
Prevents scrollbars while also avoiding clipping on certain pages.
2026-01-31 01:40:22 -06:00
ac8dbb2eef fix: separate Biome format and lint checks to enable auto-format
Biome's 'check' command runs both formatting and linting, causing
overlapping failures that prevented auto-format from triggering.
Split into separate commands and removed web-lint check since Biome
linting crashes on Svelte 5 syntax. Renamed check steps for clarity.
2026-01-31 01:05:19 -06:00
5dd35ed215 fix(web): prevent duplicate searches and background fetching on navigation
- Search page no longer triggers cascading search when validating subjects
- Scraper page stops all refresh timers and API calls when navigating away
- Wrap initial data references in untrack() to silence Svelte warnings
2026-01-31 01:03:20 -06:00
2acf52a63b fix(cli): add proper flag validation for check script 2026-01-31 00:42:52 -06:00
152 changed files with 7237 additions and 2717 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.1"
".": "0.6.2"
}
-4
View File
@@ -62,10 +62,6 @@ jobs:
bun run format:check || echo "::warning::Frontend formatting issues found (not failing on push)"
fi
- name: Lint
working-directory: web
run: bun run lint
- name: Type check
working-directory: web
run: bun run typecheck
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"rust-lang.rust-analyzer",
"nefrob.vscode-just-syntax"
]
}
+43
View File
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.2](https://github.com/Xevion/Banner/compare/v0.6.1...v0.6.2) (2026-02-01)
### Features
* **web:** Add dynamic range sliders with consolidated search options API ([f5a639e](https://github.com/Xevion/Banner/commit/f5a639e88bfe03dfc635f25e06fc22208ee0c855))
* **web:** Batch rapid search query changes into history entries, allow for query history ([e920968](https://github.com/Xevion/Banner/commit/e9209684eb051f978607a31f237b19e883af5d5a))
* **web:** Build responsive layout with mobile card view ([bd2acee](https://github.com/Xevion/Banner/commit/bd2acee6f40c0768898ab39e0524c0474ec4fd31))
* **web:** Implement aligned course codes with jetbrains mono ([567c4ae](https://github.com/Xevion/Banner/commit/567c4aec3ca7baaeb548fff2005d83f7e6228d79))
* **web:** Implement multi-dimensional course filtering system ([106bf23](https://github.com/Xevion/Banner/commit/106bf232c4b53f4ca8902a582f185e146878c54e))
* **web:** Implement smooth view transitions for search results ([5729a82](https://github.com/Xevion/Banner/commit/5729a821d54d95a00e9f4ba736a2bd884c0c409b))
### Bug Fixes
* **cli:** Add proper flag validation for check script ([2acf52a](https://github.com/Xevion/Banner/commit/2acf52a63b6dcd24ca826b99061bf7a51a9230b1))
* **data:** Handle alphanumeric course numbers in range filtering ([96a8c13](https://github.com/Xevion/Banner/commit/96a8c13125428f1cc14e46d8f580719c17c029ef))
* Re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping ([9e825cd](https://github.com/Xevion/Banner/commit/9e825cd113bbc65c10f0386b5300b6aec50bf936))
* Separate Biome format and lint checks to enable auto-format ([ac8dbb2](https://github.com/Xevion/Banner/commit/ac8dbb2eefe79ec5d898cfa719e270f4713125d5))
* **web:** Ignore .svelte-kit/generated in vite watcher ([b562fe2](https://github.com/Xevion/Banner/commit/b562fe227e89a0826fe4587372e3eeca2ab6eb33))
* **web:** Prevent duplicate searches and background fetching on navigation ([5dd35ed](https://github.com/Xevion/Banner/commit/5dd35ed215d3d1f3603e67a2aa59eaddf619f5c9))
* **web:** Prevent interaction blocking during search transitions ([7f0f087](https://github.com/Xevion/Banner/commit/7f0f08725a668c5ac88c510f43791d90ce2f795e))
* **web:** Skip view transitions for same-page navigations ([b37604f](https://github.com/Xevion/Banner/commit/b37604f8071741017a83f74a67b73cf7975827ae))
### Code Refactoring
* **api:** Extract toURLSearchParams helper for query param handling ([6c15f40](https://github.com/Xevion/Banner/commit/6c15f4082f1a4b6fb6c54c545c6e0ec47e191654))
* **api:** Rename middleware and enable database query logging ([f387401](https://github.com/Xevion/Banner/commit/f387401a4174d4d0bdf74deccdda80b3af543b74))
* Migrate API responses from manual JSON to type-safe bindings ([0ee4e8a](https://github.com/Xevion/Banner/commit/0ee4e8a8bc1fe0b079fea84ac303674083b43a59))
* Standardize error responses with ApiError and ts-rs bindings ([239f7ee](https://github.com/Xevion/Banner/commit/239f7ee38cbc0e49d9041579fc9923fd4a4608bf))
* **web:** Consolidate tooltip implementations with shared components ([d91f7ab](https://github.com/Xevion/Banner/commit/d91f7ab34299b26dc12d629bf99d502ee05e7cfa))
* **web:** Extract FilterPopover component and upgrade range sliders ([4e01406](https://github.com/Xevion/Banner/commit/4e0140693b00686e8a57561b0811fdf25a614e65))
* **web:** Replace component tooltips with delegated singleton ([d278498](https://github.com/Xevion/Banner/commit/d278498daa4afc82c877b536ecd1264970dc92a7))
* **web:** Split CourseTable into modular component structure ([bbff2b7](https://github.com/Xevion/Banner/commit/bbff2b7f36744808b62ec130be2cfbdc96f87b69))
* **web:** Streamline filter ui with simplified removal ([4426042](https://github.com/Xevion/Banner/commit/44260422d68e910ed4ad37e78cd8a1d1f8bb51a3))
### Miscellaneous
* Add aliases to Justfile ([02b18f0](https://github.com/Xevion/Banner/commit/02b18f0c66dc8b876452f35999c027475df52462))
* Add dev-build flag for embedded vite builds ([5134ae9](https://github.com/Xevion/Banner/commit/5134ae93881854ac722dc9e7f3f5040aee3e517a))
## [0.6.1](https://github.com/Xevion/Banner/compare/v0.6.0...v0.6.1) (2026-01-31)
Generated
+1 -1
View File
@@ -272,7 +272,7 @@ dependencies = [
[[package]]
name = "banner"
version = "0.6.1"
version = "0.6.2"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "banner"
version = "0.6.1"
version = "0.6.2"
edition = "2024"
default-run = "banner"
+11 -1
View File
@@ -1,5 +1,16 @@
set dotenv-load
# Aliases
alias c := check
alias d := dev
alias t := test
alias f := format
alias fmt := format
alias s := search
alias bld := build
alias bind := bindings
alias b := bun
default:
just --list
@@ -38,7 +49,6 @@ build *flags:
db cmd="start":
bun scripts/db.ts {{cmd}}
alias b := bun
bun *ARGS:
cd web && bun {{ ARGS }}
+22 -13
View File
@@ -8,7 +8,17 @@ import { c, elapsed, isStderrTTY } from "./lib/fmt";
import { run, runPiped, spawnCollect, raceInOrder, type CollectResult } from "./lib/proc";
import { existsSync, statSync, readdirSync, writeFileSync, rmSync } from "fs";
const fix = process.argv.includes("--fix") || process.argv.includes("-f");
const args = process.argv.slice(2);
let fix = false;
for (const arg of args) {
if (arg === "-f" || arg === "--fix") {
fix = true;
} else {
console.error(`Unknown flag: ${arg}`);
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Fix path: format + clippy fix, then fall through to verification
@@ -89,16 +99,15 @@ interface Check {
const checks: Check[] = [
{
name: "rustfmt",
name: "rust-format",
cmd: ["cargo", "fmt", "--all", "--", "--check"],
hint: "Run 'cargo fmt --all' to see and fix formatting issues.",
},
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-lint", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "rust-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "biome-lint", cmd: ["bun", "run", "--cwd", "web", "lint"] },
{ name: "web-format", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
{ name: "actionlint", cmd: ["actionlint"] },
];
@@ -115,19 +124,19 @@ const domains: Record<
recheck: Check[];
}
> = {
rustfmt: {
peers: ["clippy", "cargo-check", "rust-test"],
"rust-format": {
peers: ["rust-lint", "rust-check", "rust-test"],
format: () => runPiped(["cargo", "fmt", "--all"]),
recheck: [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-format", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "rust-check", cmd: ["cargo", "check", "--all-features"] },
],
},
biome: {
peers: ["svelte-check", "biome-lint", "web-test"],
"web-format": {
peers: ["svelte-check", "web-test"],
format: () => runPiped(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "web-format", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
],
},
+10 -3
View File
@@ -10,6 +10,7 @@
* -n, --no-build Run last compiled binary (no rebuild)
* -r, --release Use release profile
* -e, --embed Embed assets (implies -b)
* -d, --dev-build Use dev build for frontend (faster, no minification)
* --tracing <fmt> Tracing format (default: pretty)
*/
@@ -26,9 +27,10 @@ const { flags, passthrough } = parseFlags(
"no-build": "bool",
release: "bool",
embed: "bool",
"dev-build": "bool",
tracing: "string",
} as const,
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed" },
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed", d: "dev-build" },
{
"frontend-only": false,
"backend-only": false,
@@ -36,6 +38,7 @@ const { flags, passthrough } = parseFlags(
"no-build": false,
release: false,
embed: false,
"dev-build": false,
tracing: "pretty",
},
);
@@ -46,6 +49,7 @@ let noWatch = flags["no-watch"];
const noBuild = flags["no-build"];
const release = flags.release;
const embed = flags.embed;
const devBuild = flags["dev-build"];
const tracing = flags.tracing as string;
// -e implies -b
@@ -66,8 +70,11 @@ const group = new ProcessGroup();
// Build frontend first when embedding assets
if (embed && !noBuild) {
console.log(c("1;36", "→ Building frontend (for embedding)..."));
run(["bun", "run", "--cwd", "web", "build"]);
const buildMode = devBuild ? "development" : "production";
console.log(c("1;36", `→ Building frontend (${buildMode}, for embedding)...`));
const buildArgs = ["bun", "run", "--cwd", "web", "build"];
if (devBuild) buildArgs.push("--", "--mode", "development");
run(buildArgs);
}
// Frontend: Vite dev server
+8 -1
View File
@@ -10,8 +10,10 @@ use crate::web::auth::AuthConfig;
use anyhow::Context;
use figment::value::UncasedStr;
use figment::{Figment, providers::Env};
use sqlx::ConnectOptions;
use sqlx::postgres::PgPoolOptions;
use std::process::ExitCode;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info, warn};
@@ -45,6 +47,11 @@ impl App {
let slow_threshold = Duration::from_millis(if is_private { 200 } else { 500 });
// Create database connection pool
let connect_options = sqlx::postgres::PgConnectOptions::from_str(&config.database_url)
.context("Failed to parse database URL")?
.log_statements(tracing::log::LevelFilter::Debug)
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_secs(1));
let db_pool = PgPoolOptions::new()
.min_connections(0)
.max_connections(4)
@@ -52,7 +59,7 @@ impl App {
.acquire_timeout(Duration::from_secs(4))
.idle_timeout(Duration::from_secs(60 * 2))
.max_lifetime(Duration::from_secs(60 * 30))
.connect(&config.database_url)
.connect_with(connect_options)
.await
.context("Failed to create database pool")?;
+2 -2
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::banner::{
SessionPool, create_shared_rate_limiter, errors::BannerApiError, json::parse_json_with_context,
middleware::TransparentMiddleware, models::*, nonce, query::SearchQuery,
middleware::LoggingMiddleware, models::*, nonce, query::SearchQuery,
rate_limit_middleware::RateLimitMiddleware, util::user_agent,
};
use crate::config::RateLimitingConfig;
@@ -46,7 +46,7 @@ impl BannerApi {
.build()
.context("Failed to create HTTP client")?,
)
.with(TransparentMiddleware)
.with(LoggingMiddleware)
.with(RateLimitMiddleware::new(rate_limiter.clone()))
.build();
+12 -27
View File
@@ -5,13 +5,13 @@ use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use tracing::{debug, trace, warn};
pub struct TransparentMiddleware;
pub struct LoggingMiddleware;
/// Threshold for logging slow requests at DEBUG level (in milliseconds)
const SLOW_REQUEST_THRESHOLD_MS: u128 = 1000;
#[async_trait::async_trait]
impl Middleware for TransparentMiddleware {
impl Middleware for LoggingMiddleware {
async fn handle(
&self,
req: Request,
@@ -19,7 +19,8 @@ impl Middleware for TransparentMiddleware {
next: Next<'_>,
) -> std::result::Result<Response, reqwest_middleware::Error> {
let method = req.method().to_string();
let path = req.url().path().to_string();
// Use the full URL (including query parameters) for logging
let url = req.url().to_string();
let start = std::time::Instant::now();
let response_result = next.run(req, extensions).await;
@@ -27,41 +28,25 @@ impl Middleware for TransparentMiddleware {
match response_result {
Ok(response) => {
let status = response.status().as_u16();
let duration_ms = duration.as_millis();
if response.status().is_success() {
let duration_ms = duration.as_millis();
if duration_ms >= SLOW_REQUEST_THRESHOLD_MS {
debug!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration_ms,
"Request completed (slow)"
);
debug!(method, url, status, duration_ms, "Request completed (slow)");
} else {
trace!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration_ms,
"Request completed"
);
trace!(method, url, status, duration_ms, "Request completed");
}
Ok(response)
} else {
warn!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration.as_millis(),
"Request failed"
);
warn!(method, url, status, duration_ms, "Request failed");
Ok(response)
}
}
Err(error) => {
warn!(
method = method,
path = path,
method,
url,
duration_ms = duration.as_millis(),
"Request failed"
);
+3 -1
View File
@@ -3,6 +3,7 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, str::FromStr};
use ts_rs::TS;
use super::terms::Term;
@@ -199,7 +200,8 @@ impl TryFrom<MeetingDays> for Weekday {
}
/// Time range for meetings
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[ts(export)]
pub struct TimeRange {
pub start: NaiveTime,
pub end: NaiveTime,
+31 -47
View File
@@ -2,8 +2,8 @@
//!
//! Used by both the Discord bot commands and the web API endpoints.
use crate::data::models::DbMeetingTime;
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
use crate::data::models::{DayOfWeek, DbMeetingTime};
use chrono::{Datelike, Duration, NaiveDate, Weekday};
/// Course metadata needed for calendar generation (shared interface between bot and web).
pub struct CalendarCourse {
@@ -36,42 +36,25 @@ impl CalendarCourse {
}
// ---------------------------------------------------------------------------
// Date parsing helpers
// Day-of-week conversion
// ---------------------------------------------------------------------------
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s, "%m/%d/%Y")
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
.ok()
}
/// Parse an HHMM time string into `NaiveTime`.
fn parse_hhmm(s: &str) -> Option<NaiveTime> {
if s.len() != 4 {
return None;
/// Convert a `DayOfWeek` to a chrono `Weekday`.
fn to_weekday(day: &DayOfWeek) -> Weekday {
match day {
DayOfWeek::Monday => Weekday::Mon,
DayOfWeek::Tuesday => Weekday::Tue,
DayOfWeek::Wednesday => Weekday::Wed,
DayOfWeek::Thursday => Weekday::Thu,
DayOfWeek::Friday => Weekday::Fri,
DayOfWeek::Saturday => Weekday::Sat,
DayOfWeek::Sunday => Weekday::Sun,
}
let hours = s[..2].parse::<u32>().ok()?;
let minutes = s[2..].parse::<u32>().ok()?;
NaiveTime::from_hms_opt(hours, minutes, 0)
}
/// Active weekdays for a meeting time.
fn active_weekdays(mt: &DbMeetingTime) -> Vec<Weekday> {
let mapping: [(bool, Weekday); 7] = [
(mt.monday, Weekday::Mon),
(mt.tuesday, Weekday::Tue),
(mt.wednesday, Weekday::Wed),
(mt.thursday, Weekday::Thu),
(mt.friday, Weekday::Fri),
(mt.saturday, Weekday::Sat),
(mt.sunday, Weekday::Sun),
];
mapping
.iter()
.filter(|(active, _)| *active)
.map(|(_, day)| *day)
.collect()
mt.days.iter().map(to_weekday).collect()
}
/// ICS two-letter day code for RRULE BYDAY.
@@ -90,11 +73,16 @@ fn ics_day_code(day: Weekday) -> &'static str {
/// Location string from a `DbMeetingTime`.
fn location_string(mt: &DbMeetingTime) -> String {
let building = mt
.building_description
.as_deref()
.or(mt.building.as_deref())
.location
.as_ref()
.and_then(|loc| loc.building_description.as_deref())
.or_else(|| mt.location.as_ref().and_then(|loc| loc.building.as_deref()))
.unwrap_or("");
let room = mt
.location
.as_ref()
.and_then(|loc| loc.room.as_deref())
.unwrap_or("");
let room = mt.room.as_deref().unwrap_or("");
let combined = format!("{building} {room}").trim().to_string();
if combined.is_empty() {
"Online".to_string()
@@ -285,13 +273,11 @@ fn generate_ics_event(
mt: &DbMeetingTime,
index: usize,
) -> Result<(String, Vec<String>), anyhow::Error> {
let start_date = parse_date(&mt.start_date)
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
let end_date = parse_date(&mt.end_date)
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
let start_date = mt.date_range.start;
let end_date = mt.date_range.end;
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
// DTSTART/DTEND: first occurrence with time, or all-day on start_date
let (dtstart, dtend) = match (start_time, end_time) {
@@ -396,13 +382,11 @@ pub fn generate_gcal_url(
course: &CalendarCourse,
mt: &DbMeetingTime,
) -> Result<String, anyhow::Error> {
let start_date = parse_date(&mt.start_date)
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
let end_date = parse_date(&mt.end_date)
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
let start_date = mt.date_range.start;
let end_date = mt.date_range.end;
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
let dates_text = match (start_time, end_time) {
(Some(st), Some(et)) => {
+108 -17
View File
@@ -1,15 +1,23 @@
//! Batch database operations for improved performance.
use crate::banner::Course;
use crate::data::models::{DbMeetingTime, UpsertCounts};
use crate::banner::models::meetings::TimeRange;
use crate::data::course_types::{DateRange, MeetingLocation};
use crate::data::models::{DayOfWeek, DbMeetingTime, UpsertCounts};
use crate::data::names::{decode_html_entities, parse_banner_name};
use crate::error::Result;
use chrono::NaiveDate;
use sqlx::PgConnection;
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::time::Instant;
use tracing::info;
/// Parse a date string in MM/DD/YYYY format to `NaiveDate`.
fn parse_mm_dd_yyyy(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
}
/// Convert a Banner API course's meeting times to the DB JSONB shape.
fn to_db_meeting_times(course: &Course) -> serde_json::Value {
let meetings: Vec<DbMeetingTime> = course
@@ -17,22 +25,105 @@ fn to_db_meeting_times(course: &Course) -> serde_json::Value {
.iter()
.map(|mf| {
let mt = &mf.meeting_time;
// Build days BTreeSet from boolean flags
let mut days = BTreeSet::new();
if mt.monday {
days.insert(DayOfWeek::Monday);
}
if mt.tuesday {
days.insert(DayOfWeek::Tuesday);
}
if mt.wednesday {
days.insert(DayOfWeek::Wednesday);
}
if mt.thursday {
days.insert(DayOfWeek::Thursday);
}
if mt.friday {
days.insert(DayOfWeek::Friday);
}
if mt.saturday {
days.insert(DayOfWeek::Saturday);
}
if mt.sunday {
days.insert(DayOfWeek::Sunday);
}
// Parse time range from HHMM strings
let time_range = match (mt.begin_time.as_deref(), mt.end_time.as_deref()) {
(Some(begin), Some(end)) => {
let result = TimeRange::from_hhmm(begin, end);
if result.is_none() {
tracing::warn!(
crn = %mt.course_reference_number,
begin, end,
"failed to parse meeting time range"
);
}
result
}
_ => None,
};
// Parse date range from MM/DD/YYYY strings
let date_range = match (
parse_mm_dd_yyyy(&mt.start_date),
parse_mm_dd_yyyy(&mt.end_date),
) {
(Some(start), Some(end)) => DateRange::new(start, end).unwrap_or_else(|err| {
tracing::warn!(
crn = %mt.course_reference_number,
start_date = %mt.start_date,
end_date = %mt.end_date,
%err,
"invalid date range, swapping start/end"
);
// Swap so the invariant holds
DateRange {
start: end,
end: start,
}
}),
_ => {
tracing::warn!(
crn = %mt.course_reference_number,
start_date = %mt.start_date,
end_date = %mt.end_date,
"failed to parse meeting date range, using epoch fallback"
);
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
DateRange {
start: epoch,
end: epoch,
}
}
};
// Build location if any field is present
let location = {
let loc = MeetingLocation {
building: mt.building.clone(),
building_description: mt.building_description.clone(),
room: mt.room.clone(),
campus: mt.campus.clone(),
};
if loc.building.is_some()
|| loc.building_description.is_some()
|| loc.room.is_some()
|| loc.campus.is_some()
{
Some(loc)
} else {
None
}
};
DbMeetingTime {
begin_time: mt.begin_time.clone(),
end_time: mt.end_time.clone(),
start_date: mt.start_date.clone(),
end_date: mt.end_date.clone(),
monday: mt.monday,
tuesday: mt.tuesday,
wednesday: mt.wednesday,
thursday: mt.thursday,
friday: mt.friday,
saturday: mt.saturday,
sunday: mt.sunday,
building: mt.building.clone(),
building_description: mt.building_description.clone(),
room: mt.room.clone(),
campus: mt.campus.clone(),
time_range,
date_range,
days,
location,
meeting_type: mt.meeting_type.clone(),
meeting_schedule_type: mt.meeting_schedule_type.clone(),
}
+130
View File
@@ -0,0 +1,130 @@
//! Structured types for course API responses.
//!
//! These types replace scattered Option fields and parallel booleans with
//! proper type-safe structures.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// An inclusive date range with the invariant that `start <= end`.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DateRange {
pub start: NaiveDate,
pub end: NaiveDate,
}
impl DateRange {
/// Creates a new `DateRange`, returning an error if `start` is after `end`.
pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, String> {
if start > end {
return Err(format!(
"invalid date range: start ({start}) is after end ({end})"
));
}
Ok(Self { start, end })
}
/// Number of days in the range (inclusive of both endpoints).
#[allow(dead_code)]
pub fn days(&self) -> i64 {
(self.end - self.start).num_days() + 1
}
}
/// Physical location where a course section meets.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MeetingLocation {
pub building: Option<String>,
pub building_description: Option<String>,
pub room: Option<String>,
pub campus: Option<String>,
}
/// Credit hours for a course section — either a fixed value or a range.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export)]
pub enum CreditHours {
/// A single fixed credit hour value.
Fixed { hours: i32 },
/// A range of credit hours with the invariant that `low <= high`.
Range { low: i32, high: i32 },
}
impl CreditHours {
/// Creates a `CreditHours::Range`, returning an error if `low > high`.
#[allow(dead_code)]
pub fn range(low: i32, high: i32) -> Result<Self, String> {
if low > high {
return Err(format!(
"invalid credit hour range: low ({low}) is greater than high ({high})"
));
}
Ok(Self::Range { low, high })
}
}
/// Cross-listed section information.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CrossList {
pub identifier: String,
pub capacity: i32,
pub count: i32,
}
/// A linked section reference (e.g. lab linked to a lecture).
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SectionLink {
pub identifier: String,
}
/// Enrollment counts for a course section.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Enrollment {
pub current: i32,
pub max: i32,
pub wait_count: i32,
pub wait_capacity: i32,
}
impl Enrollment {
/// Number of open seats remaining (never negative).
#[allow(dead_code)]
pub fn open_seats(&self) -> i32 {
(self.max - self.current).max(0)
}
/// Whether the section is at or over capacity.
#[allow(dead_code)]
pub fn is_full(&self) -> bool {
self.current >= self.max
}
/// Whether the section has at least one open seat.
#[allow(dead_code)]
pub fn is_open(&self) -> bool {
!self.is_full()
}
}
/// RateMyProfessors rating summary for an instructor.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RmpRating {
pub avg_rating: f32,
pub num_ratings: i32,
pub legacy_id: i32,
pub is_confident: bool,
}
+163 -30
View File
@@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail};
use crate::error::Result;
use sqlx::PgPool;
use std::collections::HashMap;
use ts_rs::TS;
/// Column to sort search results by.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SortColumn {
CourseCode,
Title,
@@ -17,25 +19,74 @@ pub enum SortColumn {
}
/// Sort direction.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SortDirection {
Asc,
Desc,
}
/// Aggregate min/max ranges for filter sliders, computed per-term.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FilterRanges {
pub course_number_min: i32,
pub course_number_max: i32,
pub credit_hour_min: i32,
pub credit_hour_max: i32,
pub wait_count_max: i32,
}
/// Shared WHERE clause for course search filters.
///
/// Parameters $1-$8 match the bind order in `search_courses`.
/// Parameters $1-$17 match the bind order in `search_courses`.
///
/// Note: Course number filtering extracts numeric prefix to support alphanumeric
/// course numbers (e.g., "015X", "399H"). The numeric part is compared against
/// the range, so "399H" matches a search for courses 300-400.
const SEARCH_WHERE: &str = r#"
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($4::int IS NULL OR (substring(course_number from '^\d+'))::int >= $4)
AND ($5::int IS NULL OR (substring(course_number from '^\d+'))::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
AND ($7::text[] IS NULL OR instructional_method = ANY($7))
AND ($8::text[] IS NULL OR campus = ANY($8))
AND ($9::int IS NULL OR wait_count <= $9)
AND ($10::text[] IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (NOT 'monday' = ANY($10) OR (mt->>'monday')::bool)
AND (NOT 'tuesday' = ANY($10) OR (mt->>'tuesday')::bool)
AND (NOT 'wednesday' = ANY($10) OR (mt->>'wednesday')::bool)
AND (NOT 'thursday' = ANY($10) OR (mt->>'thursday')::bool)
AND (NOT 'friday' = ANY($10) OR (mt->>'friday')::bool)
AND (NOT 'saturday' = ANY($10) OR (mt->>'saturday')::bool)
AND (NOT 'sunday' = ANY($10) OR (mt->>'sunday')::bool)
))
AND ($11::text IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (mt->>'begin_time') >= $11
))
AND ($12::text IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (mt->>'end_time') <= $12
))
AND ($13::text[] IS NULL OR part_of_term = ANY($13))
AND ($14::text[] IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements_text(attributes) a
WHERE a = ANY($14)
))
AND ($15::int IS NULL OR COALESCE(credit_hours, credit_hour_low, 0) >= $15)
AND ($16::int IS NULL OR COALESCE(credit_hours, credit_hour_high, 0) <= $16)
AND ($17::text IS NULL OR EXISTS (
SELECT 1 FROM course_instructors ci
JOIN instructors i ON i.id = ci.instructor_id
WHERE ci.course_id = courses.id
AND i.display_name ILIKE '%' || $17 || '%'
))
"#;
/// Build a safe ORDER BY clause from typed sort parameters.
@@ -83,8 +134,17 @@ pub async fn search_courses(
course_number_low: Option<i32>,
course_number_high: Option<i32>,
open_only: bool,
instructional_method: Option<&str>,
campus: Option<&str>,
instructional_method: Option<&[String]>,
campus: Option<&[String]>,
wait_count_max: Option<i32>,
days: Option<&[String]>,
time_start: Option<&str>,
time_end: Option<&str>,
part_of_term: Option<&[String]>,
attributes: Option<&[String]>,
credit_hour_min: Option<i32>,
credit_hour_max: Option<i32>,
instructor: Option<&str>,
limit: i32,
offset: i32,
sort_by: Option<SortColumn>,
@@ -93,32 +153,50 @@ pub async fn search_courses(
let order_by = sort_clause(sort_by, sort_dir);
let data_query =
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $18 OFFSET $19");
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
let courses = sqlx::query_as::<_, Course>(&data_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(limit)
.bind(offset)
.bind(term_code) // $1
.bind(subject) // $2
.bind(title_query) // $3
.bind(course_number_low) // $4
.bind(course_number_high) // $5
.bind(open_only) // $6
.bind(instructional_method) // $7
.bind(campus) // $8
.bind(wait_count_max) // $9
.bind(days) // $10
.bind(time_start) // $11
.bind(time_end) // $12
.bind(part_of_term) // $13
.bind(attributes) // $14
.bind(credit_hour_min) // $15
.bind(credit_hour_max) // $16
.bind(instructor) // $17
.bind(limit) // $18
.bind(offset) // $19
.fetch_all(db_pool)
.await?;
let total: (i64,) = sqlx::query_as(&count_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(term_code) // $1
.bind(subject) // $2
.bind(title_query) // $3
.bind(course_number_low) // $4
.bind(course_number_high) // $5
.bind(open_only) // $6
.bind(instructional_method) // $7
.bind(campus) // $8
.bind(wait_count_max) // $9
.bind(days) // $10
.bind(time_start) // $11
.bind(time_end) // $12
.bind(part_of_term) // $13
.bind(attributes) // $14
.bind(credit_hour_min) // $15
.bind(credit_hour_max) // $16
.bind(instructor) // $17
.fetch_one(db_pool)
.await?;
@@ -147,7 +225,8 @@ pub async fn get_course_instructors(
) -> Result<Vec<CourseInstructorDetail>> {
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
i.email, ci.is_primary,
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
@@ -183,7 +262,8 @@ pub async fn get_instructors_for_courses(
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
i.email, ci.is_primary,
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
@@ -247,3 +327,56 @@ pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
.await?;
Ok(rows.into_iter().map(|(tc,)| tc).collect())
}
type RangeRow = (
Option<i32>,
Option<i32>,
Option<i32>,
Option<i32>,
Option<i32>,
);
/// Get aggregate filter ranges for a term (course number, credit hours, waitlist).
pub async fn get_filter_ranges(db_pool: &PgPool, term_code: &str) -> Result<FilterRanges> {
let row: RangeRow = sqlx::query_as(
r#"
SELECT
MIN(course_number::int),
MAX(course_number::int),
MIN(COALESCE(credit_hours, credit_hour_low, 0)),
MAX(COALESCE(credit_hours, credit_hour_high, 0)),
MAX(wait_count)
FROM courses
WHERE term_code = $1
AND course_number ~ '^\d+$'
"#,
)
.bind(term_code)
.fetch_one(db_pool)
.await?;
let cn_min = row.0.unwrap_or(1000);
let cn_max = row.1.unwrap_or(9000);
let ch_min = row.2.unwrap_or(0);
let ch_max = row.3.unwrap_or(8);
let wc_max_raw = row.4.unwrap_or(0);
// Round course number to hundreds: floor min, ceil max
let cn_min_rounded = (cn_min / 100) * 100;
let cn_max_rounded = ((cn_max + 99) / 100) * 100;
// Waitlist ceiling: (max / 10 + 1) * 10
let wc_max = if wc_max_raw > 0 {
(wc_max_raw / 10 + 1) * 10
} else {
0
};
Ok(FilterRanges {
course_number_min: cn_min_rounded,
course_number_max: cn_max_rounded,
credit_hour_min: ch_min,
credit_hour_max: ch_max,
wait_count_max: wc_max,
})
}
+1
View File
@@ -1,6 +1,7 @@
//! Database models and schema.
pub mod batch;
pub mod course_types;
pub mod courses;
pub mod models;
pub mod names;
+227 -18
View File
@@ -1,10 +1,15 @@
//! `sqlx` models for the database schema.
use chrono::{DateTime, Utc};
use std::collections::BTreeSet;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use ts_rs::TS;
use crate::banner::models::meetings::TimeRange;
use crate::data::course_types::{DateRange, MeetingLocation};
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&value.to_string())
@@ -41,29 +46,230 @@ fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
deserializer.deserialize_any(I64OrStringVisitor)
}
/// Day of the week.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum DayOfWeek {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
/// Represents a meeting time stored as JSONB in the courses table.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DbMeetingTime {
pub begin_time: Option<String>,
pub end_time: Option<String>,
pub start_date: String,
pub end_date: String,
pub monday: bool,
pub tuesday: bool,
pub wednesday: bool,
pub thursday: bool,
pub friday: bool,
pub saturday: bool,
pub sunday: bool,
pub building: Option<String>,
pub building_description: Option<String>,
pub room: Option<String>,
pub campus: Option<String>,
/// Time range for the meeting; `None` means TBA.
pub time_range: Option<TimeRange>,
/// Date range over which the meeting recurs.
pub date_range: DateRange,
/// Active days of the week. Empty means days are TBA.
pub days: BTreeSet<DayOfWeek>,
/// Physical location; `None` when all location fields are absent.
pub location: Option<MeetingLocation>,
pub meeting_type: String,
pub meeting_schedule_type: String,
}
impl DbMeetingTime {
/// Whether no days of the week are set (i.e. days are TBA).
#[allow(dead_code)]
pub fn is_days_tba(&self) -> bool {
self.days.is_empty()
}
/// Whether no time range is set (i.e. time is TBA).
#[allow(dead_code)]
pub fn is_time_tba(&self) -> bool {
self.time_range.is_none()
}
}
/// Normalize a date string to ISO-8601 (YYYY-MM-DD).
///
/// Accepts MM/DD/YYYY (from Banner API) and returns YYYY-MM-DD.
/// Already-normalized dates are returned as-is.
#[allow(dead_code)]
fn normalize_date(s: &str) -> String {
if let Some((month_day, year)) = s.rsplit_once('/')
&& let Some((month, day)) = month_day.split_once('/')
{
return format!("{year}-{month:0>2}-{day:0>2}");
}
s.to_string()
}
/// Parse a date string that may be in MM/DD/YYYY or YYYY-MM-DD format.
fn parse_flexible_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s, "%m/%d/%Y")
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
.ok()
}
impl<'de> Deserialize<'de> for DbMeetingTime {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
/// Intermediate representation that accepts both old and new JSON formats.
#[derive(Deserialize)]
struct Raw {
// New-format fields (camelCase in JSON)
#[serde(rename = "timeRange")]
time_range: Option<TimeRange>,
#[serde(rename = "dateRange")]
date_range: Option<DateRange>,
days: Option<BTreeSet<DayOfWeek>>,
location: Option<MeetingLocation>,
// Old-format fields (snake_case in JSON)
begin_time: Option<String>,
end_time: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
#[serde(default)]
monday: bool,
#[serde(default)]
tuesday: bool,
#[serde(default)]
wednesday: bool,
#[serde(default)]
thursday: bool,
#[serde(default)]
friday: bool,
#[serde(default)]
saturday: bool,
#[serde(default)]
sunday: bool,
building: Option<String>,
building_description: Option<String>,
room: Option<String>,
campus: Option<String>,
// Always present (camelCase in new format, snake_case in old format)
#[serde(rename = "meetingType", alias = "meeting_type")]
meeting_type: String,
#[serde(rename = "meetingScheduleType", alias = "meeting_schedule_type")]
meeting_schedule_type: String,
// Legacy computed fields (ignored on read)
#[serde(default)]
#[allow(dead_code)]
is_days_tba: bool,
#[serde(default)]
#[allow(dead_code)]
is_time_tba: bool,
#[serde(default)]
#[allow(dead_code)]
active_days: Vec<DayOfWeek>,
}
let raw = Raw::deserialize(deserializer)?;
// Resolve time_range: prefer new field, fall back to old begin_time/end_time
let time_range =
raw.time_range.or_else(
|| match (raw.begin_time.as_deref(), raw.end_time.as_deref()) {
(Some(begin), Some(end)) => {
let result = TimeRange::from_hhmm(begin, end);
if result.is_none() {
tracing::warn!(begin, end, "failed to parse old-format time range");
}
result
}
_ => None,
},
);
// Resolve date_range: prefer new field, fall back to old start_date/end_date
let date_range = if let Some(dr) = raw.date_range {
dr
} else {
let start_str = raw.start_date.as_deref().unwrap_or("");
let end_str = raw.end_date.as_deref().unwrap_or("");
let start = parse_flexible_date(start_str);
let end = parse_flexible_date(end_str);
match (start, end) {
(Some(s), Some(e)) => DateRange { start: s, end: e },
_ => {
tracing::warn!(
start_date = start_str,
end_date = end_str,
"failed to parse old-format date range, using epoch fallback"
);
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
DateRange {
start: epoch,
end: epoch,
}
}
}
};
// Resolve days: prefer new field, fall back to old boolean flags
let days = raw.days.unwrap_or_else(|| {
let mut set = BTreeSet::new();
if raw.monday {
set.insert(DayOfWeek::Monday);
}
if raw.tuesday {
set.insert(DayOfWeek::Tuesday);
}
if raw.wednesday {
set.insert(DayOfWeek::Wednesday);
}
if raw.thursday {
set.insert(DayOfWeek::Thursday);
}
if raw.friday {
set.insert(DayOfWeek::Friday);
}
if raw.saturday {
set.insert(DayOfWeek::Saturday);
}
if raw.sunday {
set.insert(DayOfWeek::Sunday);
}
set
});
// Resolve location: prefer new field, fall back to old building/room/campus fields
let location = raw.location.or_else(|| {
let loc = MeetingLocation {
building: raw.building,
building_description: raw.building_description,
room: raw.room,
campus: raw.campus,
};
// Only produce Some if at least one field is present
if loc.building.is_some()
|| loc.building_description.is_some()
|| loc.room.is_some()
|| loc.campus.is_some()
{
Some(loc)
} else {
None
}
});
Ok(DbMeetingTime {
time_range,
date_range,
days,
location,
meeting_type: raw.meeting_type,
meeting_schedule_type: raw.meeting_schedule_type,
})
}
}
#[allow(dead_code)]
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct Course {
@@ -122,6 +328,8 @@ pub struct CourseInstructorDetail {
pub instructor_id: i32,
pub banner_id: String,
pub display_name: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: String,
pub is_primary: bool,
pub avg_rating: Option<f32>,
@@ -192,8 +400,9 @@ pub enum TargetType {
}
/// Computed status for a scrape job, derived from existing fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum ScrapeJobStatus {
Processing,
StaleLock,
+4
View File
@@ -8,9 +8,11 @@ use crate::web::schedule_cache::ScheduleCache;
use crate::web::session_cache::{OAuthStateStore, SessionCache};
use crate::web::ws::ScrapeJobEvent;
use anyhow::Result;
use dashmap::DashMap;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, broadcast};
/// In-memory cache for reference data (code→description lookups).
@@ -79,6 +81,7 @@ pub struct AppState {
pub oauth_state_store: OAuthStateStore,
pub schedule_cache: ScheduleCache,
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
pub search_options_cache: Arc<DashMap<String, (Instant, serde_json::Value)>>,
}
impl AppState {
@@ -94,6 +97,7 @@ impl AppState {
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
schedule_cache,
scrape_job_tx,
search_options_cache: Arc::new(DashMap::new()),
}
}
+83 -51
View File
@@ -6,18 +6,51 @@ use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ts_rs::TS;
use crate::data::models::User;
use crate::state::AppState;
use crate::status::ServiceStatus;
use crate::web::extractors::AdminUser;
use crate::web::ws::ScrapeJobDto;
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ScrapeJobsResponse {
pub jobs: Vec<ScrapeJobDto>,
}
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AdminServiceInfo {
name: String,
status: ServiceStatus,
}
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AdminStatusResponse {
#[ts(type = "number")]
user_count: i64,
#[ts(type = "number")]
session_count: i64,
#[ts(type = "number")]
course_count: i64,
#[ts(type = "number")]
scrape_job_count: i64,
services: Vec<AdminServiceInfo>,
}
/// `GET /api/admin/status` — Enhanced system status for admins.
pub async fn admin_status(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
) -> Result<Json<AdminStatusResponse>, (StatusCode, Json<Value>)> {
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db_pool)
.await
@@ -60,25 +93,20 @@ pub async fn admin_status(
)
})?;
let services: Vec<Value> = state
let services: Vec<AdminServiceInfo> = state
.service_statuses
.all()
.into_iter()
.map(|(name, status)| {
json!({
"name": name,
"status": status,
})
})
.map(|(name, status)| AdminServiceInfo { name, status })
.collect();
Ok(Json(json!({
"userCount": user_count,
"sessionCount": session_count,
"courseCount": course_count,
"scrapeJobCount": scrape_job_count,
"services": services,
})))
Ok(Json(AdminStatusResponse {
user_count,
session_count,
course_count,
scrape_job_count,
services,
}))
}
/// `GET /api/admin/users` — List all users.
@@ -136,7 +164,7 @@ pub async fn set_user_admin(
pub async fn list_scrape_jobs(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
) -> Result<Json<ScrapeJobsResponse>, (StatusCode, Json<Value>)> {
let rows = sqlx::query_as::<_, crate::data::models::ScrapeJob>(
"SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100",
)
@@ -150,26 +178,9 @@ pub async fn list_scrape_jobs(
)
})?;
let jobs: Vec<Value> = rows
.iter()
.map(|j| {
json!({
"id": j.id,
"targetType": format!("{:?}", j.target_type),
"targetPayload": j.target_payload,
"priority": format!("{:?}", j.priority),
"executeAt": j.execute_at.to_rfc3339(),
"createdAt": j.created_at.to_rfc3339(),
"lockedAt": j.locked_at.map(|t| t.to_rfc3339()),
"retryCount": j.retry_count,
"maxRetries": j.max_retries,
"queuedAt": j.queued_at.to_rfc3339(),
"status": j.status(),
})
})
.collect();
let jobs: Vec<ScrapeJobDto> = rows.iter().map(ScrapeJobDto::from).collect();
Ok(Json(json!({ "jobs": jobs })))
Ok(Json(ScrapeJobsResponse { jobs }))
}
/// Row returned by the audit-log query (audit + joined course fields).
@@ -188,6 +199,29 @@ struct AuditRow {
title: Option<String>,
}
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AuditLogEntry {
pub id: i32,
pub course_id: i32,
pub timestamp: String,
pub field_changed: String,
pub old_value: String,
pub new_value: String,
pub subject: Option<String>,
pub course_number: Option<String>,
pub crn: Option<String>,
pub course_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AuditLogResponse {
pub entries: Vec<AuditLogEntry>,
}
/// Format a `DateTime<Utc>` as an HTTP-date (RFC 2822) for Last-Modified headers.
fn to_http_date(dt: &DateTime<Utc>) -> String {
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
@@ -241,25 +275,23 @@ pub async fn list_audit_log(
}
}
let entries: Vec<Value> = rows
let entries: Vec<AuditLogEntry> = rows
.iter()
.map(|a| {
json!({
"id": a.id,
"courseId": a.course_id,
"timestamp": a.timestamp.to_rfc3339(),
"fieldChanged": a.field_changed,
"oldValue": a.old_value,
"newValue": a.new_value,
"subject": a.subject,
"courseNumber": a.course_number,
"crn": a.crn,
"courseTitle": a.title,
})
.map(|a| AuditLogEntry {
id: a.id,
course_id: a.course_id,
timestamp: a.timestamp.to_rfc3339(),
field_changed: a.field_changed.clone(),
old_value: a.old_value.clone(),
new_value: a.new_value.clone(),
subject: a.subject.clone(),
course_number: a.course_number.clone(),
crn: a.crn.clone(),
course_title: a.title.clone(),
})
.collect();
let mut resp = Json(json!({ "entries": entries })).into_response();
let mut resp = Json(AuditLogResponse { entries }).into_response();
if let Some(latest_ts) = latest
&& let Ok(val) = to_http_date(&latest_ts).parse()
{
+19 -10
View File
@@ -14,25 +14,34 @@ use crate::web::extractors::AdminUser;
// Query / body types
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ListInstructorsParams {
status: Option<String>,
search: Option<String>,
page: Option<i32>,
per_page: Option<i32>,
sort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub per_page: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
}
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MatchBody {
rmp_legacy_id: i32,
pub rmp_legacy_id: i32,
}
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RejectCandidateBody {
rmp_legacy_id: i32,
pub rmp_legacy_id: i32,
}
// ---------------------------------------------------------------------------
+22 -7
View File
@@ -77,10 +77,12 @@ fn default_bucket_for_period(period: &str) -> &'static str {
// Endpoint 1: GET /api/admin/scraper/stats
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct StatsParams {
#[serde(default = "default_period")]
period: String,
pub period: String,
}
fn default_period() -> String {
@@ -195,11 +197,14 @@ pub async fn scraper_stats(
// Endpoint 2: GET /api/admin/scraper/timeseries
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimeseriesParams {
#[serde(default = "default_period")]
period: String,
bucket: Option<String>,
pub period: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
}
#[derive(Serialize, TS)]
@@ -215,6 +220,8 @@ pub struct TimeseriesResponse {
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct TimeseriesPoint {
/// ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z")
#[ts(type = "string")]
timestamp: DateTime<Utc>,
#[ts(type = "number")]
scrape_count: i64,
@@ -328,7 +335,11 @@ pub struct SubjectSummary {
#[ts(type = "number")]
current_interval_secs: u64,
time_multiplier: u32,
/// ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z")
#[ts(type = "string")]
last_scraped: DateTime<Utc>,
/// ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z")
#[ts(type = "string | null")]
next_eligible_at: Option<DateTime<Utc>>,
#[ts(type = "number | null")]
cooldown_remaining_secs: Option<u64>,
@@ -439,10 +450,12 @@ pub async fn scraper_subjects(
// Endpoint 4: GET /api/admin/scraper/subjects/{subject}
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SubjectDetailParams {
#[serde(default = "default_detail_limit")]
limit: i32,
pub limit: i32,
}
fn default_detail_limit() -> i32 {
@@ -463,6 +476,8 @@ pub struct SubjectDetailResponse {
pub struct SubjectResultEntry {
#[ts(type = "number")]
id: i64,
/// ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z")
#[ts(type = "string")]
completed_at: DateTime<Utc>,
duration_ms: i32,
success: bool,
+1 -10
View File
@@ -112,16 +112,7 @@ pub async fn course_gcal(
// Prefer the first meeting time that has actual days/times scheduled
let mt = meeting_times
.iter()
.find(|mt| {
mt.begin_time.is_some()
&& (mt.monday
|| mt.tuesday
|| mt.wednesday
|| mt.thursday
|| mt.friday
|| mt.saturday
|| mt.sunday)
})
.find(|mt| mt.time_range.is_some() && !mt.days.is_empty())
.unwrap_or(&meeting_times[0]);
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
+151
View File
@@ -0,0 +1,151 @@
//! Delivery mode classification for course sections.
//!
//! Moves the delivery concern logic (previously in the TypeScript frontend)
//! to the backend so it ships as part of the API response.
use crate::data::models::DbMeetingTime;
use serde::Serialize;
use ts_rs::TS;
/// Banner instructional method codes for fully-online delivery.
const ONLINE_METHODS: &[&str] = &["OA", "OS", "OH"];
/// Banner instructional method codes for hybrid delivery.
const HYBRID_METHODS: &[&str] = &["HB", "H1", "H2"];
/// Banner campus code for the main (San Antonio) campus.
const MAIN_CAMPUS: &str = "11";
/// Banner campus codes that represent online/virtual campuses.
const ONLINE_CAMPUSES: &[&str] = &["9", "ONL"];
/// Delivery mode classification for visual accents on location cells.
///
/// `None` means normal in-person on the main campus (no accent needed).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export)]
pub enum DeliveryMode {
/// Fully online with no physical location (OA, OS, OH without INT building).
Online,
/// Internet campus with INT building code.
Internet,
/// Mix of online and in-person (HB, H1, H2).
Hybrid,
/// In-person but not on Main Campus.
OffCampus,
}
/// Classify the delivery mode for a course section.
///
/// Returns `None` for normal in-person sections on the main campus.
pub fn classify_delivery_mode(
instructional_method: Option<&str>,
campus: Option<&str>,
meeting_times: &[DbMeetingTime],
) -> Option<DeliveryMode> {
if let Some(method) = instructional_method {
if ONLINE_METHODS.contains(&method) {
let has_int_building = meeting_times.iter().any(|mt| {
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
});
return Some(if has_int_building {
DeliveryMode::Internet
} else {
DeliveryMode::Online
});
}
if HYBRID_METHODS.contains(&method) {
return Some(DeliveryMode::Hybrid);
}
}
if let Some(campus) = campus
&& campus != MAIN_CAMPUS
&& !ONLINE_CAMPUSES.contains(&campus)
{
return Some(DeliveryMode::OffCampus);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::course_types::{DateRange, MeetingLocation};
use chrono::NaiveDate;
use std::collections::BTreeSet;
fn make_mt(building: Option<&str>) -> DbMeetingTime {
DbMeetingTime {
time_range: None,
date_range: DateRange {
start: NaiveDate::from_ymd_opt(2024, 8, 26).unwrap(),
end: NaiveDate::from_ymd_opt(2024, 12, 12).unwrap(),
},
days: BTreeSet::new(),
location: building.map(|b| MeetingLocation {
building: Some(b.to_string()),
building_description: None,
room: None,
campus: None,
}),
meeting_type: "CLAS".to_string(),
meeting_schedule_type: "LEC".to_string(),
}
}
#[test]
fn online_without_int_building() {
for method in &["OA", "OS", "OH"] {
assert_eq!(
classify_delivery_mode(Some(method), Some("9"), &[make_mt(None)]),
Some(DeliveryMode::Online),
);
}
}
#[test]
fn online_with_int_building() {
assert_eq!(
classify_delivery_mode(Some("OA"), Some("9"), &[make_mt(Some("INT"))]),
Some(DeliveryMode::Internet),
);
}
#[test]
fn hybrid_methods() {
for method in &["HB", "H1", "H2"] {
assert_eq!(
classify_delivery_mode(Some(method), Some("11"), &[]),
Some(DeliveryMode::Hybrid),
);
}
}
#[test]
fn off_campus() {
assert_eq!(
classify_delivery_mode(None, Some("22"), &[]),
Some(DeliveryMode::OffCampus),
);
}
#[test]
fn main_campus_in_person() {
assert_eq!(classify_delivery_mode(None, Some("11"), &[]), None);
}
#[test]
fn online_campus_no_method_is_normal() {
// Campus 9 or ONL without an online method → None (no accent)
assert_eq!(classify_delivery_mode(None, Some("9"), &[]), None);
assert_eq!(classify_delivery_mode(None, Some("ONL"), &[]), None);
}
#[test]
fn no_method_no_campus() {
assert_eq!(classify_delivery_mode(None, None, &[]), None);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Standardized API error responses.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use ts_rs::TS;
/// Machine-readable error code for API responses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[ts(export)]
pub enum ApiErrorCode {
NotFound,
BadRequest,
InternalError,
InvalidTerm,
InvalidRange,
Unauthorized,
Forbidden,
NoTerms,
}
/// Standardized error response for all API endpoints.
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ApiError {
/// Machine-readable error code
pub code: ApiErrorCode,
/// Human-readable error message
pub message: String,
/// Optional additional details (validation errors, field info, etc.)
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl ApiError {
pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
details: None,
}
}
#[allow(dead_code)]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::NotFound, message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::BadRequest, message)
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::InternalError, message)
}
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
Self::new(ApiErrorCode::InvalidTerm, format!("Invalid term: {}", term))
}
fn status_code(&self) -> StatusCode {
match self.code {
ApiErrorCode::NotFound => StatusCode::NOT_FOUND,
ApiErrorCode::BadRequest
| ApiErrorCode::InvalidTerm
| ApiErrorCode::InvalidRange
| ApiErrorCode::NoTerms => StatusCode::BAD_REQUEST,
ApiErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
ApiErrorCode::Forbidden => StatusCode::FORBIDDEN,
ApiErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status_code();
(status, Json(self)).into_response()
}
}
/// Convert `(StatusCode, String)` tuple errors to ApiError
impl From<(StatusCode, String)> for ApiError {
fn from((status, message): (StatusCode, String)) -> Self {
let code = match status {
StatusCode::NOT_FOUND => ApiErrorCode::NotFound,
StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest,
StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized,
StatusCode::FORBIDDEN => ApiErrorCode::Forbidden,
_ => ApiErrorCode::InternalError,
};
Self::new(code, message)
}
}
/// Helper for converting database errors to ApiError
pub fn db_error(context: &str, error: anyhow::Error) -> ApiError {
tracing::error!(error = %error, context = context, "Database error");
ApiError::internal_error(format!("{} failed", context))
}
+2
View File
@@ -7,8 +7,10 @@ pub mod admin_scraper;
pub mod assets;
pub mod auth;
pub mod calendar;
pub mod delivery;
#[cfg(feature = "embed-assets")]
pub mod encoding;
pub mod error;
pub mod extractors;
pub mod routes;
pub mod schedule_cache;
+421 -202
View File
@@ -4,14 +4,16 @@ use axum::{
Extension, Router,
body::Body,
extract::{Path, Query, Request, State},
http::StatusCode as AxumStatusCode,
response::{Json, Response},
routing::{get, post, put},
};
use crate::data::course_types::{CreditHours, CrossList, Enrollment, RmpRating, SectionLink};
use crate::web::admin_scraper;
use crate::web::auth::{self, AuthConfig};
use crate::web::calendar;
use crate::web::delivery::{DeliveryMode, classify_delivery_mode};
use crate::web::error::{ApiError, ApiErrorCode, db_error};
use crate::web::timeline;
use crate::web::ws;
use crate::{data, web::admin};
@@ -52,9 +54,8 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
get(calendar::course_ics),
)
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
.route("/terms", get(get_terms))
.route("/subjects", get(get_subjects))
.route("/reference/{category}", get(get_reference))
.route("/search-options", get(get_search_options))
.route("/timeline", post(timeline::timeline))
.with_state(app_state.clone());
@@ -291,7 +292,7 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
async fn metrics(
State(state): State<AppState>,
Query(params): Query<MetricsParams>,
) -> Result<Json<Value>, (AxumStatusCode, String)> {
) -> Result<Json<MetricsResponse>, ApiError> {
let limit = params.limit.clamp(1, 5000);
// Parse range shorthand, defaulting to 24h
@@ -303,8 +304,8 @@ async fn metrics(
"7d" => chrono::Duration::days(7),
"30d" => chrono::Duration::days(30),
_ => {
return Err((
AxumStatusCode::BAD_REQUEST,
return Err(ApiError::new(
ApiErrorCode::InvalidRange,
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
));
}
@@ -321,13 +322,7 @@ async fn metrics(
.bind(crn)
.fetch_optional(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Course lookup for metrics failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Course lookup failed".to_string(),
)
})?;
.map_err(|e| db_error("Course lookup for metrics", e.into()))?;
row.map(|(id,)| id)
} else {
None
@@ -361,80 +356,120 @@ async fn metrics(
.fetch_all(&state.db_pool)
.await
}
.map_err(|e| {
tracing::error!(error = %e, "Metrics query failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Metrics query failed".to_string(),
)
})?;
.map_err(|e| db_error("Metrics query", e.into()))?;
let count = metrics.len();
let metrics_json: Vec<Value> = metrics
let metrics_entries: Vec<MetricEntry> = metrics
.into_iter()
.map(
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| {
json!({
"id": id,
"courseId": course_id,
"timestamp": timestamp.to_rfc3339(),
"enrollment": enrollment,
"waitCount": wait_count,
"seatsAvailable": seats_available,
})
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| MetricEntry {
id,
course_id,
timestamp: timestamp.to_rfc3339(),
enrollment,
wait_count,
seats_available,
},
)
.collect();
Ok(Json(json!({
"metrics": metrics_json,
"count": count,
"timestamp": chrono::Utc::now().to_rfc3339(),
})))
Ok(Json(MetricsResponse {
metrics: metrics_entries,
count,
timestamp: chrono::Utc::now().to_rfc3339(),
}))
}
// ============================================================
// Course search & detail API
// ============================================================
#[derive(Deserialize)]
struct MetricsParams {
course_id: Option<i32>,
term: Option<String>,
crn: Option<String>,
/// Shorthand durations: "1h", "6h", "24h", "7d", "30d"
range: Option<String>,
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MetricEntry {
pub id: i32,
pub course_id: i32,
pub timestamp: String,
pub enrollment: i32,
pub wait_count: i32,
pub seats_available: i32,
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MetricsResponse {
pub metrics: Vec<MetricEntry>,
pub count: usize,
pub timestamp: String,
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct MetricsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub course_id: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub term: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crn: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range: Option<String>,
#[serde(default = "default_metrics_limit")]
limit: i32,
pub limit: i32,
}
fn default_metrics_limit() -> i32 {
500
}
#[derive(Deserialize)]
struct SubjectsParams {
term: String,
}
#[derive(Deserialize)]
struct SearchParams {
term: String,
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SearchParams {
pub term: String,
#[serde(default)]
subject: Vec<String>,
q: Option<String>,
course_number_low: Option<i32>,
course_number_high: Option<i32>,
pub subject: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_low")]
pub course_number_low: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_high")]
pub course_number_high: Option<i32>,
#[serde(default, alias = "open_only")]
pub open_only: bool,
#[serde(default, alias = "instructional_method")]
pub instructional_method: Vec<String>,
#[serde(default)]
open_only: bool,
instructional_method: Option<String>,
campus: Option<String>,
pub campus: Vec<String>,
#[serde(default = "default_limit")]
limit: i32,
pub limit: i32,
#[serde(default)]
offset: i32,
sort_by: Option<SortColumn>,
sort_dir: Option<SortDirection>,
pub offset: i32,
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_by")]
pub sort_by: Option<SortColumn>,
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_dir")]
pub sort_dir: Option<SortDirection>,
#[serde(skip_serializing_if = "Option::is_none", alias = "wait_count_max")]
pub wait_count_max: Option<i32>,
#[serde(default)]
pub days: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "time_start")]
pub time_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "time_end")]
pub time_end: Option<String>,
#[serde(default, alias = "part_of_term")]
pub part_of_term: Vec<String>,
#[serde(default)]
pub attributes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_min")]
pub credit_hour_min: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_max")]
pub credit_hour_max: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructor: Option<String>,
}
use crate::data::courses::{SortColumn, SortDirection};
@@ -455,21 +490,20 @@ pub struct CourseResponse {
sequence_number: Option<String>,
instructional_method: Option<String>,
campus: Option<String>,
enrollment: i32,
max_enrollment: i32,
wait_count: i32,
wait_capacity: i32,
credit_hours: Option<i32>,
credit_hour_low: Option<i32>,
credit_hour_high: Option<i32>,
cross_list: Option<String>,
cross_list_capacity: Option<i32>,
cross_list_count: Option<i32>,
link_identifier: Option<String>,
is_section_linked: Option<bool>,
enrollment: Enrollment,
credit_hours: Option<CreditHours>,
cross_list: Option<CrossList>,
section_link: Option<SectionLink>,
part_of_term: Option<String>,
meeting_times: Vec<models::DbMeetingTime>,
attributes: Vec<String>,
is_async_online: bool,
delivery_mode: Option<DeliveryMode>,
/// Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
primary_location: Option<String>,
/// Whether a physical (non-INT) building was found in meeting times.
has_physical_location: bool,
primary_instructor_id: Option<i32>,
instructors: Vec<InstructorResponse>,
}
@@ -480,11 +514,11 @@ pub struct InstructorResponse {
instructor_id: i32,
banner_id: String,
display_name: String,
first_name: Option<String>,
last_name: Option<String>,
email: String,
is_primary: bool,
rmp_rating: Option<f32>,
rmp_num_ratings: Option<i32>,
rmp_legacy_id: Option<i32>,
rmp: Option<RmpRating>,
}
#[derive(Serialize, TS)]
@@ -493,11 +527,9 @@ pub struct InstructorResponse {
pub struct SearchResponse {
courses: Vec<CourseResponse>,
total_count: i32,
offset: i32,
limit: i32,
}
#[derive(Serialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CodeDescription {
@@ -505,7 +537,7 @@ pub struct CodeDescription {
description: String,
}
#[derive(Serialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TermResponse {
@@ -514,25 +546,169 @@ pub struct TermResponse {
description: String,
}
/// Response for the consolidated search-options endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SearchOptionsResponse {
pub terms: Vec<TermResponse>,
pub subjects: Vec<CodeDescription>,
pub reference: SearchOptionsReference,
pub ranges: data::courses::FilterRanges,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SearchOptionsReference {
pub instructional_methods: Vec<CodeDescription>,
pub campuses: Vec<CodeDescription>,
pub parts_of_term: Vec<CodeDescription>,
pub attributes: Vec<CodeDescription>,
}
#[derive(Debug, Deserialize)]
pub struct SearchOptionsParams {
pub term: Option<String>,
}
/// Minimum number of ratings needed to consider RMP data reliable.
const RMP_CONFIDENCE_THRESHOLD: i32 = 7;
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
fn build_course_response(
course: &models::Course,
instructors: Vec<models::CourseInstructorDetail>,
) -> CourseResponse {
let instructors = instructors
let instructors: Vec<InstructorResponse> = instructors
.into_iter()
.map(|i| InstructorResponse {
instructor_id: i.instructor_id,
banner_id: i.banner_id,
display_name: i.display_name,
email: i.email,
is_primary: i.is_primary,
rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings,
rmp_legacy_id: i.rmp_legacy_id,
.map(|i| {
// Filter out the (0.0, 0) sentinel — treat as unrated
let has_rating =
i.avg_rating.is_some_and(|r| r != 0.0) || i.num_ratings.is_some_and(|n| n != 0);
let rmp = if has_rating {
match (i.avg_rating, i.num_ratings, i.rmp_legacy_id) {
(Some(avg_rating), Some(num_ratings), Some(legacy_id)) => Some(RmpRating {
avg_rating,
num_ratings,
legacy_id,
is_confident: num_ratings >= RMP_CONFIDENCE_THRESHOLD,
}),
_ => None,
}
} else {
None
};
InstructorResponse {
instructor_id: i.instructor_id,
banner_id: i.banner_id,
display_name: i.display_name,
first_name: i.first_name,
last_name: i.last_name,
email: i.email,
is_primary: i.is_primary,
rmp,
}
})
.collect();
// Primary = first with is_primary flag, or fall back to first instructor
let primary_instructor_id = instructors
.iter()
.find(|i| i.is_primary)
.or(instructors.first())
.map(|i| i.instructor_id);
let meeting_times: Vec<models::DbMeetingTime> =
serde_json::from_value(course.meeting_times.clone())
.map_err(|e| {
tracing::error!(
course_id = course.id,
crn = %course.crn,
term = %course.term_code,
error = %e,
"Failed to deserialize meeting_times JSONB"
);
e
})
.unwrap_or_default();
let attributes = serde_json::from_value(course.attributes.clone())
.map_err(|e| {
tracing::error!(
course_id = course.id,
crn = %course.crn,
term = %course.term_code,
error = %e,
"Failed to deserialize attributes JSONB"
);
e
})
.unwrap_or_default();
let is_async_online = meeting_times.first().is_some_and(|mt| {
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
&& mt.is_time_tba()
});
let delivery_mode = classify_delivery_mode(
course.instructional_method.as_deref(),
course.campus.as_deref(),
&meeting_times,
);
// Compute primary_location: first non-INT building+room, else "Online" or campus fallback
let physical_location = meeting_times
.iter()
.filter(|mt| mt.location.as_ref().and_then(|loc| loc.building.as_deref()) != Some("INT"))
.find_map(|mt| {
mt.location.as_ref().and_then(|loc| {
loc.building.as_ref().map(|b| match &loc.room {
Some(r) => format!("{b} {r}"),
None => b.clone(),
})
})
});
let has_physical_location = physical_location.is_some();
let primary_location = physical_location.or_else(|| match delivery_mode {
Some(DeliveryMode::Online | DeliveryMode::Internet) => Some("Online".to_string()),
_ => None,
});
let enrollment = Enrollment {
current: course.enrollment,
max: course.max_enrollment,
wait_count: course.wait_count,
wait_capacity: course.wait_capacity,
};
let credit_hours = match (
course.credit_hours,
course.credit_hour_low,
course.credit_hour_high,
) {
(Some(fixed), _, _) => Some(CreditHours::Fixed { hours: fixed }),
(None, Some(low), Some(high)) if low != high => Some(CreditHours::Range { low, high }),
(None, Some(hours), None) | (None, None, Some(hours)) => Some(CreditHours::Fixed { hours }),
_ => None,
};
let cross_list = course.cross_list.as_ref().and_then(|identifier| {
course.cross_list_capacity.and_then(|capacity| {
course.cross_list_count.map(|count| CrossList {
identifier: identifier.clone(),
capacity,
count,
})
})
});
let section_link = course
.link_identifier
.clone()
.map(|identifier| SectionLink { identifier });
CourseResponse {
crn: course.crn.clone(),
subject: course.subject.clone(),
@@ -542,21 +718,18 @@ fn build_course_response(
sequence_number: course.sequence_number.clone(),
instructional_method: course.instructional_method.clone(),
campus: course.campus.clone(),
enrollment: course.enrollment,
max_enrollment: course.max_enrollment,
wait_count: course.wait_count,
wait_capacity: course.wait_capacity,
credit_hours: course.credit_hours,
credit_hour_low: course.credit_hour_low,
credit_hour_high: course.credit_hour_high,
cross_list: course.cross_list.clone(),
cross_list_capacity: course.cross_list_capacity,
cross_list_count: course.cross_list_count,
link_identifier: course.link_identifier.clone(),
is_section_linked: course.is_section_linked,
enrollment,
credit_hours,
cross_list,
section_link,
part_of_term: course.part_of_term.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(),
is_async_online,
delivery_mode,
primary_location,
has_physical_location,
primary_instructor_id,
meeting_times,
attributes,
instructors,
}
}
@@ -565,15 +738,11 @@ fn build_course_response(
async fn search_courses(
State(state): State<AppState>,
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
) -> Result<Json<SearchResponse>, ApiError> {
use crate::banner::models::terms::Term;
let term_code = Term::resolve_to_code(&params.term).ok_or_else(|| {
(
AxumStatusCode::BAD_REQUEST,
format!("Invalid term: {}", params.term),
)
})?;
let term_code =
Term::resolve_to_code(&params.term).ok_or_else(|| ApiError::invalid_term(&params.term))?;
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
@@ -589,21 +758,44 @@ async fn search_courses(
params.course_number_low,
params.course_number_high,
params.open_only,
params.instructional_method.as_deref(),
params.campus.as_deref(),
if params.instructional_method.is_empty() {
None
} else {
Some(&params.instructional_method)
},
if params.campus.is_empty() {
None
} else {
Some(&params.campus)
},
params.wait_count_max,
if params.days.is_empty() {
None
} else {
Some(&params.days)
},
params.time_start.as_deref(),
params.time_end.as_deref(),
if params.part_of_term.is_empty() {
None
} else {
Some(&params.part_of_term)
},
if params.attributes.is_empty() {
None
} else {
Some(&params.attributes)
},
params.credit_hour_min,
params.credit_hour_max,
params.instructor.as_deref(),
limit,
offset,
params.sort_by,
params.sort_dir,
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Course search failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Search failed".to_string(),
)
})?;
.map_err(|e| db_error("Course search", e))?;
// Batch-fetch all instructors in a single query instead of N+1
let course_ids: Vec<i32> = courses.iter().map(|c| c.id).collect();
@@ -623,8 +815,6 @@ async fn search_courses(
Ok(Json(SearchResponse {
courses: course_responses,
total_count: total_count as i32,
offset,
limit,
}))
}
@@ -632,17 +822,11 @@ async fn search_courses(
async fn get_course(
State(state): State<AppState>,
Path((term, crn)): Path<(String, String)>,
) -> Result<Json<CourseResponse>, (AxumStatusCode, String)> {
) -> Result<Json<CourseResponse>, ApiError> {
let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
.await
.map_err(|e| {
tracing::error!(error = %e, "Course lookup failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Lookup failed".to_string(),
)
})?
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
.map_err(|e| db_error("Course lookup", e))?
.ok_or_else(|| ApiError::not_found("Course not found"))?;
let instructors = data::courses::get_course_instructors(&state.db_pool, course.id)
.await
@@ -650,73 +834,11 @@ async fn get_course(
Ok(Json(build_course_response(&course, instructors)))
}
/// `GET /api/terms`
async fn get_terms(
State(state): State<AppState>,
) -> Result<Json<Vec<TermResponse>>, (AxumStatusCode, String)> {
use crate::banner::models::terms::Term;
let term_codes = data::courses::get_available_terms(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to get terms");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Failed to get terms".to_string(),
)
})?;
let terms: Vec<TermResponse> = term_codes
.into_iter()
.filter_map(|code| {
let term: Term = code.parse().ok()?;
Some(TermResponse {
code,
slug: term.slug(),
description: term.description(),
})
})
.collect();
Ok(Json(terms))
}
/// `GET /api/subjects?term=202620`
async fn get_subjects(
State(state): State<AppState>,
Query(params): Query<SubjectsParams>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
use crate::banner::models::terms::Term;
let term_code = Term::resolve_to_code(&params.term).ok_or_else(|| {
(
AxumStatusCode::BAD_REQUEST,
format!("Invalid term: {}", params.term),
)
})?;
let rows = data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to get subjects");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Failed to get subjects".to_string(),
)
})?;
let subjects: Vec<CodeDescription> = rows
.into_iter()
.map(|(code, description, _enrollment)| CodeDescription { code, description })
.collect();
Ok(Json(subjects))
}
/// `GET /api/reference/:category`
async fn get_reference(
State(state): State<AppState>,
Path(category): Path<String>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
) -> Result<Json<Vec<CodeDescription>>, ApiError> {
let cache = state.reference_cache.read().await;
let entries = cache.entries_for_category(&category);
@@ -725,13 +847,7 @@ async fn get_reference(
drop(cache);
let rows = data::reference::get_by_category(&category, &state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, category = %category, "Reference lookup failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Lookup failed".to_string(),
)
})?;
.map_err(|e| db_error(&format!("Reference lookup for {}", category), e))?;
return Ok(Json(
rows.into_iter()
@@ -753,3 +869,106 @@ async fn get_reference(
.collect(),
))
}
/// `GET /api/search-options?term={slug}` (term optional, defaults to latest)
async fn get_search_options(
State(state): State<AppState>,
Query(params): Query<SearchOptionsParams>,
) -> Result<Json<SearchOptionsResponse>, ApiError> {
use crate::banner::models::terms::Term;
use std::time::Instant;
// If no term specified, get the latest term
let term_slug = if let Some(ref t) = params.term {
t.clone()
} else {
// Fetch available terms to get the default (latest)
let term_codes = data::courses::get_available_terms(&state.db_pool)
.await
.map_err(|e| db_error("Get terms for default", e))?;
let first_term: Term = term_codes
.first()
.and_then(|code| code.parse().ok())
.ok_or_else(|| ApiError::new(ApiErrorCode::NoTerms, "No terms available"))?;
first_term.slug()
};
let term_code =
Term::resolve_to_code(&term_slug).ok_or_else(|| ApiError::invalid_term(&term_slug))?;
// Check cache (10-minute TTL)
if let Some(entry) = state.search_options_cache.get(&term_code) {
let (cached_at, ref cached_value) = *entry;
if cached_at.elapsed() < Duration::from_secs(600) {
let response: SearchOptionsResponse = serde_json::from_value(cached_value.clone())
.map_err(|e| {
ApiError::internal_error(format!("Cache deserialization error: {e}"))
})?;
return Ok(Json(response));
}
}
// Fetch all data in parallel
let (term_codes, subject_rows, ranges) = tokio::try_join!(
data::courses::get_available_terms(&state.db_pool),
data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code),
data::courses::get_filter_ranges(&state.db_pool, &term_code),
)
.map_err(|e| db_error("Search options", e))?;
// Build terms
let terms: Vec<TermResponse> = term_codes
.into_iter()
.filter_map(|code| {
let term: Term = code.parse().ok()?;
Some(TermResponse {
code,
slug: term.slug(),
description: term.description(),
})
})
.collect();
// Build subjects
let subjects: Vec<CodeDescription> = subject_rows
.into_iter()
.map(|(code, description, _enrollment)| CodeDescription { code, description })
.collect();
// Build reference data from in-memory cache
let ref_cache = state.reference_cache.read().await;
let build_ref = |category: &str| -> Vec<CodeDescription> {
ref_cache
.entries_for_category(category)
.into_iter()
.map(|(code, desc)| CodeDescription {
code: code.to_string(),
description: desc.to_string(),
})
.collect()
};
let reference = SearchOptionsReference {
instructional_methods: build_ref("instructional_method"),
campuses: build_ref("campus"),
parts_of_term: build_ref("part_of_term"),
attributes: build_ref("attribute"),
};
let response = SearchOptionsResponse {
terms,
subjects,
reference,
ranges,
};
// Cache the response
let cached_value = serde_json::to_value(&response).unwrap_or_default();
state
.search_options_cache
.insert(term_code, (Instant::now(), cached_value));
Ok(Json(response))
}
+4 -2
View File
@@ -238,9 +238,11 @@ fn parse_hhmm(s: &str) -> Option<u16> {
Some(hours * 60 + mins)
}
/// Parse "MM/DD/YYYY" → NaiveDate.
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
NaiveDate::parse_from_str(s, "%m/%d/%Y")
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
.ok()
}
// ── Slot matching ───────────────────────────────────────────────────
+23 -42
View File
@@ -9,11 +9,7 @@
//! [`ScheduleCache`]) that refreshes hourly in the background with
//! stale-while-revalidate semantics.
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use axum::{extract::State, response::Json};
use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc};
use chrono_tz::US::Central;
use serde::{Deserialize, Serialize};
@@ -21,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet};
use ts_rs::TS;
use crate::state::AppState;
use crate::web::error::ApiError;
use crate::web::schedule_cache::weekday_bit;
/// 15 minutes in seconds, matching the frontend `SLOT_INTERVAL_MS`.
@@ -38,14 +35,22 @@ const MAX_TOTAL_SPAN: Duration = Duration::hours(168); // 1 week
// ── Request / Response types ────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub(crate) struct TimelineRequest {
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimelineRequest {
ranges: Vec<TimeRange>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct TimeRange {
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimeRange {
/// ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z")
#[ts(type = "string")]
start: DateTime<Utc>,
/// ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z")
#[ts(type = "string")]
end: DateTime<Utc>,
}
@@ -63,38 +68,14 @@ pub struct TimelineResponse {
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimelineSlot {
/// ISO-8601 timestamp at the start of this 15-minute bucket.
/// ISO-8601 UTC timestamp at the start of this 15-minute bucket (e.g., "2024-01-15T10:30:00Z")
#[ts(type = "string")]
time: DateTime<Utc>,
/// Subject code → total enrollment in this slot.
#[ts(type = "Record<string, number>")]
subjects: BTreeMap<String, i64>,
}
// ── Error type ──────────────────────────────────────────────────────
pub(crate) struct TimelineError {
status: StatusCode,
message: String,
}
impl TimelineError {
fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
}
impl IntoResponse for TimelineError {
fn into_response(self) -> Response {
(
self.status,
Json(serde_json::json!({ "error": self.message })),
)
.into_response()
}
}
// ── Alignment helpers ───────────────────────────────────────────────
/// Floor a timestamp to the nearest 15-minute boundary.
@@ -161,13 +142,13 @@ fn generate_slots(merged: &[AlignedRange]) -> BTreeSet<DateTime<Utc>> {
pub(crate) async fn timeline(
State(state): State<AppState>,
Json(body): Json<TimelineRequest>,
) -> Result<Json<TimelineResponse>, TimelineError> {
) -> Result<Json<TimelineResponse>, ApiError> {
// ── Validate ────────────────────────────────────────────────────
if body.ranges.is_empty() {
return Err(TimelineError::bad_request("At least one range is required"));
return Err(ApiError::bad_request("At least one range is required"));
}
if body.ranges.len() > MAX_RANGES {
return Err(TimelineError::bad_request(format!(
return Err(ApiError::bad_request(format!(
"Too many ranges (max {MAX_RANGES})"
)));
}
@@ -175,14 +156,14 @@ pub(crate) async fn timeline(
let mut aligned: Vec<AlignedRange> = Vec::with_capacity(body.ranges.len());
for r in &body.ranges {
if r.end <= r.start {
return Err(TimelineError::bad_request(format!(
return Err(ApiError::bad_request(format!(
"Range end ({}) must be after start ({})",
r.end, r.start
)));
}
let span = r.end - r.start;
if span > MAX_RANGE_SPAN {
return Err(TimelineError::bad_request(format!(
return Err(ApiError::bad_request(format!(
"Range span ({} hours) exceeds maximum ({} hours)",
span.num_hours(),
MAX_RANGE_SPAN.num_hours()
@@ -199,7 +180,7 @@ pub(crate) async fn timeline(
// Validate total span
let total_span: Duration = merged.iter().map(|r| r.end - r.start).sum();
if total_span > MAX_TOTAL_SPAN {
return Err(TimelineError::bad_request(format!(
return Err(ApiError::bad_request(format!(
"Total time span ({} hours) exceeds maximum ({} hours)",
total_span.num_hours(),
MAX_TOTAL_SPAN.num_hours()
+8 -2
View File
@@ -12,14 +12,16 @@ use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tracing::debug;
use ts_rs::TS;
use crate::data::models::{ScrapeJob, ScrapeJobStatus};
use crate::state::AppState;
use crate::web::extractors::AdminUser;
/// A serializable DTO for `ScrapeJob` with computed `status`.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ScrapeJobDto {
pub id: i32,
pub target_type: String,
@@ -53,8 +55,9 @@ impl From<&ScrapeJob> for ScrapeJobDto {
}
/// Events broadcast when scrape job state changes.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
pub enum ScrapeJobEvent {
Init {
jobs: Vec<ScrapeJobDto>,
@@ -64,6 +67,7 @@ pub enum ScrapeJobEvent {
},
JobLocked {
id: i32,
#[serde(rename = "lockedAt")]
locked_at: String,
status: ScrapeJobStatus,
},
@@ -72,7 +76,9 @@ pub enum ScrapeJobEvent {
},
JobRetried {
id: i32,
#[serde(rename = "retryCount")]
retry_count: i32,
#[serde(rename = "queuedAt")]
queued_at: String,
status: ScrapeJobStatus,
},
+106
View File
@@ -0,0 +1,106 @@
//! Test course search with alphanumeric course numbers (e.g., "015X", "399H").
mod helpers;
use banner::data::batch::batch_upsert_courses;
use banner::data::courses::search_courses;
use helpers::make_course;
#[sqlx::test]
async fn test_search_alphanumeric_course_numbers(pool: sqlx::PgPool) {
let term = "202620";
// Insert courses with both numeric and alphanumeric course numbers
let courses = vec![
make_course("10001", term, "CS", "0100", "Intro to CS", 20, 30, 0, 10),
make_course("10002", term, "CS", "015X", "Special Topics", 15, 25, 0, 5),
make_course(
"10003",
term,
"CS",
"0200",
"Data Structures",
25,
30,
0,
10,
),
make_course("10004", term, "CS", "0399", "Advanced Topics", 18, 25, 0, 5),
make_course("10005", term, "CS", "399H", "Honors Course", 12, 20, 0, 5),
make_course(
"10006",
term,
"CS",
"5500",
"Graduate Seminar",
10,
15,
0,
3,
),
];
batch_upsert_courses(&courses, &pool)
.await
.expect("Failed to insert test courses");
// Test: Search for course numbers 100-5500 (should include alphanumeric)
let (results, _total) = search_courses(
&pool,
term,
None, // subject
None, // title_query
Some(100), // course_number_low
Some(5500), // course_number_high
false, // open_only
None, // instructional_method
None, // campus
None, // wait_count_max
None, // days
None, // time_start
None, // time_end
None, // part_of_term
None, // attributes
None, // credit_hour_min
None, // credit_hour_max
None, // instructor
100, // limit
0, // offset
None, // sort_by
None, // sort_dir
)
.await
.expect("Search failed");
// Should include:
// - 0100 (100 >= 100)
// - 0200 (200 in range)
// - 0399 (399 in range)
// - 399H (numeric prefix 399 in range)
// - 5500 (5500 <= 5500)
//
// Should exclude:
// - 015X (numeric prefix 15 < 100)
let crns: Vec<&str> = results.iter().map(|c| c.crn.as_str()).collect();
assert_eq!(
results.len(),
5,
"Expected 5 courses in range, got {}: {:?}",
results.len(),
crns
);
assert!(crns.contains(&"10001"), "Should include CS 0100");
assert!(crns.contains(&"10003"), "Should include CS 0200");
assert!(crns.contains(&"10004"), "Should include CS 0399");
assert!(
crns.contains(&"10005"),
"Should include CS 399H (numeric prefix 399)"
);
assert!(crns.contains(&"10006"), "Should include CS 5500");
assert!(
!crns.contains(&"10002"),
"Should exclude CS 015X (numeric prefix 15 < 100)"
);
}
+22 -15
View File
@@ -5,6 +5,8 @@
"": {
"name": "banner-web",
"dependencies": {
"@floating-ui/dom": "^1.7.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@icons-pack/svelte-simple-icons": "^6.5.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
@@ -16,27 +18,28 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@fontsource-variable/inter": "^5.2.5",
"@lucide/svelte": "^0.563.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@lucide/svelte": "^0.563.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/table-core": "^8.21.3",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/d3-time-format": "^4.0.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"bits-ui": "^1.8.0",
"clsx": "^2.1.1",
"jsdom": "^26.0.0",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vitest": "^3.0.5",
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^3.2.4",
},
},
},
@@ -137,6 +140,8 @@
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
"@icons-pack/svelte-simple-icons": ["@icons-pack/svelte-simple-icons@6.5.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.5.0", "svelte": "^4.2.0 || ^5.0.0" } }, "sha512-Xj3PTioiV3TJ1NTKsXY95NFG8FUqw90oeyDZIlslWHs1KkuCheu1HOPrlHb0/IM0b4cldPgx/0TldzxzBlM8Cw=="],
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
@@ -637,6 +642,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-range-slider-pips": ["svelte-range-slider-pips@4.1.0", "", { "peerDependencies": { "svelte": "^4.2.7 || ^5.0.0" } }, "sha512-2Zw7MngIuPeqdyJ3ueEp7jPSx0hce+Sx8r1eteCeUPxEWlNavKhBtqJyuoAdpvh5csPPFVZJ4TJ4MX9s4G70uw=="],
"svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+4 -1
View File
@@ -8,7 +8,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "biome check .",
"lint": "biome lint .",
"test": "vitest run",
"format": "biome format --write .",
"format:check": "biome format ."
@@ -31,6 +31,7 @@
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
@@ -38,6 +39,8 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@floating-ui/dom": "^1.7.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@icons-pack/svelte-simple-icons": "^6.5.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { formatMeetingTimeSummary } from "$lib/course";
import type { CourseResponse, DbMeetingTime } from "$lib/api";
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
const mt: DbMeetingTime = {
timeRange: null,
dateRange: { start: "2025-01-13", end: "2025-05-08" },
days: [],
location: null,
meetingType: "CLAS",
meetingScheduleType: "LEC",
...overrides,
};
return mt;
}
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
return {
crn: "12345",
subject: "CS",
courseNumber: "1234",
title: "Test Course",
termCode: "202510",
sequenceNumber: null,
instructionalMethod: null,
campus: null,
enrollment: { current: 10, max: 30, waitCount: 0, waitCapacity: 0 },
creditHours: { type: "fixed", hours: 3 },
crossList: null,
sectionLink: null,
partOfTerm: null,
isAsyncOnline: false,
deliveryMode: null,
primaryLocation: null,
hasPhysicalLocation: false,
primaryInstructorId: null,
meetingTimes: [],
attributes: [],
instructors: [],
...overrides,
};
}
describe("formatMeetingTimeSummary", () => {
it("returns 'Async' for async online courses", () => {
const course = makeCourse({
isAsyncOnline: true,
meetingTimes: [
makeMeetingTime({
location: { building: "INT", buildingDescription: null, room: null, campus: null },
}),
],
});
expect(formatMeetingTimeSummary(course)).toBe("Async");
});
it("returns 'TBA' for courses with no meeting times", () => {
const course = makeCourse({ meetingTimes: [] });
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns 'TBA' when days and times are all TBA", () => {
const course = makeCourse({
meetingTimes: [makeMeetingTime()],
});
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns formatted days and time for normal meeting", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
days: ["monday", "wednesday", "friday"],
timeRange: { start: "09:00:00", end: "09:50:00" },
}),
],
});
expect(formatMeetingTimeSummary(course)).toBe("MWF 9:009:50 AM");
});
it("returns formatted days with TBA time", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
days: ["tuesday", "thursday"],
}),
],
});
// Days are set but time is TBA — not both TBA, so it enters the final branch
expect(formatMeetingTimeSummary(course)).toBe("TTh TBA");
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { parseTimeInput, formatTime, toggleDay, toggleValue } from "$lib/filters";
describe("parseTimeInput", () => {
it("parses AM time", () => {
expect(parseTimeInput("10:30 AM")).toBe("1030");
});
it("parses PM time", () => {
expect(parseTimeInput("3:00 PM")).toBe("1500");
});
it("parses 12:00 PM as noon", () => {
expect(parseTimeInput("12:00 PM")).toBe("1200");
});
it("parses 12:00 AM as midnight", () => {
expect(parseTimeInput("12:00 AM")).toBe("0000");
});
it("parses case-insensitive AM/PM", () => {
expect(parseTimeInput("9:15 am")).toBe("0915");
expect(parseTimeInput("2:45 Pm")).toBe("1445");
});
it("parses military time", () => {
expect(parseTimeInput("14:30")).toBe("1430");
expect(parseTimeInput("9:05")).toBe("0905");
});
it("returns null for empty string", () => {
expect(parseTimeInput("")).toBeNull();
expect(parseTimeInput(" ")).toBeNull();
});
it("returns null for non-time strings", () => {
expect(parseTimeInput("abc")).toBeNull();
expect(parseTimeInput("hello world")).toBeNull();
});
it("parses out-of-range military time (no validation beyond format)", () => {
// The regex matches but doesn't validate hour/minute ranges
expect(parseTimeInput("25:00")).toBe("2500");
});
it("trims whitespace", () => {
expect(parseTimeInput(" 10:00 AM ")).toBe("1000");
});
});
describe("formatTime", () => {
it("formats morning time", () => {
expect(formatTime("0930")).toBe("9:30 AM");
});
it("formats afternoon time", () => {
expect(formatTime("1500")).toBe("3:00 PM");
});
it("formats noon", () => {
expect(formatTime("1200")).toBe("12:00 PM");
});
it("formats midnight", () => {
expect(formatTime("0000")).toBe("12:00 AM");
});
it("returns empty string for null", () => {
expect(formatTime(null)).toBe("");
});
it("returns empty string for invalid length", () => {
expect(formatTime("12")).toBe("");
expect(formatTime("123456")).toBe("");
});
});
describe("toggleDay", () => {
it("adds a day not in the list", () => {
expect(toggleDay(["monday"], "wednesday")).toEqual(["monday", "wednesday"]);
});
it("removes a day already in the list", () => {
expect(toggleDay(["monday", "wednesday"], "monday")).toEqual(["wednesday"]);
});
it("adds to empty list", () => {
expect(toggleDay([], "friday")).toEqual(["friday"]);
});
it("removes last day", () => {
expect(toggleDay(["monday"], "monday")).toEqual([]);
});
});
describe("toggleValue", () => {
it("adds a value not in the array", () => {
expect(toggleValue(["OA"], "HB")).toEqual(["OA", "HB"]);
});
it("removes a value already in the array", () => {
expect(toggleValue(["OA", "HB"], "OA")).toEqual(["HB"]);
});
it("adds to empty array", () => {
expect(toggleValue([], "OA")).toEqual(["OA"]);
});
it("removes last value", () => {
expect(toggleValue(["OA"], "OA")).toEqual([]);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import {
FADE_DISTANCE,
FADE_PERCENT,
leftOpacity,
rightOpacity,
maskGradient,
type ScrollMetrics,
} from "$lib/scroll-fade";
describe("leftOpacity", () => {
it("returns 0 when scrollLeft is 0", () => {
expect(leftOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when scrollLeft >= FADE_DISTANCE", () => {
expect(leftOpacity({ scrollLeft: FADE_DISTANCE, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
expect(
leftOpacity({ scrollLeft: FADE_DISTANCE + 50, scrollWidth: 1000, clientWidth: 500 })
).toBe(1);
});
it("returns proportional value for partial scroll", () => {
const half = FADE_DISTANCE / 2;
expect(leftOpacity({ scrollLeft: half, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("rightOpacity", () => {
it("returns 0 when content fits (no scroll needed)", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 500, clientWidth: 500 })).toBe(0);
});
it("returns 0 when scrolled to the end", () => {
expect(rightOpacity({ scrollLeft: 500, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when far from the end", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
});
it("returns proportional value near the end", () => {
const maxScroll = 500; // scrollWidth(1000) - clientWidth(500)
const remaining = FADE_DISTANCE / 2;
const scrollLeft = maxScroll - remaining;
expect(rightOpacity({ scrollLeft, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("maskGradient", () => {
it("returns full transparent-to-transparent gradient when no scroll", () => {
const metrics: ScrollMetrics = { scrollLeft: 0, scrollWidth: 500, clientWidth: 500 };
const result = maskGradient(metrics);
// leftOpacity=0, rightOpacity=0 → leftEnd=0%, rightStart=100%
expect(result).toBe(
"linear-gradient(to right, transparent 0%, black 0%, black 100%, transparent 100%)"
);
});
it("includes fade zones when scrolled to the middle", () => {
const metrics: ScrollMetrics = {
scrollLeft: FADE_DISTANCE,
scrollWidth: 1000,
clientWidth: 500,
};
const result = maskGradient(metrics);
// leftOpacity=1 → leftEnd=FADE_PERCENT%, rightOpacity=1 → rightStart=100-FADE_PERCENT%
expect(result).toContain(`black ${FADE_PERCENT}%`);
expect(result).toContain(`black ${100 - FADE_PERCENT}%`);
});
});
+3 -41
View File
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
@@ -60,15 +58,15 @@ describe("BannerApiClient", () => {
const result = await apiClient.searchCourses({
term: "202420",
subjects: ["CS"],
subject: ["CS"],
q: "data",
open_only: true,
openOnly: true,
limit: 25,
offset: 50,
});
expect(fetch).toHaveBeenCalledWith(
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
"/api/courses/search?term=202420&subject=CS&q=data&openOnly=true&limit=25&offset=50"
);
expect(result).toEqual(mockResponse);
});
@@ -77,8 +75,6 @@ describe("BannerApiClient", () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
@@ -91,40 +87,6 @@ describe("BannerApiClient", () => {
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" },
+160 -141
View File
@@ -1,27 +1,53 @@
import { authStore } from "$lib/auth.svelte";
import type {
AdminStatusResponse,
ApiError,
ApiErrorCode,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DayOfWeek,
DbMeetingTime,
DeliveryMode,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsParams as ListInstructorsParamsGenerated,
ListInstructorsResponse,
MatchBody,
MetricEntry,
MetricsParams as MetricsParamsGenerated,
MetricsResponse,
RejectCandidateBody,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
SearchParams as SearchParamsGenerated,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeRange,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -32,10 +58,18 @@ const API_BASE_URL = "/api";
// Re-export generated types under their canonical names
export type {
AdminStatusResponse,
ApiError,
ApiErrorCode,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DayOfWeek,
DbMeetingTime,
DeliveryMode,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
@@ -43,16 +77,29 @@ export type {
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
MetricEntry,
MetricsResponse,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeRange,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -63,122 +110,69 @@ export type Term = TermResponse;
export type Subject = CodeDescription;
export type ReferenceEntry = CodeDescription;
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
// Re-export with simplified names
export type SearchResponse = SearchResponseGenerated;
export type SearchParams = SearchParamsGenerated;
export type MetricsParams = MetricsParamsGenerated;
export type ListInstructorsParams = ListInstructorsParamsGenerated;
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
// Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
/**
* Converts a typed object to URLSearchParams, preserving camelCase keys.
* Handles arrays, optional values, and primitives.
*/
function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
const params = new URLSearchParams();
export interface AdminStatus {
userCount: number;
sessionCount: number;
courseCount: number;
scrapeJobCount: number;
services: { name: string; status: string }[];
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) {
continue; // Skip undefined/null values
}
if (Array.isArray(value)) {
// Append each array element
for (const item of value) {
if (item !== undefined && item !== null) {
params.append(key, String(item));
}
}
} else {
// Convert primitives to string
params.set(key, String(value));
}
}
return params;
}
export interface ScrapeJob {
id: number;
targetType: string;
targetPayload: unknown;
priority: string;
executeAt: string;
createdAt: string;
lockedAt: string | null;
retryCount: number;
maxRetries: number;
queuedAt: string;
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
}
/**
* API error class that wraps the structured ApiError response from the backend.
*/
export class ApiErrorClass extends Error {
public readonly code: ApiErrorCode;
public readonly details: unknown | null;
export interface ScrapeJobsResponse {
jobs: ScrapeJob[];
}
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "ApiError";
this.code = apiError.code;
this.details = apiError.details;
}
export interface AuditLogEntry {
id: number;
courseId: number;
timestamp: string;
fieldChanged: string;
oldValue: string;
newValue: string;
subject: string | null;
courseNumber: string | null;
crn: string | null;
courseTitle: string | null;
}
isNotFound(): boolean {
return this.code === "NOT_FOUND";
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
}
isBadRequest(): boolean {
return (
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
);
}
export interface MetricEntry {
id: number;
courseId: number;
timestamp: string;
enrollment: number;
waitCount: number;
seatsAvailable: number;
}
export interface MetricsResponse {
metrics: MetricEntry[];
count: number;
timestamp: string;
}
export interface MetricsParams {
course_id?: number;
term?: string;
crn?: string;
range?: "1h" | "6h" | "24h" | "7d" | "30d";
limit?: number;
}
/** A time range for timeline queries (ISO-8601 strings). */
export interface TimelineRange {
start: string;
end: string;
}
/** Request body for POST /api/timeline. */
export interface TimelineRequest {
ranges: TimelineRange[];
}
/** A single 15-minute slot returned by the timeline API. */
export interface TimelineSlot {
time: string;
subjects: Record<string, number>;
}
/** Response from POST /api/timeline. */
export interface TimelineResponse {
slots: TimelineSlot[];
subjects: string[];
}
export interface SearchParams {
term: string;
subjects?: string[];
q?: string;
open_only?: boolean;
limit?: number;
offset?: number;
sort_by?: SortColumn;
sort_dir?: SortDirection;
}
// Admin instructor query params (client-only, not generated)
export interface AdminInstructorListParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
sort?: string;
isInternalError(): boolean {
return this.code === "INTERNAL_ERROR";
}
}
export class BannerApiClient {
@@ -220,7 +214,17 @@ export class BannerApiClient {
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
let apiError: ApiError;
try {
apiError = (await response.json()) as ApiError;
} catch {
apiError = {
code: "INTERNAL_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
return (await response.json()) as T;
@@ -241,7 +245,17 @@ export class BannerApiClient {
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
let apiError: ApiError;
try {
apiError = (await response.json()) as ApiError;
} catch {
apiError = {
code: "INTERNAL_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
}
@@ -249,20 +263,8 @@ export class BannerApiClient {
return this.request<StatusResponse>("/status");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
if (params.subjects) {
for (const s of params.subjects) {
query.append("subject", s);
}
}
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));
if (params.sort_by) query.set("sort_by", params.sort_by);
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
const query = toURLSearchParams(params as Record<string, unknown>);
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
@@ -278,9 +280,28 @@ export class BannerApiClient {
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
}
// In-memory cache for search options per term
private searchOptionsCache = new Map<
string,
{ data: SearchOptionsResponse; fetchedAt: number }
>();
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
const cacheKey = term || "__default__";
const cached = this.searchOptionsCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
return cached.data;
}
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
const data = await this.request<SearchOptionsResponse>(url);
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
return data;
}
// Admin endpoints
async getAdminStatus(): Promise<AdminStatus> {
return this.request<AdminStatus>("/admin/status");
async getAdminStatus(): Promise<AdminStatusResponse> {
return this.request<AdminStatusResponse>("/admin/status");
}
async getAdminUsers(): Promise<User[]> {
@@ -331,33 +352,31 @@ export class BannerApiClient {
/** Stored `Last-Modified` value for audit log conditional requests. */
private _auditLastModified: string | null = null;
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
return this.request<TimelineResponse>("/timeline", {
method: "POST",
body: { ranges } satisfies TimelineRequest,
});
}
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
if (params?.term) query.set("term", params.term);
if (params?.crn) query.set("crn", params.crn);
if (params?.range) query.set("range", params.range);
if (params?.limit !== undefined) query.set("limit", String(params.limit));
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
if (!params) {
return this.request<MetricsResponse>("/metrics");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
// Admin instructor endpoints
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
const query = new URLSearchParams();
if (params?.status) query.set("status", params.status);
if (params?.search) query.set("search", params.search);
if (params?.page !== undefined) query.set("page", String(params.page));
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
if (params?.sort) query.set("sort", params.sort);
async getAdminInstructors(
params?: Partial<ListInstructorsParams>
): Promise<ListInstructorsResponse> {
if (!params) {
return this.request<ListInstructorsResponse>("/admin/instructors");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
}
@@ -369,14 +388,14 @@ export class BannerApiClient {
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
method: "POST",
body: { rmpLegacyId },
body: { rmpLegacyId } satisfies MatchBody,
});
}
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
method: "POST",
body: { rmpLegacyId },
body: { rmpLegacyId } satisfies RejectCandidateBody,
});
}
@@ -389,7 +408,7 @@ export class BannerApiClient {
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
method: "POST",
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } satisfies MatchBody } : {}),
});
}
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceStatus } from "./ServiceStatus";
export type AdminServiceInfo = { name: string, status: ServiceStatus, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdminServiceInfo } from "./AdminServiceInfo";
export type AdminStatusResponse = { userCount: number, sessionCount: number, courseCount: number, scrapeJobCount: number, services: Array<AdminServiceInfo>, };
+20
View File
@@ -0,0 +1,20 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApiErrorCode } from "./ApiErrorCode";
import type { JsonValue } from "./serde_json/JsonValue";
/**
* Standardized error response for all API endpoints.
*/
export type ApiError = {
/**
* Machine-readable error code
*/
code: ApiErrorCode,
/**
* Human-readable error message
*/
message: string,
/**
* Optional additional details (validation errors, field info, etc.)
*/
details: JsonValue | null, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Machine-readable error code for API responses.
*/
export type ApiErrorCode = "NOT_FOUND" | "BAD_REQUEST" | "INTERNAL_ERROR" | "INVALID_TERM" | "INVALID_RANGE" | "UNAUTHORIZED" | "FORBIDDEN" | "NO_TERMS";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AuditLogEntry = { id: number, courseId: number, timestamp: string, fieldChanged: string, oldValue: string, newValue: string, subject: string | null, courseNumber: string | null, crn: string | null, courseTitle: string | null, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuditLogEntry } from "./AuditLogEntry";
export type AuditLogResponse = { entries: Array<AuditLogEntry>, };
+14 -1
View File
@@ -1,5 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CreditHours } from "./CreditHours";
import type { CrossList } from "./CrossList";
import type { DbMeetingTime } from "./DbMeetingTime";
import type { DeliveryMode } from "./DeliveryMode";
import type { Enrollment } from "./Enrollment";
import type { InstructorResponse } from "./InstructorResponse";
import type { SectionLink } from "./SectionLink";
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: number, maxEnrollment: number, waitCount: number, waitCapacity: number, creditHours: number | null, creditHourLow: number | null, creditHourHigh: number | null, crossList: string | null, crossListCapacity: number | null, crossListCount: number | null, linkIdentifier: string | null, isSectionLinked: boolean | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, instructors: Array<InstructorResponse>, };
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: Enrollment, creditHours: CreditHours | null, crossList: CrossList | null, sectionLink: SectionLink | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, isAsyncOnline: boolean, deliveryMode: DeliveryMode | null,
/**
* Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
*/
primaryLocation: string | null,
/**
* Whether a physical (non-INT) building was found in meeting times.
*/
hasPhysicalLocation: boolean, primaryInstructorId: number | null, instructors: Array<InstructorResponse>, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Credit hours for a course section — either a fixed value or a range.
*/
export type CreditHours = { "type": "fixed", hours: number, } | { "type": "range", low: number, high: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Cross-listed section information.
*/
export type CrossList = { identifier: string, capacity: number, count: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* An inclusive date range with the invariant that `start <= end`.
*/
export type DateRange = { start: string, end: string, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Day of the week.
*/
export type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
+21 -1
View File
@@ -1,6 +1,26 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DateRange } from "./DateRange";
import type { DayOfWeek } from "./DayOfWeek";
import type { MeetingLocation } from "./MeetingLocation";
import type { TimeRange } from "./TimeRange";
/**
* Represents a meeting time stored as JSONB in the courses table.
*/
export type DbMeetingTime = { begin_time: string | null, end_time: string | null, start_date: string, end_date: string, monday: boolean, tuesday: boolean, wednesday: boolean, thursday: boolean, friday: boolean, saturday: boolean, sunday: boolean, building: string | null, building_description: string | null, room: string | null, campus: string | null, meeting_type: string, meeting_schedule_type: string, };
export type DbMeetingTime = {
/**
* Time range for the meeting; `None` means TBA.
*/
timeRange: TimeRange | null,
/**
* Date range over which the meeting recurs.
*/
dateRange: DateRange,
/**
* Active days of the week. Empty means days are TBA.
*/
days: Array<DayOfWeek>,
/**
* Physical location; `None` when all location fields are absent.
*/
location: MeetingLocation | null, meetingType: string, meetingScheduleType: string, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Delivery mode classification for visual accents on location cells.
*
* `None` means normal in-person on the main campus (no accent needed).
*/
export type DeliveryMode = "online" | "internet" | "hybrid" | "off-campus";
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Enrollment counts for a course section.
*/
export type Enrollment = { current: number, max: number, waitCount: number, waitCapacity: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Aggregate min/max ranges for filter sliders, computed per-term.
*/
export type FilterRanges = { courseNumberMin: number, courseNumberMax: number, creditHourMin: number, creditHourMax: number, waitCountMax: number, };
+2 -1
View File
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RmpRating } from "./RmpRating";
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, email: string, isPrimary: boolean, rmpRating: number | null, rmpNumRatings: number | null, rmpLegacyId: number | null, };
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, firstName: string | null, lastName: string | null, email: string, isPrimary: boolean, rmp: RmpRating | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ListInstructorsParams = { status: string | null, search: string | null, page: number | null, perPage: number | null, sort: string | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MatchBody = { rmpLegacyId: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Physical location where a course section meets.
*/
export type MeetingLocation = { building: string | null, buildingDescription: string | null, room: string | null, campus: string | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MetricEntry = { id: number, courseId: number, timestamp: string, enrollment: number, waitCount: number, seatsAvailable: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MetricsParams = { courseId: number | null, term: string | null, crn: string | null, range: string | null, limit: number, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MetricEntry } from "./MetricEntry";
export type MetricsResponse = { metrics: Array<MetricEntry>, count: number, timestamp: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RejectCandidateBody = { rmpLegacyId: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* RateMyProfessors rating summary for an instructor.
*/
export type RmpRating = { avgRating: number, numRatings: number, legacyId: number, isConfident: boolean, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ScrapeJobStatus } from "./ScrapeJobStatus";
import type { JsonValue } from "./serde_json/JsonValue";
/**
* A serializable DTO for `ScrapeJob` with computed `status`.
*/
export type ScrapeJobDto = { id: number, targetType: string, targetPayload: JsonValue, priority: string, executeAt: string, createdAt: string, lockedAt: string | null, retryCount: number, maxRetries: number, queuedAt: string, status: ScrapeJobStatus, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ScrapeJobDto } from "./ScrapeJobDto";
import type { ScrapeJobStatus } from "./ScrapeJobStatus";
/**
* Events broadcast when scrape job state changes.
*/
export type ScrapeJobEvent = { "type": "init", jobs: Array<ScrapeJobDto>, } | { "type": "jobCreated", job: ScrapeJobDto, } | { "type": "jobLocked", id: number, lockedAt: string, status: ScrapeJobStatus, } | { "type": "jobCompleted", id: number, } | { "type": "jobRetried", id: number, retryCount: number, queuedAt: string, status: ScrapeJobStatus, } | { "type": "jobExhausted", id: number, } | { "type": "jobDeleted", id: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Computed status for a scrape job, derived from existing fields.
*/
export type ScrapeJobStatus = "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ScrapeJobDto } from "./ScrapeJobDto";
export type ScrapeJobsResponse = { jobs: Array<ScrapeJobDto>, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CodeDescription } from "./CodeDescription";
export type SearchOptionsReference = { instructionalMethods: Array<CodeDescription>, campuses: Array<CodeDescription>, partsOfTerm: Array<CodeDescription>, attributes: Array<CodeDescription>, };
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CodeDescription } from "./CodeDescription";
import type { FilterRanges } from "./FilterRanges";
import type { SearchOptionsReference } from "./SearchOptionsReference";
import type { TermResponse } from "./TermResponse";
/**
* Response for the consolidated search-options endpoint.
*/
export type SearchOptionsResponse = { terms: Array<TermResponse>, subjects: Array<CodeDescription>, reference: SearchOptionsReference, ranges: FilterRanges, };
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SortColumn } from "./SortColumn";
import type { SortDirection } from "./SortDirection";
export type SearchParams = { term: string, subject: Array<string>, q: string | null, courseNumberLow: number | null, courseNumberHigh: number | null, openOnly: boolean, instructionalMethod: Array<string>, campus: Array<string>, limit: number, offset: number, sortBy: SortColumn | null, sortDir: SortDirection | null, waitCountMax: number | null, days: Array<string>, timeStart: string | null, timeEnd: string | null, partOfTerm: Array<string>, attributes: Array<string>, creditHourMin: number | null, creditHourMax: number | null, instructor: string | null, };
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CourseResponse } from "./CourseResponse";
export type SearchResponse = { courses: Array<CourseResponse>, totalCount: number, offset: number, limit: number, };
export type SearchResponse = { courses: Array<CourseResponse>, totalCount: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A linked section reference (e.g. lab linked to a lecture).
*/
export type SectionLink = { identifier: string, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Column to sort search results by.
*/
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Sort direction.
*/
export type SortDirection = "asc" | "desc";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type StatsParams = { period: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubjectDetailParams = { limit: number, };
+5 -1
View File
@@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubjectResultEntry = { id: number, completedAt: string, durationMs: number, success: boolean, errorMessage: string | null, coursesFetched: number | null, coursesChanged: number | null, coursesUnchanged: number | null, auditsGenerated: number | null, metricsGenerated: number | null, };
export type SubjectResultEntry = { id: number,
/**
* ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z")
*/
completedAt: string, durationMs: number, success: boolean, errorMessage: string | null, coursesFetched: number | null, coursesChanged: number | null, coursesUnchanged: number | null, auditsGenerated: number | null, metricsGenerated: number | null, };
+9 -1
View File
@@ -1,3 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubjectSummary = { subject: string, subjectDescription: string | null, trackedCourseCount: number, scheduleState: string, currentIntervalSecs: number, timeMultiplier: number, lastScraped: string, nextEligibleAt: string | null, cooldownRemainingSecs: number | null, avgChangeRatio: number, consecutiveZeroChanges: number, recentRuns: number, recentFailures: number, };
export type SubjectSummary = { subject: string, subjectDescription: string | null, trackedCourseCount: number, scheduleState: string, currentIntervalSecs: number, timeMultiplier: number,
/**
* ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z")
*/
lastScraped: string,
/**
* ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z")
*/
nextEligibleAt: string | null, cooldownRemainingSecs: number | null, avgChangeRatio: number, consecutiveZeroChanges: number, recentRuns: number, recentFailures: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Time range for meetings
*/
export type TimeRange = { start: string, end: string, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TimeRange } from "./TimeRange";
export type TimelineRequest = { ranges: Array<TimeRange>, };
+2 -2
View File
@@ -2,10 +2,10 @@
export type TimelineSlot = {
/**
* ISO-8601 timestamp at the start of this 15-minute bucket.
* ISO-8601 UTC timestamp at the start of this 15-minute bucket (e.g., "2024-01-15T10:30:00Z")
*/
time: string,
/**
* Subject code → total enrollment in this slot.
*/
subjects: { [key in string]?: bigint }, };
subjects: Record<string, number>, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TimeseriesParams = { period: string, bucket: string | null, };
+5 -1
View File
@@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TimeseriesPoint = { timestamp: string, scrapeCount: number, successCount: number, errorCount: number, coursesChanged: number, avgDurationMs: number, };
export type TimeseriesPoint = {
/**
* ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z")
*/
timestamp: string, scrapeCount: number, successCount: number, errorCount: number, coursesChanged: number, avgDurationMs: number, };
+36
View File
@@ -1,28 +1,64 @@
export type { AdminServiceInfo } from "./AdminServiceInfo";
export type { AdminStatusResponse } from "./AdminStatusResponse";
export type { ApiError } from "./ApiError";
export type { ApiErrorCode } from "./ApiErrorCode";
export type { AuditLogEntry } from "./AuditLogEntry";
export type { AuditLogResponse } from "./AuditLogResponse";
export type { CandidateResponse } from "./CandidateResponse";
export type { CodeDescription } from "./CodeDescription";
export type { CourseResponse } from "./CourseResponse";
export type { CreditHours } from "./CreditHours";
export type { CrossList } from "./CrossList";
export type { DateRange } from "./DateRange";
export type { DayOfWeek } from "./DayOfWeek";
export type { DbMeetingTime } from "./DbMeetingTime";
export type { DeliveryMode } from "./DeliveryMode";
export type { Enrollment } from "./Enrollment";
export type { FilterRanges } from "./FilterRanges";
export type { InstructorDetail } from "./InstructorDetail";
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
export type { InstructorListItem } from "./InstructorListItem";
export type { InstructorResponse } from "./InstructorResponse";
export type { InstructorStats } from "./InstructorStats";
export type { LinkedRmpProfile } from "./LinkedRmpProfile";
export type { ListInstructorsParams } from "./ListInstructorsParams";
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
export type { MatchBody } from "./MatchBody";
export type { MeetingLocation } from "./MeetingLocation";
export type { MetricEntry } from "./MetricEntry";
export type { MetricsParams } from "./MetricsParams";
export type { MetricsResponse } from "./MetricsResponse";
export type { OkResponse } from "./OkResponse";
export type { RejectCandidateBody } from "./RejectCandidateBody";
export type { RescoreResponse } from "./RescoreResponse";
export type { RmpRating } from "./RmpRating";
export type { ScrapeJobDto } from "./ScrapeJobDto";
export type { ScrapeJobEvent } from "./ScrapeJobEvent";
export type { ScrapeJobStatus } from "./ScrapeJobStatus";
export type { ScrapeJobsResponse } from "./ScrapeJobsResponse";
export type { ScraperStatsResponse } from "./ScraperStatsResponse";
export type { SearchOptionsReference } from "./SearchOptionsReference";
export type { SearchOptionsResponse } from "./SearchOptionsResponse";
export type { SearchParams } from "./SearchParams";
export type { SearchResponse } from "./SearchResponse";
export type { SectionLink } from "./SectionLink";
export type { ServiceInfo } from "./ServiceInfo";
export type { ServiceStatus } from "./ServiceStatus";
export type { SortColumn } from "./SortColumn";
export type { SortDirection } from "./SortDirection";
export type { StatsParams } from "./StatsParams";
export type { StatusResponse } from "./StatusResponse";
export type { SubjectDetailParams } from "./SubjectDetailParams";
export type { SubjectDetailResponse } from "./SubjectDetailResponse";
export type { SubjectResultEntry } from "./SubjectResultEntry";
export type { SubjectSummary } from "./SubjectSummary";
export type { SubjectsResponse } from "./SubjectsResponse";
export type { TermResponse } from "./TermResponse";
export type { TimeRange } from "./TimeRange";
export type { TimelineRequest } from "./TimelineRequest";
export type { TimelineResponse } from "./TimelineResponse";
export type { TimelineSlot } from "./TimelineSlot";
export type { TimeseriesParams } from "./TimeseriesParams";
export type { TimeseriesPoint } from "./TimeseriesPoint";
export type { TimeseriesResponse } from "./TimeseriesResponse";
export type { TopCandidateResponse } from "./TopCandidateResponse";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
@@ -0,0 +1,88 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import { toggleValue } from "$lib/filters";
import FilterPopover from "./FilterPopover.svelte";
let {
instructionalMethod = $bindable<string[]>([]),
campus = $bindable<string[]>([]),
partOfTerm = $bindable<string[]>([]),
attributes = $bindable<string[]>([]),
referenceData,
}: {
instructionalMethod: string[];
campus: string[];
partOfTerm: string[];
attributes: string[];
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
} = $props();
const hasActiveFilters = $derived(
instructionalMethod.length > 0 ||
campus.length > 0 ||
partOfTerm.length > 0 ||
attributes.length > 0
);
const sections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
}[] = [
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
{ label: "Campus", key: "campus", dataKey: "campuses" },
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
];
function getSelected(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
): string[] {
if (key === "instructionalMethod") return instructionalMethod;
if (key === "campus") return campus;
if (key === "partOfTerm") return partOfTerm;
return attributes;
}
function toggle(key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes", code: string) {
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
else if (key === "campus") campus = toggleValue(campus, code);
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
else attributes = toggleValue(attributes, code);
}
</script>
<FilterPopover label="Attributes" active={hasActiveFilters} width="w-80 max-h-96 overflow-y-auto">
{#snippet content()}
{#each sections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggle(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
{/snippet}
</FilterPopover>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fly, fade } from "svelte/transition";
const DISMISS_THRESHOLD = 100;
let {
open = $bindable(false),
maxHeight = "80vh",
label,
children,
}: {
open: boolean;
maxHeight?: string;
label?: string;
children: Snippet;
} = $props();
let dragOffset = $state(0);
let dragging = $state(false);
let dragStartY = 0;
function close() {
open = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
function onPointerDown(e: PointerEvent) {
dragging = true;
dragStartY = e.clientY;
dragOffset = 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const delta = e.clientY - dragStartY;
dragOffset = Math.max(0, delta);
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
if (dragOffset > DISMISS_THRESHOLD) {
close();
}
dragOffset = 0;
}
$effect(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40 bg-black/40"
transition:fade={{ duration: 200 }}
onclick={close}
></div>
<!-- Sheet -->
<div
class="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl border-t border-border bg-background shadow-[0_-4px_20px_rgba(0,0,0,0.1)] pb-[env(safe-area-inset-bottom)]"
style="max-height: {maxHeight}; transform: translateY({dragOffset}px);"
class:transition-transform={!dragging}
class:duration-250={!dragging}
class:ease-out={!dragging}
transition:fly={{ y: 300, duration: 250 }}
role="dialog"
aria-modal="true"
aria-label={label}
>
<!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="flex shrink-0 cursor-grab items-center justify-center py-3 touch-none"
class:cursor-grabbing={dragging}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="h-1 w-10 rounded-full bg-muted-foreground/30"></div>
</div>
<!-- Content -->
<div class="overflow-y-auto">
{@render children()}
</div>
</div>
{/if}
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
abbreviateInstructor,
formatMeetingTimeSummary,
getPrimaryInstructor,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { formatNumber } from "$lib/utils";
import { slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
let {
course,
expanded,
onToggle,
}: {
course: CourseResponse;
expanded: boolean;
onToggle: () => void;
} = $props();
</script>
<div
class="rounded-lg border border-border bg-card overflow-hidden transition-colors
{expanded ? 'border-border/80' : 'hover:bg-muted/30'}"
>
<button
class="w-full text-left p-3 cursor-pointer"
aria-expanded={expanded}
onclick={onToggle}
>
<!-- Line 1: Course code + title + seats -->
<div class="flex items-baseline justify-between gap-2">
{#snippet seatsDisplay()}
{@const openSeats = course.enrollment.max - course.enrollment.current}
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
<span class="size-1.5 rounded-full {seatsDotColor(openSeats)} shrink-0"></span>
<span class="{seatsColor(openSeats)} font-medium tabular-nums">
{#if openSeats === 0}Full{:else}{openSeats}/{formatNumber(course.enrollment.max)}{/if}
</span>
</span>
{/snippet}
<div class="flex items-baseline gap-1.5 min-w-0">
<span class="font-mono font-semibold text-sm tracking-tight shrink-0">
{course.subject} {course.courseNumber}
</span>
<span class="text-sm text-muted-foreground truncate">{course.title}</span>
</div>
{@render seatsDisplay()}
</div>
<!-- Line 2: Instructor + time -->
<div class="flex items-center justify-between gap-2 mt-1">
<span class="text-xs text-muted-foreground truncate">
{abbreviateInstructor(getPrimaryInstructor(course.instructors, course.primaryInstructorId)?.displayName ?? "Staff")}
</span>
<span class="text-xs text-muted-foreground shrink-0">
{formatMeetingTimeSummary(course)}
</span>
</div>
</button>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<CourseDetail {course} />
</div>
{/if}
</div>
+131 -138
View File
@@ -2,18 +2,15 @@
import type { CourseResponse } from "$lib/api";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import {
RMP_CONFIDENCE_THRESHOLD,
formatCreditHours,
formatDate,
formatMeetingDaysLong,
formatTime,
isMeetingTimeTBA,
isTimeTBA,
ratingStyle,
rmpUrl,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import { formatNumber } from "$lib/utils";
import {
Calendar,
Check,
@@ -24,7 +21,7 @@ import {
Star,
Triangle,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import RichTooltip from "./RichTooltip.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let { course }: { course: CourseResponse } = $props();
@@ -40,73 +37,68 @@ const clipboard = useClipboard();
{#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor}
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger>
<RichTooltip delay={200} contentClass="px-3 py-2">
{#snippet children()}
<span
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
{instructor.displayName}
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
{@const lowConfidence =
(instructor.rmpNumRatings ?? 0) <
RMP_CONFIDENCE_THRESHOLD}
<span
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
style={ratingStyle(
rating,
themeStore.isDark,
)}
>
{rating.toFixed(1)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
{/if}
{instructor.displayName}
{#if instructor.rmp != null}
{@const rating = instructor.rmp.avgRating}
{@const lowConfidence = !instructor.rmp.isConfident}
<span
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
style={ratingStyle(
rating,
themeStore.isDark,
)}
>
{rating.toFixed(1)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-y-1.5">
<div class="font-medium">
{instructor.displayName}
</div>
{#if instructor.isPrimary}
<div class="text-muted-foreground">
Primary instructor
</div>
{/if}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{instructor.rmpRating.toFixed(1)}/5
· {instructor.rmpNumRatings ?? 0} ratings
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
</div>
{/if}
{#if instructor.rmpLegacyId != null}
<a
href={rmpUrl(
instructor.rmpLegacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="size-3" />
<span>View on RMP</span>
</a>
{/if}
{#if instructor.isPrimary}
<div class="text-muted-foreground">
Primary instructor
</div>
{/if}
{#if instructor.rmp != null}
<div class="text-muted-foreground">
{instructor.rmp.avgRating.toFixed(1)}/5
· {instructor.rmp.numRatings} ratings
{#if !instructor.rmp.isConfident}
(low)
{/if}
</div>
{/if}
{#if instructor.rmp?.legacyId != null}
<a
href={rmpUrl(
instructor.rmp.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="size-3" />
<span>View on RMP</span>
</a>
{/if}
{#if instructor.email}
<button
onclick={(e) =>
@@ -126,8 +118,8 @@ const clipboard = useClipboard();
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/each}
</div>
{:else}
@@ -139,56 +131,56 @@ const clipboard = useClipboard();
<div>
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground"
>TBA</span
>
{:else}
<div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)}
<span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(
mt.begin_time,
)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if}
</div>
{/if}
{#if mt.building || mt.room}
<div
class="text-xs text-muted-foreground mt-0.5"
>
{mt.building_description ??
mt.building}{mt.room
? ` ${mt.room}`
: ""}
</div>
{/if}
<div
class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.start_date)} &ndash; {formatDate(
mt.end_date,
)}
</div>
</li>
{/each}
</ul>
<ul class="flex flex-col gap-y-2">
{#each course.meetingTimes as mt}
<li>
{#if mt.days.length === 0 && mt.timeRange === null}
<span class="italic text-muted-foreground"
>TBA</span
>
{:else}
<div class="flex items-baseline gap-1.5">
{#if mt.days.length > 0}
<span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if mt.timeRange !== null}
<span class="text-muted-foreground">
{formatTime(
mt.timeRange.start,
)}&ndash;{formatTime(mt.timeRange.end)}
</span>
{:else}
<span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if}
</div>
{/if}
{#if mt.location?.building || mt.location?.room}
<div
class="text-xs text-muted-foreground mt-0.5"
>
{mt.location.buildingDescription ??
mt.location.building}{mt.location.room
? ` ${mt.location.room}`
: ""}
</div>
{/if}
<div
class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.dateRange.start)} &ndash; {formatDate(
mt.dateRange.end,
)}
</div>
</li>
{/each}
</ul>
{:else}
<span class="italic text-muted-foreground">TBA</span>
{/if}
@@ -259,6 +251,7 @@ const clipboard = useClipboard();
<!-- Cross-list -->
{#if course.crossList}
{@const crossList = course.crossList}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
@@ -272,45 +265,45 @@ const clipboard = useClipboard();
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<RichTooltip passthrough>
{#snippet children()}
<span
class="inline-flex items-center gap-1.5 text-foreground font-mono"
>
<span
class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"
>
{course.crossList}
{crossList.identifier}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
{#if crossList.count != null && crossList.capacity != null}
<span class="text-muted-foreground text-xs">
{formatNumber(course.crossListCount)}/{formatNumber(course.crossListCapacity)}
{formatNumber(crossList.count)}/{formatNumber(crossList.capacity)}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
{/snippet}
{#snippet content()}
Group <span class="font-mono font-medium"
>{course.crossList}</span
>{crossList.identifier}</span
>
{#if course.crossListCount != null && course.crossListCapacity != null}
{formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
{#if crossList.count != null && crossList.capacity != null}
{formatNumber(crossList.count)} enrolled across {formatNumber(crossList.capacity)}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
</div>
{/if}
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
<span class="text-2foreground"
>{formatNumber(course.waitCount)} / {formatNumber(course.waitCapacity)}</span
>
</div>
{/if}
<!-- Waitlist -->
{#if course.enrollment.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
<span class="text-2foreground"
>{formatNumber(course.enrollment.waitCount)} / {formatNumber(course.enrollment.waitCapacity)}</span
>
</div>
{/if}
<!-- Calendar Export -->
{#if course.meetingTimes.length > 0}
-793
View File
@@ -1,793 +0,0 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import {
RMP_CONFIDENCE_THRESHOLD,
abbreviateInstructor,
concernAccentColor,
formatLocationDisplay,
formatLocationTooltip,
formatMeetingDays,
formatMeetingTimesTooltip,
formatTimeRange,
getDeliveryConcern,
getPrimaryInstructor,
isAsyncOnline,
isMeetingTimeTBA,
isTimeTBA,
openSeats,
ratingStyle,
rmpUrl,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Columns3,
ExternalLink,
RotateCcw,
Star,
Triangle,
} from "@lucide/svelte";
import {
type ColumnDef,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
import { flip } from "svelte/animate";
import { fade, fly, slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
courses,
loading,
sorting = [],
onSortingChange,
manualSorting = false,
subjectMap = {},
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
} = $props();
let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = 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
$effect(() => {
courses; // track dependency
expandedCrn = null;
});
useOverlayScrollbars(() => tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: { autoHide: "never" },
});
// Column visibility state
let columnVisibility: VisibilityState = $state({});
function resetColumnVisibility() {
columnVisibility = {};
}
function handleVisibilityChange(updater: Updater<VisibilityState>) {
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
columnVisibility = newVisibility;
}
// visibleColumnIds and hasCustomVisibility derived after column definitions below
function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName);
}
function primaryRating(
course: CourseResponse
): { rating: number; count: number; legacyId: number | null } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
return {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
};
}
function timeIsTBA(course: CourseResponse): boolean {
if (course.meetingTimes.length === 0) return true;
const mt = course.meetingTimes[0];
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
}
// Column definitions
const columns: ColumnDef<CourseResponse, unknown>[] = [
{
id: "crn",
accessorKey: "crn",
header: "CRN",
enableSorting: false,
},
{
id: "course_code",
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
header: "Course",
enableSorting: true,
},
{
id: "title",
accessorKey: "title",
header: "Title",
enableSorting: true,
},
{
id: "instructor",
accessorFn: (row) => primaryInstructorDisplay(row),
header: "Instructor",
enableSorting: true,
},
{
id: "time",
accessorFn: (row) => {
if (row.meetingTimes.length === 0) return "";
const mt = row.meetingTimes[0];
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
},
header: "Time",
enableSorting: true,
},
{
id: "location",
accessorFn: (row) => formatLocationDisplay(row) ?? "",
header: "Location",
enableSorting: false,
},
{
id: "seats",
accessorFn: (row) => openSeats(row),
header: "Seats",
enableSorting: true,
},
];
/** Column IDs that are currently visible */
let visibleColumnIds = $derived(
columns.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
);
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
function handleSortingChange(updater: Updater<SortingState>) {
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
onSortingChange?.(newSorting);
}
const table = createSvelteTable({
get data() {
return courses;
},
getRowId: (row) => String(row.crn),
columns,
state: {
get sorting() {
return sorting;
},
get columnVisibility() {
return columnVisibility;
},
},
onSortingChange: handleSortingChange,
onColumnVisibilityChange: handleVisibilityChange,
getCoreRowModel: getCoreRowModel(),
get getSortedRowModel() {
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
},
get manualSorting() {
return manualSorting;
},
enableSortingRemoval: true,
});
</script>
{#snippet columnVisibilityGroup(
Group: typeof DropdownMenu.Group,
GroupHeading: typeof DropdownMenu.GroupHeading,
CheckboxItem: typeof DropdownMenu.CheckboxItem,
Separator: typeof DropdownMenu.Separator,
Item: typeof DropdownMenu.Item,
)}
<Group>
<GroupHeading
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
Toggle columns
</GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = {
...columnVisibility,
[id]: checked,
};
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{#snippet children({ checked })}
<span
class="flex size-4 items-center justify-center rounded-sm border border-border"
>
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</CheckboxItem>
{/each}
</Group>
{#if hasCustomVisibility}
<Separator class="mx-1 my-1 h-px bg-border" />
<Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</Item>
{/if}
{/snippet}
<!-- Toolbar: View columns button -->
<div class="flex items-center justify-end pb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
<Columns3 class="size-3.5" />
View
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
align="end"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
transition:fly={{ duration: 150, y: -10 }}
>
{@render columnVisibilityGroup(
DropdownMenu.Group,
DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem,
DropdownMenu.Separator,
DropdownMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<!-- Table with context menu on header -->
<div bind:this={tableWrapper} class="overflow-x-auto">
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr
class="border-b border-border text-left text-muted-foreground"
>
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium {header.id ===
'seats'
? 'text-right'
: ''}"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span
class="inline-flex items-center gap-1"
>
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef
.header}
{:else}
<FlexRender
content={header.column
.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown
class="size-3.5"
/>
{:else}
<ArrowUpDown
class="size-3.5 text-muted-foreground/40"
/>
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef
.header}
context={header.getContext()}
/>
{/if}
</th>
{/if}
{/each}
</tr>
{/each}
</thead>
{#if loading && courses.length === 0}
<tbody>
{#each Array(5) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
<div
class="h-4 bg-muted rounded animate-pulse {col.id ===
'seats'
? 'w-14 ml-auto'
: col.id === 'title'
? 'w-40'
: col.id === 'crn'
? 'w-10'
: 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
</tbody>
{:else if courses.length === 0}
<tbody>
<tr>
<td
colspan={visibleColumnIds.length}
class="py-12 text-center text-muted-foreground"
>
No courses found. Try adjusting your filters.
</td>
</tr>
</tbody>
{:else}
<!-- No out: transition — Svelte outros break table layout (tbody loses positioning and overlaps) -->
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
}}
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
course.crn
? 'bg-muted/30'
: ''}"
onclick={() => toggleRow(course.crn)}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "crn"}
<td class="py-2 px-2 relative">
<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) =>
clipboard.copy(
course.crn,
e,
)}
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 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={{
duration: 100,
}}
out:fade={{
duration: 200,
}}
>
Copied!
</span>
{/if}
</button>
</td>
{:else if colId === "course_code"}
{@const subjectDesc =
subjectMap[course.subject]}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={subjectDesc
? `${subjectDesc} ${course.courseNumber}`
: `${course.subject} ${course.courseNumber}`}
delay={200}
side="bottom"
passthrough
>
<span class="font-semibold"
>{course.subject}
{course.courseNumber}</span
>{#if course.sequenceNumber}<span
class="text-muted-foreground"
>-{course.sequenceNumber}</span
>{/if}
</SimpleTooltip>
</td>
{:else if colId === "title"}
<td
class="py-2 px-2 font-medium max-w-50 truncate"
>
<SimpleTooltip
text={course.title}
delay={200}
side="bottom"
passthrough
>
<span class="block truncate"
>{course.title}</span
>
</SimpleTooltip>
</td>
{:else if colId === "instructor"}
{@const primary = getPrimaryInstructor(
course.instructors,
)}
{@const display =
primaryInstructorDisplay(course)}
{@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span
class="text-xs text-muted-foreground/60 uppercase"
>Staff</span
>
{:else}
<SimpleTooltip
text={primary?.displayName ??
"Staff"}
delay={200}
side="bottom"
passthrough
>
{#if commaIdx !== -1}
<span
>{display.slice(
0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else}
<span>{display}</span>
{/if}
</SimpleTooltip>
{/if}
{#if ratingData}
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
>
<Tooltip.Trigger>
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
ratingData.rating,
themeStore.isDark,
)}
>
{ratingData.rating.toFixed(
1,
)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
<span
class="inline-flex items-center gap-1.5 text-xs"
>
{ratingData.rating.toFixed(
1,
)}/5 · {formatNumber(ratingData.count)}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(
ratingData.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink
class="size-3"
/>
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough
>
{#if isAsyncOnline(course)}
<span
class="text-xs text-muted-foreground/60"
>Async</span
>
{:else if timeIsTBA(course)}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{:else}
{@const mt =
course.meetingTimes[0]}
<span>
{#if !isMeetingTimeTBA(mt)}
<span
class="font-mono font-medium"
>{formatMeetingDays(
mt,
)}</span
>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span
class="text-muted-foreground"
>{formatTimeRange(
mt.begin_time,
mt.end_time,
)}</span
>
{:else}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{/if}
{#if course.meetingTimes.length > 1}
<span
class="ml-1 text-xs text-muted-foreground/70 font-medium"
>+{course
.meetingTimes
.length -
1}</span
>
{/if}
</span>
{/if}
</SimpleTooltip>
</td>
{:else if colId === "location"}
{@const concern =
getDeliveryConcern(course)}
{@const accentColor =
concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip}
<SimpleTooltip
text={locTooltip}
delay={200}
passthrough
>
<span
class="text-muted-foreground"
class:pl-2={accentColor !==
null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span
class="text-muted-foreground"
>
{locDisplay}
</span>
{:else}
<span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if}
</td>
{:else if colId === "seats"}
<td
class="py-2 px-2 text-right whitespace-nowrap"
>
<SimpleTooltip
text="{formatNumber(openSeats(
course,
))} of {formatNumber(course.maxEnrollment)} seats open, {formatNumber(course.enrollment)} enrolled{course.waitCount >
0
? `, ${formatNumber(course.waitCount)} waitlisted`
: ''}"
delay={200}
side="left"
passthrough
>
<span
class="inline-flex items-center gap-1.5"
>
<span
class="size-1.5 rounded-full {seatsDotColor(
course,
)} shrink-0"
></span>
<span
class="{seatsColor(
course,
)} font-medium tabular-nums"
>{#if openSeats(course) === 0}Full{:else}{openSeats(
course,
)} open{/if}</span
>
<span
class="text-muted-foreground/60 tabular-nums"
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
>
</span>
</SimpleTooltip>
</td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td
colspan={visibleColumnIds.length}
class="p-0"
>
<div
transition:slide={{ duration: 200 }}
>
<CourseDetail {course} />
</div>
</td>
</tr>
{/if}
</tbody>
{/each}
{/if}
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
>
{@render columnVisibilityGroup(
ContextMenu.Group,
ContextMenu.GroupHeading,
ContextMenu.CheckboxItem,
ContextMenu.Separator,
ContextMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
@@ -19,38 +19,52 @@ let errorStack = $derived(error instanceof Error ? error.stack : null);
</script>
<div class="flex items-center justify-center py-16 px-4">
<div class="w-full max-w-lg rounded-lg border border-status-red/25 bg-status-red/5 overflow-hidden text-sm">
<div class="px-4 py-2.5 border-b border-status-red/15 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 text-status-red">
<TriangleAlert size={16} strokeWidth={2.25} />
<span class="font-semibold">{title}</span>
</div>
<span class="text-xs text-muted-foreground font-mono">{page.url.pathname}</span>
</div>
<div
class="w-full max-w-lg rounded-lg border border-status-red/25 bg-status-red/5 overflow-hidden text-sm"
>
<div
class="px-4 py-2.5 border-b border-status-red/15 flex items-center justify-between gap-4"
>
<div class="flex items-center gap-2 text-status-red">
<TriangleAlert size={16} strokeWidth={2.25} />
<span class="font-semibold">{title}</span>
</div>
<span class="text-xs text-muted-foreground font-mono"
>{page.url.pathname}</span
>
</div>
<div class="px-4 py-3 border-b border-status-red/15">
<span class="text-xs text-muted-foreground/70 font-mono">{errorName}</span>
<pre class="mt-1 text-xs text-foreground/80 overflow-auto whitespace-pre-wrap break-words">{errorMessage}</pre>
</div>
<div class="px-4 py-3 border-b border-status-red/15">
<span class="text-xs text-muted-foreground/70 font-mono"
>{errorName}</span
>
<pre
class="mt-1 text-xs text-foreground/80 overflow-auto whitespace-pre-wrap wrap-break-word">{errorMessage}</pre>
</div>
{#if errorStack}
<details class="border-b border-status-red/15">
<summary class="px-4 py-2 text-xs text-muted-foreground/70 cursor-pointer hover:text-muted-foreground select-none">
Stack trace
</summary>
<pre class="px-4 py-3 text-xs text-muted-foreground/60 overflow-auto whitespace-pre-wrap break-words max-h-48">{errorStack}</pre>
</details>
{/if}
{#if errorStack}
<details class="border-b border-status-red/15">
<summary
class="px-4 py-2 text-xs text-muted-foreground/70 cursor-pointer hover:text-muted-foreground select-none"
>
Stack trace
</summary>
<pre
class="px-4 py-3 text-xs text-muted-foreground/60 overflow-auto whitespace-pre-wrap break-words max-h-48">{errorStack}</pre>
</details>
{/if}
<div class="px-4 py-2.5 flex items-center justify-end gap-3">
<span class="text-xs text-muted-foreground/60">Retries this section, not the full page</span>
<button
class="shrink-0 cursor-pointer inline-flex items-center gap-1.5 rounded-md bg-status-red px-3 py-1.5 text-sm font-medium text-white hover:brightness-110 transition-all"
onclick={reset}
>
<RotateCcw size={14} strokeWidth={2.25} />
Try again
</button>
<div class="px-4 py-2.5 flex items-center justify-end gap-3">
<span class="text-xs text-muted-foreground/60"
>Retries this section, not the full page</span
>
<button
class="shrink-0 cursor-pointer inline-flex items-center gap-1.5 rounded-md bg-status-red px-3 py-1.5 text-sm font-medium text-white hover:brightness-110 transition-all"
onclick={reset}
>
<RotateCcw size={14} strokeWidth={2.25} />
Try again
</button>
</div>
</div>
</div>
</div>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
let {
label,
onRemove,
}: {
label: string;
onRemove: () => void;
} = $props();
</script>
<button
type="button"
class="inline-flex items-center rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors cursor-pointer select-none whitespace-nowrap shrink-0"
onclick={onRemove}
aria-label="Remove {label} filter"
>
{label}
</button>
@@ -0,0 +1,51 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import type { Snippet } from "svelte";
import { fly } from "svelte/transition";
let {
label,
active = false,
width = "w-72",
content,
}: {
label: string;
active?: boolean;
width?: string;
content: Snippet;
} = $props();
</script>
<Popover.Root>
<Popover.Trigger
aria-label="{label} filters"
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer select-none
{active
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if active}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
{label}
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg {width}"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
{@render content()}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,65 @@
<script lang="ts">
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
let {
delay = 150,
side = "top" as "top" | "bottom" | "left" | "right",
sideOffset = 6,
triggerClass = "",
contentClass = "",
avoidCollisions = true,
collisionPadding = 8,
children,
content,
}: {
delay?: number;
side?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
triggerClass?: string;
contentClass?: string;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
content: Snippet;
} = $props();
let hovered = $state(false);
let focused = $state(false);
let active = $derived(hovered || focused);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class={triggerClass}
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
onfocusin={() => (focused = true)}
onfocusout={() => (focused = false)}
>
{#if active}
<Tooltip.Root delayDuration={delay} disableHoverableContent={false}>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props}>
{@render children()}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{:else}
{@render children()}
{/if}
</span>
@@ -0,0 +1,350 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import {
DAY_OPTIONS,
toggleDay as _toggleDay,
parseTimeInput,
formatTime,
toggleValue,
} from "$lib/filters";
import { ChevronDown } from "@lucide/svelte";
import BottomSheet from "./BottomSheet.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
open = $bindable(false),
openOnly = $bindable(),
waitCountMax = $bindable(),
days = $bindable(),
timeStart = $bindable(),
timeEnd = $bindable(),
instructionalMethod = $bindable(),
campus = $bindable(),
partOfTerm = $bindable(),
attributes = $bindable(),
creditHourMin = $bindable(),
creditHourMax = $bindable(),
instructor = $bindable(),
courseNumberMin = $bindable(),
courseNumberMax = $bindable(),
referenceData,
ranges,
}: {
open: boolean;
openOnly: boolean;
waitCountMax: number | null;
days: string[];
timeStart: string | null;
timeEnd: string | null;
instructionalMethod: string[];
campus: string[];
partOfTerm: string[];
attributes: string[];
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberMin: number | null;
courseNumberMax: number | null;
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
ranges: {
courseNumber: { min: number; max: number };
creditHours: { min: number; max: number };
waitCount: { max: number };
};
} = $props();
let expandedSection = $state<string | null>(null);
function toggleSection(id: string) {
expandedSection = expandedSection === id ? null : id;
}
function toggleDay(day: string) {
days = _toggleDay(days, day);
}
const attributeSections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
}[] = [
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
{ label: "Campus", key: "campus", dataKey: "campuses" },
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
];
function getAttrSelected(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
): string[] {
if (key === "instructionalMethod") return instructionalMethod;
if (key === "campus") return campus;
if (key === "partOfTerm") return partOfTerm;
return attributes;
}
function toggleAttr(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes",
code: string
) {
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
else if (key === "campus") campus = toggleValue(campus, code);
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
else attributes = toggleValue(attributes, code);
}
</script>
<BottomSheet bind:open>
<div class="flex flex-col">
<!-- Status section -->
<button
onclick={() => toggleSection("status")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Status
{#if openOnly || waitCountMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'status'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "status"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Availability</span>
<button
type="button"
aria-pressed={openOnly}
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer select-none
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if ranges.waitCount.max > 0}
<RangeSlider
min={0}
max={ranges.waitCount.max}
step={5}
bind:value={waitCountMax}
label="Max waitlist"
dual={false}
pips
pipstep={2}
formatValue={(v) => (v === 0 ? "Off" : String(v))}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Max waitlist</span>
<span class="text-xs text-muted-foreground select-none">No waitlisted courses</span>
</div>
{/if}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Schedule section -->
<button
onclick={() => toggleSection("schedule")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Schedule
{#if days.length > 0 || timeStart !== null || timeEnd !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'schedule'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "schedule"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer select-none min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
>
{label}
</button>
{/each}
</div>
</div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
autocomplete="off"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground select-none">to</span>
<input
type="text"
placeholder="3:00 PM"
autocomplete="off"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Attributes section -->
<button
onclick={() => toggleSection("attributes")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Attributes
{#if instructionalMethod.length > 0 || campus.length > 0 || partOfTerm.length > 0 || attributes.length > 0}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'attributes'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "attributes"}
<div class="px-4 pb-3 flex flex-col gap-3">
{#each attributeSections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getAttrSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleAttr(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- More section -->
<button
onclick={() => toggleSection("more")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
More
{#if creditHourMin !== null || creditHourMax !== null || instructor !== "" || courseNumberMin !== null || courseNumberMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'more'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "more"}
<div class="px-4 pb-3 flex flex-col gap-3">
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label
for="mobile-instructor-input"
class="text-xs font-medium text-muted-foreground select-none"
>
Instructor
</label>
<input
id="mobile-instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
</div>
{/if}
</div>
</BottomSheet>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import FilterPopover from "./FilterPopover.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
creditHourMin = $bindable<number | null>(null),
creditHourMax = $bindable<number | null>(null),
instructor = $bindable(""),
courseNumberMin = $bindable<number | null>(null),
courseNumberMax = $bindable<number | null>(null),
ranges,
}: {
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberMin: number | null;
courseNumberMax: number | null;
ranges: { courseNumber: { min: number; max: number }; creditHours: { min: number; max: number } };
} = $props();
const hasActiveFilters = $derived(
creditHourMin !== null ||
creditHourMax !== null ||
instructor !== "" ||
courseNumberMin !== null ||
courseNumberMax !== null
);
</script>
<FilterPopover label="More" active={hasActiveFilters}>
{#snippet content()}
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label for="instructor-input" class="text-xs font-medium text-muted-foreground select-none">
Instructor
</label>
<input
id="instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
{/snippet}
</FilterPopover>
+195 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import { navbar } from "$lib/stores/navigation.svelte";
import { Clock, Search, User } from "@lucide/svelte";
import ThemeToggle from "./ThemeToggle.svelte";
@@ -28,33 +29,215 @@ function isActive(tabHref: string): boolean {
}
return page.url.pathname.startsWith(tabHref);
}
/** Label expansion check using a deferred path that updates only after
* view transitions finish, so CSS transitions run on visible DOM. */
function isLabelExpanded(tabHref: string): boolean {
if (tabHref === "/") return navbar.path === "/";
if (tabHref === "/profile") {
return APP_PREFIXES.some((p) => navbar.path.startsWith(p));
}
return navbar.path.startsWith(tabHref);
}
// DOM refs
let tabRefs: HTMLAnchorElement[] = $state([]);
let containerRef: HTMLDivElement | undefined = $state();
let pillRef: HTMLDivElement | undefined = $state();
// Pill animation state — driven by JS, not CSS transitions
let targetLeft = 0;
let targetWidth = 0;
let currentLeft = 0;
let currentWidth = 0;
let animationId: number | null = null;
let mounted = $state(false);
const ANIMATION_DURATION = 300;
const EASING = cubicOut;
function cubicOut(t: number): number {
const f = t - 1;
return f * f * f + 1;
}
function allTabs() {
return [...staticTabs.map((t) => t.href), profileTab.href];
}
function activeIndex(): number {
return allTabs().findIndex((href) => isActive(href));
}
function measureActiveTab(): { left: number; width: number } | null {
const idx = activeIndex();
if (idx < 0 || !tabRefs[idx] || !containerRef) return null;
const containerRect = containerRef.getBoundingClientRect();
const tabRect = tabRefs[idx].getBoundingClientRect();
return {
left: tabRect.left - containerRect.left,
width: tabRect.width,
};
}
function applyPill(left: number, width: number) {
if (!pillRef) return;
pillRef.style.transform = `translateX(${left}px)`;
pillRef.style.width = `${width}px`;
currentLeft = left;
currentWidth = width;
}
function animatePill(fromLeft: number, fromWidth: number, toLeft: number, toWidth: number) {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / ANIMATION_DURATION, 1);
const eased = EASING(progress);
const left = fromLeft + (toLeft - fromLeft) * eased;
const width = fromWidth + (toWidth - fromWidth) * eased;
applyPill(left, width);
if (progress < 1) {
animationId = requestAnimationFrame(tick);
} else {
animationId = null;
}
}
animationId = requestAnimationFrame(tick);
}
function updateTarget() {
const measured = measureActiveTab();
if (!measured) return;
targetLeft = measured.left;
targetWidth = measured.width;
if (!mounted) {
// First render — snap immediately, no animation
applyPill(targetLeft, targetWidth);
mounted = true;
return;
}
// Always (re)start animation from current position — handles both fresh
// navigations and rapid route changes that interrupt a running animation
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
}
function updateTargetFromResize() {
const measured = measureActiveTab();
if (!measured) return;
const newLeft = measured.left;
const newWidth = measured.width;
// If nothing changed, skip
if (newLeft === targetLeft && newWidth === targetWidth) return;
targetLeft = newLeft;
targetWidth = newWidth;
if (animationId !== null) {
// Animation in progress — retarget it smoothly by starting a new
// animation from the current interpolated position to the new target
cancelAnimationFrame(animationId);
animationId = null;
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
} else {
// No animation running — snap (this handles window resize, etc.)
applyPill(targetLeft, targetWidth);
}
}
// Start animation when route changes
$effect(() => {
page.url.pathname;
profileTab.href;
requestAnimationFrame(() => {
updateTarget();
});
});
// Track the active tab's size during label transitions and window resizes
$effect(() => {
if (!containerRef) return;
const observer = new ResizeObserver(() => {
updateTargetFromResize();
});
observer.observe(containerRef);
for (const ref of tabRefs) {
if (ref) observer.observe(ref);
}
return () => observer.disconnect();
});
</script>
<nav class="w-full flex justify-center pt-5 px-5">
<nav class="w-full flex justify-center pt-5 px-3 sm:px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<!-- pointer-events-auto: root layout wraps nav in pointer-events-none overlay -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto">
{#each staticTabs as tab}
<div
class="relative flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto"
bind:this={containerRef}
>
<!-- Sliding pill — animated via JS (RAF) to stay smooth even when
heavy page transitions cause CSS transition skipping -->
<div
class="absolute top-1 bottom-1 left-0 rounded-md bg-background shadow-sm will-change-[transform,width]"
bind:this={pillRef}
></div>
{#each staticTabs as tab, i}
<a
href={tab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(tab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
bind:this={tabRefs[i]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(tab.href) ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
>
<tab.icon size={15} strokeWidth={2} />
{tab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(tab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{tab.label}</span>
</span>
</a>
{/each}
<a
href={profileTab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
bind:this={tabRefs[staticTabs.length]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(profileTab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
<User size={15} strokeWidth={2} />
{#if profileTab.label}{profileTab.label}{/if}
{#if profileTab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(profileTab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{profileTab.label}</span>
</span>
{/if}
</a>
<ThemeToggle />
</div>
@@ -1,76 +0,0 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
type Axis = "horizontal" | "vertical";
let {
key,
children,
axis = "horizontal",
inDelay = 0,
outDelay = 0,
}: {
key: string;
children: Snippet;
axis?: Axis;
inDelay?: number;
outDelay?: number;
} = $props();
const DURATION = 400;
const OFFSET = 40;
function translate(axis: Axis, value: number): string {
return axis === "vertical" ? `translateY(${value}px)` : `translateX(${value}px)`;
}
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}`,
};
}
const offset = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
const base = "position: absolute; top: 0; left: 0; width: 100%; height: 100%";
if (dir === "fade") {
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}`,
};
}
const offset = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
</script>
<div class="relative flex flex-1 flex-col">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
{@render children()}
</div>
{/key}
</div>
+118 -83
View File
@@ -8,7 +8,10 @@ const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{
transform: `translateX(${direction * 20}px)`,
opacity: 0,
},
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
@@ -20,11 +23,13 @@ let {
totalCount,
offset,
limit,
loading = false,
onPageChange,
}: {
totalCount: number;
offset: number;
limit: number;
loading?: boolean;
onPageChange: (newOffset: number) => void;
} = $props();
@@ -60,43 +65,53 @@ const selectValue = $derived(String(currentPage));
</script>
{#if totalCount > 0 && totalPages > 1}
<div class="flex items-start text-xs -mt-3 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<div class="flex items-start text-xs mt-2 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
rounded-md text-sm font-medium tabular-nums
border border-border bg-card text-foreground
hover:bg-muted/50 active:bg-muted transition-colors
cursor-pointer select-none outline-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
{loading ? 'animate-pulse' : ''}"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}
>{currentPage}</span
>
<ChevronUp
class="size-3 text-muted-foreground"
/>
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
max-h-72 min-w-16 w-auto
select-none rounded-md p-1
data-[state=open]:animate-in data-[state=closed]:animate-out
@@ -104,64 +119,84 @@ const selectValue = $derived(String(currentPage));
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=top]:slide-in-from-bottom-2
data-[side=bottom]:slide-in-from-top-2"
side="top"
sideOffset={6}
>
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
<ChevronUp class="size-3.5 text-muted-foreground" />
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
side="top"
sideOffset={6}
>
<Select.ScrollUpButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronUp
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
justify-center px-3 text-sm tabular-nums
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
data-[selected]:font-semibold"
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
<ChevronDown class="size-3.5 text-muted-foreground" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronDown
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
rounded-md text-sm tabular-nums
text-muted-foreground
hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors
cursor-pointer select-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
{isSlotVisible(page) ? '' : 'invisible pointer-events-none'}"
onclick={() => goToPage(page)}
aria-label="Go to page {page}"
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
{!isSlotVisible(page)
? 'invisible'
: loading
? 'opacity-40'
: ''}
{!isSlotVisible(page) || loading
? 'pointer-events-none'
: ''}"
onclick={() => goToPage(page)}
aria-label="Go to page {page}"
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page) || loading}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs -mt-3 pl-2">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs mt-2 pl-2">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
{/if}
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import LibRangeSlider from "svelte-range-slider-pips";
type LibProps = ComponentProps<LibRangeSlider>;
/**
* Two modes:
* - `dual` (default): bind `valueLow` and `valueHigh` for a two-thumb range.
* - `dual={false}`: bind `value` for a single-thumb slider. `valueLow`/`valueHigh` are ignored.
*
* All three are null when at their default (boundary) position.
*/
type Props = Omit<
LibProps,
| "values"
| "value"
| "formatter"
| "range"
| "min"
| "max"
| "float"
| "hoverable"
| "springValues"
> & {
min: number;
max: number;
label: string;
formatValue?: (v: number) => string;
dual?: boolean;
float?: boolean;
hoverable?: boolean;
springValues?: { stiffness?: number; damping?: number };
valueLow?: number | null;
valueHigh?: number | null;
value?: number | null;
};
let {
min,
max,
valueLow = $bindable(null),
valueHigh = $bindable(null),
value = $bindable(null),
label,
formatValue = (v: number) => String(v),
dual = true,
float = true,
hoverable = true,
// Intentionally snappier than library defaults (0.15/0.4)
springValues = { stiffness: 0.3, damping: 0.7 },
...libProps
}: Props = $props();
let internalValues = $state<number[]>([min, max]);
let internalValue = $state(max);
if (import.meta.env.DEV) {
$effect(() => {
if (min >= max) {
console.warn(`RangeSlider "${label}": min (${min}) must be less than max (${max})`);
}
});
}
// Sync external -> internal (equality guards prevent loops)
$effect(() => {
if (dual) {
const nextLow = valueLow ?? min;
const nextHigh = valueHigh ?? max;
if (internalValues[0] !== nextLow || internalValues[1] !== nextHigh) {
internalValues = [nextLow, nextHigh];
}
} else {
const next = value ?? max;
if (internalValue !== next) {
internalValue = next;
}
}
});
const isDefault = $derived(dual ? valueLow === null && valueHigh === null : value === null);
function handleDualChange(event: CustomEvent<{ values: number[] }>) {
const [low, high] = event.detail.values;
const nextLow = low === min && high === max ? null : low;
const nextHigh = low === min && high === max ? null : high;
if (nextLow === valueLow && nextHigh === valueHigh) return;
valueLow = nextLow;
valueHigh = nextHigh;
}
function handleSingleChange(event: CustomEvent<{ value: number }>) {
const next = event.detail.value === max ? null : event.detail.value;
if (next === value) return;
value = next;
}
</script>
<div class="range-slider-wrapper flex flex-col gap-1.5" role="group" aria-label={label}>
<div class="flex items-center justify-between select-none">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
{#if !isDefault}
<span class="text-xs text-muted-foreground">
{#if dual}
{formatValue(valueLow ?? min)} {formatValue(valueHigh ?? max)}
{:else}
{formatValue(value ?? max)}
{/if}
</span>
{/if}
</div>
<div class="pt-0.5">
{#if dual}
<LibRangeSlider
bind:values={internalValues}
{min}
{max}
{float}
{hoverable}
{springValues}
range
formatter={formatValue}
{...libProps}
on:change={handleDualChange}
/>
{:else}
<LibRangeSlider
bind:value={internalValue}
{min}
{max}
{float}
{hoverable}
{springValues}
formatter={formatValue}
{...libProps}
on:change={handleSingleChange}
/>
{/if}
</div>
</div>
<style>
/* Theme color mapping */
.range-slider-wrapper :global(.rangeSlider) {
--range-slider: var(--border);
--range-handle-inactive: var(--muted-foreground);
--range-handle: var(--muted-foreground);
--range-handle-focus: var(--foreground);
--range-handle-border: var(--muted-foreground);
--range-range-inactive: var(--muted-foreground);
--range-range: var(--foreground);
--range-range-hover: var(--foreground);
--range-range-press: var(--foreground);
--range-float-inactive: var(--card);
--range-float: var(--card);
--range-float-text: var(--card-foreground);
--range-range-limit: var(--muted);
font-size: 0.75rem;
margin: 0.5em;
height: 0.375em;
}
/* Smaller handles, plain circles */
.range-slider-wrapper :global(.rangeSlider .rangeHandle) {
height: 1em;
width: 1em;
}
.range-slider-wrapper :global(.rangeSlider.rsRange:not(.rsMin):not(.rsMax) .rangeNub) {
border-radius: 9999px;
}
.range-slider-wrapper :global(.rangeSlider.rsRange .rangeHandle .rangeNub) {
transform: none;
}
/* Hover / press effects */
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle:hover::before) {
box-shadow: 0 0 0 6px var(--handle-border);
opacity: 0.15;
}
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress::before),
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress:hover::before) {
box-shadow: 0 0 0 8px var(--handle-border);
opacity: 0.25;
}
/* Track bar */
.range-slider-wrapper :global(.rangeSlider .rangeBar),
.range-slider-wrapper :global(.rangeSlider .rangeLimit) {
height: 0.375em;
}
/* Float label */
.range-slider-wrapper :global(.rangeSlider .rangeFloat) {
font-size: 0.7em;
font-weight: 400;
line-height: 1;
padding: 0.25em 0.4em 0.35em;
border-radius: 0.375em;
border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Pip labels */
.range-slider-wrapper :global(.rangeSlider .rangePip .pipVal) {
color: var(--muted-foreground);
font-size: 0.6em;
font-weight: 400;
}
/* Pip spacing */
.range-slider-wrapper :global(.rangeSlider.rsPips) {
margin-bottom: 1.2em;
}
.range-slider-wrapper :global(.rangeSlider.rsPipLabels) {
margin-bottom: 2em;
}
</style>

Some files were not shown because too many files have changed in this diff Show More