29 Commits

Author SHA1 Message Date
2123bcbe3e chore(master): release 0.6.2 (#2) 2026-02-01 02:42:48 -06:00
6c15f4082f refactor(api): extract toURLSearchParams helper for query param handling 2026-02-01 02:05:29 -06:00
bbff2b7f36 refactor(web): split CourseTable into modular component structure
Decompose monolithic CourseTable.svelte into separate desktop/mobile views
with dedicated cell components and extracted state management for improved
maintainability and code organization.
2026-02-01 01:43:58 -06:00
b37604f807 fix(web): skip view transitions for same-page navigations
Prevents document-level view transitions from blocking pointer events
during query param updates (e.g. filter changes). Only use transitions
when the pathname actually changes.
2026-02-01 01:08:00 -06:00
d278498daa refactor(web): replace component tooltips with delegated singleton 2026-02-01 01:06:56 -06:00
bd2acee6f4 feat(web): build responsive layout with mobile card view 2026-02-01 00:40:58 -06:00
7e7fc1df94 style(web): improve ux by preventing unwanted text selection 2026-01-31 21:04:09 -06:00
005adb8792 style(web): update spacing and color classes across components 2026-01-31 20:51:28 -06:00
dfaaa88d54 chore: add vscode workspace extension recommendations 2026-01-31 20:14:12 -06:00
f387401a41 refactor(api): rename middleware and enable database query logging 2026-01-31 20:13:54 -06:00
4e0140693b refactor(web): extract FilterPopover component and upgrade range sliders
Replace basic HTML range inputs with svelte-range-slider-pips library
for better UX. Create shared FilterPopover component to eliminate
duplicate popover structure across Attributes, Schedule, Status, and
More filter components.
2026-01-31 17:16:00 -06:00
e9209684eb feat(web): batch rapid search query changes into history entries, allow for query history 2026-01-31 16:44:26 -06:00
b562fe227e fix(web): ignore .svelte-kit/generated in vite watcher
Prevents spurious full-page reloads when svelte-kit sync runs
externally (e.g. during `just check`). The SvelteKit vite plugin
already watches source files and writes generated output itself.
2026-01-31 16:25:58 -06:00
44260422d6 refactor(web): streamline filter ui with simplified removal 2026-01-31 15:12:53 -06:00
96a8c13125 fix(data): handle alphanumeric course numbers in range filtering 2026-01-31 15:12:53 -06:00
567c4aec3c feat(web): implement aligned course codes with jetbrains mono 2026-01-31 14:16:10 -06:00
f5a639e88b feat(web): add dynamic range sliders with consolidated search options API 2026-01-31 13:50:26 -06:00
d91f7ab342 refactor(web): consolidate tooltip implementations with shared components 2026-01-31 12:26:31 -06:00
7f0f08725a fix(web): prevent interaction blocking during search transitions
Remove document-level view transition fallback that applies
visibility:hidden to the entire page. Use scoped table transitions to
keep filters and controls interactive during search result updates.
2026-01-31 12:16:36 -06:00
02b18f0c66 chore: add aliases to Justfile 2026-01-31 11:30:43 -06:00
106bf232c4 feat(web): implement multi-dimensional course filtering system
Add schedule, attribute, instructor, and credit hour filters to course
search. Extend backend query with 9 new parameters and create reusable
popover components for filter UI with active state indicators.
2026-01-31 11:29:04 -06:00
239f7ee38c refactor: standardize error responses with ApiError and ts-rs bindings 2026-01-31 10:35:04 -06:00
0ee4e8a8bc refactor: migrate API responses from manual JSON to type-safe bindings
Replace hand-written TypeScript interfaces and serde_json::Value responses
with ts-rs generated bindings across admin, metrics, timeline, and WebSocket
APIs. Eliminates manual type maintenance and ensures frontend types stay
in sync with backend definitions.
2026-01-31 10:00:36 -06:00
5729a821d5 feat(web): implement smooth view transitions for search results 2026-01-31 09:33:09 -06:00
5134ae9388 chore: add dev-build flag for embedded vite builds 2026-01-31 01:59:56 -06:00
9e825cd113 fix: re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping
Prevents scrollbars while also avoiding clipping on certain pages.
2026-01-31 01:40:22 -06:00
ac8dbb2eef fix: separate Biome format and lint checks to enable auto-format
Biome's 'check' command runs both formatting and linting, causing
overlapping failures that prevented auto-format from triggering.
Split into separate commands and removed web-lint check since Biome
linting crashes on Svelte 5 syntax. Renamed check steps for clarity.
2026-01-31 01:05:19 -06:00
5dd35ed215 fix(web): prevent duplicate searches and background fetching on navigation
- Search page no longer triggers cascading search when validating subjects
- Scraper page stops all refresh timers and API calls when navigating away
- Wrap initial data references in untrack() to silence Svelte warnings
2026-01-31 01:03:20 -06:00
2acf52a63b fix(cli): add proper flag validation for check script 2026-01-31 00:42:52 -06:00
128 changed files with 6019 additions and 2005 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.1"
".": "0.6.2"
}
-4
View File
@@ -62,10 +62,6 @@ jobs:
bun run format:check || echo "::warning::Frontend formatting issues found (not failing on push)"
fi
- name: Lint
working-directory: web
run: bun run lint
- name: Type check
working-directory: web
run: bun run typecheck
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"rust-lang.rust-analyzer",
"nefrob.vscode-just-syntax"
]
}
+43
View File
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.2](https://github.com/Xevion/Banner/compare/v0.6.1...v0.6.2) (2026-02-01)
### Features
* **web:** Add dynamic range sliders with consolidated search options API ([f5a639e](https://github.com/Xevion/Banner/commit/f5a639e88bfe03dfc635f25e06fc22208ee0c855))
* **web:** Batch rapid search query changes into history entries, allow for query history ([e920968](https://github.com/Xevion/Banner/commit/e9209684eb051f978607a31f237b19e883af5d5a))
* **web:** Build responsive layout with mobile card view ([bd2acee](https://github.com/Xevion/Banner/commit/bd2acee6f40c0768898ab39e0524c0474ec4fd31))
* **web:** Implement aligned course codes with jetbrains mono ([567c4ae](https://github.com/Xevion/Banner/commit/567c4aec3ca7baaeb548fff2005d83f7e6228d79))
* **web:** Implement multi-dimensional course filtering system ([106bf23](https://github.com/Xevion/Banner/commit/106bf232c4b53f4ca8902a582f185e146878c54e))
* **web:** Implement smooth view transitions for search results ([5729a82](https://github.com/Xevion/Banner/commit/5729a821d54d95a00e9f4ba736a2bd884c0c409b))
### Bug Fixes
* **cli:** Add proper flag validation for check script ([2acf52a](https://github.com/Xevion/Banner/commit/2acf52a63b6dcd24ca826b99061bf7a51a9230b1))
* **data:** Handle alphanumeric course numbers in range filtering ([96a8c13](https://github.com/Xevion/Banner/commit/96a8c13125428f1cc14e46d8f580719c17c029ef))
* Re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping ([9e825cd](https://github.com/Xevion/Banner/commit/9e825cd113bbc65c10f0386b5300b6aec50bf936))
* Separate Biome format and lint checks to enable auto-format ([ac8dbb2](https://github.com/Xevion/Banner/commit/ac8dbb2eefe79ec5d898cfa719e270f4713125d5))
* **web:** Ignore .svelte-kit/generated in vite watcher ([b562fe2](https://github.com/Xevion/Banner/commit/b562fe227e89a0826fe4587372e3eeca2ab6eb33))
* **web:** Prevent duplicate searches and background fetching on navigation ([5dd35ed](https://github.com/Xevion/Banner/commit/5dd35ed215d3d1f3603e67a2aa59eaddf619f5c9))
* **web:** Prevent interaction blocking during search transitions ([7f0f087](https://github.com/Xevion/Banner/commit/7f0f08725a668c5ac88c510f43791d90ce2f795e))
* **web:** Skip view transitions for same-page navigations ([b37604f](https://github.com/Xevion/Banner/commit/b37604f8071741017a83f74a67b73cf7975827ae))
### Code Refactoring
* **api:** Extract toURLSearchParams helper for query param handling ([6c15f40](https://github.com/Xevion/Banner/commit/6c15f4082f1a4b6fb6c54c545c6e0ec47e191654))
* **api:** Rename middleware and enable database query logging ([f387401](https://github.com/Xevion/Banner/commit/f387401a4174d4d0bdf74deccdda80b3af543b74))
* Migrate API responses from manual JSON to type-safe bindings ([0ee4e8a](https://github.com/Xevion/Banner/commit/0ee4e8a8bc1fe0b079fea84ac303674083b43a59))
* Standardize error responses with ApiError and ts-rs bindings ([239f7ee](https://github.com/Xevion/Banner/commit/239f7ee38cbc0e49d9041579fc9923fd4a4608bf))
* **web:** Consolidate tooltip implementations with shared components ([d91f7ab](https://github.com/Xevion/Banner/commit/d91f7ab34299b26dc12d629bf99d502ee05e7cfa))
* **web:** Extract FilterPopover component and upgrade range sliders ([4e01406](https://github.com/Xevion/Banner/commit/4e0140693b00686e8a57561b0811fdf25a614e65))
* **web:** Replace component tooltips with delegated singleton ([d278498](https://github.com/Xevion/Banner/commit/d278498daa4afc82c877b536ecd1264970dc92a7))
* **web:** Split CourseTable into modular component structure ([bbff2b7](https://github.com/Xevion/Banner/commit/bbff2b7f36744808b62ec130be2cfbdc96f87b69))
* **web:** Streamline filter ui with simplified removal ([4426042](https://github.com/Xevion/Banner/commit/44260422d68e910ed4ad37e78cd8a1d1f8bb51a3))
### Miscellaneous
* Add aliases to Justfile ([02b18f0](https://github.com/Xevion/Banner/commit/02b18f0c66dc8b876452f35999c027475df52462))
* Add dev-build flag for embedded vite builds ([5134ae9](https://github.com/Xevion/Banner/commit/5134ae93881854ac722dc9e7f3f5040aee3e517a))
## [0.6.1](https://github.com/Xevion/Banner/compare/v0.6.0...v0.6.1) (2026-01-31)
Generated
+1 -1
View File
@@ -272,7 +272,7 @@ dependencies = [
[[package]]
name = "banner"
version = "0.6.1"
version = "0.6.2"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "banner"
version = "0.6.1"
version = "0.6.2"
edition = "2024"
default-run = "banner"
+11 -1
View File
@@ -1,5 +1,16 @@
set dotenv-load
# Aliases
alias c := check
alias d := dev
alias t := test
alias f := format
alias fmt := format
alias s := search
alias bld := build
alias bind := bindings
alias b := bun
default:
just --list
@@ -38,7 +49,6 @@ build *flags:
db cmd="start":
bun scripts/db.ts {{cmd}}
alias b := bun
bun *ARGS:
cd web && bun {{ ARGS }}
+22 -13
View File
@@ -8,7 +8,17 @@ import { c, elapsed, isStderrTTY } from "./lib/fmt";
import { run, runPiped, spawnCollect, raceInOrder, type CollectResult } from "./lib/proc";
import { existsSync, statSync, readdirSync, writeFileSync, rmSync } from "fs";
const fix = process.argv.includes("--fix") || process.argv.includes("-f");
const args = process.argv.slice(2);
let fix = false;
for (const arg of args) {
if (arg === "-f" || arg === "--fix") {
fix = true;
} else {
console.error(`Unknown flag: ${arg}`);
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Fix path: format + clippy fix, then fall through to verification
@@ -89,16 +99,15 @@ interface Check {
const checks: Check[] = [
{
name: "rustfmt",
name: "rust-format",
cmd: ["cargo", "fmt", "--all", "--", "--check"],
hint: "Run 'cargo fmt --all' to see and fix formatting issues.",
},
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-lint", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "rust-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "biome-lint", cmd: ["bun", "run", "--cwd", "web", "lint"] },
{ name: "web-format", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
{ name: "actionlint", cmd: ["actionlint"] },
];
@@ -115,19 +124,19 @@ const domains: Record<
recheck: Check[];
}
> = {
rustfmt: {
peers: ["clippy", "cargo-check", "rust-test"],
"rust-format": {
peers: ["rust-lint", "rust-check", "rust-test"],
format: () => runPiped(["cargo", "fmt", "--all"]),
recheck: [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
{ name: "rust-format", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "rust-check", cmd: ["cargo", "check", "--all-features"] },
],
},
biome: {
peers: ["svelte-check", "biome-lint", "web-test"],
"web-format": {
peers: ["svelte-check", "web-test"],
format: () => runPiped(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "web-format", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
],
},
+10 -3
View File
@@ -10,6 +10,7 @@
* -n, --no-build Run last compiled binary (no rebuild)
* -r, --release Use release profile
* -e, --embed Embed assets (implies -b)
* -d, --dev-build Use dev build for frontend (faster, no minification)
* --tracing <fmt> Tracing format (default: pretty)
*/
@@ -26,9 +27,10 @@ const { flags, passthrough } = parseFlags(
"no-build": "bool",
release: "bool",
embed: "bool",
"dev-build": "bool",
tracing: "string",
} as const,
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed" },
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed", d: "dev-build" },
{
"frontend-only": false,
"backend-only": false,
@@ -36,6 +38,7 @@ const { flags, passthrough } = parseFlags(
"no-build": false,
release: false,
embed: false,
"dev-build": false,
tracing: "pretty",
},
);
@@ -46,6 +49,7 @@ let noWatch = flags["no-watch"];
const noBuild = flags["no-build"];
const release = flags.release;
const embed = flags.embed;
const devBuild = flags["dev-build"];
const tracing = flags.tracing as string;
// -e implies -b
@@ -66,8 +70,11 @@ const group = new ProcessGroup();
// Build frontend first when embedding assets
if (embed && !noBuild) {
console.log(c("1;36", "→ Building frontend (for embedding)..."));
run(["bun", "run", "--cwd", "web", "build"]);
const buildMode = devBuild ? "development" : "production";
console.log(c("1;36", `→ Building frontend (${buildMode}, for embedding)...`));
const buildArgs = ["bun", "run", "--cwd", "web", "build"];
if (devBuild) buildArgs.push("--", "--mode", "development");
run(buildArgs);
}
// Frontend: Vite dev server
+8 -1
View File
@@ -10,8 +10,10 @@ use crate::web::auth::AuthConfig;
use anyhow::Context;
use figment::value::UncasedStr;
use figment::{Figment, providers::Env};
use sqlx::ConnectOptions;
use sqlx::postgres::PgPoolOptions;
use std::process::ExitCode;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info, warn};
@@ -45,6 +47,11 @@ impl App {
let slow_threshold = Duration::from_millis(if is_private { 200 } else { 500 });
// Create database connection pool
let connect_options = sqlx::postgres::PgConnectOptions::from_str(&config.database_url)
.context("Failed to parse database URL")?
.log_statements(tracing::log::LevelFilter::Debug)
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_secs(1));
let db_pool = PgPoolOptions::new()
.min_connections(0)
.max_connections(4)
@@ -52,7 +59,7 @@ impl App {
.acquire_timeout(Duration::from_secs(4))
.idle_timeout(Duration::from_secs(60 * 2))
.max_lifetime(Duration::from_secs(60 * 30))
.connect(&config.database_url)
.connect_with(connect_options)
.await
.context("Failed to create database pool")?;
+2 -2
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::banner::{
SessionPool, create_shared_rate_limiter, errors::BannerApiError, json::parse_json_with_context,
middleware::TransparentMiddleware, models::*, nonce, query::SearchQuery,
middleware::LoggingMiddleware, models::*, nonce, query::SearchQuery,
rate_limit_middleware::RateLimitMiddleware, util::user_agent,
};
use crate::config::RateLimitingConfig;
@@ -46,7 +46,7 @@ impl BannerApi {
.build()
.context("Failed to create HTTP client")?,
)
.with(TransparentMiddleware)
.with(LoggingMiddleware)
.with(RateLimitMiddleware::new(rate_limiter.clone()))
.build();
+12 -27
View File
@@ -5,13 +5,13 @@ use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use tracing::{debug, trace, warn};
pub struct TransparentMiddleware;
pub struct LoggingMiddleware;
/// Threshold for logging slow requests at DEBUG level (in milliseconds)
const SLOW_REQUEST_THRESHOLD_MS: u128 = 1000;
#[async_trait::async_trait]
impl Middleware for TransparentMiddleware {
impl Middleware for LoggingMiddleware {
async fn handle(
&self,
req: Request,
@@ -19,7 +19,8 @@ impl Middleware for TransparentMiddleware {
next: Next<'_>,
) -> std::result::Result<Response, reqwest_middleware::Error> {
let method = req.method().to_string();
let path = req.url().path().to_string();
// Use the full URL (including query parameters) for logging
let url = req.url().to_string();
let start = std::time::Instant::now();
let response_result = next.run(req, extensions).await;
@@ -27,41 +28,25 @@ impl Middleware for TransparentMiddleware {
match response_result {
Ok(response) => {
let status = response.status().as_u16();
let duration_ms = duration.as_millis();
if response.status().is_success() {
let duration_ms = duration.as_millis();
if duration_ms >= SLOW_REQUEST_THRESHOLD_MS {
debug!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration_ms,
"Request completed (slow)"
);
debug!(method, url, status, duration_ms, "Request completed (slow)");
} else {
trace!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration_ms,
"Request completed"
);
trace!(method, url, status, duration_ms, "Request completed");
}
Ok(response)
} else {
warn!(
method = method,
path = path,
status = response.status().as_u16(),
duration_ms = duration.as_millis(),
"Request failed"
);
warn!(method, url, status, duration_ms, "Request failed");
Ok(response)
}
}
Err(error) => {
warn!(
method = method,
path = path,
method,
url,
duration_ms = duration.as_millis(),
"Request failed"
);
+159 -28
View File
@@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail};
use crate::error::Result;
use sqlx::PgPool;
use std::collections::HashMap;
use ts_rs::TS;
/// Column to sort search results by.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SortColumn {
CourseCode,
Title,
@@ -17,25 +19,74 @@ pub enum SortColumn {
}
/// Sort direction.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SortDirection {
Asc,
Desc,
}
/// Aggregate min/max ranges for filter sliders, computed per-term.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FilterRanges {
pub course_number_min: i32,
pub course_number_max: i32,
pub credit_hour_min: i32,
pub credit_hour_max: i32,
pub wait_count_max: i32,
}
/// Shared WHERE clause for course search filters.
///
/// Parameters $1-$8 match the bind order in `search_courses`.
/// Parameters $1-$17 match the bind order in `search_courses`.
///
/// Note: Course number filtering extracts numeric prefix to support alphanumeric
/// course numbers (e.g., "015X", "399H"). The numeric part is compared against
/// the range, so "399H" matches a search for courses 300-400.
const SEARCH_WHERE: &str = r#"
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($4::int IS NULL OR (substring(course_number from '^\d+'))::int >= $4)
AND ($5::int IS NULL OR (substring(course_number from '^\d+'))::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
AND ($7::text[] IS NULL OR instructional_method = ANY($7))
AND ($8::text[] IS NULL OR campus = ANY($8))
AND ($9::int IS NULL OR wait_count <= $9)
AND ($10::text[] IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (NOT 'monday' = ANY($10) OR (mt->>'monday')::bool)
AND (NOT 'tuesday' = ANY($10) OR (mt->>'tuesday')::bool)
AND (NOT 'wednesday' = ANY($10) OR (mt->>'wednesday')::bool)
AND (NOT 'thursday' = ANY($10) OR (mt->>'thursday')::bool)
AND (NOT 'friday' = ANY($10) OR (mt->>'friday')::bool)
AND (NOT 'saturday' = ANY($10) OR (mt->>'saturday')::bool)
AND (NOT 'sunday' = ANY($10) OR (mt->>'sunday')::bool)
))
AND ($11::text IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (mt->>'begin_time') >= $11
))
AND ($12::text IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
WHERE (mt->>'end_time') <= $12
))
AND ($13::text[] IS NULL OR part_of_term = ANY($13))
AND ($14::text[] IS NULL OR EXISTS (
SELECT 1 FROM jsonb_array_elements_text(attributes) a
WHERE a = ANY($14)
))
AND ($15::int IS NULL OR COALESCE(credit_hours, credit_hour_low, 0) >= $15)
AND ($16::int IS NULL OR COALESCE(credit_hours, credit_hour_high, 0) <= $16)
AND ($17::text IS NULL OR EXISTS (
SELECT 1 FROM course_instructors ci
JOIN instructors i ON i.id = ci.instructor_id
WHERE ci.course_id = courses.id
AND i.display_name ILIKE '%' || $17 || '%'
))
"#;
/// Build a safe ORDER BY clause from typed sort parameters.
@@ -83,8 +134,17 @@ pub async fn search_courses(
course_number_low: Option<i32>,
course_number_high: Option<i32>,
open_only: bool,
instructional_method: Option<&str>,
campus: Option<&str>,
instructional_method: Option<&[String]>,
campus: Option<&[String]>,
wait_count_max: Option<i32>,
days: Option<&[String]>,
time_start: Option<&str>,
time_end: Option<&str>,
part_of_term: Option<&[String]>,
attributes: Option<&[String]>,
credit_hour_min: Option<i32>,
credit_hour_max: Option<i32>,
instructor: Option<&str>,
limit: i32,
offset: i32,
sort_by: Option<SortColumn>,
@@ -93,32 +153,50 @@ pub async fn search_courses(
let order_by = sort_clause(sort_by, sort_dir);
let data_query =
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $18 OFFSET $19");
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
let courses = sqlx::query_as::<_, Course>(&data_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(limit)
.bind(offset)
.bind(term_code) // $1
.bind(subject) // $2
.bind(title_query) // $3
.bind(course_number_low) // $4
.bind(course_number_high) // $5
.bind(open_only) // $6
.bind(instructional_method) // $7
.bind(campus) // $8
.bind(wait_count_max) // $9
.bind(days) // $10
.bind(time_start) // $11
.bind(time_end) // $12
.bind(part_of_term) // $13
.bind(attributes) // $14
.bind(credit_hour_min) // $15
.bind(credit_hour_max) // $16
.bind(instructor) // $17
.bind(limit) // $18
.bind(offset) // $19
.fetch_all(db_pool)
.await?;
let total: (i64,) = sqlx::query_as(&count_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.bind(term_code) // $1
.bind(subject) // $2
.bind(title_query) // $3
.bind(course_number_low) // $4
.bind(course_number_high) // $5
.bind(open_only) // $6
.bind(instructional_method) // $7
.bind(campus) // $8
.bind(wait_count_max) // $9
.bind(days) // $10
.bind(time_start) // $11
.bind(time_end) // $12
.bind(part_of_term) // $13
.bind(attributes) // $14
.bind(credit_hour_min) // $15
.bind(credit_hour_max) // $16
.bind(instructor) // $17
.fetch_one(db_pool)
.await?;
@@ -247,3 +325,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,
},
+106
View File
@@ -0,0 +1,106 @@
//! Test course search with alphanumeric course numbers (e.g., "015X", "399H").
mod helpers;
use banner::data::batch::batch_upsert_courses;
use banner::data::courses::search_courses;
use helpers::make_course;
#[sqlx::test]
async fn test_search_alphanumeric_course_numbers(pool: sqlx::PgPool) {
let term = "202620";
// Insert courses with both numeric and alphanumeric course numbers
let courses = vec![
make_course("10001", term, "CS", "0100", "Intro to CS", 20, 30, 0, 10),
make_course("10002", term, "CS", "015X", "Special Topics", 15, 25, 0, 5),
make_course(
"10003",
term,
"CS",
"0200",
"Data Structures",
25,
30,
0,
10,
),
make_course("10004", term, "CS", "0399", "Advanced Topics", 18, 25, 0, 5),
make_course("10005", term, "CS", "399H", "Honors Course", 12, 20, 0, 5),
make_course(
"10006",
term,
"CS",
"5500",
"Graduate Seminar",
10,
15,
0,
3,
),
];
batch_upsert_courses(&courses, &pool)
.await
.expect("Failed to insert test courses");
// Test: Search for course numbers 100-5500 (should include alphanumeric)
let (results, _total) = search_courses(
&pool,
term,
None, // subject
None, // title_query
Some(100), // course_number_low
Some(5500), // course_number_high
false, // open_only
None, // instructional_method
None, // campus
None, // wait_count_max
None, // days
None, // time_start
None, // time_end
None, // part_of_term
None, // attributes
None, // credit_hour_min
None, // credit_hour_max
None, // instructor
100, // limit
0, // offset
None, // sort_by
None, // sort_dir
)
.await
.expect("Search failed");
// Should include:
// - 0100 (100 >= 100)
// - 0200 (200 in range)
// - 0399 (399 in range)
// - 399H (numeric prefix 399 in range)
// - 5500 (5500 <= 5500)
//
// Should exclude:
// - 015X (numeric prefix 15 < 100)
let crns: Vec<&str> = results.iter().map(|c| c.crn.as_str()).collect();
assert_eq!(
results.len(),
5,
"Expected 5 courses in range, got {}: {:?}",
results.len(),
crns
);
assert!(crns.contains(&"10001"), "Should include CS 0100");
assert!(crns.contains(&"10003"), "Should include CS 0200");
assert!(crns.contains(&"10004"), "Should include CS 0399");
assert!(
crns.contains(&"10005"),
"Should include CS 399H (numeric prefix 399)"
);
assert!(crns.contains(&"10006"), "Should include CS 5500");
assert!(
!crns.contains(&"10002"),
"Should exclude CS 015X (numeric prefix 15 < 100)"
);
}
+22 -15
View File
@@ -5,6 +5,8 @@
"": {
"name": "banner-web",
"dependencies": {
"@floating-ui/dom": "^1.7.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@icons-pack/svelte-simple-icons": "^6.5.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
@@ -16,27 +18,28 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@fontsource-variable/inter": "^5.2.5",
"@lucide/svelte": "^0.563.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@lucide/svelte": "^0.563.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/table-core": "^8.21.3",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/d3-time-format": "^4.0.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"bits-ui": "^1.8.0",
"clsx": "^2.1.1",
"jsdom": "^26.0.0",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vitest": "^3.0.5",
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^3.2.4",
},
},
},
@@ -137,6 +140,8 @@
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
"@icons-pack/svelte-simple-icons": ["@icons-pack/svelte-simple-icons@6.5.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.5.0", "svelte": "^4.2.0 || ^5.0.0" } }, "sha512-Xj3PTioiV3TJ1NTKsXY95NFG8FUqw90oeyDZIlslWHs1KkuCheu1HOPrlHb0/IM0b4cldPgx/0TldzxzBlM8Cw=="],
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
@@ -637,6 +642,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-range-slider-pips": ["svelte-range-slider-pips@4.1.0", "", { "peerDependencies": { "svelte": "^4.2.7 || ^5.0.0" } }, "sha512-2Zw7MngIuPeqdyJ3ueEp7jPSx0hce+Sx8r1eteCeUPxEWlNavKhBtqJyuoAdpvh5csPPFVZJ4TJ4MX9s4G70uw=="],
"svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+4 -1
View File
@@ -8,7 +8,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "biome check .",
"lint": "biome lint .",
"test": "vitest run",
"format": "biome format --write .",
"format:check": "biome format ."
@@ -31,6 +31,7 @@
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
@@ -38,6 +39,8 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@floating-ui/dom": "^1.7.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@icons-pack/svelte-simple-icons": "^6.5.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { formatMeetingTimeSummary } from "$lib/course";
import type { CourseResponse, DbMeetingTime } from "$lib/api";
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
return {
begin_time: null,
end_time: null,
start_date: "2025-01-13",
end_date: "2025-05-08",
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
building: null,
building_description: null,
room: null,
campus: null,
meeting_type: "CLAS",
meeting_schedule_type: "LEC",
...overrides,
};
}
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
return {
crn: "12345",
subject: "CS",
courseNumber: "1234",
title: "Test Course",
termCode: "202510",
sequenceNumber: null,
instructionalMethod: null,
campus: null,
enrollment: 10,
maxEnrollment: 30,
waitCount: 0,
waitCapacity: 0,
creditHours: 3,
creditHourLow: null,
creditHourHigh: null,
crossList: null,
crossListCapacity: null,
crossListCount: null,
linkIdentifier: null,
isSectionLinked: null,
partOfTerm: null,
meetingTimes: [],
attributes: [],
instructors: [],
...overrides,
};
}
describe("formatMeetingTimeSummary", () => {
it("returns 'Async' for async online courses", () => {
const course = makeCourse({
meetingTimes: [makeMeetingTime({ building: "INT" })],
});
expect(formatMeetingTimeSummary(course)).toBe("Async");
});
it("returns 'TBA' for courses with no meeting times", () => {
const course = makeCourse({ meetingTimes: [] });
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns 'TBA' when days and times are all TBA", () => {
const course = makeCourse({
meetingTimes: [makeMeetingTime()],
});
expect(formatMeetingTimeSummary(course)).toBe("TBA");
});
it("returns formatted days and time for normal meeting", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
monday: true,
wednesday: true,
friday: true,
begin_time: "0900",
end_time: "0950",
}),
],
});
expect(formatMeetingTimeSummary(course)).toBe("MWF 9:009:50 AM");
});
it("returns formatted days with TBA time", () => {
const course = makeCourse({
meetingTimes: [
makeMeetingTime({
tuesday: true,
thursday: true,
}),
],
});
// Days are set but time is TBA — not both TBA, so it enters the final branch
expect(formatMeetingTimeSummary(course)).toBe("TTh TBA");
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { parseTimeInput, formatTime, toggleDay, toggleValue } from "$lib/filters";
describe("parseTimeInput", () => {
it("parses AM time", () => {
expect(parseTimeInput("10:30 AM")).toBe("1030");
});
it("parses PM time", () => {
expect(parseTimeInput("3:00 PM")).toBe("1500");
});
it("parses 12:00 PM as noon", () => {
expect(parseTimeInput("12:00 PM")).toBe("1200");
});
it("parses 12:00 AM as midnight", () => {
expect(parseTimeInput("12:00 AM")).toBe("0000");
});
it("parses case-insensitive AM/PM", () => {
expect(parseTimeInput("9:15 am")).toBe("0915");
expect(parseTimeInput("2:45 Pm")).toBe("1445");
});
it("parses military time", () => {
expect(parseTimeInput("14:30")).toBe("1430");
expect(parseTimeInput("9:05")).toBe("0905");
});
it("returns null for empty string", () => {
expect(parseTimeInput("")).toBeNull();
expect(parseTimeInput(" ")).toBeNull();
});
it("returns null for non-time strings", () => {
expect(parseTimeInput("abc")).toBeNull();
expect(parseTimeInput("hello world")).toBeNull();
});
it("parses out-of-range military time (no validation beyond format)", () => {
// The regex matches but doesn't validate hour/minute ranges
expect(parseTimeInput("25:00")).toBe("2500");
});
it("trims whitespace", () => {
expect(parseTimeInput(" 10:00 AM ")).toBe("1000");
});
});
describe("formatTime", () => {
it("formats morning time", () => {
expect(formatTime("0930")).toBe("9:30 AM");
});
it("formats afternoon time", () => {
expect(formatTime("1500")).toBe("3:00 PM");
});
it("formats noon", () => {
expect(formatTime("1200")).toBe("12:00 PM");
});
it("formats midnight", () => {
expect(formatTime("0000")).toBe("12:00 AM");
});
it("returns empty string for null", () => {
expect(formatTime(null)).toBe("");
});
it("returns empty string for invalid length", () => {
expect(formatTime("12")).toBe("");
expect(formatTime("123456")).toBe("");
});
});
describe("toggleDay", () => {
it("adds a day not in the list", () => {
expect(toggleDay(["monday"], "wednesday")).toEqual(["monday", "wednesday"]);
});
it("removes a day already in the list", () => {
expect(toggleDay(["monday", "wednesday"], "monday")).toEqual(["wednesday"]);
});
it("adds to empty list", () => {
expect(toggleDay([], "friday")).toEqual(["friday"]);
});
it("removes last day", () => {
expect(toggleDay(["monday"], "monday")).toEqual([]);
});
});
describe("toggleValue", () => {
it("adds a value not in the array", () => {
expect(toggleValue(["OA"], "HB")).toEqual(["OA", "HB"]);
});
it("removes a value already in the array", () => {
expect(toggleValue(["OA", "HB"], "OA")).toEqual(["HB"]);
});
it("adds to empty array", () => {
expect(toggleValue([], "OA")).toEqual(["OA"]);
});
it("removes last value", () => {
expect(toggleValue(["OA"], "OA")).toEqual([]);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import {
FADE_DISTANCE,
FADE_PERCENT,
leftOpacity,
rightOpacity,
maskGradient,
type ScrollMetrics,
} from "$lib/scroll-fade";
describe("leftOpacity", () => {
it("returns 0 when scrollLeft is 0", () => {
expect(leftOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when scrollLeft >= FADE_DISTANCE", () => {
expect(leftOpacity({ scrollLeft: FADE_DISTANCE, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
expect(
leftOpacity({ scrollLeft: FADE_DISTANCE + 50, scrollWidth: 1000, clientWidth: 500 })
).toBe(1);
});
it("returns proportional value for partial scroll", () => {
const half = FADE_DISTANCE / 2;
expect(leftOpacity({ scrollLeft: half, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("rightOpacity", () => {
it("returns 0 when content fits (no scroll needed)", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 500, clientWidth: 500 })).toBe(0);
});
it("returns 0 when scrolled to the end", () => {
expect(rightOpacity({ scrollLeft: 500, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
});
it("returns 1 when far from the end", () => {
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
});
it("returns proportional value near the end", () => {
const maxScroll = 500; // scrollWidth(1000) - clientWidth(500)
const remaining = FADE_DISTANCE / 2;
const scrollLeft = maxScroll - remaining;
expect(rightOpacity({ scrollLeft, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
});
});
describe("maskGradient", () => {
it("returns full transparent-to-transparent gradient when no scroll", () => {
const metrics: ScrollMetrics = { scrollLeft: 0, scrollWidth: 500, clientWidth: 500 };
const result = maskGradient(metrics);
// leftOpacity=0, rightOpacity=0 → leftEnd=0%, rightStart=100%
expect(result).toBe(
"linear-gradient(to right, transparent 0%, black 0%, black 100%, transparent 100%)"
);
});
it("includes fade zones when scrolled to the middle", () => {
const metrics: ScrollMetrics = {
scrollLeft: FADE_DISTANCE,
scrollWidth: 1000,
clientWidth: 500,
};
const result = maskGradient(metrics);
// leftOpacity=1 → leftEnd=FADE_PERCENT%, rightOpacity=1 → rightStart=100-FADE_PERCENT%
expect(result).toContain(`black ${FADE_PERCENT}%`);
expect(result).toContain(`black ${100 - FADE_PERCENT}%`);
});
});
+3 -41
View File
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
@@ -60,15 +58,15 @@ describe("BannerApiClient", () => {
const result = await apiClient.searchCourses({
term: "202420",
subjects: ["CS"],
subject: ["CS"],
q: "data",
open_only: true,
openOnly: true,
limit: 25,
offset: 50,
});
expect(fetch).toHaveBeenCalledWith(
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
"/api/courses/search?term=202420&subject=CS&q=data&openOnly=true&limit=25&offset=50"
);
expect(result).toEqual(mockResponse);
});
@@ -77,8 +75,6 @@ describe("BannerApiClient", () => {
const mockResponse = {
courses: [],
totalCount: 0,
offset: 0,
limit: 25,
};
vi.mocked(fetch).mockResolvedValueOnce({
@@ -91,40 +87,6 @@ describe("BannerApiClient", () => {
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
});
it("should fetch terms", async () => {
const mockTerms = [
{ code: "202420", description: "Fall 2024" },
{ code: "202510", description: "Spring 2025" },
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTerms),
} as Response);
const result = await apiClient.getTerms();
expect(fetch).toHaveBeenCalledWith("/api/terms");
expect(result).toEqual(mockTerms);
});
it("should fetch subjects for a term", async () => {
const mockSubjects = [
{ code: "CS", description: "Computer Science" },
{ code: "MAT", description: "Mathematics" },
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSubjects),
} as Response);
const result = await apiClient.getSubjects("202420");
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
expect(result).toEqual(mockSubjects);
});
it("should fetch reference data", async () => {
const mockRef = [
{ code: "F", description: "Face to Face" },
+154 -141
View File
@@ -1,27 +1,50 @@
import { authStore } from "$lib/auth.svelte";
import type {
AdminStatusResponse,
ApiError,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DbMeetingTime,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsParams as ListInstructorsParamsGenerated,
ListInstructorsResponse,
MatchBody,
MetricEntry,
MetricsParams as MetricsParamsGenerated,
MetricsResponse,
RejectCandidateBody,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
SearchParams as SearchParamsGenerated,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeRange,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -32,10 +55,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 +71,29 @@ export type {
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
MetricEntry,
MetricsResponse,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeRange,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -63,122 +104,69 @@ export type Term = TermResponse;
export type Subject = CodeDescription;
export type ReferenceEntry = CodeDescription;
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
// Re-export with simplified names
export type SearchResponse = SearchResponseGenerated;
export type SearchParams = SearchParamsGenerated;
export type MetricsParams = MetricsParamsGenerated;
export type ListInstructorsParams = ListInstructorsParamsGenerated;
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
// Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
/**
* Converts a typed object to URLSearchParams, preserving camelCase keys.
* Handles arrays, optional values, and primitives.
*/
function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
const params = new URLSearchParams();
export interface AdminStatus {
userCount: number;
sessionCount: number;
courseCount: number;
scrapeJobCount: number;
services: { name: string; status: string }[];
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) {
continue; // Skip undefined/null values
}
if (Array.isArray(value)) {
// Append each array element
for (const item of value) {
if (item !== undefined && item !== null) {
params.append(key, String(item));
}
}
} else {
// Convert primitives to string
params.set(key, String(value));
}
}
return params;
}
export interface ScrapeJob {
id: number;
targetType: string;
targetPayload: unknown;
priority: string;
executeAt: string;
createdAt: string;
lockedAt: string | null;
retryCount: number;
maxRetries: number;
queuedAt: string;
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
}
/**
* API error class that wraps the structured ApiError response from the backend.
*/
export class ApiErrorClass extends Error {
public readonly code: string;
public readonly details: unknown | null;
export interface ScrapeJobsResponse {
jobs: ScrapeJob[];
}
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "ApiError";
this.code = apiError.code;
this.details = apiError.details;
}
export interface AuditLogEntry {
id: number;
courseId: number;
timestamp: string;
fieldChanged: string;
oldValue: string;
newValue: string;
subject: string | null;
courseNumber: string | null;
crn: string | null;
courseTitle: string | null;
}
isNotFound(): boolean {
return this.code === "NOT_FOUND";
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
}
isBadRequest(): boolean {
return (
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
);
}
export interface MetricEntry {
id: number;
courseId: number;
timestamp: string;
enrollment: number;
waitCount: number;
seatsAvailable: number;
}
export interface MetricsResponse {
metrics: MetricEntry[];
count: number;
timestamp: string;
}
export interface MetricsParams {
course_id?: number;
term?: string;
crn?: string;
range?: "1h" | "6h" | "24h" | "7d" | "30d";
limit?: number;
}
/** A time range for timeline queries (ISO-8601 strings). */
export interface TimelineRange {
start: string;
end: string;
}
/** Request body for POST /api/timeline. */
export interface TimelineRequest {
ranges: TimelineRange[];
}
/** A single 15-minute slot returned by the timeline API. */
export interface TimelineSlot {
time: string;
subjects: Record<string, number>;
}
/** Response from POST /api/timeline. */
export interface TimelineResponse {
slots: TimelineSlot[];
subjects: string[];
}
export interface SearchParams {
term: string;
subjects?: string[];
q?: string;
open_only?: boolean;
limit?: number;
offset?: number;
sort_by?: SortColumn;
sort_dir?: SortDirection;
}
// Admin instructor query params (client-only, not generated)
export interface AdminInstructorListParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
sort?: string;
isInternalError(): boolean {
return this.code === "INTERNAL_ERROR";
}
}
export class BannerApiClient {
@@ -220,7 +208,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 +239,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 +257,8 @@ export class BannerApiClient {
return this.request<StatusResponse>("/status");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
if (params.subjects) {
for (const s of params.subjects) {
query.append("subject", s);
}
}
if (params.q) query.set("q", params.q);
if (params.open_only) query.set("open_only", "true");
if (params.limit !== undefined) query.set("limit", String(params.limit));
if (params.offset !== undefined) query.set("offset", String(params.offset));
if (params.sort_by) query.set("sort_by", params.sort_by);
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
const query = toURLSearchParams(params as Record<string, unknown>);
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
@@ -278,9 +274,28 @@ export class BannerApiClient {
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
}
// In-memory cache for search options per term
private searchOptionsCache = new Map<
string,
{ data: SearchOptionsResponse; fetchedAt: number }
>();
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
const cacheKey = term || "__default__";
const cached = this.searchOptionsCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
return cached.data;
}
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
const data = await this.request<SearchOptionsResponse>(url);
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
return data;
}
// Admin endpoints
async getAdminStatus(): Promise<AdminStatus> {
return this.request<AdminStatus>("/admin/status");
async getAdminStatus(): Promise<AdminStatusResponse> {
return this.request<AdminStatusResponse>("/admin/status");
}
async getAdminUsers(): Promise<User[]> {
@@ -331,33 +346,31 @@ export class BannerApiClient {
/** Stored `Last-Modified` value for audit log conditional requests. */
private _auditLastModified: string | null = null;
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
return this.request<TimelineResponse>("/timeline", {
method: "POST",
body: { ranges } satisfies TimelineRequest,
});
}
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
if (params?.term) query.set("term", params.term);
if (params?.crn) query.set("crn", params.crn);
if (params?.range) query.set("range", params.range);
if (params?.limit !== undefined) query.set("limit", String(params.limit));
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
if (!params) {
return this.request<MetricsResponse>("/metrics");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
// Admin instructor endpoints
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
const query = new URLSearchParams();
if (params?.status) query.set("status", params.status);
if (params?.search) query.set("search", params.search);
if (params?.page !== undefined) query.set("page", String(params.page));
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
if (params?.sort) query.set("sort", params.sort);
async getAdminInstructors(
params?: Partial<ListInstructorsParams>
): Promise<ListInstructorsResponse> {
if (!params) {
return this.request<ListInstructorsResponse>("/admin/instructors");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
}
@@ -369,14 +382,14 @@ export class BannerApiClient {
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
method: "POST",
body: { rmpLegacyId },
body: { rmpLegacyId } satisfies MatchBody,
});
}
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
method: "POST",
body: { rmpLegacyId },
body: { rmpLegacyId } satisfies RejectCandidateBody,
});
}
@@ -389,7 +402,7 @@ export class BannerApiClient {
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
method: "POST",
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } satisfies MatchBody } : {}),
});
}
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceStatus } from "./ServiceStatus";
export type AdminServiceInfo = { name: string, status: ServiceStatus, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdminServiceInfo } from "./AdminServiceInfo";
export type AdminStatusResponse = { userCount: number, sessionCount: number, courseCount: number, scrapeJobCount: number, services: Array<AdminServiceInfo>, };
+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,88 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import { toggleValue } from "$lib/filters";
import FilterPopover from "./FilterPopover.svelte";
let {
instructionalMethod = $bindable<string[]>([]),
campus = $bindable<string[]>([]),
partOfTerm = $bindable<string[]>([]),
attributes = $bindable<string[]>([]),
referenceData,
}: {
instructionalMethod: string[];
campus: string[];
partOfTerm: string[];
attributes: string[];
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
} = $props();
const hasActiveFilters = $derived(
instructionalMethod.length > 0 ||
campus.length > 0 ||
partOfTerm.length > 0 ||
attributes.length > 0
);
const sections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
}[] = [
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
{ label: "Campus", key: "campus", dataKey: "campuses" },
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
];
function getSelected(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
): string[] {
if (key === "instructionalMethod") return instructionalMethod;
if (key === "campus") return campus;
if (key === "partOfTerm") return partOfTerm;
return attributes;
}
function toggle(key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes", code: string) {
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
else if (key === "campus") campus = toggleValue(campus, code);
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
else attributes = toggleValue(attributes, code);
}
</script>
<FilterPopover label="Attributes" active={hasActiveFilters} width="w-80 max-h-96 overflow-y-auto">
{#snippet content()}
{#each sections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggle(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
{/snippet}
</FilterPopover>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fly, fade } from "svelte/transition";
const DISMISS_THRESHOLD = 100;
let {
open = $bindable(false),
maxHeight = "80vh",
label,
children,
}: {
open: boolean;
maxHeight?: string;
label?: string;
children: Snippet;
} = $props();
let dragOffset = $state(0);
let dragging = $state(false);
let dragStartY = 0;
function close() {
open = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
function onPointerDown(e: PointerEvent) {
dragging = true;
dragStartY = e.clientY;
dragOffset = 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const delta = e.clientY - dragStartY;
dragOffset = Math.max(0, delta);
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
if (dragOffset > DISMISS_THRESHOLD) {
close();
}
dragOffset = 0;
}
$effect(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40 bg-black/40"
transition:fade={{ duration: 200 }}
onclick={close}
></div>
<!-- Sheet -->
<div
class="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl border-t border-border bg-background shadow-[0_-4px_20px_rgba(0,0,0,0.1)] pb-[env(safe-area-inset-bottom)]"
style="max-height: {maxHeight}; transform: translateY({dragOffset}px);"
class:transition-transform={!dragging}
class:duration-250={!dragging}
class:ease-out={!dragging}
transition:fly={{ y: 300, duration: 250 }}
role="dialog"
aria-modal="true"
aria-label={label}
>
<!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="flex shrink-0 cursor-grab items-center justify-center py-3 touch-none"
class:cursor-grabbing={dragging}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="h-1 w-10 rounded-full bg-muted-foreground/30"></div>
</div>
<!-- Content -->
<div class="overflow-y-auto">
{@render children()}
</div>
</div>
{/if}
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
abbreviateInstructor,
formatMeetingTimeSummary,
getPrimaryInstructor,
openSeats,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { formatNumber } from "$lib/utils";
import { slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
let {
course,
expanded,
onToggle,
}: {
course: CourseResponse;
expanded: boolean;
onToggle: () => void;
} = $props();
</script>
<div
class="rounded-lg border border-border bg-card overflow-hidden transition-colors
{expanded ? 'border-border/80' : 'hover:bg-muted/30'}"
>
<button
class="w-full text-left p-3 cursor-pointer"
aria-expanded={expanded}
onclick={onToggle}
>
<!-- Line 1: Course code + title + seats -->
<div class="flex items-baseline justify-between gap-2">
<div class="flex items-baseline gap-1.5 min-w-0">
<span class="font-mono font-semibold text-sm tracking-tight shrink-0">
{course.subject} {course.courseNumber}
</span>
<span class="text-sm text-muted-foreground truncate">{course.title}</span>
</div>
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
<span class="{seatsColor(course)} font-medium tabular-nums">
{#if openSeats(course) === 0}Full{:else}{openSeats(course)}/{formatNumber(course.maxEnrollment)}{/if}
</span>
</span>
</div>
<!-- Line 2: Instructor + time -->
<div class="flex items-center justify-between gap-2 mt-1">
<span class="text-xs text-muted-foreground truncate">
{abbreviateInstructor(getPrimaryInstructor(course.instructors)?.displayName ?? "Staff")}
</span>
<span class="text-xs text-muted-foreground shrink-0">
{formatMeetingTimeSummary(course)}
</span>
</div>
</button>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<CourseDetail {course} />
</div>
{/if}
</div>
+16 -19
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,12 +71,9 @@ const clipboard = useClipboard();
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-y-1.5">
<div class="font-medium">
{instructor.displayName}
</div>
@@ -126,8 +123,8 @@ const clipboard = useClipboard();
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/each}
</div>
{:else}
@@ -139,7 +136,7 @@ const clipboard = useClipboard();
<div>
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
<ul class="flex flex-col gap-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
@@ -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}
-793
View File
@@ -1,793 +0,0 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import {
RMP_CONFIDENCE_THRESHOLD,
abbreviateInstructor,
concernAccentColor,
formatLocationDisplay,
formatLocationTooltip,
formatMeetingDays,
formatMeetingTimesTooltip,
formatTimeRange,
getDeliveryConcern,
getPrimaryInstructor,
isAsyncOnline,
isMeetingTimeTBA,
isTimeTBA,
openSeats,
ratingStyle,
rmpUrl,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Columns3,
ExternalLink,
RotateCcw,
Star,
Triangle,
} from "@lucide/svelte";
import {
type ColumnDef,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
import { flip } from "svelte/animate";
import { fade, fly, slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
courses,
loading,
sorting = [],
onSortingChange,
manualSorting = false,
subjectMap = {},
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
} = $props();
let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = undefined!;
const clipboard = useClipboard(1000);
// Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content
$effect(() => {
courses; // track dependency
expandedCrn = null;
});
useOverlayScrollbars(() => tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: { autoHide: "never" },
});
// Column visibility state
let columnVisibility: VisibilityState = $state({});
function resetColumnVisibility() {
columnVisibility = {};
}
function handleVisibilityChange(updater: Updater<VisibilityState>) {
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
columnVisibility = newVisibility;
}
// visibleColumnIds and hasCustomVisibility derived after column definitions below
function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName);
}
function primaryRating(
course: CourseResponse
): { rating: number; count: number; legacyId: number | null } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
return {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
};
}
function timeIsTBA(course: CourseResponse): boolean {
if (course.meetingTimes.length === 0) return true;
const mt = course.meetingTimes[0];
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
}
// Column definitions
const columns: ColumnDef<CourseResponse, unknown>[] = [
{
id: "crn",
accessorKey: "crn",
header: "CRN",
enableSorting: false,
},
{
id: "course_code",
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
header: "Course",
enableSorting: true,
},
{
id: "title",
accessorKey: "title",
header: "Title",
enableSorting: true,
},
{
id: "instructor",
accessorFn: (row) => primaryInstructorDisplay(row),
header: "Instructor",
enableSorting: true,
},
{
id: "time",
accessorFn: (row) => {
if (row.meetingTimes.length === 0) return "";
const mt = row.meetingTimes[0];
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
},
header: "Time",
enableSorting: true,
},
{
id: "location",
accessorFn: (row) => formatLocationDisplay(row) ?? "",
header: "Location",
enableSorting: false,
},
{
id: "seats",
accessorFn: (row) => openSeats(row),
header: "Seats",
enableSorting: true,
},
];
/** Column IDs that are currently visible */
let visibleColumnIds = $derived(
columns.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
);
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
function handleSortingChange(updater: Updater<SortingState>) {
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
onSortingChange?.(newSorting);
}
const table = createSvelteTable({
get data() {
return courses;
},
getRowId: (row) => String(row.crn),
columns,
state: {
get sorting() {
return sorting;
},
get columnVisibility() {
return columnVisibility;
},
},
onSortingChange: handleSortingChange,
onColumnVisibilityChange: handleVisibilityChange,
getCoreRowModel: getCoreRowModel(),
get getSortedRowModel() {
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
},
get manualSorting() {
return manualSorting;
},
enableSortingRemoval: true,
});
</script>
{#snippet columnVisibilityGroup(
Group: typeof DropdownMenu.Group,
GroupHeading: typeof DropdownMenu.GroupHeading,
CheckboxItem: typeof DropdownMenu.CheckboxItem,
Separator: typeof DropdownMenu.Separator,
Item: typeof DropdownMenu.Item,
)}
<Group>
<GroupHeading
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
Toggle columns
</GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = {
...columnVisibility,
[id]: checked,
};
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{#snippet children({ checked })}
<span
class="flex size-4 items-center justify-center rounded-sm border border-border"
>
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</CheckboxItem>
{/each}
</Group>
{#if hasCustomVisibility}
<Separator class="mx-1 my-1 h-px bg-border" />
<Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</Item>
{/if}
{/snippet}
<!-- Toolbar: View columns button -->
<div class="flex items-center justify-end pb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
<Columns3 class="size-3.5" />
View
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
align="end"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
transition:fly={{ duration: 150, y: -10 }}
>
{@render columnVisibilityGroup(
DropdownMenu.Group,
DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem,
DropdownMenu.Separator,
DropdownMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<!-- Table with context menu on header -->
<div bind:this={tableWrapper} class="overflow-x-auto">
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr
class="border-b border-border text-left text-muted-foreground"
>
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium {header.id ===
'seats'
? 'text-right'
: ''}"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span
class="inline-flex items-center gap-1"
>
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef
.header}
{:else}
<FlexRender
content={header.column
.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown
class="size-3.5"
/>
{:else}
<ArrowUpDown
class="size-3.5 text-muted-foreground/40"
/>
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef
.header}
context={header.getContext()}
/>
{/if}
</th>
{/if}
{/each}
</tr>
{/each}
</thead>
{#if loading && courses.length === 0}
<tbody>
{#each Array(5) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
<div
class="h-4 bg-muted rounded animate-pulse {col.id ===
'seats'
? 'w-14 ml-auto'
: col.id === 'title'
? 'w-40'
: col.id === 'crn'
? 'w-10'
: 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
</tbody>
{:else if courses.length === 0}
<tbody>
<tr>
<td
colspan={visibleColumnIds.length}
class="py-12 text-center text-muted-foreground"
>
No courses found. Try adjusting your filters.
</td>
</tr>
</tbody>
{:else}
<!-- No out: transition — Svelte outros break table layout (tbody loses positioning and overlaps) -->
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
}}
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
course.crn
? 'bg-muted/30'
: ''}"
onclick={() => toggleRow(course.crn)}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "crn"}
<td class="py-2 px-2 relative">
<button
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
onclick={(e) =>
clipboard.copy(
course.crn,
e,
)}
onkeydown={(e) => {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
clipboard.copy(
course.crn,
e,
);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
>
{course.crn}
{#if clipboard.copiedValue === course.crn}
<span
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
in:fade={{
duration: 100,
}}
out:fade={{
duration: 200,
}}
>
Copied!
</span>
{/if}
</button>
</td>
{:else if colId === "course_code"}
{@const subjectDesc =
subjectMap[course.subject]}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={subjectDesc
? `${subjectDesc} ${course.courseNumber}`
: `${course.subject} ${course.courseNumber}`}
delay={200}
side="bottom"
passthrough
>
<span class="font-semibold"
>{course.subject}
{course.courseNumber}</span
>{#if course.sequenceNumber}<span
class="text-muted-foreground"
>-{course.sequenceNumber}</span
>{/if}
</SimpleTooltip>
</td>
{:else if colId === "title"}
<td
class="py-2 px-2 font-medium max-w-50 truncate"
>
<SimpleTooltip
text={course.title}
delay={200}
side="bottom"
passthrough
>
<span class="block truncate"
>{course.title}</span
>
</SimpleTooltip>
</td>
{:else if colId === "instructor"}
{@const primary = getPrimaryInstructor(
course.instructors,
)}
{@const display =
primaryInstructorDisplay(course)}
{@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span
class="text-xs text-muted-foreground/60 uppercase"
>Staff</span
>
{:else}
<SimpleTooltip
text={primary?.displayName ??
"Staff"}
delay={200}
side="bottom"
passthrough
>
{#if commaIdx !== -1}
<span
>{display.slice(
0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else}
<span>{display}</span>
{/if}
</SimpleTooltip>
{/if}
{#if ratingData}
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
>
<Tooltip.Trigger>
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
ratingData.rating,
themeStore.isDark,
)}
>
{ratingData.rating.toFixed(
1,
)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
<span
class="inline-flex items-center gap-1.5 text-xs"
>
{ratingData.rating.toFixed(
1,
)}/5 · {formatNumber(ratingData.count)}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(
ratingData.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink
class="size-3"
/>
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough
>
{#if isAsyncOnline(course)}
<span
class="text-xs text-muted-foreground/60"
>Async</span
>
{:else if timeIsTBA(course)}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{:else}
{@const mt =
course.meetingTimes[0]}
<span>
{#if !isMeetingTimeTBA(mt)}
<span
class="font-mono font-medium"
>{formatMeetingDays(
mt,
)}</span
>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span
class="text-muted-foreground"
>{formatTimeRange(
mt.begin_time,
mt.end_time,
)}</span
>
{:else}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{/if}
{#if course.meetingTimes.length > 1}
<span
class="ml-1 text-xs text-muted-foreground/70 font-medium"
>+{course
.meetingTimes
.length -
1}</span
>
{/if}
</span>
{/if}
</SimpleTooltip>
</td>
{:else if colId === "location"}
{@const concern =
getDeliveryConcern(course)}
{@const accentColor =
concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip}
<SimpleTooltip
text={locTooltip}
delay={200}
passthrough
>
<span
class="text-muted-foreground"
class:pl-2={accentColor !==
null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span
class="text-muted-foreground"
>
{locDisplay}
</span>
{:else}
<span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if}
</td>
{:else if colId === "seats"}
<td
class="py-2 px-2 text-right whitespace-nowrap"
>
<SimpleTooltip
text="{formatNumber(openSeats(
course,
))} of {formatNumber(course.maxEnrollment)} seats open, {formatNumber(course.enrollment)} enrolled{course.waitCount >
0
? `, ${formatNumber(course.waitCount)} waitlisted`
: ''}"
delay={200}
side="left"
passthrough
>
<span
class="inline-flex items-center gap-1.5"
>
<span
class="size-1.5 rounded-full {seatsDotColor(
course,
)} shrink-0"
></span>
<span
class="{seatsColor(
course,
)} font-medium tabular-nums"
>{#if openSeats(course) === 0}Full{:else}{openSeats(
course,
)} open{/if}</span
>
<span
class="text-muted-foreground/60 tabular-nums"
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
>
</span>
</SimpleTooltip>
</td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td
colspan={visibleColumnIds.length}
class="p-0"
>
<div
transition:slide={{ duration: 200 }}
>
<CourseDetail {course} />
</div>
</td>
</tr>
{/if}
</tbody>
{/each}
{/if}
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
>
{@render columnVisibilityGroup(
ContextMenu.Group,
ContextMenu.GroupHeading,
ContextMenu.CheckboxItem,
ContextMenu.Separator,
ContextMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
@@ -19,38 +19,52 @@ let errorStack = $derived(error instanceof Error ? error.stack : null);
</script>
<div class="flex items-center justify-center py-16 px-4">
<div class="w-full max-w-lg rounded-lg border border-status-red/25 bg-status-red/5 overflow-hidden text-sm">
<div class="px-4 py-2.5 border-b border-status-red/15 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 text-status-red">
<TriangleAlert size={16} strokeWidth={2.25} />
<span class="font-semibold">{title}</span>
</div>
<span class="text-xs text-muted-foreground font-mono">{page.url.pathname}</span>
</div>
<div
class="w-full max-w-lg rounded-lg border border-status-red/25 bg-status-red/5 overflow-hidden text-sm"
>
<div
class="px-4 py-2.5 border-b border-status-red/15 flex items-center justify-between gap-4"
>
<div class="flex items-center gap-2 text-status-red">
<TriangleAlert size={16} strokeWidth={2.25} />
<span class="font-semibold">{title}</span>
</div>
<span class="text-xs text-muted-foreground font-mono"
>{page.url.pathname}</span
>
</div>
<div class="px-4 py-3 border-b border-status-red/15">
<span class="text-xs text-muted-foreground/70 font-mono">{errorName}</span>
<pre class="mt-1 text-xs text-foreground/80 overflow-auto whitespace-pre-wrap break-words">{errorMessage}</pre>
</div>
<div class="px-4 py-3 border-b border-status-red/15">
<span class="text-xs text-muted-foreground/70 font-mono"
>{errorName}</span
>
<pre
class="mt-1 text-xs text-foreground/80 overflow-auto whitespace-pre-wrap wrap-break-word">{errorMessage}</pre>
</div>
{#if errorStack}
<details class="border-b border-status-red/15">
<summary class="px-4 py-2 text-xs text-muted-foreground/70 cursor-pointer hover:text-muted-foreground select-none">
Stack trace
</summary>
<pre class="px-4 py-3 text-xs text-muted-foreground/60 overflow-auto whitespace-pre-wrap break-words max-h-48">{errorStack}</pre>
</details>
{/if}
{#if errorStack}
<details class="border-b border-status-red/15">
<summary
class="px-4 py-2 text-xs text-muted-foreground/70 cursor-pointer hover:text-muted-foreground select-none"
>
Stack trace
</summary>
<pre
class="px-4 py-3 text-xs text-muted-foreground/60 overflow-auto whitespace-pre-wrap break-words max-h-48">{errorStack}</pre>
</details>
{/if}
<div class="px-4 py-2.5 flex items-center justify-end gap-3">
<span class="text-xs text-muted-foreground/60">Retries this section, not the full page</span>
<button
class="shrink-0 cursor-pointer inline-flex items-center gap-1.5 rounded-md bg-status-red px-3 py-1.5 text-sm font-medium text-white hover:brightness-110 transition-all"
onclick={reset}
>
<RotateCcw size={14} strokeWidth={2.25} />
Try again
</button>
<div class="px-4 py-2.5 flex items-center justify-end gap-3">
<span class="text-xs text-muted-foreground/60"
>Retries this section, not the full page</span
>
<button
class="shrink-0 cursor-pointer inline-flex items-center gap-1.5 rounded-md bg-status-red px-3 py-1.5 text-sm font-medium text-white hover:brightness-110 transition-all"
onclick={reset}
>
<RotateCcw size={14} strokeWidth={2.25} />
Try again
</button>
</div>
</div>
</div>
</div>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
let {
label,
onRemove,
}: {
label: string;
onRemove: () => void;
} = $props();
</script>
<button
type="button"
class="inline-flex items-center rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors cursor-pointer select-none whitespace-nowrap shrink-0"
onclick={onRemove}
aria-label="Remove {label} filter"
>
{label}
</button>
@@ -0,0 +1,51 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import type { Snippet } from "svelte";
import { fly } from "svelte/transition";
let {
label,
active = false,
width = "w-72",
content,
}: {
label: string;
active?: boolean;
width?: string;
content: Snippet;
} = $props();
</script>
<Popover.Root>
<Popover.Trigger
aria-label="{label} filters"
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer select-none
{active
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if active}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
{label}
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg {width}"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
{@render content()}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,65 @@
<script lang="ts">
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
let {
delay = 150,
side = "top" as "top" | "bottom" | "left" | "right",
sideOffset = 6,
triggerClass = "",
contentClass = "",
avoidCollisions = true,
collisionPadding = 8,
children,
content,
}: {
delay?: number;
side?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
triggerClass?: string;
contentClass?: string;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
content: Snippet;
} = $props();
let hovered = $state(false);
let focused = $state(false);
let active = $derived(hovered || focused);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class={triggerClass}
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
onfocusin={() => (focused = true)}
onfocusout={() => (focused = false)}
>
{#if active}
<Tooltip.Root delayDuration={delay} disableHoverableContent={false}>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props}>
{@render children()}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{:else}
{@render children()}
{/if}
</span>
@@ -0,0 +1,350 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import {
DAY_OPTIONS,
toggleDay as _toggleDay,
parseTimeInput,
formatTime,
toggleValue,
} from "$lib/filters";
import { ChevronDown } from "@lucide/svelte";
import BottomSheet from "./BottomSheet.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
open = $bindable(false),
openOnly = $bindable(),
waitCountMax = $bindable(),
days = $bindable(),
timeStart = $bindable(),
timeEnd = $bindable(),
instructionalMethod = $bindable(),
campus = $bindable(),
partOfTerm = $bindable(),
attributes = $bindable(),
creditHourMin = $bindable(),
creditHourMax = $bindable(),
instructor = $bindable(),
courseNumberMin = $bindable(),
courseNumberMax = $bindable(),
referenceData,
ranges,
}: {
open: boolean;
openOnly: boolean;
waitCountMax: number | null;
days: string[];
timeStart: string | null;
timeEnd: string | null;
instructionalMethod: string[];
campus: string[];
partOfTerm: string[];
attributes: string[];
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberMin: number | null;
courseNumberMax: number | null;
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
ranges: {
courseNumber: { min: number; max: number };
creditHours: { min: number; max: number };
waitCount: { max: number };
};
} = $props();
let expandedSection = $state<string | null>(null);
function toggleSection(id: string) {
expandedSection = expandedSection === id ? null : id;
}
function toggleDay(day: string) {
days = _toggleDay(days, day);
}
const attributeSections: {
label: string;
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
}[] = [
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
{ label: "Campus", key: "campus", dataKey: "campuses" },
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
];
function getAttrSelected(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
): string[] {
if (key === "instructionalMethod") return instructionalMethod;
if (key === "campus") return campus;
if (key === "partOfTerm") return partOfTerm;
return attributes;
}
function toggleAttr(
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes",
code: string
) {
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
else if (key === "campus") campus = toggleValue(campus, code);
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
else attributes = toggleValue(attributes, code);
}
</script>
<BottomSheet bind:open>
<div class="flex flex-col">
<!-- Status section -->
<button
onclick={() => toggleSection("status")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Status
{#if openOnly || waitCountMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'status'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "status"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Availability</span>
<button
type="button"
aria-pressed={openOnly}
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer select-none
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if ranges.waitCount.max > 0}
<RangeSlider
min={0}
max={ranges.waitCount.max}
step={5}
bind:value={waitCountMax}
label="Max waitlist"
dual={false}
pips
pipstep={2}
formatValue={(v) => (v === 0 ? "Off" : String(v))}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Max waitlist</span>
<span class="text-xs text-muted-foreground select-none">No waitlisted courses</span>
</div>
{/if}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Schedule section -->
<button
onclick={() => toggleSection("schedule")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Schedule
{#if days.length > 0 || timeStart !== null || timeEnd !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'schedule'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "schedule"}
<div class="px-4 pb-3 flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer select-none min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
>
{label}
</button>
{/each}
</div>
</div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
autocomplete="off"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground select-none">to</span>
<input
type="text"
placeholder="3:00 PM"
autocomplete="off"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- Attributes section -->
<button
onclick={() => toggleSection("attributes")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
Attributes
{#if instructionalMethod.length > 0 || campus.length > 0 || partOfTerm.length > 0 || attributes.length > 0}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'attributes'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "attributes"}
<div class="px-4 pb-3 flex flex-col gap-3">
{#each attributeSections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getAttrSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleAttr(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<div class="h-px bg-border mx-4"></div>
<!-- More section -->
<button
onclick={() => toggleSection("more")}
class="flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground"
>
<span class="flex items-center gap-2">
More
{#if creditHourMin !== null || creditHourMax !== null || instructor !== "" || courseNumberMin !== null || courseNumberMax !== null}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform {expandedSection === 'more'
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedSection === "more"}
<div class="px-4 pb-3 flex flex-col gap-3">
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label
for="mobile-instructor-input"
class="text-xs font-medium text-muted-foreground select-none"
>
Instructor
</label>
<input
id="mobile-instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
</div>
{/if}
</div>
</BottomSheet>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import FilterPopover from "./FilterPopover.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
creditHourMin = $bindable<number | null>(null),
creditHourMax = $bindable<number | null>(null),
instructor = $bindable(""),
courseNumberMin = $bindable<number | null>(null),
courseNumberMax = $bindable<number | null>(null),
ranges,
}: {
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberMin: number | null;
courseNumberMax: number | null;
ranges: { courseNumber: { min: number; max: number }; creditHours: { min: number; max: number } };
} = $props();
const hasActiveFilters = $derived(
creditHourMin !== null ||
creditHourMax !== null ||
instructor !== "" ||
courseNumberMin !== null ||
courseNumberMax !== null
);
</script>
<FilterPopover label="More" active={hasActiveFilters}>
{#snippet content()}
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label for="instructor-input" class="text-xs font-medium text-muted-foreground select-none">
Instructor
</label>
<input
id="instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
{/snippet}
</FilterPopover>
+195 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import { navbar } from "$lib/stores/navigation.svelte";
import { Clock, Search, User } from "@lucide/svelte";
import ThemeToggle from "./ThemeToggle.svelte";
@@ -28,33 +29,215 @@ function isActive(tabHref: string): boolean {
}
return page.url.pathname.startsWith(tabHref);
}
/** Label expansion check using a deferred path that updates only after
* view transitions finish, so CSS transitions run on visible DOM. */
function isLabelExpanded(tabHref: string): boolean {
if (tabHref === "/") return navbar.path === "/";
if (tabHref === "/profile") {
return APP_PREFIXES.some((p) => navbar.path.startsWith(p));
}
return navbar.path.startsWith(tabHref);
}
// DOM refs
let tabRefs: HTMLAnchorElement[] = $state([]);
let containerRef: HTMLDivElement | undefined = $state();
let pillRef: HTMLDivElement | undefined = $state();
// Pill animation state — driven by JS, not CSS transitions
let targetLeft = 0;
let targetWidth = 0;
let currentLeft = 0;
let currentWidth = 0;
let animationId: number | null = null;
let mounted = $state(false);
const ANIMATION_DURATION = 300;
const EASING = cubicOut;
function cubicOut(t: number): number {
const f = t - 1;
return f * f * f + 1;
}
function allTabs() {
return [...staticTabs.map((t) => t.href), profileTab.href];
}
function activeIndex(): number {
return allTabs().findIndex((href) => isActive(href));
}
function measureActiveTab(): { left: number; width: number } | null {
const idx = activeIndex();
if (idx < 0 || !tabRefs[idx] || !containerRef) return null;
const containerRect = containerRef.getBoundingClientRect();
const tabRect = tabRefs[idx].getBoundingClientRect();
return {
left: tabRect.left - containerRect.left,
width: tabRect.width,
};
}
function applyPill(left: number, width: number) {
if (!pillRef) return;
pillRef.style.transform = `translateX(${left}px)`;
pillRef.style.width = `${width}px`;
currentLeft = left;
currentWidth = width;
}
function animatePill(fromLeft: number, fromWidth: number, toLeft: number, toWidth: number) {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / ANIMATION_DURATION, 1);
const eased = EASING(progress);
const left = fromLeft + (toLeft - fromLeft) * eased;
const width = fromWidth + (toWidth - fromWidth) * eased;
applyPill(left, width);
if (progress < 1) {
animationId = requestAnimationFrame(tick);
} else {
animationId = null;
}
}
animationId = requestAnimationFrame(tick);
}
function updateTarget() {
const measured = measureActiveTab();
if (!measured) return;
targetLeft = measured.left;
targetWidth = measured.width;
if (!mounted) {
// First render — snap immediately, no animation
applyPill(targetLeft, targetWidth);
mounted = true;
return;
}
// Always (re)start animation from current position — handles both fresh
// navigations and rapid route changes that interrupt a running animation
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
}
function updateTargetFromResize() {
const measured = measureActiveTab();
if (!measured) return;
const newLeft = measured.left;
const newWidth = measured.width;
// If nothing changed, skip
if (newLeft === targetLeft && newWidth === targetWidth) return;
targetLeft = newLeft;
targetWidth = newWidth;
if (animationId !== null) {
// Animation in progress — retarget it smoothly by starting a new
// animation from the current interpolated position to the new target
cancelAnimationFrame(animationId);
animationId = null;
animatePill(currentLeft, currentWidth, targetLeft, targetWidth);
} else {
// No animation running — snap (this handles window resize, etc.)
applyPill(targetLeft, targetWidth);
}
}
// Start animation when route changes
$effect(() => {
page.url.pathname;
profileTab.href;
requestAnimationFrame(() => {
updateTarget();
});
});
// Track the active tab's size during label transitions and window resizes
$effect(() => {
if (!containerRef) return;
const observer = new ResizeObserver(() => {
updateTargetFromResize();
});
observer.observe(containerRef);
for (const ref of tabRefs) {
if (ref) observer.observe(ref);
}
return () => observer.disconnect();
});
</script>
<nav class="w-full flex justify-center pt-5 px-5">
<nav class="w-full flex justify-center pt-5 px-3 sm:px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<!-- pointer-events-auto: root layout wraps nav in pointer-events-none overlay -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto">
{#each staticTabs as tab}
<div
class="relative flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto"
bind:this={containerRef}
>
<!-- Sliding pill — animated via JS (RAF) to stay smooth even when
heavy page transitions cause CSS transition skipping -->
<div
class="absolute top-1 bottom-1 left-0 rounded-md bg-background shadow-sm will-change-[transform,width]"
bind:this={pillRef}
></div>
{#each staticTabs as tab, i}
<a
href={tab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(tab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
bind:this={tabRefs[i]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(tab.href) ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
>
<tab.icon size={15} strokeWidth={2} />
{tab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(tab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{tab.label}</span>
</span>
</a>
{/each}
<a
href={profileTab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
bind:this={tabRefs[staticTabs.length]}
class="relative z-10 flex items-center gap-1.5 rounded-md px-2 sm:px-3 py-1.5 text-sm font-medium transition-colors no-underline select-none
{isActive(profileTab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
<User size={15} strokeWidth={2} />
{#if profileTab.label}{profileTab.label}{/if}
{#if profileTab.label}
<span
class="grid overflow-hidden transition-[grid-template-columns,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]
{isLabelExpanded(profileTab.href)
? 'grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0 sm:grid-cols-[1fr] sm:opacity-100'}"
>
<span class="overflow-hidden whitespace-nowrap">{profileTab.label}</span>
</span>
{/if}
</a>
<ThemeToggle />
</div>
@@ -1,76 +0,0 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
type Axis = "horizontal" | "vertical";
let {
key,
children,
axis = "horizontal",
inDelay = 0,
outDelay = 0,
}: {
key: string;
children: Snippet;
axis?: Axis;
inDelay?: number;
outDelay?: number;
} = $props();
const DURATION = 400;
const OFFSET = 40;
function translate(axis: Axis, value: number): string {
return axis === "vertical" ? `translateY(${value}px)` : `translateX(${value}px)`;
}
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}`,
};
}
const offset = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
const base = "position: absolute; top: 0; left: 0; width: 100%; height: 100%";
if (dir === "fade") {
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}`,
};
}
const offset = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
</script>
<div class="relative flex flex-1 flex-col">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
{@render children()}
</div>
{/key}
</div>
+118 -83
View File
@@ -8,7 +8,10 @@ const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{
transform: `translateX(${direction * 20}px)`,
opacity: 0,
},
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
@@ -20,11 +23,13 @@ let {
totalCount,
offset,
limit,
loading = false,
onPageChange,
}: {
totalCount: number;
offset: number;
limit: number;
loading?: boolean;
onPageChange: (newOffset: number) => void;
} = $props();
@@ -60,43 +65,53 @@ const selectValue = $derived(String(currentPage));
</script>
{#if totalCount > 0 && totalPages > 1}
<div class="flex items-start text-xs -mt-3 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<div class="flex items-start text-xs mt-2 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
rounded-md text-sm font-medium tabular-nums
border border-border bg-card text-foreground
hover:bg-muted/50 active:bg-muted transition-colors
cursor-pointer select-none outline-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
{loading ? 'animate-pulse' : ''}"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}
>{currentPage}</span
>
<ChevronUp
class="size-3 text-muted-foreground"
/>
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
max-h-72 min-w-16 w-auto
select-none rounded-md p-1
data-[state=open]:animate-in data-[state=closed]:animate-out
@@ -104,64 +119,84 @@ const selectValue = $derived(String(currentPage));
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=top]:slide-in-from-bottom-2
data-[side=bottom]:slide-in-from-top-2"
side="top"
sideOffset={6}
>
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
<ChevronUp class="size-3.5 text-muted-foreground" />
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
side="top"
sideOffset={6}
>
<Select.ScrollUpButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronUp
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
justify-center px-3 text-sm tabular-nums
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
data-[selected]:font-semibold"
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
<ChevronDown class="size-3.5 text-muted-foreground" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronDown
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
rounded-md text-sm tabular-nums
text-muted-foreground
hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors
cursor-pointer select-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
{isSlotVisible(page) ? '' : 'invisible pointer-events-none'}"
onclick={() => goToPage(page)}
aria-label="Go to page {page}"
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
{!isSlotVisible(page)
? 'invisible'
: loading
? 'opacity-40'
: ''}
{!isSlotVisible(page) || loading
? 'pointer-events-none'
: ''}"
onclick={() => goToPage(page)}
aria-label="Go to page {page}"
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page) || loading}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs -mt-3 pl-2">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs mt-2 pl-2">
<span class="text-muted-foreground select-none hidden md:inline">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
<span class="text-muted-foreground select-none tabular-nums md:hidden">
{formatNumber(start)}&ndash;{formatNumber(end)} / {formatNumber(totalCount)}
</span>
</div>
{/if}
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import LibRangeSlider from "svelte-range-slider-pips";
type LibProps = ComponentProps<LibRangeSlider>;
/**
* Two modes:
* - `dual` (default): bind `valueLow` and `valueHigh` for a two-thumb range.
* - `dual={false}`: bind `value` for a single-thumb slider. `valueLow`/`valueHigh` are ignored.
*
* All three are null when at their default (boundary) position.
*/
type Props = Omit<
LibProps,
| "values"
| "value"
| "formatter"
| "range"
| "min"
| "max"
| "float"
| "hoverable"
| "springValues"
> & {
min: number;
max: number;
label: string;
formatValue?: (v: number) => string;
dual?: boolean;
float?: boolean;
hoverable?: boolean;
springValues?: { stiffness?: number; damping?: number };
valueLow?: number | null;
valueHigh?: number | null;
value?: number | null;
};
let {
min,
max,
valueLow = $bindable(null),
valueHigh = $bindable(null),
value = $bindable(null),
label,
formatValue = (v: number) => String(v),
dual = true,
float = true,
hoverable = true,
// Intentionally snappier than library defaults (0.15/0.4)
springValues = { stiffness: 0.3, damping: 0.7 },
...libProps
}: Props = $props();
let internalValues = $state<number[]>([min, max]);
let internalValue = $state(max);
if (import.meta.env.DEV) {
$effect(() => {
if (min >= max) {
console.warn(`RangeSlider "${label}": min (${min}) must be less than max (${max})`);
}
});
}
// Sync external -> internal (equality guards prevent loops)
$effect(() => {
if (dual) {
const nextLow = valueLow ?? min;
const nextHigh = valueHigh ?? max;
if (internalValues[0] !== nextLow || internalValues[1] !== nextHigh) {
internalValues = [nextLow, nextHigh];
}
} else {
const next = value ?? max;
if (internalValue !== next) {
internalValue = next;
}
}
});
const isDefault = $derived(dual ? valueLow === null && valueHigh === null : value === null);
function handleDualChange(event: CustomEvent<{ values: number[] }>) {
const [low, high] = event.detail.values;
const nextLow = low === min && high === max ? null : low;
const nextHigh = low === min && high === max ? null : high;
if (nextLow === valueLow && nextHigh === valueHigh) return;
valueLow = nextLow;
valueHigh = nextHigh;
}
function handleSingleChange(event: CustomEvent<{ value: number }>) {
const next = event.detail.value === max ? null : event.detail.value;
if (next === value) return;
value = next;
}
</script>
<div class="range-slider-wrapper flex flex-col gap-1.5" role="group" aria-label={label}>
<div class="flex items-center justify-between select-none">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
{#if !isDefault}
<span class="text-xs text-muted-foreground">
{#if dual}
{formatValue(valueLow ?? min)} {formatValue(valueHigh ?? max)}
{:else}
{formatValue(value ?? max)}
{/if}
</span>
{/if}
</div>
<div class="pt-0.5">
{#if dual}
<LibRangeSlider
bind:values={internalValues}
{min}
{max}
{float}
{hoverable}
{springValues}
range
formatter={formatValue}
{...libProps}
on:change={handleDualChange}
/>
{:else}
<LibRangeSlider
bind:value={internalValue}
{min}
{max}
{float}
{hoverable}
{springValues}
formatter={formatValue}
{...libProps}
on:change={handleSingleChange}
/>
{/if}
</div>
</div>
<style>
/* Theme color mapping */
.range-slider-wrapper :global(.rangeSlider) {
--range-slider: var(--border);
--range-handle-inactive: var(--muted-foreground);
--range-handle: var(--muted-foreground);
--range-handle-focus: var(--foreground);
--range-handle-border: var(--muted-foreground);
--range-range-inactive: var(--muted-foreground);
--range-range: var(--foreground);
--range-range-hover: var(--foreground);
--range-range-press: var(--foreground);
--range-float-inactive: var(--card);
--range-float: var(--card);
--range-float-text: var(--card-foreground);
--range-range-limit: var(--muted);
font-size: 0.75rem;
margin: 0.5em;
height: 0.375em;
}
/* Smaller handles, plain circles */
.range-slider-wrapper :global(.rangeSlider .rangeHandle) {
height: 1em;
width: 1em;
}
.range-slider-wrapper :global(.rangeSlider.rsRange:not(.rsMin):not(.rsMax) .rangeNub) {
border-radius: 9999px;
}
.range-slider-wrapper :global(.rangeSlider.rsRange .rangeHandle .rangeNub) {
transform: none;
}
/* Hover / press effects */
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle:hover::before) {
box-shadow: 0 0 0 6px var(--handle-border);
opacity: 0.15;
}
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress::before),
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress:hover::before) {
box-shadow: 0 0 0 8px var(--handle-border);
opacity: 0.25;
}
/* Track bar */
.range-slider-wrapper :global(.rangeSlider .rangeBar),
.range-slider-wrapper :global(.rangeSlider .rangeLimit) {
height: 0.375em;
}
/* Float label */
.range-slider-wrapper :global(.rangeSlider .rangeFloat) {
font-size: 0.7em;
font-weight: 400;
line-height: 1;
padding: 0.25em 0.4em 0.35em;
border-radius: 0.375em;
border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Pip labels */
.range-slider-wrapper :global(.rangeSlider .rangePip .pipVal) {
color: var(--muted-foreground);
font-size: 0.6em;
font-weight: 400;
}
/* Pip spacing */
.range-slider-wrapper :global(.rangeSlider.rsPips) {
margin-bottom: 1.2em;
}
.range-slider-wrapper :global(.rangeSlider.rsPipLabels) {
margin-bottom: 2em;
}
</style>
+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,77 @@
<script lang="ts">
import { DAY_OPTIONS, toggleDay as _toggleDay, parseTimeInput, formatTime } from "$lib/filters";
import FilterPopover from "./FilterPopover.svelte";
let {
days = $bindable<string[]>([]),
timeStart = $bindable<string | null>(null),
timeEnd = $bindable<string | null>(null),
}: {
days: string[];
timeStart: string | null;
timeEnd: string | null;
} = $props();
const hasActiveFilters = $derived(days.length > 0 || timeStart !== null || timeEnd !== null);
function toggleDay(day: string) {
days = _toggleDay(days, day);
}
</script>
<FilterPopover label="Schedule" active={hasActiveFilters}>
{#snippet content()}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer select-none min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
>
{label}
</button>
{/each}
</div>
</div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
autocomplete="off"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground select-none">to</span>
<input
type="text"
placeholder="3:00 PM"
autocomplete="off"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
{/snippet}
</FilterPopover>
+148 -12
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import type { Subject, Term } from "$lib/api";
import SimpleTooltip from "./SimpleTooltip.svelte";
import type { CodeDescription, Subject, Term } from "$lib/api";
import { SlidersHorizontal } from "@lucide/svelte";
import AttributesPopover from "./AttributesPopover.svelte";
import MobileFilterSheet from "./MobileFilterSheet.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 +16,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(),
courseNumberMin = $bindable(),
courseNumberMax = $bindable(),
referenceData,
ranges,
}: {
terms: Term[];
subjects: Subject[];
@@ -18,14 +38,95 @@ 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;
courseNumberMin: number | null;
courseNumberMax: number | null;
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
partsOfTerm: CodeDescription[];
attributes: CodeDescription[];
};
ranges: {
courseNumber: { min: number; max: number };
creditHours: { min: number; max: number };
waitCount: { max: number };
};
} = $props();
// Mobile bottom sheet state
let filterSheetOpen = $state(false);
let activeFilterCount = $derived(
[
openOnly,
waitCountMax !== null,
days.length > 0,
timeStart !== null || timeEnd !== null,
instructionalMethod.length > 0,
campus.length > 0,
partOfTerm.length > 0,
attributes.length > 0,
creditHourMin !== null || creditHourMax !== null,
instructor !== "",
courseNumberMin !== null || courseNumberMax !== null,
].filter(Boolean).length
);
</script>
<div class="flex flex-wrap gap-3 items-start">
<!-- Mobile row 1: Term + Subject side by side -->
<div class="flex gap-2 md:hidden">
<div class="flex-1 min-w-0">
<TermCombobox {terms} bind:value={selectedTerm} />
</div>
<div class="flex-1 min-w-0">
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
</div>
</div>
<!-- Mobile row 2: Search + Filters button -->
<div class="flex gap-2 md:hidden">
<input
type="text"
placeholder="Search courses..."
aria-label="Search courses"
bind:value={query}
class="h-9 border border-border bg-card text-foreground rounded-md px-3 text-sm flex-1 min-w-0
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
transition-colors"
/>
<button
onclick={() => (filterSheetOpen = true)}
class="inline-flex items-center gap-1.5 rounded-md border h-9 px-3 text-sm font-medium transition-colors cursor-pointer select-none shrink-0
{activeFilterCount > 0
? 'border-primary/50 bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<SlidersHorizontal class="size-3.5" />
Filters
{#if activeFilterCount > 0}
<span
class="inline-flex items-center justify-center size-4 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold"
>{activeFilterCount}</span
>
{/if}
</button>
</div>
<!-- Desktop row 1: Term + Subject + Search (unchanged) -->
<div class="hidden md:flex flex-wrap gap-3 items-start">
<TermCombobox {terms} bind:value={selectedTerm} />
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
<input
type="text"
placeholder="Search courses..."
@@ -35,11 +136,46 @@ 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>
<!-- Desktop row 2: Category filter popovers -->
<div class="hidden md: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:courseNumberMin
bind:courseNumberMax
ranges={{ courseNumber: ranges.courseNumber, creditHours: ranges.creditHours }}
/>
</div>
<!-- Mobile: Filter bottom sheet -->
<MobileFilterSheet
bind:open={filterSheetOpen}
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:courseNumberMin
bind:courseNumberMax
{referenceData}
{ranges}
/>
+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 select-none {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,29 @@
<script lang="ts">
let {
segments,
onRemoveSegment,
}: {
segments: string[];
onRemoveSegment: (segment: string) => void;
} = $props();
</script>
{#if segments.length > 0}
<span
class="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground shrink-0"
>
{#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 select-none first:rounded-l-full last:rounded-r-full"
onclick={() => onRemoveSegment(segment)}
aria-label="Remove {segment} filter"
>
{segment}
</button>
{/each}
</span>
{/if}
+42 -14
View File
@@ -11,6 +11,9 @@ let {
triggerClass = "",
contentClass = "",
sideOffset = 6,
portal = true,
avoidCollisions = true,
collisionPadding = 8,
children,
}: {
text: string;
@@ -20,23 +23,48 @@ let {
triggerClass?: string;
contentClass?: string;
sideOffset?: number;
portal?: boolean;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
} = $props();
</script>
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
<Tooltip.Trigger>
{#snippet child({ props })}
<span class={triggerClass} {...props}>
{@render children()}
</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>
<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(
"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,56 @@
<script lang="ts">
import FilterPopover from "./FilterPopover.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
openOnly = $bindable(false),
waitCountMax = $bindable<number | null>(null),
waitCountMaxRange = 0,
}: {
openOnly: boolean;
waitCountMax: number | null;
waitCountMaxRange: number;
} = $props();
const hasActiveFilters = $derived(openOnly || waitCountMax !== null);
</script>
<FilterPopover label="Status" active={hasActiveFilters} width="w-64">
{#snippet content()}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Availability</span>
<button
type="button"
aria-pressed={openOnly}
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer select-none
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if waitCountMaxRange > 0}
<RangeSlider
min={0}
max={waitCountMaxRange}
step={5}
bind:value={waitCountMax}
label="Max waitlist"
dual={false}
pips
pipstep={2}
formatValue={(v) => (v === 0 ? "Off" : String(v))}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground select-none">Max waitlist</span>
<span class="text-xs text-muted-foreground select-none">No waitlisted courses</span>
</div>
{/if}
{/snippet}
</FilterPopover>
@@ -72,7 +72,7 @@ $effect(() => {
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-9 rounded-md border border-border bg-card
flex flex-nowrap items-center gap-1 w-56 pr-9 overflow-hidden cursor-pointer
flex flex-nowrap items-center gap-1 w-full md:w-56 pr-9 overflow-hidden cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
bind:this={containerEl}
onclick={() => { containerEl?.querySelector('input')?.focus(); }}
@@ -85,15 +85,15 @@ $effect(() => {
onmousedown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onclick={(e) => { e.stopPropagation(); removeSubject(code); }}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation(); removeSubject(code); } }}
class="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs font-mono shrink-0
text-muted-foreground hover:outline hover:outline-1 hover:outline-ring
cursor-pointer transition-[outline] duration-100 first:ml-2"
class="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs font-mono shrink-0 select-none
text-muted-foreground hover:bg-muted-foreground/15
cursor-pointer transition-colors first:ml-2"
>
{code}
</span>
{/each}
{#if !open && overflowCount > 0}
<span class="text-xs text-muted-foreground shrink-0">+{formatNumber(overflowCount)}</span>
<span class="text-xs text-muted-foreground shrink-0 select-none">+{formatNumber(overflowCount)}</span>
{/if}
{/if}
<Combobox.Input
+1 -1
View File
@@ -60,7 +60,7 @@ $effect(() => {
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-9 rounded-md border border-border bg-card
flex items-center w-40 cursor-pointer
flex items-center w-full md:w-40 cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
role="presentation"
bind:this={containerEl}
+9 -1
View File
@@ -23,11 +23,19 @@ async function handleToggle(event: MouseEvent) {
const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// Suppress named view-transition elements during theme change so they don't
// get their own transition group and snap to the new theme ahead of the mask.
document.documentElement.classList.add("theme-transitioning");
const transition = document.startViewTransition(async () => {
themeStore.toggle();
await tick();
});
transition.finished.finally(() => {
document.documentElement.classList.remove("theme-transitioning");
});
transition.ready.then(() => {
document.documentElement.animate(
{
@@ -48,7 +56,7 @@ async function handleToggle(event: MouseEvent) {
type="button"
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
class="cursor-pointer border-none rounded-md flex items-center justify-center p-1.5
class="cursor-pointer border-none rounded-md flex items-center justify-center p-1.5 select-none
text-muted-foreground hover:text-foreground hover:bg-background/50 bg-transparent transition-colors"
>
<div class="relative size-[18px]">
+137 -92
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import BottomSheet from "$lib/components/BottomSheet.svelte";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
import { getSubjectColor } from "$lib/timeline/data";
import { Filter, X } from "@lucide/svelte";
@@ -32,107 +33,151 @@ function onKeyDown(e: KeyboardEvent) {
}
</script>
{#snippet followStatus()}
{#if followEnabled}
<div
class="px-2 py-1 rounded-md text-xs font-medium text-center
bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
>
FOLLOWING
</div>
{:else}
<button
class="w-full px-2 py-1 rounded-md text-xs font-medium text-center
bg-muted/80 text-muted-foreground hover:text-foreground
border border-border/50 transition-colors cursor-pointer"
onclick={onResumeFollow}
aria-label="Resume following current time"
>
FOLLOW
</button>
{/if}
{/snippet}
{#snippet subjectToggles()}
<div class="flex items-center justify-between mb-2 text-xs text-muted-foreground">
<span class="uppercase tracking-wider font-medium">Subjects</span>
<div class="flex gap-1.5">
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onEnableAll}>All</button
>
<span class="opacity-40">|</span>
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onDisableAll}>None</button
>
</div>
</div>
<div class="flex flex-col gap-y-0.5">
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
onclick={() => onToggleSubject(subject)}
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
? 'text-foreground'
: 'text-muted-foreground/50'}"
>
{subject}
</span>
</button>
{/each}
</div>
{/snippet}
<svelte:window onkeydown={onKeyDown} />
<!-- Filter toggle button — slides out when drawer opens -->
<button
class="absolute right-3 z-50 p-2 rounded-md
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-md transition-all duration-200 ease-in-out cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
style="top: 20%; transform: translateX({open ? '60px' : '0'});"
onclick={() => (open = true)}
aria-label="Open filters"
>
<Filter size={18} strokeWidth={2} />
</button>
<!-- Drawer panel -->
<div
class="absolute right-0 z-40 rounded-l-lg shadow-xl transition-transform duration-200 ease-in-out {open ? '' : 'pointer-events-none'}"
style="top: 20%; width: {DRAWER_WIDTH}px; height: 60%; transform: translateX({open
? 0
: DRAWER_WIDTH}px);"
>
<div
class="h-full flex flex-col bg-background/90 backdrop-blur-md border border-border/40 rounded-l-lg overflow-hidden"
style="width: {DRAWER_WIDTH}px;"
<!-- Desktop: Filter toggle button — slides out when drawer opens -->
<div class="hidden md:block">
<button
class="absolute right-3 z-50 p-2 rounded-md
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-md transition-all duration-200 ease-in-out cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
style="top: 20%; transform: translateX({open ? '60px' : '0'});"
onclick={() => (open = true)}
aria-label="Open filters"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-border/40">
<span class="text-xs font-semibold text-foreground">Filters</span>
<button
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onclick={() => (open = false)}
aria-label="Close filters"
>
<X size={14} strokeWidth={2} />
</button>
</div>
<Filter size={18} strokeWidth={2} />
</button>
</div>
<!-- Follow status -->
<div class="px-3 py-2 border-b border-border/40">
{#if followEnabled}
<div
class="px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
>
FOLLOWING
</div>
{:else}
<!-- Desktop: Drawer panel -->
<div class="hidden md:block">
<div
class="absolute right-0 z-40 rounded-l-lg shadow-xl transition-transform duration-200 ease-in-out {open ? '' : 'pointer-events-none'}"
style="top: 20%; width: {DRAWER_WIDTH}px; height: 60%; transform: translateX({open
? 0
: DRAWER_WIDTH}px);"
>
<div
class="h-full flex flex-col bg-background/90 backdrop-blur-md border border-border/40 rounded-l-lg overflow-hidden"
style="width: {DRAWER_WIDTH}px;"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-border/40">
<span class="text-xs font-semibold text-foreground">Filters</span>
<button
class="w-full px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-muted/80 text-muted-foreground hover:text-foreground
border border-border/50 transition-colors cursor-pointer"
onclick={onResumeFollow}
aria-label="Resume following current time"
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onclick={() => (open = false)}
aria-label="Close filters"
>
FOLLOW
<X size={14} strokeWidth={2} />
</button>
{/if}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-3 py-2">
<div class="flex items-center justify-between mb-2 text-[10px] text-muted-foreground">
<span class="uppercase tracking-wider font-medium">Subjects</span>
<div class="flex gap-1.5">
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onEnableAll}>All</button
>
<span class="opacity-40">|</span>
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onDisableAll}>None</button
>
</div>
</div>
<div class="space-y-0.5">
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
onclick={() => onToggleSubject(subject)}
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
? 'text-foreground'
: 'text-muted-foreground/50'}"
>
{subject}
</span>
</button>
{/each}
<!-- Follow status -->
<div class="px-3 py-2 border-b border-border/40">
{@render followStatus()}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-3 py-2">
{@render subjectToggles()}
</div>
</div>
</div>
</div>
<!-- Mobile: Floating filter button -->
<button
class="fixed right-3 bottom-3 z-50 p-3 rounded-full md:hidden
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-lg cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}
transition-opacity duration-200"
onclick={() => (open = true)}
aria-label="Open filters"
>
<Filter size={20} strokeWidth={2} />
</button>
<!-- Mobile: Bottom sheet -->
<div class="md:hidden">
<BottomSheet bind:open maxHeight="50vh">
<div class="flex flex-col">
<!-- Follow status -->
<div class="px-4 py-2 border-b border-border/40">
{@render followStatus()}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-4 py-2">
{@render subjectToggles()}
</div>
</div>
</BottomSheet>
</div>
+32 -30
View File
@@ -18,35 +18,37 @@ const fmtTime = timeFormat("%-I:%M %p");
</script>
{#if visible && slot}
{@const total = enabledTotalClasses(slot, activeSubjects)}
<div
class="pointer-events-none fixed z-50 rounded-lg border border-border/60 bg-background/95
backdrop-blur-sm shadow-lg px-3 py-2 text-xs min-w-[140px]"
style="left: {x + 12}px; top: {y - 10}px; transform: translateY(-100%);"
>
<div class="font-semibold text-foreground mb-1.5">
{fmtTime(slot.time)}
{@const total = enabledTotalClasses(slot, activeSubjects)}
<div
class="pointer-events-none fixed z-50 rounded-lg border border-border/60 bg-background/95
backdrop-blur-sm shadow-lg px-3 py-2 text-xs min-w-35"
style="left: {x + 12}px; top: {y - 10}px; transform: translateY(-100%);"
>
<div class="font-semibold text-foreground mb-1.5">
{fmtTime(slot.time)}
</div>
<div class="flex flex-col gap-y-0.5">
{#each activeSubjects as subject}
{@const count = slot.subjects[subject] || 0}
{#if count > 0}
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1.5">
<span
class="inline-block w-2 h-2 rounded-sm"
style="background: {getSubjectColor(subject)}"
></span>
<span class="text-muted-foreground">{subject}</span>
</div>
<span class="font-medium tabular-nums">{count}</span>
</div>
{/if}
{/each}
</div>
<div
class="mt-1.5 pt-1.5 border-t border-border/40 flex justify-between font-medium"
>
<span>Total</span>
<span class="tabular-nums">{total}</span>
</div>
</div>
<div class="space-y-0.5">
{#each activeSubjects as subject}
{@const count = slot.subjects[subject] || 0}
{#if count > 0}
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1.5">
<span
class="inline-block w-2 h-2 rounded-sm"
style="background: {getSubjectColor(subject)}"
></span>
<span class="text-muted-foreground">{subject}</span>
</div>
<span class="font-medium tabular-nums">{count}</span>
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 pt-1.5 border-t border-border/40 flex justify-between font-medium">
<span>Total</span>
<span class="tabular-nums">{total}</span>
</div>
</div>
{/if}
@@ -0,0 +1,56 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import type { SortingState, VisibilityState } from "@tanstack/table-core";
import { useCourseTableState } from "./useCourseTableState.svelte";
import CourseTableDesktop from "./CourseTableDesktop.svelte";
import CourseTableMobile from "./CourseTableMobile.svelte";
let {
courses,
loading,
sorting = [],
onSortingChange,
manualSorting = false,
subjectMap = {},
limit = 25,
columnVisibility = $bindable({}),
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
limit?: number;
columnVisibility?: VisibilityState;
} = $props();
const state = useCourseTableState(
() => courses,
() => limit
);
</script>
<CourseTableMobile
{courses}
{loading}
skeletonRowCount={state.skeletonRowCount}
expandedCrn={state.expandedCrn}
onToggle={state.toggleRow}
/>
<CourseTableDesktop
{courses}
{loading}
{sorting}
{onSortingChange}
{manualSorting}
{subjectMap}
bind:columnVisibility
expandedCrn={state.expandedCrn}
onToggle={state.toggleRow}
skeletonRowCount={state.skeletonRowCount}
hadResults={state.hadResults}
observeHeight={state.observeHeight}
contentHeight={state.contentHeight}
/>
@@ -0,0 +1,308 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { useTooltipDelegation } from "$lib/composables/useTooltipDelegation";
import { ArrowDown, ArrowUp, ArrowUpDown, Check, RotateCcw } from "@lucide/svelte";
import {
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu } from "bits-ui";
import { flip } from "svelte/animate";
import { fade, slide } from "svelte/transition";
import { setContext } from "svelte";
import CourseDetail from "$lib/components/CourseDetail.svelte";
import { COLUMN_DEFS, CELL_COMPONENTS } from "./columns";
import { buildSkeletonHtml } from "./skeletons";
import EmptyState from "./EmptyState.svelte";
import { TABLE_CONTEXT_KEY } from "./context";
let {
courses,
loading,
sorting = [],
onSortingChange,
manualSorting = false,
subjectMap = {},
columnVisibility = $bindable({}),
expandedCrn,
onToggle,
skeletonRowCount,
hadResults,
observeHeight,
contentHeight,
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
columnVisibility?: VisibilityState;
expandedCrn: string | null;
onToggle: (crn: string) => void;
skeletonRowCount: number;
hadResults: boolean;
observeHeight: (el: HTMLTableElement) => () => void;
contentHeight: number | null;
} = $props();
let tableWrapper: HTMLDivElement = undefined!;
let tableElement: HTMLTableElement = undefined!;
const clipboard = useClipboard(1000);
// Set context once for all cells - shared utilities
setContext(TABLE_CONTEXT_KEY, {
clipboard,
get subjectMap() {
return subjectMap;
},
get maxSubjectLength() {
return maxSubjectLength;
},
});
useOverlayScrollbars(() => tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: { autoHide: "never" },
});
// Singleton tooltip delegation
$effect(() => {
if (!tableElement) return;
const { destroy } = useTooltipDelegation(tableElement);
return destroy;
});
// Height observation via composable
$effect(() => {
if (!tableElement) return;
return observeHeight(tableElement);
});
let maxSubjectLength = $derived(
courses.length > 0 ? Math.max(...courses.map((c) => c.subject.length)) : 3
);
let visibleColumnIds = $derived(
COLUMN_DEFS.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
);
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
function resetColumnVisibility() {
columnVisibility = {};
}
function handleVisibilityChange(updater: Updater<VisibilityState>) {
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
columnVisibility = newVisibility;
}
function handleSortingChange(updater: Updater<SortingState>) {
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
onSortingChange?.(newSorting);
}
const table = createSvelteTable({
get data() {
return courses;
},
getRowId: (row) => String(row.crn),
columns: COLUMN_DEFS,
state: {
get sorting() {
return sorting;
},
get columnVisibility() {
return columnVisibility;
},
},
onSortingChange: handleSortingChange,
onColumnVisibilityChange: handleVisibilityChange,
getCoreRowModel: getCoreRowModel(),
get getSortedRowModel() {
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
},
get manualSorting() {
return manualSorting;
},
enableSortingRemoval: true,
});
</script>
<!-- Desktop table
IMPORTANT: !important flags on hidden/block are required because OverlayScrollbars
applies inline styles (style="display: ...") to set up its custom scrollbar UI. -->
<div
bind:this={tableWrapper}
class="!hidden sm:!block 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 bind:this={tableElement} class="w-full min-w-120 md:min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr class="border-b border-border text-left text-muted-foreground">
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium select-none {header.id === 'seats' ? 'text-right' : ''}"
class:cursor-pointer={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span class="inline-flex items-center gap-1">
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown class="size-3.5" />
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</th>
{/if}
{/each}
</tr>
{/each}
</thead>
{#if loading && courses.length === 0}
<tbody>
{@html buildSkeletonHtml(visibleColumnIds, skeletonRowCount)}
</tbody>
{:else if courses.length === 0 && !loading}
<tbody>
<tr>
<td
colspan={visibleColumnIds.length}
class="py-12 text-center text-muted-foreground"
>
<EmptyState />
</td>
</tr>
</tbody>
{:else}
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
class="transition-opacity duration-200 animate-fade-in {loading ? 'opacity-45 pointer-events-none' : ''}"
animate:flip={{ duration: hadResults ? 300 : 0 }}
style:animation-delay="{Math.min(i * 25, 300)}ms"
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
onclick={() => onToggle(course.crn)}
>
{#each visibleColumnIds as colId (colId)}
{@const CellComponent = CELL_COMPONENTS[colId]}
{#if CellComponent}
<CellComponent {course} />
{:else}
<td class="py-2 px-2 text-muted-foreground"></td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td colspan={visibleColumnIds.length} class="p-0">
<div transition:slide={{ duration: 200 }}>
<CourseDetail {course} />
</div>
</td>
</tr>
{/if}
</tbody>
{/each}
{/if}
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
>
<ContextMenu.Group>
<ContextMenu.GroupHeading
class="px-2 py-1.5 text-xs font-medium text-muted-foreground select-none"
>
Toggle columns
</ContextMenu.GroupHeading>
{#each COLUMN_DEFS as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<ContextMenu.CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = {
...columnVisibility,
[id]: checked,
};
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{#snippet children({ checked })}
<span
class="flex size-4 items-center justify-center rounded-sm border border-border"
>
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</ContextMenu.CheckboxItem>
{/each}
</ContextMenu.Group>
{#if hasCustomVisibility}
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
<ContextMenu.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
</ContextMenu.Item>
{/if}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import CourseCard from "$lib/components/CourseCard.svelte";
import { buildCardSkeletonHtml } from "./skeletons";
import EmptyState from "./EmptyState.svelte";
let {
courses,
loading,
skeletonRowCount,
expandedCrn,
onToggle,
}: {
courses: CourseResponse[];
loading: boolean;
skeletonRowCount: number;
expandedCrn: string | null;
onToggle: (crn: string) => void;
} = $props();
</script>
<div class="flex flex-col gap-2 sm:hidden">
{#if loading && courses.length === 0}
{@html buildCardSkeletonHtml(skeletonRowCount)}
{:else if courses.length === 0 && !loading}
<EmptyState />
{:else}
{#each courses as course (course.crn)}
<div class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}">
<CourseCard
{course}
expanded={expandedCrn === course.crn}
onToggle={() => onToggle(course.crn)}
/>
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
// Empty state component for course table
</script>
<div class="py-8 text-center text-sm text-muted-foreground">
No courses found. Try adjusting your filters.
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { getTableContext } from "../context";
let { course }: { course: CourseResponse } = $props();
const { subjectMap, maxSubjectLength } = getTableContext();
let subjectDesc = $derived(subjectMap[course.subject]);
let paddedSubject = $derived(course.subject.padStart(maxSubjectLength, " "));
</script>
<td class="py-2 px-2 whitespace-nowrap">
<span
data-tooltip={subjectDesc
? `${subjectDesc} ${course.courseNumber}`
: `${course.subject} ${course.courseNumber}`}
data-tooltip-side="bottom"
data-tooltip-delay="200"
>
<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}
</span>
</td>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { fade } from "svelte/transition";
import { getTableContext } from "../context";
let { course }: { course: CourseResponse } = $props();
const { clipboard } = getTableContext();
</script>
<td class="py-2 px-2 relative">
<button
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy select-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
onclick={(e) => clipboard.copy(course.crn, e)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
clipboard.copy(course.crn, e);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
>
{course.crn}
{#if clipboard.copiedValue === course.crn}
<span
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
in:fade={{ duration: 100 }}
out:fade={{ duration: 200 }}
>
Copied!
</span>
{/if}
</button>
</td>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
RMP_CONFIDENCE_THRESHOLD,
abbreviateInstructor,
getPrimaryInstructor,
ratingStyle,
rmpUrl,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { formatNumber } from "$lib/utils";
import { ExternalLink, Star, Triangle } from "@lucide/svelte";
import LazyRichTooltip from "$lib/components/LazyRichTooltip.svelte";
let { course }: { course: CourseResponse } = $props();
let primary = $derived(getPrimaryInstructor(course.instructors));
let display = $derived(primary ? abbreviateInstructor(primary.displayName) : "Staff");
let commaIdx = $derived(display.indexOf(", "));
let ratingData = $derived(
primary?.rmpRating != null
? {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
}
: null
);
</script>
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span class="text-xs text-muted-foreground/60 uppercase select-none">Staff</span>
{:else}
<span
data-tooltip={primary?.displayName ?? "Staff"}
data-tooltip-side="bottom"
data-tooltip-delay="200"
>
{#if commaIdx !== -1}
<span
>{display.slice(0, commaIdx)},
<span class="text-muted-foreground"
>{display.slice(commaIdx + 1)}</span
></span
>
{:else}
<span>{display}</span>
{/if}
</span>
{/if}
{#if ratingData}
{@const lowConfidence = ratingData.count < RMP_CONFIDENCE_THRESHOLD}
<LazyRichTooltip side="bottom" sideOffset={6} contentClass="px-2.5 py-1.5">
{#snippet children()}
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5 select-none"
style={ratingStyle(ratingData.rating, themeStore.isDark)}
>
{ratingData.rating.toFixed(1)}
{#if lowConfidence}
<Triangle class="size-2 fill-current" />
{:else}
<Star class="size-2.5 fill-current" />
{/if}
</span>
{/snippet}
{#snippet content()}
<span class="inline-flex items-center gap-1.5 text-xs">
{ratingData.rating.toFixed(1)}/5 · {formatNumber(ratingData.count)}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(ratingData.legacyId)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink class="size-3" />
</a>
{/if}
</span>
{/snippet}
</LazyRichTooltip>
{/if}
</td>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import {
concernAccentColor,
formatLocationDisplay,
formatLocationTooltip,
getDeliveryConcern,
} from "$lib/course";
let { course }: { course: CourseResponse } = $props();
let concern = $derived(getDeliveryConcern(course));
let accentColor = $derived(concernAccentColor(concern));
let locTooltip = $derived(formatLocationTooltip(course, concern));
let locDisplay = $derived(formatLocationDisplay(course, concern));
</script>
<td class="py-2 px-2 whitespace-nowrap">
{#if locDisplay}
<span
class="text-muted-foreground"
class:pl-2={accentColor !== null}
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
data-tooltip={locTooltip}
data-tooltip-delay="200"
>
{locDisplay}
</span>
{:else}
<span class="text-xs text-muted-foreground/50"></span>
{/if}
</td>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { openSeats, seatsColor, seatsDotColor } from "$lib/course";
import { formatNumber } from "$lib/utils";
let { course }: { course: CourseResponse } = $props();
let open = $derived(openSeats(course));
let seatsTip = $derived(
`${formatNumber(open)} of ${formatNumber(course.maxEnrollment)} seats open, ${formatNumber(course.enrollment)} enrolled${course.waitCount > 0 ? `, ${formatNumber(course.waitCount)} waitlisted` : ""}`
);
</script>
<td class="py-2 px-2 text-right whitespace-nowrap">
<span
class="inline-flex items-center gap-1.5 select-none"
data-tooltip={seatsTip}
data-tooltip-side="left"
data-tooltip-delay="200"
>
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
<span class="{seatsColor(course)} font-medium tabular-nums"
>{#if open === 0}Full{:else}{open} open{/if}</span
>
<span class="text-muted-foreground/60 tabular-nums"
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
>
</span>
</td>

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