15 Commits

Author SHA1 Message Date
b69c1eec54 chore(master): release 0.6.2 2026-01-31 14:20:22 -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
81 changed files with 2969 additions and 856 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
+32
View File
@@ -4,6 +4,38 @@ 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-01-31)
### Features
* **web:** Add dynamic range sliders with consolidated search options API ([f5a639e](https://github.com/Xevion/Banner/commit/f5a639e88bfe03dfc635f25e06fc22208ee0c855))
* **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))
* 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:** 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))
### Code Refactoring
* 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))
### 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
+153 -26
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,16 +19,29 @@ 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`.
const SEARCH_WHERE: &str = r#"
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
@@ -34,8 +49,40 @@ const SEARCH_WHERE: &str = r#"
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::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 +130,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 +149,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?;
@@ -247,3 +321,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,
})
}
+2 -1
View File
@@ -192,8 +192,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,
+90
View File
@@ -0,0 +1,90 @@
//! Standardized API error responses.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use ts_rs::TS;
/// 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 (e.g., "NOT_FOUND", "INVALID_TERM")
pub code: String,
/// 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: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
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("NOT_FOUND", message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new("BAD_REQUEST", message)
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new("INTERNAL_ERROR", message)
}
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
Self::new("INVALID_TERM", format!("Invalid term: {}", term))
}
fn status_code(&self) -> StatusCode {
match self.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"BAD_REQUEST" | "INVALID_TERM" | "INVALID_RANGE" => StatusCode::BAD_REQUEST,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
"FORBIDDEN" => StatusCode::FORBIDDEN,
_ => 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 => "NOT_FOUND",
StatusCode::BAD_REQUEST => "BAD_REQUEST",
StatusCode::UNAUTHORIZED => "UNAUTHORIZED",
StatusCode::FORBIDDEN => "FORBIDDEN",
_ => "INTERNAL_ERROR",
};
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))
}
+1
View File
@@ -9,6 +9,7 @@ pub mod auth;
pub mod calendar;
#[cfg(feature = "embed-assets")]
pub mod encoding;
pub mod error;
pub mod extractors;
pub mod routes;
pub mod schedule_cache;
+294 -165
View File
@@ -4,7 +4,6 @@ use axum::{
Extension, Router,
body::Body,
extract::{Path, Query, Request, State},
http::StatusCode as AxumStatusCode,
response::{Json, Response},
routing::{get, post, put},
};
@@ -12,6 +11,7 @@ use axum::{
use crate::web::admin_scraper;
use crate::web::auth::{self, AuthConfig};
use crate::web::calendar;
use crate::web::error::{ApiError, db_error};
use crate::web::timeline;
use crate::web::ws;
use crate::{data, web::admin};
@@ -52,9 +52,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 +290,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 +302,8 @@ async fn metrics(
"7d" => chrono::Duration::days(7),
"30d" => chrono::Duration::days(30),
_ => {
return Err((
AxumStatusCode::BAD_REQUEST,
return Err(ApiError::new(
"INVALID_RANGE",
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
));
}
@@ -321,13 +320,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 +354,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};
@@ -493,11 +526,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 +536,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,6 +545,32 @@ 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>,
}
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
fn build_course_response(
course: &models::Course,
@@ -533,6 +590,32 @@ fn build_course_response(
})
.collect();
let meeting_times = 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();
CourseResponse {
crn: course.crn.clone(),
subject: course.subject.clone(),
@@ -555,8 +638,8 @@ fn build_course_response(
link_identifier: course.link_identifier.clone(),
is_section_linked: course.is_section_linked,
part_of_term: course.part_of_term.clone(),
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
meeting_times,
attributes,
instructors,
}
}
@@ -565,15 +648,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 +668,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 +725,6 @@ async fn search_courses(
Ok(Json(SearchResponse {
courses: course_responses,
total_count: total_count as i32,
offset,
limit,
}))
}
@@ -632,17 +732,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 +744,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 +757,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 +779,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("NO_TERMS", "No terms available".to_string()))?;
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))
}
+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,
},
+18 -15
View File
@@ -5,6 +5,7 @@
"": {
"name": "banner-web",
"dependencies": {
"@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 +17,27 @@
},
"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",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^3.2.4",
},
},
},
@@ -137,6 +138,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=="],
+2 -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 ."
@@ -38,6 +38,7 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@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",
+2 -40
View File
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
@@ -60,9 +58,9 @@ describe("BannerApiClient", () => {
const result = await apiClient.searchCourses({
term: "202420",
subjects: ["CS"],
subject: ["CS"],
q: "data",
open_only: true,
openOnly: true,
limit: 25,
offset: 50,
});
@@ -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" },
+167 -118
View File
@@ -1,9 +1,14 @@
import { authStore } from "$lib/auth.svelte";
import type {
AdminStatusResponse,
ApiError,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DbMeetingTime,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
@@ -11,17 +16,32 @@ import type {
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
MetricEntry,
MetricsParams as MetricsParamsGenerated,
MetricsResponse,
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 +52,15 @@ const API_BASE_URL = "/api";
// Re-export generated types under their canonical names
export type {
AdminStatusResponse,
ApiError,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DbMeetingTime,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
@@ -43,16 +68,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,115 +101,13 @@ 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 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";
export interface AdminStatus {
userCount: number;
sessionCount: number;
courseCount: number;
scrapeJobCount: number;
services: { name: string; status: string }[];
}
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";
}
export interface ScrapeJobsResponse {
jobs: ScrapeJob[];
}
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;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
}
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;
@@ -181,6 +117,35 @@ export interface AdminInstructorListParams {
sort?: string;
}
/**
* API error class that wraps the structured ApiError response from the backend.
*/
export class ApiErrorClass extends Error {
public readonly code: string;
public readonly details: unknown | null;
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "ApiError";
this.code = apiError.code;
this.details = apiError.details;
}
isNotFound(): boolean {
return this.code === "NOT_FOUND";
}
isBadRequest(): boolean {
return (
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
);
}
isInternalError(): boolean {
return this.code === "INTERNAL_ERROR";
}
}
export class BannerApiClient {
private baseUrl: string;
private fetchFn: typeof fetch;
@@ -220,7 +185,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: "UNKNOWN_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
return (await response.json()) as T;
@@ -241,7 +216,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: "UNKNOWN_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
}
@@ -249,20 +234,63 @@ export class BannerApiClient {
return this.request<StatusResponse>("/status");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> {
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
if (params.subjects) {
for (const s of params.subjects) {
if (params.subject && params.subject.length > 0) {
for (const s of params.subject) {
query.append("subject", s);
}
}
if (params.q) query.set("q", params.q);
if (params.open_only) query.set("open_only", "true");
if (params.openOnly) query.set("open_only", "true");
if (params.courseNumberLow !== undefined && params.courseNumberLow !== null) {
query.set("course_number_low", String(params.courseNumberLow));
}
if (params.courseNumberHigh !== undefined && params.courseNumberHigh !== null) {
query.set("course_number_high", String(params.courseNumberHigh));
}
if (params.instructionalMethod && params.instructionalMethod.length > 0) {
for (const m of params.instructionalMethod) {
query.append("instructional_method", m);
}
}
if (params.campus && params.campus.length > 0) {
for (const c of params.campus) {
query.append("campus", c);
}
}
if (params.waitCountMax !== undefined && params.waitCountMax !== null) {
query.set("wait_count_max", String(params.waitCountMax));
}
if (params.days && params.days.length > 0) {
for (const d of params.days) {
query.append("days", d);
}
}
if (params.timeStart) query.set("time_start", params.timeStart);
if (params.timeEnd) query.set("time_end", params.timeEnd);
if (params.partOfTerm && params.partOfTerm.length > 0) {
for (const p of params.partOfTerm) {
query.append("part_of_term", p);
}
}
if (params.attributes && params.attributes.length > 0) {
for (const a of params.attributes) {
query.append("attributes", a);
}
}
if (params.creditHourMin !== undefined && params.creditHourMin !== null) {
query.set("credit_hour_min", String(params.creditHourMin));
}
if (params.creditHourMax !== undefined && params.creditHourMax !== null) {
query.set("credit_hour_max", String(params.creditHourMax));
}
if (params.instructor) query.set("instructor", params.instructor);
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);
if (params.sortBy) query.set("sort_by", params.sortBy);
if (params.sortDir) query.set("sort_dir", params.sortDir);
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
@@ -278,9 +306,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,16 +378,18 @@ 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> {
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
if (params?.courseId !== undefined && params.courseId !== null) {
query.set("course_id", String(params.courseId));
}
if (params?.term) query.set("term", params.term);
if (params?.crn) query.set("crn", params.crn);
if (params?.range) query.set("range", params.range);
+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>, };
+19
View File
@@ -0,0 +1,19 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "./serde_json/JsonValue";
/**
* Standardized error response for all API endpoints.
*/
export type ApiError = {
/**
* Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM")
*/
code: string,
/**
* Human-readable error message
*/
message: string,
/**
* Optional additional details (validation errors, field info, etc.)
*/
details: JsonValue | 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 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>, };
+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, };
@@ -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, };
+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, };
+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.
/**
* 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, };
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TimeRange = {
/**
* ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z")
*/
start: string,
/**
* ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z")
*/
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, };
+26
View File
@@ -1,28 +1,54 @@
export type { AdminServiceInfo } from "./AdminServiceInfo";
export type { AdminStatusResponse } from "./AdminStatusResponse";
export type { ApiError } from "./ApiError";
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 { DbMeetingTime } from "./DbMeetingTime";
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 { 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 { 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 { 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,118 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
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
);
function toggleValue(arr: string[], code: string): string[] {
return arr.includes(code) ? arr.filter((v) => v !== code) : [...arr, code];
}
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>
<Popover.Root>
<Popover.Trigger
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
{hasActiveFilters
? '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 hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
Attributes
<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 w-80 max-h-96 overflow-y-auto"
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">
{#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">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getSelected(key)}
<button
type="button"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer
{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}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
+14 -17
View File
@@ -13,7 +13,7 @@ import {
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 +24,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,8 +40,8 @@ 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"
>
@@ -71,11 +71,8 @@ const clipboard = useClipboard();
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
{/snippet}
{#snippet content()}
<div class="space-y-1.5">
<div class="font-medium">
{instructor.displayName}
@@ -126,8 +123,8 @@ const clipboard = useClipboard();
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/each}
</div>
{:else}
@@ -272,8 +269,8 @@ 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"
>
@@ -288,8 +285,8 @@ const clipboard = useClipboard();
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
{/snippet}
{#snippet content()}
Group <span class="font-mono font-medium"
>{course.crossList}</span
>
@@ -297,8 +294,8 @@ const clipboard = useClipboard();
{formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
</div>
{/if}
+64 -72
View File
@@ -24,13 +24,12 @@ import {
seatsDotColor,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import { formatNumber } from "$lib/utils";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Columns3,
ExternalLink,
RotateCcw,
Star,
@@ -44,10 +43,12 @@ import {
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
import { ContextMenu, DropdownMenu } from "bits-ui";
import { flip } from "svelte/animate";
import { fade, fly, slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { fade, slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
import RichTooltip from "./RichTooltip.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
@@ -57,6 +58,8 @@ let {
onSortingChange,
manualSorting = false,
subjectMap = {},
limit = 25,
columnVisibility = $bindable({}),
}: {
courses: CourseResponse[];
loading: boolean;
@@ -64,12 +67,35 @@ let {
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
limit?: number;
columnVisibility?: VisibilityState;
} = $props();
let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = undefined!;
let tableElement: HTMLTableElement = undefined!;
const clipboard = useClipboard(1000);
// Track previous row count so skeleton matches expected result size
let previousRowCount = $state(0);
$effect(() => {
if (courses.length > 0) {
previousRowCount = courses.length;
}
});
let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : limit);
// Animate container height via ResizeObserver
let contentHeight = $state<number | null>(null);
$effect(() => {
if (!tableElement) return;
const observer = new ResizeObserver(([entry]) => {
contentHeight = entry.contentRect.height;
});
observer.observe(tableElement);
return () => observer.disconnect();
});
// Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content
$effect(() => {
@@ -82,9 +108,6 @@ useOverlayScrollbars(() => tableWrapper, {
scrollbars: { autoHide: "never" },
});
// Column visibility state
let columnVisibility: VisibilityState = $state({});
function resetColumnVisibility() {
columnVisibility = {};
}
@@ -124,6 +147,11 @@ function timeIsTBA(course: CourseResponse): boolean {
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
}
// Calculate max subject code length for alignment
let maxSubjectLength = $derived(
courses.length > 0 ? Math.max(...courses.map((c) => c.subject.length)) : 3
);
// Column definitions
const columns: ColumnDef<CourseResponse, unknown>[] = [
{
@@ -265,50 +293,18 @@ const table = createSvelteTable({
{/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">
<div
bind:this={tableWrapper}
class="overflow-x-auto overflow-y-hidden transition-[height] duration-200"
style:height={contentHeight != null ? `${contentHeight}px` : undefined}
style:view-transition-name="search-results"
style:contain="layout"
data-search-results
>
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full min-w-160 border-collapse text-sm">
<table bind:this={tableElement} class="w-full min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr
@@ -368,7 +364,7 @@ const table = createSvelteTable({
</thead>
{#if loading && courses.length === 0}
<tbody>
{#each Array(5) as _}
{#each Array(skeletonRowCount) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
@@ -387,7 +383,7 @@ const table = createSvelteTable({
</tr>
{/each}
</tbody>
{:else if courses.length === 0}
{:else if courses.length === 0 && !loading}
<tbody>
<tr>
<td
@@ -403,10 +399,12 @@ const table = createSvelteTable({
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}"
animate:flip={{ duration: 300 }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
delay: Math.min(i * 25, 300),
easing: cubicOut,
}}
>
<tr
@@ -460,6 +458,11 @@ const table = createSvelteTable({
{:else if colId === "course_code"}
{@const subjectDesc =
subjectMap[course.subject]}
{@const paddedSubject =
course.subject.padStart(
maxSubjectLength,
" ",
)}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={subjectDesc
@@ -469,13 +472,7 @@ const table = createSvelteTable({
side="bottom"
passthrough
>
<span class="font-semibold"
>{course.subject}
{course.courseNumber}</span
>{#if course.sequenceNumber}<span
class="text-muted-foreground"
>-{course.sequenceNumber}</span
>{/if}
<span class="font-semibold font-mono tracking-tight whitespace-pre">{paddedSubject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground font-mono tracking-tight">-{course.sequenceNumber}</span>{/if}
</SimpleTooltip>
</td>
{:else if colId === "title"}
@@ -540,10 +537,12 @@ const table = createSvelteTable({
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
<RichTooltip
side="bottom"
sideOffset={6}
contentClass="px-2.5 py-1.5"
>
<Tooltip.Trigger>
{#snippet children()}
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
@@ -564,15 +563,8 @@ const table = createSvelteTable({
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
{/snippet}
{#snippet content()}
<span
class="inline-flex items-center gap-1.5 text-xs"
>
@@ -600,8 +592,8 @@ const table = createSvelteTable({
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/if}
</td>
{:else if colId === "time"}
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { X } from "@lucide/svelte";
let {
label,
onRemove,
onclick,
}: {
label: string;
onRemove: () => void;
onclick?: () => void;
} = $props();
</script>
<span
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-foreground"
>
<button
type="button"
class="hover:text-foreground/80 transition-colors cursor-pointer"
onclick={onclick}
>
{label}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
onclick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="Remove {label} filter"
>
<X class="size-3" />
</button>
</span>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import RangeSlider from "./RangeSlider.svelte";
let {
creditHourMin = $bindable<number | null>(null),
creditHourMax = $bindable<number | null>(null),
instructor = $bindable(""),
courseNumberLow = $bindable<number | null>(null),
courseNumberHigh = $bindable<number | null>(null),
ranges,
}: {
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberLow: number | null;
courseNumberHigh: number | null;
ranges: { courseNumber: { min: number; max: number }; creditHours: { min: number; max: number } };
} = $props();
const hasActiveFilters = $derived(
creditHourMin !== null ||
creditHourMax !== null ||
instructor !== "" ||
courseNumberLow !== null ||
courseNumberHigh !== null
);
</script>
<Popover.Root>
<Popover.Trigger
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
{hasActiveFilters
? '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 hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
More
<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 w-72"
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">
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
/>
<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">
Instructor
</label>
<input
id="instructor-input"
type="text"
placeholder="Search by name..."
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={courseNumberLow}
bind:valueHigh={courseNumberHigh}
label="Course number"
/>
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
+4 -3
View File
@@ -48,7 +48,8 @@ function inTransition(_node: HTMLElement): TransitionConfig {
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
const base = "position: absolute; top: 0; left: 0; width: 100%; height: 100%";
const base =
"position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none";
if (dir === "fade") {
return {
duration: DURATION,
@@ -67,9 +68,9 @@ function outTransition(_node: HTMLElement): TransitionConfig {
}
</script>
<div class="relative flex flex-1 flex-col">
<div class="relative flex flex-1 flex-col overflow-hidden p-8">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
<div in:inTransition out:outTransition class="flex flex-1 flex-col -m-8">
{@render children()}
</div>
{/key}
+112 -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,50 @@ 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">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</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 +116,81 @@ 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">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
</div>
{/if}
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
let {
min,
max,
step = 1,
valueLow = $bindable<number | null>(null),
valueHigh = $bindable<number | null>(null),
label,
formatValue = (v: number) => String(v),
dual = true,
}: {
min: number;
max: number;
step?: number;
valueLow: number | null;
valueHigh: number | null;
label: string;
formatValue?: (v: number) => string;
dual?: boolean;
} = $props();
// Internal slider values — full range when filter is null (inactive)
let internalLow = $state(0);
let internalHigh = $state(0);
// Sync external → internal when props change (e.g., reset)
$effect(() => {
internalLow = valueLow ?? min;
internalHigh = valueHigh ?? max;
});
// Whether the slider is at its default (full range) position
const isDefault = $derived(internalLow === min && internalHigh === max);
function commitLow(value: number) {
internalLow = value;
// At full range = no filter
if (value === min && internalHigh === max) {
valueLow = null;
valueHigh = null;
} else {
valueLow = value;
if (valueHigh === null) valueHigh = internalHigh;
}
}
function commitHigh(value: number) {
internalHigh = value;
if (internalLow === min && value === max) {
valueLow = null;
valueHigh = null;
} else {
valueHigh = value;
if (valueLow === null) valueLow = internalLow;
}
}
function commitSingle(value: number) {
internalHigh = value;
valueHigh = value === 0 ? null : value;
}
</script>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
{#if !isDefault}
<span class="text-xs text-muted-foreground">
{#if dual}
{formatValue(internalLow)} {formatValue(internalHigh)}
{:else}
{formatValue(internalHigh)}
{/if}
</span>
{/if}
</div>
{#if dual}
<div class="flex items-center gap-2">
<input
type="range"
{min}
max={internalHigh}
{step}
value={internalLow}
oninput={(e) => commitLow(Number(e.currentTarget.value))}
class="flex-1 accent-primary h-1.5"
/>
<input
type="range"
min={internalLow}
{max}
{step}
value={internalHigh}
oninput={(e) => commitHigh(Number(e.currentTarget.value))}
class="flex-1 accent-primary h-1.5"
/>
</div>
{:else}
<input
type="range"
min={0}
{max}
{step}
value={internalHigh}
oninput={(e) => commitSingle(Number(e.currentTarget.value))}
class="w-full accent-primary h-1.5"
/>
{/if}
</div>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
let {
delay,
side = "top",
sideOffset = 6,
passthrough = false,
triggerClass = "",
contentClass = "",
portal = true,
avoidCollisions = true,
collisionPadding = 8,
children,
content,
}: {
delay?: number;
side?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
passthrough?: boolean;
triggerClass?: string;
contentClass?: string;
portal?: boolean;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
content: Snippet;
} = $props();
</script>
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
<Tooltip.Trigger>
{#snippet child({ props })}
<span class={triggerClass} {...props}>
{@render children()}
</span>
{/snippet}
</Tooltip.Trigger>
{#if portal}
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
</Tooltip.Portal>
{:else}
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
{/if}
</Tooltip.Root>
@@ -0,0 +1,153 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
let {
days = $bindable<string[]>([]),
timeStart = $bindable<string | null>(null),
timeEnd = $bindable<string | null>(null),
}: {
days: string[];
timeStart: string | null;
timeEnd: string | null;
} = $props();
const DAY_OPTIONS: { label: string; value: string }[] = [
{ label: "M", value: "monday" },
{ label: "T", value: "tuesday" },
{ label: "W", value: "wednesday" },
{ label: "Th", value: "thursday" },
{ label: "F", value: "friday" },
{ label: "Sa", value: "saturday" },
{ label: "Su", value: "sunday" },
];
const hasActiveFilters = $derived(days.length > 0 || timeStart !== null || timeEnd !== null);
function toggleDay(day: string) {
if (days.includes(day)) {
days = days.filter((d) => d !== day);
} else {
days = [...days, day];
}
}
/** Convert "10:00 AM" or "14:30" input to 24h string like "1000" or "1430" */
function parseTimeInput(input: string): string | null {
const trimmed = input.trim();
if (trimmed === "") return null;
// Try HH:MM AM/PM format
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (ampmMatch) {
let hours = parseInt(ampmMatch[1], 10);
const minutes = parseInt(ampmMatch[2], 10);
const period = ampmMatch[3].toUpperCase();
if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
// Try HH:MM 24h format
const militaryMatch = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (militaryMatch) {
const hours = parseInt(militaryMatch[1], 10);
const minutes = parseInt(militaryMatch[2], 10);
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
return null;
}
/** Convert 24h string like "1000" to "10:00 AM" for display */
function formatTime(time: string | null): string {
if (time === null || time.length !== 4) return "";
const hours = parseInt(time.slice(0, 2), 10);
const minutes = time.slice(2);
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${displayHours}:${minutes} ${period}`;
}
</script>
<Popover.Root>
<Popover.Trigger
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
{hasActiveFilters
? '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 hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
Schedule
<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 w-72"
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">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(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">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
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">to</span>
<input
type="text"
placeholder="3:00 PM"
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>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
+66 -9
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import type { Subject, Term } from "$lib/api";
import SimpleTooltip from "./SimpleTooltip.svelte";
import type { CodeDescription, Subject, Term } from "$lib/api";
import AttributesPopover from "./AttributesPopover.svelte";
import MorePopover from "./MorePopover.svelte";
import SchedulePopover from "./SchedulePopover.svelte";
import StatusPopover from "./StatusPopover.svelte";
import SubjectCombobox from "./SubjectCombobox.svelte";
import TermCombobox from "./TermCombobox.svelte";
@@ -11,6 +14,21 @@ let {
selectedSubjects = $bindable(),
query = $bindable(),
openOnly = $bindable(),
waitCountMax = $bindable(),
days = $bindable(),
timeStart = $bindable(),
timeEnd = $bindable(),
instructionalMethod = $bindable(),
campus = $bindable(),
partOfTerm = $bindable(),
attributes = $bindable(),
creditHourMin = $bindable(),
creditHourMax = $bindable(),
instructor = $bindable(),
courseNumberLow = $bindable(),
courseNumberHigh = $bindable(),
referenceData,
ranges,
}: {
terms: Term[];
subjects: Subject[];
@@ -18,9 +36,34 @@ let {
selectedSubjects: string[];
query: string;
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;
courseNumberLow: number | null;
courseNumberHigh: 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();
</script>
<!-- Row 1: Primary filters -->
<div class="flex flex-wrap gap-3 items-start">
<TermCombobox {terms} bind:value={selectedTerm} />
@@ -35,11 +78,25 @@ let {
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
transition-colors"
/>
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
<label class="flex items-center gap-1.5 h-9 text-sm text-muted-foreground cursor-pointer">
<input type="checkbox" bind:checked={openOnly} />
Open only
</label>
</SimpleTooltip>
</div>
<!-- Row 2: Category popovers -->
<div class="flex flex-wrap gap-2 items-center">
<StatusPopover bind:openOnly bind:waitCountMax waitCountMaxRange={ranges.waitCount.max} />
<SchedulePopover bind:days bind:timeStart bind:timeEnd />
<AttributesPopover
bind:instructionalMethod
bind:campus
bind:partOfTerm
bind:attributes
{referenceData}
/>
<MorePopover
bind:creditHourMin
bind:creditHourMax
bind:instructor
bind:courseNumberLow
bind:courseNumberHigh
ranges={{ courseNumber: ranges.courseNumber, creditHours: ranges.creditHours }}
/>
</div>
+24 -15
View File
@@ -3,6 +3,7 @@ import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
import { onMount } from "svelte";
import { fade } from "svelte/transition";
export interface SearchMeta {
totalCount: number;
@@ -10,7 +11,7 @@ export interface SearchMeta {
timestamp: Date;
}
let { meta }: { meta: SearchMeta | null } = $props();
let { meta, loading = false }: { meta: SearchMeta | null; loading?: boolean } = $props();
let now = $state(new Date());
@@ -51,18 +52,26 @@ onMount(() => {
});
</script>
<SimpleTooltip
text={tooltipText}
contentClass="whitespace-nowrap text-[12px] px-2 py-1"
triggerClass="self-start"
sideOffset={0}
>
<span
class="pl-1 text-xs transition-opacity duration-200"
style:opacity={meta ? 1 : 0}
{#if meta}
<SimpleTooltip
text={tooltipText}
contentClass="whitespace-nowrap text-[12px]/1 px-2"
sideOffset={0}
>
<span class="text-muted-foreground/70">{countLabel}</span>
<span class="text-muted-foreground/35">{resultNoun} in</span>
<span class="text-muted-foreground/70">{durationLabel}</span>
</span>
</SimpleTooltip>
<span
class="pl-1 text-xs transition-opacity duration-200 {loading
? 'opacity-40'
: ''}"
in:fade={{ duration: 300 }}
>
<span class="text-muted-foreground/70">{countLabel}</span>
<span class="text-muted-foreground/35">{resultNoun} in</span>
<span class="text-muted-foreground/70">{durationLabel}</span>
</span>
</SimpleTooltip>
{:else}
<!-- Invisible placeholder to maintain layout height -->
<span class="pl-1 text-xs opacity-0 pointer-events-none" aria-hidden="true"
>&nbsp;</span
>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
import { X } from "@lucide/svelte";
let {
segments,
onRemoveSegment,
onRemoveAll,
}: {
segments: string[];
onRemoveSegment: (segment: string) => void;
onRemoveAll: () => void;
} = $props();
</script>
{#if segments.length > 0}
<span
class="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs text-foreground"
>
{#each segments as segment, i}
{#if i > 0}
<span class="w-px self-stretch bg-border"></span>
{/if}
<button
type="button"
class="px-2 py-0.5 hover:bg-muted/60 transition-colors cursor-pointer first:rounded-l-full"
onclick={() => onRemoveSegment(segment)}
aria-label="Remove {segment} filter"
>
{segment}
</button>
{/each}
<span class="w-px self-stretch bg-border"></span>
<button
type="button"
class="inline-flex items-center justify-center px-1.5 py-0.5 rounded-r-full hover:bg-muted/60 transition-colors cursor-pointer"
onclick={onRemoveAll}
aria-label="Remove all subject filters"
>
<X class="size-3" />
</button>
</span>
{/if}
+29 -7
View File
@@ -11,6 +11,9 @@ let {
triggerClass = "",
contentClass = "",
sideOffset = 6,
portal = true,
avoidCollisions = true,
collisionPadding = 8,
children,
}: {
text: string;
@@ -20,6 +23,9 @@ let {
triggerClass?: string;
contentClass?: string;
sideOffset?: number;
portal?: boolean;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
} = $props();
</script>
@@ -32,11 +38,27 @@ let {
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
{side}
{sideOffset}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
{#if portal}
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
</Tooltip.Portal>
{:else}
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
{/if}
</Tooltip.Root>
@@ -0,0 +1,84 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import RangeSlider from "./RangeSlider.svelte";
let {
openOnly = $bindable(false),
waitCountMax = $bindable<number | null>(null),
waitCountMaxRange = 0,
}: {
openOnly: boolean;
waitCountMax: number | null;
waitCountMaxRange: number;
} = $props();
let _dummyLow = $state<number | null>(null);
const hasActiveFilters = $derived(openOnly || waitCountMax !== null);
</script>
<Popover.Root>
<Popover.Trigger
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
{hasActiveFilters
? '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 hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
Status
<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 w-64"
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">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Availability</span>
<button
type="button"
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer
{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 waitCountMaxRange > 0}
<RangeSlider
min={0}
max={waitCountMaxRange}
step={10}
bind:valueLow={_dummyLow}
bind:valueHigh={waitCountMax}
label="Max waitlist"
dual={false}
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">Max waitlist</span>
<span class="text-xs text-muted-foreground">No waitlisted courses</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
+61
View File
@@ -0,0 +1,61 @@
/**
* Utilities for ISO-8601 date string validation and conversion.
*
* All DateTime<Utc> fields from Rust are serialized as ISO-8601 strings.
*/
/**
* Validates if a string is a valid ISO-8601 date string.
*
* @param value - The string to validate
* @returns True if the string is a valid ISO-8601 date
*/
export function isValidISODate(value: string): boolean {
try {
const date = new Date(value);
return !isNaN(date.getTime()) && date.toISOString() === value;
} catch {
return false;
}
}
/**
* Parses an ISO-8601 date string to a Date object.
*
* @param value - The ISO-8601 string to parse
* @returns Date object, or null if invalid
*/
export function parseISODate(value: string): Date | null {
try {
const date = new Date(value);
if (isNaN(date.getTime())) {
return null;
}
return date;
} catch {
return null;
}
}
/**
* Asserts that a string is a valid ISO-8601 date, throwing if not.
*
* @param value - The string to validate
* @param fieldName - Name of the field for error messages
* @throws Error if the string is not a valid ISO-8601 date
*/
export function assertISODate(value: string, fieldName = "date"): void {
if (!isValidISODate(value)) {
throw new Error(`Invalid ISO-8601 date for ${fieldName}: ${value}`);
}
}
/**
* Converts a Date to an ISO-8601 UTC string.
*
* @param date - The Date object to convert
* @returns ISO-8601 string in UTC (e.g., "2024-01-15T10:30:00Z")
*/
export function toISOString(date: Date): string {
return date.toISOString();
}
+5 -3
View File
@@ -5,7 +5,7 @@
* the missing segments when the view expands into unloaded territory.
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
*/
import { type TimelineRange, client } from "$lib/api";
import { type TimeRange, client } from "$lib/api";
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
@@ -74,7 +74,7 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
* Converts gap ranges into the API request format.
*/
async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
const ranges: TimelineRange[] = gaps.map(([start, end]) => ({
const ranges: TimeRange[] = gaps.map(([start, end]) => ({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}));
@@ -83,7 +83,9 @@ async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
return response.slots.map((slot) => ({
time: new Date(slot.time),
subjects: slot.subjects,
subjects: Object.fromEntries(
Object.entries(slot.subjects).map(([k, v]) => [k, Number(v)])
) as Record<string, number>,
}));
}
+5 -22
View File
@@ -1,21 +1,4 @@
import type { ScrapeJob } from "$lib/api";
export type ScrapeJobStatus = "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
export type ScrapeJobEvent =
| { type: "init"; jobs: ScrapeJob[] }
| { type: "jobCreated"; job: ScrapeJob }
| { 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 };
import type { ScrapeJobDto, ScrapeJobEvent } from "$lib/bindings";
export type ConnectionState = "connected" | "reconnecting" | "disconnected";
@@ -29,7 +12,7 @@ const PRIORITY_ORDER: Record<string, number> = {
const MAX_RECONNECT_DELAY = 30_000;
const MAX_RECONNECT_ATTEMPTS = 10;
function sortJobs(jobs: Iterable<ScrapeJob>): ScrapeJob[] {
function sortJobs(jobs: Iterable<ScrapeJobDto>): ScrapeJobDto[] {
return Array.from(jobs).sort((a, b) => {
const pa = PRIORITY_ORDER[a.priority.toLowerCase()] ?? 2;
const pb = PRIORITY_ORDER[b.priority.toLowerCase()] ?? 2;
@@ -40,7 +23,7 @@ function sortJobs(jobs: Iterable<ScrapeJob>): ScrapeJob[] {
export class ScrapeJobsStore {
private ws: WebSocket | null = null;
private jobs = new Map<number, ScrapeJob>();
private jobs = new Map<number, ScrapeJobDto>();
private _connectionState: ConnectionState = "disconnected";
private _initialized = false;
private onUpdate: () => void;
@@ -49,14 +32,14 @@ export class ScrapeJobsStore {
private intentionalClose = false;
/** Cached sorted array, invalidated on data mutations. */
private cachedJobs: ScrapeJob[] = [];
private cachedJobs: ScrapeJobDto[] = [];
private cacheDirty = false;
constructor(onUpdate: () => void) {
this.onUpdate = onUpdate;
}
getJobs(): ScrapeJob[] {
getJobs(): ScrapeJobDto[] {
if (this.cacheDirty) {
this.cachedJobs = sortJobs(this.jobs.values());
this.cacheDirty = false;
+2 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { type AdminStatus, client } from "$lib/api";
import { type AdminStatusResponse, client } from "$lib/api";
import { formatNumber } from "$lib/utils";
import { onMount } from "svelte";
let status = $state<AdminStatus | null>(null);
let status = $state<AdminStatusResponse | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { type ScrapeJob, client } from "$lib/api";
import { type ScrapeJobDto, client } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { formatAbsoluteDate } from "$lib/date";
import { formatDuration } from "$lib/time";
@@ -14,7 +14,7 @@ import {
} from "@tanstack/table-core";
import { onMount } from "svelte";
let jobs = $state<ScrapeJob[]>([]);
let jobs = $state<ScrapeJobDto[]>([]);
let connectionState = $state<ConnectionState>("disconnected");
let initialized = $state(false);
let error = $state<string | null>(null);
@@ -97,7 +97,7 @@ function handleSortingChange(updater: Updater<SortingState>) {
// --- Helper functions ---
function formatJobDetails(job: ScrapeJob, subjects: Map<string, string>): string {
function formatJobDetails(job: ScrapeJobDto, subjects: Map<string, string>): string {
const payload = job.targetPayload as Record<string, unknown>;
switch (job.targetType) {
case "Subject": {
@@ -169,7 +169,7 @@ function overdueDurationColor(ms: number): string {
// --- Table columns ---
const columns: ColumnDef<ScrapeJob, unknown>[] = [
const columns: ColumnDef<ScrapeJobDto, unknown>[] = [
{
id: "id",
accessorKey: "id",
@@ -265,7 +265,7 @@ const skeletonWidths: Record<string, string> = {
// Unified timing display: shows the most relevant duration for the job's current state.
// Uses _tick dependency so Svelte re-evaluates every second.
function getTimingDisplay(
job: ScrapeJob,
job: ScrapeJobDto,
_tick: number
): { text: string; colorClass: string; icon: "warning" | "none"; tooltip: string } {
const now = Date.now();
@@ -71,12 +71,14 @@ function scheduleTick() {
const MIN_INTERVAL = 5_000;
const MAX_INTERVAL = 60_000;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let destroyed = false;
const MIN_SPIN_MS = 700;
let spinnerVisible = $state(false);
let spinHoldTimer: ReturnType<typeof setTimeout> | undefined;
async function fetchAll() {
if (destroyed) return;
refreshError = false;
spinnerVisible = true;
clearTimeout(spinHoldTimer);
@@ -88,16 +90,19 @@ async function fetchAll() {
client.getScraperTimeseries(selectedPeriod),
client.getScraperSubjects(),
]);
if (destroyed) return;
stats = statsRes;
timeseries = timeseriesRes;
subjects = subjectsRes.subjects;
error = null;
refreshInterval = MIN_INTERVAL;
} catch (e) {
if (destroyed) return;
error = e instanceof Error ? e.message : "Failed to load scraper data";
refreshError = true;
refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL);
} finally {
if (destroyed) return;
const elapsed = performance.now() - startedAt;
const remaining = MIN_SPIN_MS - elapsed;
if (remaining > 0) {
@@ -112,6 +117,7 @@ async function fetchAll() {
}
function scheduleRefresh() {
if (destroyed) return;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(fetchAll, refreshInterval);
}
@@ -302,20 +308,27 @@ const detailGridCols = "grid-cols-[7fr_5fr_3fr_4fr_4fr_3fr_4fr_minmax(6rem,1fr)]
// --- Lifecycle ---
onMount(() => {
destroyed = false;
mounted = true;
fetchAll();
scheduleTick();
});
onDestroy(() => {
destroyed = true;
mounted = false;
clearTimeout(tickTimer);
clearTimeout(refreshTimer);
clearTimeout(spinHoldTimer);
});
// Refetch when period changes
// Refetch when period changes (skip initial run since onMount handles it)
let mounted = false;
$effect(() => {
void selectedPeriod;
fetchAll();
if (mounted && !destroyed) {
fetchAll();
}
});
</script>
+1 -1
View File
@@ -39,7 +39,7 @@ onMount(() => {
});
</script>
<Tooltip.Provider>
<Tooltip.Provider delayDuration={150} skipDelayDuration={50}>
<div class="relative flex min-h-screen flex-col">
<!-- pointer-events-none so the navbar doesn't block canvas interactions;
NavBar re-enables pointer-events on its own container. -->
+682 -102
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
type CodeDescription,
type SearchOptionsResponse,
type SearchResponse,
type SortColumn,
type SortDirection,
@@ -8,12 +10,17 @@ import {
client,
} from "$lib/api";
import CourseTable from "$lib/components/CourseTable.svelte";
import FilterChip from "$lib/components/FilterChip.svelte";
import Footer from "$lib/components/Footer.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte";
import type { SortingState } from "@tanstack/table-core";
import { untrack } from "svelte";
import SegmentedChip from "$lib/components/SegmentedChip.svelte";
import { Check, Columns3, RotateCcw } from "@lucide/svelte";
import type { SortingState, VisibilityState } from "@tanstack/table-core";
import { DropdownMenu } from "bits-ui";
import { tick, untrack } from "svelte";
import { fly } from "svelte/transition";
let { data } = $props();
@@ -21,12 +28,23 @@ let { data } = $props();
const initialParams = untrack(() => new URLSearchParams(data.url.search));
// The default term is the first one returned by the backend (most current)
const defaultTermSlug = data.terms[0]?.slug ?? "";
const defaultTermSlug = untrack(() => data.searchOptions?.terms[0]?.slug ?? "");
// Helper to parse a URL param as a number or null
function parseNumParam(key: string): number | null {
const v = initialParams.get(key);
if (v === null || v === "") return null;
const n = Number(v);
return Number.isNaN(n) ? null : n;
}
// Default to the first term when no URL param is present
const urlTerm = initialParams.get("term");
let selectedTerm = $state(
urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug
untrack(() => {
const terms = data.searchOptions?.terms ?? [];
return urlTerm && terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug;
})
);
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
let query = $state(initialParams.get("q") ?? "");
@@ -34,6 +52,52 @@ let openOnly = $state(initialParams.get("open") === "true");
let offset = $state(Number(initialParams.get("offset")) || 0);
const limit = 25;
// New filter state from URL
let waitCountMax = $state<number | null>(parseNumParam("wait_count_max"));
let days: string[] = $state(initialParams.getAll("days"));
let timeStart = $state<string | null>(initialParams.get("time_start"));
let timeEnd = $state<string | null>(initialParams.get("time_end"));
let instructionalMethod: string[] = $state(initialParams.getAll("instructional_method"));
let campus: string[] = $state(initialParams.getAll("campus"));
let partOfTerm: string[] = $state(initialParams.getAll("part_of_term"));
let attributes: string[] = $state(initialParams.getAll("attributes"));
let creditHourMin = $state<number | null>(parseNumParam("credit_hour_min"));
let creditHourMax = $state<number | null>(parseNumParam("credit_hour_max"));
let instructor = $state(initialParams.get("instructor") ?? "");
let courseNumberLow = $state<number | null>(parseNumParam("course_number_low"));
let courseNumberHigh = $state<number | null>(parseNumParam("course_number_high"));
let searchOptions = $state<SearchOptionsResponse | null>(null);
// Sync data prop to local state
$effect(() => {
searchOptions = data.searchOptions;
});
// Derived from search options
const terms = $derived(searchOptions?.terms ?? []);
const subjects: Subject[] = $derived(searchOptions?.subjects ?? []);
let subjectMap: Record<string, string> = $derived(
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
);
const referenceData = $derived({
instructionalMethods: searchOptions?.reference.instructionalMethods ?? [],
campuses: searchOptions?.reference.campuses ?? [],
partsOfTerm: searchOptions?.reference.partsOfTerm ?? [],
attributes: searchOptions?.reference.attributes ?? [],
});
const ranges = $derived(
searchOptions?.ranges ?? {
courseNumberMin: 1000,
courseNumberMax: 9000,
creditHourMin: 0,
creditHourMax: 8,
waitCountMax: 0,
}
);
// Sorting state — maps TanStack column IDs to server sort params
const SORT_COLUMN_MAP: Record<string, SortColumn> = {
course_code: "course_code",
@@ -58,47 +122,92 @@ function handleSortingChange(newSorting: SortingState) {
}
// Data state
let subjects: Subject[] = $state([]);
let subjectMap: Record<string, string> = $derived(
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
);
let searchResult: SearchResponse | null = $state(null);
let searchMeta: SearchMeta | null = $state(null);
let loading = $state(false);
let error = $state<string | null>(null);
// Fetch subjects when term changes
// Track if we're validating subjects to prevent cascading search
let validatingSubjects = false;
// Fetch new search options when term changes
$effect(() => {
const term = selectedTerm;
if (!term) return;
client
.getSubjects(term)
.then((s) => {
subjects = s;
const validCodes = new Set(s.map((sub) => sub.code));
selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
.getSearchOptions(term)
.then((opts) => {
searchOptions = opts;
// Validate selected subjects against new term's subjects
const validCodes = new Set(opts.subjects.map((s) => s.code));
const filtered = selectedSubjects.filter((code) => validCodes.has(code));
if (filtered.length !== selectedSubjects.length) {
validatingSubjects = true;
selectedSubjects = filtered;
validatingSubjects = false;
}
})
.catch((e) => {
console.error("Failed to fetch subjects:", e);
console.error("Failed to fetch search options:", e);
});
});
// Centralized throttle configuration - maps trigger source to throttle delay (ms)
const THROTTLE_MS = {
term: 0, // Immediate
subjects: 100, // Short delay for combobox selection
query: 300, // Standard input debounce
openOnly: 0, // Immediate
offset: 0, // Immediate (pagination)
sorting: 0, // Immediate (column sort)
term: 0,
subjects: 100,
query: 300,
openOnly: 0,
offset: 0,
sorting: 0,
waitCountMax: 300,
days: 100,
timeStart: 300,
timeEnd: 300,
instructionalMethod: 100,
campus: 100,
partOfTerm: 100,
attributes: 100,
creditHourMin: 300,
creditHourMax: 300,
instructor: 300,
courseNumberLow: 300,
courseNumberHigh: 300,
} as const;
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let lastSearchKey = "";
function buildSearchKey(): string {
return [
selectedTerm,
selectedSubjects.join(","),
query,
openOnly,
offset,
JSON.stringify(sorting),
waitCountMax,
days.join(","),
timeStart,
timeEnd,
instructionalMethod.join(","),
campus.join(","),
partOfTerm.join(","),
attributes.join(","),
creditHourMin,
creditHourMax,
instructor,
courseNumberLow,
courseNumberHigh,
].join("|");
}
function scheduleSearch(source: keyof typeof THROTTLE_MS) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting);
const key = buildSearchKey();
if (key === lastSearchKey) return;
performSearch();
}, THROTTLE_MS[source]);
}
@@ -111,7 +220,9 @@ $effect(() => {
$effect(() => {
selectedSubjects;
scheduleSearch("subjects");
if (!validatingSubjects) {
scheduleSearch("subjects");
}
return () => clearTimeout(searchTimeout);
});
@@ -139,66 +250,214 @@ $effect(() => {
return () => clearTimeout(searchTimeout);
});
$effect(() => {
waitCountMax;
scheduleSearch("waitCountMax");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
days;
scheduleSearch("days");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
timeStart;
scheduleSearch("timeStart");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
timeEnd;
scheduleSearch("timeEnd");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
instructionalMethod;
scheduleSearch("instructionalMethod");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
campus;
scheduleSearch("campus");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
partOfTerm;
scheduleSearch("partOfTerm");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
attributes;
scheduleSearch("attributes");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
creditHourMin;
scheduleSearch("creditHourMin");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
creditHourMax;
scheduleSearch("creditHourMax");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
instructor;
scheduleSearch("instructor");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
courseNumberLow;
scheduleSearch("courseNumberLow");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
courseNumberHigh;
scheduleSearch("courseNumberHigh");
return () => clearTimeout(searchTimeout);
});
// Build a filter key that excludes offset/sorting — used to detect filter changes for offset reset
function buildFilterKey(): string {
return [
selectedTerm,
selectedSubjects.join(","),
query,
openOnly,
waitCountMax,
days.join(","),
timeStart,
timeEnd,
instructionalMethod.join(","),
campus.join(","),
partOfTerm.join(","),
attributes.join(","),
creditHourMin,
creditHourMax,
instructor,
courseNumberLow,
courseNumberHigh,
].join("|");
}
// Reset offset when filters change (not offset itself)
let prevFilters = $state("");
$effect(() => {
const key = `${selectedTerm}|${selectedSubjects.join(",")}|${query}|${openOnly}`;
const key = buildFilterKey();
if (prevFilters && key !== prevFilters) {
offset = 0;
}
prevFilters = key;
});
async function performSearch(
term: string,
subjects: string[],
q: string,
open: boolean,
off: number,
sort: SortingState
) {
if (!term) return;
async function performSearch() {
if (!selectedTerm) return;
const key = buildSearchKey();
lastSearchKey = key;
loading = true;
error = null;
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
const sortBy = sorting.length > 0 ? SORT_COLUMN_MAP[sorting[0].id] : undefined;
const sortDir: SortDirection | undefined =
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : undefined;
// Build URL params for browser URL sync
const params = new URLSearchParams();
for (const s of subjects) {
for (const s of selectedSubjects) {
params.append("subject", s);
}
if (q) params.set("q", q);
if (open) params.set("open", "true");
if (off > 0) params.set("offset", String(off));
if (query) params.set("q", query);
if (openOnly) params.set("open", "true");
if (offset > 0) params.set("offset", String(offset));
if (sortBy) params.set("sort_by", sortBy);
if (sortDir && sortBy) params.set("sort_dir", sortDir);
if (waitCountMax !== null) params.set("wait_count_max", String(waitCountMax));
for (const d of days) params.append("days", d);
if (timeStart) params.set("time_start", timeStart);
if (timeEnd) params.set("time_end", timeEnd);
for (const m of instructionalMethod) params.append("instructional_method", m);
for (const c of campus) params.append("campus", c);
for (const p of partOfTerm) params.append("part_of_term", p);
for (const a of attributes) params.append("attributes", a);
if (creditHourMin !== null) params.set("credit_hour_min", String(creditHourMin));
if (creditHourMax !== null) params.set("credit_hour_max", String(creditHourMax));
if (instructor) params.set("instructor", instructor);
if (courseNumberLow !== null) params.set("course_number_low", String(courseNumberLow));
if (courseNumberHigh !== null) params.set("course_number_high", String(courseNumberHigh));
// Include term in URL only when it differs from the default or other params are active
const hasOtherParams = params.size > 0;
if (term !== defaultTermSlug || hasOtherParams) {
params.set("term", term);
if (selectedTerm !== defaultTermSlug || hasOtherParams) {
params.set("term", selectedTerm);
}
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
goto(`?${params.toString()}`, {
replaceState: true,
noScroll: true,
keepFocus: true,
});
const t0 = performance.now();
try {
searchResult = await client.searchCourses({
term,
subjects: subjects.length > 0 ? subjects : undefined,
q: q || undefined,
open_only: open || undefined,
const result = await client.searchCourses({
term: selectedTerm,
subject: selectedSubjects.length > 0 ? selectedSubjects : [],
q: query || undefined,
openOnly: openOnly || false,
limit,
offset: off,
sort_by: sortBy,
sort_dir: sortDir,
offset,
sortBy,
sortDir,
waitCountMax: waitCountMax ?? undefined,
days: days.length > 0 ? days : undefined,
timeStart: timeStart ?? undefined,
timeEnd: timeEnd ?? undefined,
instructionalMethod: instructionalMethod.length > 0 ? instructionalMethod : undefined,
campus: campus.length > 0 ? campus : undefined,
partOfTerm: partOfTerm.length > 0 ? partOfTerm : undefined,
attributes: attributes.length > 0 ? attributes : undefined,
creditHourMin: creditHourMin ?? undefined,
creditHourMax: creditHourMax ?? undefined,
instructor: instructor || undefined,
courseNumberLow: courseNumberLow ?? undefined,
courseNumberHigh: courseNumberHigh ?? undefined,
});
searchMeta = {
totalCount: searchResult.totalCount,
durationMs: performance.now() - t0,
timestamp: new Date(),
const applyUpdate = () => {
searchResult = result;
searchMeta = {
totalCount: result.totalCount,
durationMs: performance.now() - t0,
timestamp: new Date(),
};
};
// Scoped view transitions only affect the table element, so filters and
// other controls remain fully interactive. Document-level transitions
// apply visibility:hidden to the entire page for the transition duration,
// blocking all pointer interactions — so we skip those entirely and let
// Svelte's animate:flip / in:fade handle the visual update instead.
const tableEl = document.querySelector("[data-search-results]") as HTMLElement | null;
if (tableEl && "startViewTransition" in tableEl) {
const transition = (tableEl as any).startViewTransition(async () => {
applyUpdate();
await tick();
});
await transition.updateCallbackDone;
} else {
applyUpdate();
}
} catch (e) {
error = e instanceof Error ? e.message : "Search failed";
} finally {
@@ -209,57 +468,378 @@ async function performSearch(
function handlePageChange(newOffset: number) {
offset = newOffset;
}
// Column visibility state (lifted from CourseTable)
let columnVisibility: VisibilityState = $state({});
function resetColumnVisibility() {
columnVisibility = {};
}
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
// All possible column IDs for the View dropdown
const columnDefs = [
{ id: "crn", label: "CRN" },
{ id: "course_code", label: "Course" },
{ id: "title", label: "Title" },
{ id: "instructor", label: "Instructor" },
{ id: "time", label: "Time" },
{ id: "location", label: "Location" },
{ id: "seats", label: "Seats" },
];
// Chip helpers
const DAY_ABBREV: Record<string, string> = {
monday: "M",
tuesday: "T",
wednesday: "W",
thursday: "Th",
friday: "F",
saturday: "Sa",
sunday: "Su",
};
function formatDaysChip(d: string[]): string {
return d.map((day) => DAY_ABBREV[day] ?? day).join("");
}
function formatTimeChip(start: string | null, end: string | null): string {
const fmt = (t: string) => {
if (t.length !== 4) return t;
const h = parseInt(t.slice(0, 2), 10);
const m = t.slice(2);
const period = h >= 12 ? "PM" : "AM";
const dh = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${dh}:${m} ${period}`;
};
if (start && end) return `${fmt(start)} ${fmt(end)}`;
if (start) return `After ${fmt(start)}`;
if (end) return `Before ${fmt(end)}`;
return "";
}
function formatMultiChip(codes: string[], refItems: CodeDescription[]): string {
const lookup = new Map(refItems.map((r) => [r.code, r.description]));
const first = lookup.get(codes[0]) ?? codes[0];
if (codes.length === 1) return first;
return `${first} + ${codes.length - 1} more`;
}
let activeFilterCount = $derived(
(selectedSubjects.length > 0 ? 1 : 0) +
(openOnly ? 1 : 0) +
(waitCountMax !== null ? 1 : 0) +
(days.length > 0 ? 1 : 0) +
(timeStart !== null || timeEnd !== null ? 1 : 0) +
(instructionalMethod.length > 0 ? 1 : 0) +
(campus.length > 0 ? 1 : 0) +
(partOfTerm.length > 0 ? 1 : 0) +
(attributes.length > 0 ? 1 : 0) +
(creditHourMin !== null || creditHourMax !== null ? 1 : 0) +
(instructor !== "" ? 1 : 0) +
(courseNumberLow !== null || courseNumberHigh !== null ? 1 : 0)
);
function removeSubject(code: string) {
selectedSubjects = selectedSubjects.filter((s) => s !== code);
}
function clearAllSubjects() {
selectedSubjects = [];
}
function clearAllFilters() {
selectedSubjects = [];
openOnly = false;
waitCountMax = null;
days = [];
timeStart = null;
timeEnd = null;
instructionalMethod = [];
campus = [];
partOfTerm = [];
attributes = [];
creditHourMin = null;
creditHourMax = null;
instructor = "";
courseNumberLow = null;
courseNumberHigh = null;
}
</script>
<div class="min-h-screen flex flex-col items-center px-5 pb-5 pt-20">
<div class="w-full max-w-6xl flex flex-col gap-6 pt-2">
<div class="w-full max-w-6xl flex flex-col pt-2">
<!-- Chips bar: status | chips | view button -->
<div class="flex items-end gap-3 min-h-7">
<SearchStatus meta={searchMeta} {loading} />
<!-- Search status + Filters -->
<div class="flex flex-col gap-1.5">
<SearchStatus meta={searchMeta} />
<!-- Filters -->
<SearchFilters
terms={data.terms}
{subjects}
bind:selectedTerm
bind:selectedSubjects
bind:query
bind:openOnly
/>
<!-- Active filter chips -->
<div
class="flex items-center gap-1.5 flex-1 min-w-0 flex-wrap pb-1.5"
>
{#if selectedSubjects.length > 0}
<SegmentedChip
segments={selectedSubjects}
onRemoveSegment={removeSubject}
onRemoveAll={clearAllSubjects}
/>
{/if}
{#if openOnly}
<FilterChip
label="Open only"
onRemove={() => (openOnly = false)}
/>
{/if}
{#if waitCountMax !== null}
<FilterChip
label="Waitlist ≤ {waitCountMax}"
onRemove={() => (waitCountMax = null)}
/>
{/if}
{#if days.length > 0}
<FilterChip
label={formatDaysChip(days)}
onRemove={() => (days = [])}
/>
{/if}
{#if timeStart !== null || timeEnd !== null}
<FilterChip
label={formatTimeChip(timeStart, timeEnd)}
onRemove={() => {
timeStart = null;
timeEnd = null;
}}
/>
{/if}
{#if instructionalMethod.length > 0}
<FilterChip
label={formatMultiChip(
instructionalMethod,
referenceData.instructionalMethods,
)}
onRemove={() => (instructionalMethod = [])}
/>
{/if}
{#if campus.length > 0}
<FilterChip
label={formatMultiChip(campus, referenceData.campuses)}
onRemove={() => (campus = [])}
/>
{/if}
{#if partOfTerm.length > 0}
<FilterChip
label={formatMultiChip(
partOfTerm,
referenceData.partsOfTerm,
)}
onRemove={() => (partOfTerm = [])}
/>
{/if}
{#if attributes.length > 0}
<FilterChip
label={formatMultiChip(
attributes,
referenceData.attributes,
)}
onRemove={() => (attributes = [])}
/>
{/if}
{#if creditHourMin !== null || creditHourMax !== null}
<FilterChip
label={creditHourMin !== null && creditHourMax !== null
? `${creditHourMin}${creditHourMax} credits`
: creditHourMin !== null
? ` ${creditHourMin} credits`
: ` ${creditHourMax} credits`}
onRemove={() => {
creditHourMin = null;
creditHourMax = null;
}}
/>
{/if}
{#if instructor !== ""}
<FilterChip
label="Instructor: {instructor}"
onRemove={() => (instructor = "")}
/>
{/if}
{#if courseNumberLow !== null || courseNumberHigh !== null}
<FilterChip
label={courseNumberLow !== null &&
courseNumberHigh !== null
? `Course ${courseNumberLow}${courseNumberHigh}`
: courseNumberLow !== null
? `Course ${courseNumberLow}`
: `Course ${courseNumberHigh}`}
onRemove={() => {
courseNumberLow = null;
courseNumberHigh = null;
}}
/>
{/if}
{#if activeFilterCount >= 2}
<button
type="button"
class="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer ml-1"
onclick={clearAllFilters}
>
Clear all
</button>
{/if}
</div>
<!-- View columns dropdown (moved from CourseTable) -->
<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 shrink-0"
>
<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,
}}
>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
Toggle columns
</DropdownMenu.GroupHeading>
{#each columnDefs as col}
<DropdownMenu.CheckboxItem
checked={columnVisibility[
col.id
] !== false}
closeOnSelect={false}
onCheckedChange={(
checked,
) => {
columnVisibility = {
...columnVisibility,
[col.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>
{col.label}
{/snippet}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
{#if hasCustomVisibility}
<DropdownMenu.Separator
class="mx-1 my-1 h-px bg-border"
/>
<DropdownMenu.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
</DropdownMenu.Item>
{/if}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<!-- Filter bar -->
<div class="flex flex-col gap-2 pb-4">
<SearchFilters
{terms}
{subjects}
bind:selectedTerm
bind:selectedSubjects
bind:query
bind:openOnly
bind:waitCountMax
bind:days
bind:timeStart
bind:timeEnd
bind:instructionalMethod
bind:campus
bind:partOfTerm
bind:attributes
bind:creditHourMin
bind:creditHourMax
bind:instructor
bind:courseNumberLow
bind:courseNumberHigh
{referenceData}
ranges={{
courseNumber: { min: ranges.courseNumberMin, max: ranges.courseNumberMax },
creditHours: { min: ranges.creditHourMin, max: ranges.creditHourMax },
waitCount: { max: ranges.waitCountMax },
}}
/>
</div>
<!-- Results -->
{#if error}
<div class="text-center py-8">
<p class="text-status-red">{error}</p>
<button
onclick={() => performSearch()}
class="mt-2 text-sm text-muted-foreground hover:underline"
>
Retry
</button>
</div>
{:else}
<CourseTable
courses={searchResult?.courses ?? []}
{loading}
{sorting}
onSortingChange={handleSortingChange}
manualSorting={true}
{subjectMap}
{limit}
bind:columnVisibility
/>
{#if searchResult}
<Pagination
totalCount={searchResult.totalCount}
{offset}
{limit}
{loading}
onPageChange={handlePageChange}
/>
{/if}
{/if}
<!-- Footer -->
<Footer />
</div>
<!-- Results -->
{#if error}
<div class="text-center py-8">
<p class="text-status-red">{error}</p>
<button
onclick={() => performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting)}
class="mt-2 text-sm text-muted-foreground hover:underline"
>
Retry
</button>
</div>
{:else}
<CourseTable
courses={searchResult?.courses ?? []}
{loading}
{sorting}
onSortingChange={handleSortingChange}
manualSorting={true}
{subjectMap}
/>
{#if searchResult}
<Pagination
totalCount={searchResult.totalCount}
offset={searchResult.offset}
{limit}
onPageChange={handlePageChange}
/>
{/if}
{/if}
<!-- Footer -->
<Footer />
</div>
</div>
+6 -4
View File
@@ -4,10 +4,12 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ url, fetch }) => {
const client = new BannerApiClient(undefined, fetch);
try {
const terms = await client.getTerms();
return { terms, url };
const urlTerm = url.searchParams.get("term");
// Backend defaults to latest term if not specified
const searchOptions = await client.getSearchOptions(urlTerm ?? undefined);
return { searchOptions, url };
} catch (e) {
console.error("Failed to load terms:", e);
return { terms: [], url };
console.error("Failed to load search options:", e);
return { searchOptions: null, url };
}
};
+14 -1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "@fontsource-variable/inter";
@import "@fontsource-variable/jetbrains-mono";
@custom-variant dark (&:where(.dark, .dark *));
@@ -60,6 +61,7 @@
--color-surface-100: var(--card);
--color-surface-content: var(--foreground);
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
--animate-accordion-down: accordion-down 200ms ease-out;
--animate-accordion-up: accordion-up 200ms ease-out;
}
@@ -133,7 +135,7 @@ input[type="checkbox"]:checked::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
/* View Transitions API - disable default cross-fade for scoped table transitions */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
@@ -240,3 +242,14 @@ body::-webkit-scrollbar {
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
/* View Transitions: scope crossfade to search results table */
::view-transition-group(search-results) {
z-index: 1;
}
::view-transition-old(search-results) {
animation-duration: 150ms;
}
::view-transition-new(search-results) {
animation-duration: 200ms;
}