mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 06:23:38 -06:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 215703593b | |||
| 2123bcbe3e | |||
| 6c15f4082f | |||
| bbff2b7f36 | |||
| b37604f807 | |||
| d278498daa | |||
| bd2acee6f4 | |||
| 7e7fc1df94 | |||
| 005adb8792 | |||
| dfaaa88d54 | |||
| f387401a41 | |||
| 4e0140693b | |||
| e9209684eb | |||
| b562fe227e | |||
| 44260422d6 | |||
| 96a8c13125 | |||
| 567c4aec3c | |||
| f5a639e88b | |||
| d91f7ab342 | |||
| 7f0f08725a | |||
| 02b18f0c66 | |||
| 106bf232c4 | |||
| 239f7ee38c | |||
| 0ee4e8a8bc | |||
| 5729a821d5 | |||
| 5134ae9388 | |||
| 9e825cd113 | |||
| ac8dbb2eef | |||
| 5dd35ed215 | |||
| 2acf52a63b |
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.6.1"
|
||||
".": "0.6.2"
|
||||
}
|
||||
|
||||
Vendored
-4
@@ -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
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"nefrob.vscode-just-syntax"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -272,7 +272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "banner"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "banner"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
edition = "2024"
|
||||
default-run = "banner"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -5,13 +5,13 @@ use reqwest::{Request, Response};
|
||||
use reqwest_middleware::{Middleware, Next};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
pub struct TransparentMiddleware;
|
||||
pub struct LoggingMiddleware;
|
||||
|
||||
/// Threshold for logging slow requests at DEBUG level (in milliseconds)
|
||||
const SLOW_REQUEST_THRESHOLD_MS: u128 = 1000;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Middleware for TransparentMiddleware {
|
||||
impl Middleware for LoggingMiddleware {
|
||||
async fn handle(
|
||||
&self,
|
||||
req: Request,
|
||||
@@ -19,7 +19,8 @@ impl Middleware for TransparentMiddleware {
|
||||
next: Next<'_>,
|
||||
) -> std::result::Result<Response, reqwest_middleware::Error> {
|
||||
let method = req.method().to_string();
|
||||
let path = req.url().path().to_string();
|
||||
// Use the full URL (including query parameters) for logging
|
||||
let url = req.url().to_string();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let response_result = next.run(req, extensions).await;
|
||||
@@ -27,41 +28,25 @@ impl Middleware for TransparentMiddleware {
|
||||
|
||||
match response_result {
|
||||
Ok(response) => {
|
||||
let status = response.status().as_u16();
|
||||
let duration_ms = duration.as_millis();
|
||||
|
||||
if response.status().is_success() {
|
||||
let duration_ms = duration.as_millis();
|
||||
if duration_ms >= SLOW_REQUEST_THRESHOLD_MS {
|
||||
debug!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration_ms,
|
||||
"Request completed (slow)"
|
||||
);
|
||||
debug!(method, url, status, duration_ms, "Request completed (slow)");
|
||||
} else {
|
||||
trace!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration_ms,
|
||||
"Request completed"
|
||||
);
|
||||
trace!(method, url, status, duration_ms, "Request completed");
|
||||
}
|
||||
Ok(response)
|
||||
} else {
|
||||
warn!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration.as_millis(),
|
||||
"Request failed"
|
||||
);
|
||||
warn!(method, url, status, duration_ms, "Request failed");
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
method = method,
|
||||
path = path,
|
||||
method,
|
||||
url,
|
||||
duration_ms = duration.as_millis(),
|
||||
"Request failed"
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
|
||||
use extension_traits::extension;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{cmp::Ordering, str::FromStr};
|
||||
use ts_rs::TS;
|
||||
|
||||
use super::terms::Term;
|
||||
|
||||
@@ -199,7 +200,8 @@ impl TryFrom<MeetingDays> for Weekday {
|
||||
}
|
||||
|
||||
/// Time range for meetings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[ts(export)]
|
||||
pub struct TimeRange {
|
||||
pub start: NaiveTime,
|
||||
pub end: NaiveTime,
|
||||
|
||||
+31
-47
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Used by both the Discord bot commands and the web API endpoints.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime};
|
||||
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||
|
||||
/// Course metadata needed for calendar generation (shared interface between bot and web).
|
||||
pub struct CalendarCourse {
|
||||
@@ -36,42 +36,25 @@ impl CalendarCourse {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date parsing helpers
|
||||
// Day-of-week conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse an HHMM time string into `NaiveTime`.
|
||||
fn parse_hhmm(s: &str) -> Option<NaiveTime> {
|
||||
if s.len() != 4 {
|
||||
return None;
|
||||
/// Convert a `DayOfWeek` to a chrono `Weekday`.
|
||||
fn to_weekday(day: &DayOfWeek) -> Weekday {
|
||||
match day {
|
||||
DayOfWeek::Monday => Weekday::Mon,
|
||||
DayOfWeek::Tuesday => Weekday::Tue,
|
||||
DayOfWeek::Wednesday => Weekday::Wed,
|
||||
DayOfWeek::Thursday => Weekday::Thu,
|
||||
DayOfWeek::Friday => Weekday::Fri,
|
||||
DayOfWeek::Saturday => Weekday::Sat,
|
||||
DayOfWeek::Sunday => Weekday::Sun,
|
||||
}
|
||||
let hours = s[..2].parse::<u32>().ok()?;
|
||||
let minutes = s[2..].parse::<u32>().ok()?;
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0)
|
||||
}
|
||||
|
||||
/// Active weekdays for a meeting time.
|
||||
fn active_weekdays(mt: &DbMeetingTime) -> Vec<Weekday> {
|
||||
let mapping: [(bool, Weekday); 7] = [
|
||||
(mt.monday, Weekday::Mon),
|
||||
(mt.tuesday, Weekday::Tue),
|
||||
(mt.wednesday, Weekday::Wed),
|
||||
(mt.thursday, Weekday::Thu),
|
||||
(mt.friday, Weekday::Fri),
|
||||
(mt.saturday, Weekday::Sat),
|
||||
(mt.sunday, Weekday::Sun),
|
||||
];
|
||||
mapping
|
||||
.iter()
|
||||
.filter(|(active, _)| *active)
|
||||
.map(|(_, day)| *day)
|
||||
.collect()
|
||||
mt.days.iter().map(to_weekday).collect()
|
||||
}
|
||||
|
||||
/// ICS two-letter day code for RRULE BYDAY.
|
||||
@@ -90,11 +73,16 @@ fn ics_day_code(day: Weekday) -> &'static str {
|
||||
/// Location string from a `DbMeetingTime`.
|
||||
fn location_string(mt: &DbMeetingTime) -> String {
|
||||
let building = mt
|
||||
.building_description
|
||||
.as_deref()
|
||||
.or(mt.building.as_deref())
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.building_description.as_deref())
|
||||
.or_else(|| mt.location.as_ref().and_then(|loc| loc.building.as_deref()))
|
||||
.unwrap_or("");
|
||||
let room = mt
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.room.as_deref())
|
||||
.unwrap_or("");
|
||||
let room = mt.room.as_deref().unwrap_or("");
|
||||
let combined = format!("{building} {room}").trim().to_string();
|
||||
if combined.is_empty() {
|
||||
"Online".to_string()
|
||||
@@ -285,13 +273,11 @@ fn generate_ics_event(
|
||||
mt: &DbMeetingTime,
|
||||
index: usize,
|
||||
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
// DTSTART/DTEND: first occurrence with time, or all-day on start_date
|
||||
let (dtstart, dtend) = match (start_time, end_time) {
|
||||
@@ -396,13 +382,11 @@ pub fn generate_gcal_url(
|
||||
course: &CalendarCourse,
|
||||
mt: &DbMeetingTime,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
let dates_text = match (start_time, end_time) {
|
||||
(Some(st), Some(et)) => {
|
||||
|
||||
+108
-17
@@ -1,15 +1,23 @@
|
||||
//! Batch database operations for improved performance.
|
||||
|
||||
use crate::banner::Course;
|
||||
use crate::data::models::{DbMeetingTime, UpsertCounts};
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime, UpsertCounts};
|
||||
use crate::data::names::{decode_html_entities, parse_banner_name};
|
||||
use crate::error::Result;
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::PgConnection;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
/// Parse a date string in MM/DD/YYYY format to `NaiveDate`.
|
||||
fn parse_mm_dd_yyyy(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
}
|
||||
|
||||
/// Convert a Banner API course's meeting times to the DB JSONB shape.
|
||||
fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
let meetings: Vec<DbMeetingTime> = course
|
||||
@@ -17,22 +25,105 @@ fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|mf| {
|
||||
let mt = &mf.meeting_time;
|
||||
|
||||
// Build days BTreeSet from boolean flags
|
||||
let mut days = BTreeSet::new();
|
||||
if mt.monday {
|
||||
days.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if mt.tuesday {
|
||||
days.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if mt.wednesday {
|
||||
days.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if mt.thursday {
|
||||
days.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if mt.friday {
|
||||
days.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if mt.saturday {
|
||||
days.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if mt.sunday {
|
||||
days.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
|
||||
// Parse time range from HHMM strings
|
||||
let time_range = match (mt.begin_time.as_deref(), mt.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
begin, end,
|
||||
"failed to parse meeting time range"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse date range from MM/DD/YYYY strings
|
||||
let date_range = match (
|
||||
parse_mm_dd_yyyy(&mt.start_date),
|
||||
parse_mm_dd_yyyy(&mt.end_date),
|
||||
) {
|
||||
(Some(start), Some(end)) => DateRange::new(start, end).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
%err,
|
||||
"invalid date range, swapping start/end"
|
||||
);
|
||||
// Swap so the invariant holds
|
||||
DateRange {
|
||||
start: end,
|
||||
end: start,
|
||||
}
|
||||
}),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
"failed to parse meeting date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build location if any field is present
|
||||
let location = {
|
||||
let loc = MeetingLocation {
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
};
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
DbMeetingTime {
|
||||
begin_time: mt.begin_time.clone(),
|
||||
end_time: mt.end_time.clone(),
|
||||
start_date: mt.start_date.clone(),
|
||||
end_date: mt.end_date.clone(),
|
||||
monday: mt.monday,
|
||||
tuesday: mt.tuesday,
|
||||
wednesday: mt.wednesday,
|
||||
thursday: mt.thursday,
|
||||
friday: mt.friday,
|
||||
saturday: mt.saturday,
|
||||
sunday: mt.sunday,
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: mt.meeting_type.clone(),
|
||||
meeting_schedule_type: mt.meeting_schedule_type.clone(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Structured types for course API responses.
|
||||
//!
|
||||
//! These types replace scattered Option fields and parallel booleans with
|
||||
//! proper type-safe structures.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// An inclusive date range with the invariant that `start <= end`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DateRange {
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
impl DateRange {
|
||||
/// Creates a new `DateRange`, returning an error if `start` is after `end`.
|
||||
pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, String> {
|
||||
if start > end {
|
||||
return Err(format!(
|
||||
"invalid date range: start ({start}) is after end ({end})"
|
||||
));
|
||||
}
|
||||
Ok(Self { start, end })
|
||||
}
|
||||
|
||||
/// Number of days in the range (inclusive of both endpoints).
|
||||
#[allow(dead_code)]
|
||||
pub fn days(&self) -> i64 {
|
||||
(self.end - self.start).num_days() + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical location where a course section meets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MeetingLocation {
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
}
|
||||
|
||||
/// Credit hours for a course section — either a fixed value or a range.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export)]
|
||||
pub enum CreditHours {
|
||||
/// A single fixed credit hour value.
|
||||
Fixed { hours: i32 },
|
||||
/// A range of credit hours with the invariant that `low <= high`.
|
||||
Range { low: i32, high: i32 },
|
||||
}
|
||||
|
||||
impl CreditHours {
|
||||
/// Creates a `CreditHours::Range`, returning an error if `low > high`.
|
||||
#[allow(dead_code)]
|
||||
pub fn range(low: i32, high: i32) -> Result<Self, String> {
|
||||
if low > high {
|
||||
return Err(format!(
|
||||
"invalid credit hour range: low ({low}) is greater than high ({high})"
|
||||
));
|
||||
}
|
||||
Ok(Self::Range { low, high })
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-listed section information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CrossList {
|
||||
pub identifier: String,
|
||||
pub capacity: i32,
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
/// A linked section reference (e.g. lab linked to a lecture).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SectionLink {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
/// Enrollment counts for a course section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Enrollment {
|
||||
pub current: i32,
|
||||
pub max: i32,
|
||||
pub wait_count: i32,
|
||||
pub wait_capacity: i32,
|
||||
}
|
||||
|
||||
impl Enrollment {
|
||||
/// Number of open seats remaining (never negative).
|
||||
#[allow(dead_code)]
|
||||
pub fn open_seats(&self) -> i32 {
|
||||
(self.max - self.current).max(0)
|
||||
}
|
||||
|
||||
/// Whether the section is at or over capacity.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.current >= self.max
|
||||
}
|
||||
|
||||
/// Whether the section has at least one open seat.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_open(&self) -> bool {
|
||||
!self.is_full()
|
||||
}
|
||||
}
|
||||
|
||||
/// RateMyProfessors rating summary for an instructor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct RmpRating {
|
||||
pub avg_rating: f32,
|
||||
pub num_ratings: i32,
|
||||
pub legacy_id: i32,
|
||||
pub is_confident: bool,
|
||||
}
|
||||
+163
-30
@@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail};
|
||||
use crate::error::Result;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Column to sort search results by.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortColumn {
|
||||
CourseCode,
|
||||
Title,
|
||||
@@ -17,25 +19,74 @@ pub enum SortColumn {
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Aggregate min/max ranges for filter sliders, computed per-term.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct FilterRanges {
|
||||
pub course_number_min: i32,
|
||||
pub course_number_max: i32,
|
||||
pub credit_hour_min: i32,
|
||||
pub credit_hour_max: i32,
|
||||
pub wait_count_max: i32,
|
||||
}
|
||||
|
||||
/// Shared WHERE clause for course search filters.
|
||||
///
|
||||
/// Parameters $1-$8 match the bind order in `search_courses`.
|
||||
/// Parameters $1-$17 match the bind order in `search_courses`.
|
||||
///
|
||||
/// Note: Course number filtering extracts numeric prefix to support alphanumeric
|
||||
/// course numbers (e.g., "015X", "399H"). The numeric part is compared against
|
||||
/// the range, so "399H" matches a search for courses 300-400.
|
||||
const SEARCH_WHERE: &str = r#"
|
||||
WHERE term_code = $1
|
||||
AND ($2::text[] IS NULL OR subject = ANY($2))
|
||||
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
||||
AND ($4::int IS NULL OR course_number::int >= $4)
|
||||
AND ($5::int IS NULL OR course_number::int <= $5)
|
||||
AND ($4::int IS NULL OR (substring(course_number from '^\d+'))::int >= $4)
|
||||
AND ($5::int IS NULL OR (substring(course_number from '^\d+'))::int <= $5)
|
||||
AND ($6::bool = false OR max_enrollment > enrollment)
|
||||
AND ($7::text IS NULL OR instructional_method = $7)
|
||||
AND ($8::text IS NULL OR campus = $8)
|
||||
AND ($7::text[] IS NULL OR instructional_method = ANY($7))
|
||||
AND ($8::text[] IS NULL OR campus = ANY($8))
|
||||
AND ($9::int IS NULL OR wait_count <= $9)
|
||||
AND ($10::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (NOT 'monday' = ANY($10) OR (mt->>'monday')::bool)
|
||||
AND (NOT 'tuesday' = ANY($10) OR (mt->>'tuesday')::bool)
|
||||
AND (NOT 'wednesday' = ANY($10) OR (mt->>'wednesday')::bool)
|
||||
AND (NOT 'thursday' = ANY($10) OR (mt->>'thursday')::bool)
|
||||
AND (NOT 'friday' = ANY($10) OR (mt->>'friday')::bool)
|
||||
AND (NOT 'saturday' = ANY($10) OR (mt->>'saturday')::bool)
|
||||
AND (NOT 'sunday' = ANY($10) OR (mt->>'sunday')::bool)
|
||||
))
|
||||
AND ($11::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'begin_time') >= $11
|
||||
))
|
||||
AND ($12::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'end_time') <= $12
|
||||
))
|
||||
AND ($13::text[] IS NULL OR part_of_term = ANY($13))
|
||||
AND ($14::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(attributes) a
|
||||
WHERE a = ANY($14)
|
||||
))
|
||||
AND ($15::int IS NULL OR COALESCE(credit_hours, credit_hour_low, 0) >= $15)
|
||||
AND ($16::int IS NULL OR COALESCE(credit_hours, credit_hour_high, 0) <= $16)
|
||||
AND ($17::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM course_instructors ci
|
||||
JOIN instructors i ON i.id = ci.instructor_id
|
||||
WHERE ci.course_id = courses.id
|
||||
AND i.display_name ILIKE '%' || $17 || '%'
|
||||
))
|
||||
"#;
|
||||
|
||||
/// Build a safe ORDER BY clause from typed sort parameters.
|
||||
@@ -83,8 +134,17 @@ pub async fn search_courses(
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
open_only: bool,
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
instructional_method: Option<&[String]>,
|
||||
campus: Option<&[String]>,
|
||||
wait_count_max: Option<i32>,
|
||||
days: Option<&[String]>,
|
||||
time_start: Option<&str>,
|
||||
time_end: Option<&str>,
|
||||
part_of_term: Option<&[String]>,
|
||||
attributes: Option<&[String]>,
|
||||
credit_hour_min: Option<i32>,
|
||||
credit_hour_max: Option<i32>,
|
||||
instructor: Option<&str>,
|
||||
limit: i32,
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
@@ -93,32 +153,50 @@ pub async fn search_courses(
|
||||
let order_by = sort_clause(sort_by, sort_dir);
|
||||
|
||||
let data_query =
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $18 OFFSET $19");
|
||||
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
|
||||
|
||||
let courses = sqlx::query_as::<_, Course>(&data_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.bind(limit) // $18
|
||||
.bind(offset) // $19
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
let total: (i64,) = sqlx::query_as(&count_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
@@ -147,7 +225,8 @@ pub async fn get_course_instructors(
|
||||
) -> Result<Vec<CourseInstructorDetail>> {
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
@@ -183,7 +262,8 @@ pub async fn get_instructors_for_courses(
|
||||
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
@@ -247,3 +327,56 @@ pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(tc,)| tc).collect())
|
||||
}
|
||||
|
||||
type RangeRow = (
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
);
|
||||
|
||||
/// Get aggregate filter ranges for a term (course number, credit hours, waitlist).
|
||||
pub async fn get_filter_ranges(db_pool: &PgPool, term_code: &str) -> Result<FilterRanges> {
|
||||
let row: RangeRow = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
MIN(course_number::int),
|
||||
MAX(course_number::int),
|
||||
MIN(COALESCE(credit_hours, credit_hour_low, 0)),
|
||||
MAX(COALESCE(credit_hours, credit_hour_high, 0)),
|
||||
MAX(wait_count)
|
||||
FROM courses
|
||||
WHERE term_code = $1
|
||||
AND course_number ~ '^\d+$'
|
||||
"#,
|
||||
)
|
||||
.bind(term_code)
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
let cn_min = row.0.unwrap_or(1000);
|
||||
let cn_max = row.1.unwrap_or(9000);
|
||||
let ch_min = row.2.unwrap_or(0);
|
||||
let ch_max = row.3.unwrap_or(8);
|
||||
let wc_max_raw = row.4.unwrap_or(0);
|
||||
|
||||
// Round course number to hundreds: floor min, ceil max
|
||||
let cn_min_rounded = (cn_min / 100) * 100;
|
||||
let cn_max_rounded = ((cn_max + 99) / 100) * 100;
|
||||
|
||||
// Waitlist ceiling: (max / 10 + 1) * 10
|
||||
let wc_max = if wc_max_raw > 0 {
|
||||
(wc_max_raw / 10 + 1) * 10
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(FilterRanges {
|
||||
course_number_min: cn_min_rounded,
|
||||
course_number_max: cn_max_rounded,
|
||||
credit_hour_min: ch_min,
|
||||
credit_hour_max: ch_max,
|
||||
wait_count_max: wc_max,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Database models and schema.
|
||||
|
||||
pub mod batch;
|
||||
pub mod course_types;
|
||||
pub mod courses;
|
||||
pub mod models;
|
||||
pub mod names;
|
||||
|
||||
+227
-18
@@ -1,10 +1,15 @@
|
||||
//! `sqlx` models for the database schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
|
||||
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
|
||||
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&value.to_string())
|
||||
@@ -41,29 +46,230 @@ fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
|
||||
deserializer.deserialize_any(I64OrStringVisitor)
|
||||
}
|
||||
|
||||
/// Day of the week.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum DayOfWeek {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
/// Represents a meeting time stored as JSONB in the courses table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DbMeetingTime {
|
||||
pub begin_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub monday: bool,
|
||||
pub tuesday: bool,
|
||||
pub wednesday: bool,
|
||||
pub thursday: bool,
|
||||
pub friday: bool,
|
||||
pub saturday: bool,
|
||||
pub sunday: bool,
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
/// Time range for the meeting; `None` means TBA.
|
||||
pub time_range: Option<TimeRange>,
|
||||
/// Date range over which the meeting recurs.
|
||||
pub date_range: DateRange,
|
||||
/// Active days of the week. Empty means days are TBA.
|
||||
pub days: BTreeSet<DayOfWeek>,
|
||||
/// Physical location; `None` when all location fields are absent.
|
||||
pub location: Option<MeetingLocation>,
|
||||
pub meeting_type: String,
|
||||
pub meeting_schedule_type: String,
|
||||
}
|
||||
|
||||
impl DbMeetingTime {
|
||||
/// Whether no days of the week are set (i.e. days are TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_days_tba(&self) -> bool {
|
||||
self.days.is_empty()
|
||||
}
|
||||
|
||||
/// Whether no time range is set (i.e. time is TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_time_tba(&self) -> bool {
|
||||
self.time_range.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a date string to ISO-8601 (YYYY-MM-DD).
|
||||
///
|
||||
/// Accepts MM/DD/YYYY (from Banner API) and returns YYYY-MM-DD.
|
||||
/// Already-normalized dates are returned as-is.
|
||||
#[allow(dead_code)]
|
||||
fn normalize_date(s: &str) -> String {
|
||||
if let Some((month_day, year)) = s.rsplit_once('/')
|
||||
&& let Some((month, day)) = month_day.split_once('/')
|
||||
{
|
||||
return format!("{year}-{month:0>2}-{day:0>2}");
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
/// Parse a date string that may be in MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_flexible_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DbMeetingTime {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
/// Intermediate representation that accepts both old and new JSON formats.
|
||||
#[derive(Deserialize)]
|
||||
struct Raw {
|
||||
// New-format fields (camelCase in JSON)
|
||||
#[serde(rename = "timeRange")]
|
||||
time_range: Option<TimeRange>,
|
||||
#[serde(rename = "dateRange")]
|
||||
date_range: Option<DateRange>,
|
||||
days: Option<BTreeSet<DayOfWeek>>,
|
||||
location: Option<MeetingLocation>,
|
||||
|
||||
// Old-format fields (snake_case in JSON)
|
||||
begin_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
#[serde(default)]
|
||||
monday: bool,
|
||||
#[serde(default)]
|
||||
tuesday: bool,
|
||||
#[serde(default)]
|
||||
wednesday: bool,
|
||||
#[serde(default)]
|
||||
thursday: bool,
|
||||
#[serde(default)]
|
||||
friday: bool,
|
||||
#[serde(default)]
|
||||
saturday: bool,
|
||||
#[serde(default)]
|
||||
sunday: bool,
|
||||
building: Option<String>,
|
||||
building_description: Option<String>,
|
||||
room: Option<String>,
|
||||
campus: Option<String>,
|
||||
|
||||
// Always present (camelCase in new format, snake_case in old format)
|
||||
#[serde(rename = "meetingType", alias = "meeting_type")]
|
||||
meeting_type: String,
|
||||
#[serde(rename = "meetingScheduleType", alias = "meeting_schedule_type")]
|
||||
meeting_schedule_type: String,
|
||||
|
||||
// Legacy computed fields (ignored on read)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_days_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_time_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
active_days: Vec<DayOfWeek>,
|
||||
}
|
||||
|
||||
let raw = Raw::deserialize(deserializer)?;
|
||||
|
||||
// Resolve time_range: prefer new field, fall back to old begin_time/end_time
|
||||
let time_range =
|
||||
raw.time_range.or_else(
|
||||
|| match (raw.begin_time.as_deref(), raw.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(begin, end, "failed to parse old-format time range");
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve date_range: prefer new field, fall back to old start_date/end_date
|
||||
let date_range = if let Some(dr) = raw.date_range {
|
||||
dr
|
||||
} else {
|
||||
let start_str = raw.start_date.as_deref().unwrap_or("");
|
||||
let end_str = raw.end_date.as_deref().unwrap_or("");
|
||||
let start = parse_flexible_date(start_str);
|
||||
let end = parse_flexible_date(end_str);
|
||||
match (start, end) {
|
||||
(Some(s), Some(e)) => DateRange { start: s, end: e },
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
start_date = start_str,
|
||||
end_date = end_str,
|
||||
"failed to parse old-format date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve days: prefer new field, fall back to old boolean flags
|
||||
let days = raw.days.unwrap_or_else(|| {
|
||||
let mut set = BTreeSet::new();
|
||||
if raw.monday {
|
||||
set.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if raw.tuesday {
|
||||
set.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if raw.wednesday {
|
||||
set.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if raw.thursday {
|
||||
set.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if raw.friday {
|
||||
set.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if raw.saturday {
|
||||
set.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if raw.sunday {
|
||||
set.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
set
|
||||
});
|
||||
|
||||
// Resolve location: prefer new field, fall back to old building/room/campus fields
|
||||
let location = raw.location.or_else(|| {
|
||||
let loc = MeetingLocation {
|
||||
building: raw.building,
|
||||
building_description: raw.building_description,
|
||||
room: raw.room,
|
||||
campus: raw.campus,
|
||||
};
|
||||
// Only produce Some if at least one field is present
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DbMeetingTime {
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: raw.meeting_type,
|
||||
meeting_schedule_type: raw.meeting_schedule_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct Course {
|
||||
@@ -122,6 +328,8 @@ pub struct CourseInstructorDetail {
|
||||
pub instructor_id: i32,
|
||||
pub banner_id: String,
|
||||
pub display_name: String,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub email: String,
|
||||
pub is_primary: bool,
|
||||
pub avg_rating: Option<f32>,
|
||||
@@ -192,8 +400,9 @@ pub enum TargetType {
|
||||
}
|
||||
|
||||
/// Computed status for a scrape job, derived from existing fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub enum ScrapeJobStatus {
|
||||
Processing,
|
||||
StaleLock,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -77,10 +77,12 @@ fn default_bucket_for_period(period: &str) -> &'static str {
|
||||
// Endpoint 1: GET /api/admin/scraper/stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct StatsParams {
|
||||
#[serde(default = "default_period")]
|
||||
period: String,
|
||||
pub period: String,
|
||||
}
|
||||
|
||||
fn default_period() -> String {
|
||||
@@ -195,11 +197,14 @@ pub async fn scraper_stats(
|
||||
// Endpoint 2: GET /api/admin/scraper/timeseries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TimeseriesParams {
|
||||
#[serde(default = "default_period")]
|
||||
period: String,
|
||||
bucket: Option<String>,
|
||||
pub period: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -215,6 +220,8 @@ pub struct TimeseriesResponse {
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimeseriesPoint {
|
||||
/// ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z")
|
||||
#[ts(type = "string")]
|
||||
timestamp: DateTime<Utc>,
|
||||
#[ts(type = "number")]
|
||||
scrape_count: i64,
|
||||
@@ -328,7 +335,11 @@ pub struct SubjectSummary {
|
||||
#[ts(type = "number")]
|
||||
current_interval_secs: u64,
|
||||
time_multiplier: u32,
|
||||
/// ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
last_scraped: DateTime<Utc>,
|
||||
/// ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z")
|
||||
#[ts(type = "string | null")]
|
||||
next_eligible_at: Option<DateTime<Utc>>,
|
||||
#[ts(type = "number | null")]
|
||||
cooldown_remaining_secs: Option<u64>,
|
||||
@@ -439,10 +450,12 @@ pub async fn scraper_subjects(
|
||||
// Endpoint 4: GET /api/admin/scraper/subjects/{subject}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SubjectDetailParams {
|
||||
#[serde(default = "default_detail_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
fn default_detail_limit() -> i32 {
|
||||
@@ -463,6 +476,8 @@ pub struct SubjectDetailResponse {
|
||||
pub struct SubjectResultEntry {
|
||||
#[ts(type = "number")]
|
||||
id: i64,
|
||||
/// ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
completed_at: DateTime<Utc>,
|
||||
duration_ms: i32,
|
||||
success: bool,
|
||||
|
||||
+1
-10
@@ -112,16 +112,7 @@ pub async fn course_gcal(
|
||||
// Prefer the first meeting time that has actual days/times scheduled
|
||||
let mt = meeting_times
|
||||
.iter()
|
||||
.find(|mt| {
|
||||
mt.begin_time.is_some()
|
||||
&& (mt.monday
|
||||
|| mt.tuesday
|
||||
|| mt.wednesday
|
||||
|| mt.thursday
|
||||
|| mt.friday
|
||||
|| mt.saturday
|
||||
|| mt.sunday)
|
||||
})
|
||||
.find(|mt| mt.time_range.is_some() && !mt.days.is_empty())
|
||||
.unwrap_or(&meeting_times[0]);
|
||||
|
||||
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Delivery mode classification for course sections.
|
||||
//!
|
||||
//! Moves the delivery concern logic (previously in the TypeScript frontend)
|
||||
//! to the backend so it ships as part of the API response.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Banner instructional method codes for fully-online delivery.
|
||||
const ONLINE_METHODS: &[&str] = &["OA", "OS", "OH"];
|
||||
|
||||
/// Banner instructional method codes for hybrid delivery.
|
||||
const HYBRID_METHODS: &[&str] = &["HB", "H1", "H2"];
|
||||
|
||||
/// Banner campus code for the main (San Antonio) campus.
|
||||
const MAIN_CAMPUS: &str = "11";
|
||||
|
||||
/// Banner campus codes that represent online/virtual campuses.
|
||||
const ONLINE_CAMPUSES: &[&str] = &["9", "ONL"];
|
||||
|
||||
/// Delivery mode classification for visual accents on location cells.
|
||||
///
|
||||
/// `None` means normal in-person on the main campus (no accent needed).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[ts(export)]
|
||||
pub enum DeliveryMode {
|
||||
/// Fully online with no physical location (OA, OS, OH without INT building).
|
||||
Online,
|
||||
/// Internet campus with INT building code.
|
||||
Internet,
|
||||
/// Mix of online and in-person (HB, H1, H2).
|
||||
Hybrid,
|
||||
/// In-person but not on Main Campus.
|
||||
OffCampus,
|
||||
}
|
||||
|
||||
/// Classify the delivery mode for a course section.
|
||||
///
|
||||
/// Returns `None` for normal in-person sections on the main campus.
|
||||
pub fn classify_delivery_mode(
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
meeting_times: &[DbMeetingTime],
|
||||
) -> Option<DeliveryMode> {
|
||||
if let Some(method) = instructional_method {
|
||||
if ONLINE_METHODS.contains(&method) {
|
||||
let has_int_building = meeting_times.iter().any(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
});
|
||||
return Some(if has_int_building {
|
||||
DeliveryMode::Internet
|
||||
} else {
|
||||
DeliveryMode::Online
|
||||
});
|
||||
}
|
||||
if HYBRID_METHODS.contains(&method) {
|
||||
return Some(DeliveryMode::Hybrid);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(campus) = campus
|
||||
&& campus != MAIN_CAMPUS
|
||||
&& !ONLINE_CAMPUSES.contains(&campus)
|
||||
{
|
||||
return Some(DeliveryMode::OffCampus);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn make_mt(building: Option<&str>) -> DbMeetingTime {
|
||||
DbMeetingTime {
|
||||
time_range: None,
|
||||
date_range: DateRange {
|
||||
start: NaiveDate::from_ymd_opt(2024, 8, 26).unwrap(),
|
||||
end: NaiveDate::from_ymd_opt(2024, 12, 12).unwrap(),
|
||||
},
|
||||
days: BTreeSet::new(),
|
||||
location: building.map(|b| MeetingLocation {
|
||||
building: Some(b.to_string()),
|
||||
building_description: None,
|
||||
room: None,
|
||||
campus: None,
|
||||
}),
|
||||
meeting_type: "CLAS".to_string(),
|
||||
meeting_schedule_type: "LEC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_without_int_building() {
|
||||
for method in &["OA", "OS", "OH"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("9"), &[make_mt(None)]),
|
||||
Some(DeliveryMode::Online),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_with_int_building() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some("OA"), Some("9"), &[make_mt(Some("INT"))]),
|
||||
Some(DeliveryMode::Internet),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_methods() {
|
||||
for method in &["HB", "H1", "H2"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("11"), &[]),
|
||||
Some(DeliveryMode::Hybrid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_campus() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(None, Some("22"), &[]),
|
||||
Some(DeliveryMode::OffCampus),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_campus_in_person() {
|
||||
assert_eq!(classify_delivery_mode(None, Some("11"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_campus_no_method_is_normal() {
|
||||
// Campus 9 or ONL without an online method → None (no accent)
|
||||
assert_eq!(classify_delivery_mode(None, Some("9"), &[]), None);
|
||||
assert_eq!(classify_delivery_mode(None, Some("ONL"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_method_no_campus() {
|
||||
assert_eq!(classify_delivery_mode(None, None, &[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Standardized API error responses.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Machine-readable error code for API responses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[ts(export)]
|
||||
pub enum ApiErrorCode {
|
||||
NotFound,
|
||||
BadRequest,
|
||||
InternalError,
|
||||
InvalidTerm,
|
||||
InvalidRange,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NoTerms,
|
||||
}
|
||||
|
||||
/// Standardized error response for all API endpoints.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ApiError {
|
||||
/// Machine-readable error code
|
||||
pub code: ApiErrorCode,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional additional details (validation errors, field info, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::NotFound, message)
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::BadRequest, message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::InternalError, message)
|
||||
}
|
||||
|
||||
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
|
||||
Self::new(ApiErrorCode::InvalidTerm, format!("Invalid term: {}", term))
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.code {
|
||||
ApiErrorCode::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiErrorCode::BadRequest
|
||||
| ApiErrorCode::InvalidTerm
|
||||
| ApiErrorCode::InvalidRange
|
||||
| ApiErrorCode::NoTerms => StatusCode::BAD_REQUEST,
|
||||
ApiErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
ApiErrorCode::Forbidden => StatusCode::FORBIDDEN,
|
||||
ApiErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
(status, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `(StatusCode, String)` tuple errors to ApiError
|
||||
impl From<(StatusCode, String)> for ApiError {
|
||||
fn from((status, message): (StatusCode, String)) -> Self {
|
||||
let code = match status {
|
||||
StatusCode::NOT_FOUND => ApiErrorCode::NotFound,
|
||||
StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest,
|
||||
StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized,
|
||||
StatusCode::FORBIDDEN => ApiErrorCode::Forbidden,
|
||||
_ => ApiErrorCode::InternalError,
|
||||
};
|
||||
Self::new(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for converting database errors to ApiError
|
||||
pub fn db_error(context: &str, error: anyhow::Error) -> ApiError {
|
||||
tracing::error!(error = %error, context = context, "Database error");
|
||||
ApiError::internal_error(format!("{} failed", context))
|
||||
}
|
||||
@@ -7,8 +7,10 @@ pub mod admin_scraper;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod delivery;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod encoding;
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod routes;
|
||||
pub mod schedule_cache;
|
||||
|
||||
+421
-202
@@ -4,14 +4,16 @@ use axum::{
|
||||
Extension, Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode as AxumStatusCode,
|
||||
response::{Json, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
|
||||
use crate::data::course_types::{CreditHours, CrossList, Enrollment, RmpRating, SectionLink};
|
||||
use crate::web::admin_scraper;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::calendar;
|
||||
use crate::web::delivery::{DeliveryMode, classify_delivery_mode};
|
||||
use crate::web::error::{ApiError, ApiErrorCode, db_error};
|
||||
use crate::web::timeline;
|
||||
use crate::web::ws;
|
||||
use crate::{data, web::admin};
|
||||
@@ -52,9 +54,8 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
||||
get(calendar::course_ics),
|
||||
)
|
||||
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
|
||||
.route("/terms", get(get_terms))
|
||||
.route("/subjects", get(get_subjects))
|
||||
.route("/reference/{category}", get(get_reference))
|
||||
.route("/search-options", get(get_search_options))
|
||||
.route("/timeline", post(timeline::timeline))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
@@ -291,7 +292,7 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
async fn metrics(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MetricsParams>,
|
||||
) -> Result<Json<Value>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<MetricsResponse>, ApiError> {
|
||||
let limit = params.limit.clamp(1, 5000);
|
||||
|
||||
// Parse range shorthand, defaulting to 24h
|
||||
@@ -303,8 +304,8 @@ async fn metrics(
|
||||
"7d" => chrono::Duration::days(7),
|
||||
"30d" => chrono::Duration::days(30),
|
||||
_ => {
|
||||
return Err((
|
||||
AxumStatusCode::BAD_REQUEST,
|
||||
return Err(ApiError::new(
|
||||
ApiErrorCode::InvalidRange,
|
||||
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
|
||||
));
|
||||
}
|
||||
@@ -321,13 +322,7 @@ async fn metrics(
|
||||
.bind(crn)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup for metrics failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Course lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Course lookup for metrics", e.into()))?;
|
||||
row.map(|(id,)| id)
|
||||
} else {
|
||||
None
|
||||
@@ -361,80 +356,120 @@ async fn metrics(
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Metrics query failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Metrics query failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Metrics query", e.into()))?;
|
||||
|
||||
let count = metrics.len();
|
||||
let metrics_json: Vec<Value> = metrics
|
||||
let metrics_entries: Vec<MetricEntry> = metrics
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"courseId": course_id,
|
||||
"timestamp": timestamp.to_rfc3339(),
|
||||
"enrollment": enrollment,
|
||||
"waitCount": wait_count,
|
||||
"seatsAvailable": seats_available,
|
||||
})
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| MetricEntry {
|
||||
id,
|
||||
course_id,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
enrollment,
|
||||
wait_count,
|
||||
seats_available,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"metrics": metrics_json,
|
||||
"count": count,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})))
|
||||
Ok(Json(MetricsResponse {
|
||||
metrics: metrics_entries,
|
||||
count,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Course search & detail API
|
||||
// ============================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MetricsParams {
|
||||
course_id: Option<i32>,
|
||||
term: Option<String>,
|
||||
crn: Option<String>,
|
||||
/// Shorthand durations: "1h", "6h", "24h", "7d", "30d"
|
||||
range: Option<String>,
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricEntry {
|
||||
pub id: i32,
|
||||
pub course_id: i32,
|
||||
pub timestamp: String,
|
||||
pub enrollment: i32,
|
||||
pub wait_count: i32,
|
||||
pub seats_available: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsResponse {
|
||||
pub metrics: Vec<MetricEntry>,
|
||||
pub count: usize,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub course_id: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub term: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crn: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub range: Option<String>,
|
||||
#[serde(default = "default_metrics_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
fn default_metrics_limit() -> i32 {
|
||||
500
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubjectsParams {
|
||||
term: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchParams {
|
||||
term: String,
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchParams {
|
||||
pub term: String,
|
||||
#[serde(default)]
|
||||
subject: Vec<String>,
|
||||
q: Option<String>,
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
pub subject: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub q: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_low")]
|
||||
pub course_number_low: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_high")]
|
||||
pub course_number_high: Option<i32>,
|
||||
#[serde(default, alias = "open_only")]
|
||||
pub open_only: bool,
|
||||
#[serde(default, alias = "instructional_method")]
|
||||
pub instructional_method: Vec<String>,
|
||||
#[serde(default)]
|
||||
open_only: bool,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
pub campus: Vec<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
#[serde(default)]
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
sort_dir: Option<SortDirection>,
|
||||
pub offset: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_by")]
|
||||
pub sort_by: Option<SortColumn>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_dir")]
|
||||
pub sort_dir: Option<SortDirection>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "wait_count_max")]
|
||||
pub wait_count_max: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub days: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_start")]
|
||||
pub time_start: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_end")]
|
||||
pub time_end: Option<String>,
|
||||
#[serde(default, alias = "part_of_term")]
|
||||
pub part_of_term: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub attributes: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_min")]
|
||||
pub credit_hour_min: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_max")]
|
||||
pub credit_hour_max: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructor: Option<String>,
|
||||
}
|
||||
|
||||
use crate::data::courses::{SortColumn, SortDirection};
|
||||
@@ -455,21 +490,20 @@ pub struct CourseResponse {
|
||||
sequence_number: Option<String>,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
enrollment: i32,
|
||||
max_enrollment: i32,
|
||||
wait_count: i32,
|
||||
wait_capacity: i32,
|
||||
credit_hours: Option<i32>,
|
||||
credit_hour_low: Option<i32>,
|
||||
credit_hour_high: Option<i32>,
|
||||
cross_list: Option<String>,
|
||||
cross_list_capacity: Option<i32>,
|
||||
cross_list_count: Option<i32>,
|
||||
link_identifier: Option<String>,
|
||||
is_section_linked: Option<bool>,
|
||||
enrollment: Enrollment,
|
||||
credit_hours: Option<CreditHours>,
|
||||
cross_list: Option<CrossList>,
|
||||
section_link: Option<SectionLink>,
|
||||
part_of_term: Option<String>,
|
||||
meeting_times: Vec<models::DbMeetingTime>,
|
||||
attributes: Vec<String>,
|
||||
is_async_online: bool,
|
||||
delivery_mode: Option<DeliveryMode>,
|
||||
/// Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
primary_location: Option<String>,
|
||||
/// Whether a physical (non-INT) building was found in meeting times.
|
||||
has_physical_location: bool,
|
||||
primary_instructor_id: Option<i32>,
|
||||
instructors: Vec<InstructorResponse>,
|
||||
}
|
||||
|
||||
@@ -480,11 +514,11 @@ pub struct InstructorResponse {
|
||||
instructor_id: i32,
|
||||
banner_id: String,
|
||||
display_name: String,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
email: String,
|
||||
is_primary: bool,
|
||||
rmp_rating: Option<f32>,
|
||||
rmp_num_ratings: Option<i32>,
|
||||
rmp_legacy_id: Option<i32>,
|
||||
rmp: Option<RmpRating>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -493,11 +527,9 @@ pub struct InstructorResponse {
|
||||
pub struct SearchResponse {
|
||||
courses: Vec<CourseResponse>,
|
||||
total_count: i32,
|
||||
offset: i32,
|
||||
limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CodeDescription {
|
||||
@@ -505,7 +537,7 @@ pub struct CodeDescription {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TermResponse {
|
||||
@@ -514,25 +546,169 @@ pub struct TermResponse {
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// Response for the consolidated search-options endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsResponse {
|
||||
pub terms: Vec<TermResponse>,
|
||||
pub subjects: Vec<CodeDescription>,
|
||||
pub reference: SearchOptionsReference,
|
||||
pub ranges: data::courses::FilterRanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsReference {
|
||||
pub instructional_methods: Vec<CodeDescription>,
|
||||
pub campuses: Vec<CodeDescription>,
|
||||
pub parts_of_term: Vec<CodeDescription>,
|
||||
pub attributes: Vec<CodeDescription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchOptionsParams {
|
||||
pub term: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimum number of ratings needed to consider RMP data reliable.
|
||||
const RMP_CONFIDENCE_THRESHOLD: i32 = 7;
|
||||
|
||||
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
|
||||
fn build_course_response(
|
||||
course: &models::Course,
|
||||
instructors: Vec<models::CourseInstructorDetail>,
|
||||
) -> CourseResponse {
|
||||
let instructors = instructors
|
||||
let instructors: Vec<InstructorResponse> = instructors
|
||||
.into_iter()
|
||||
.map(|i| InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
email: i.email,
|
||||
is_primary: i.is_primary,
|
||||
rmp_rating: i.avg_rating,
|
||||
rmp_num_ratings: i.num_ratings,
|
||||
rmp_legacy_id: i.rmp_legacy_id,
|
||||
.map(|i| {
|
||||
// Filter out the (0.0, 0) sentinel — treat as unrated
|
||||
let has_rating =
|
||||
i.avg_rating.is_some_and(|r| r != 0.0) || i.num_ratings.is_some_and(|n| n != 0);
|
||||
let rmp = if has_rating {
|
||||
match (i.avg_rating, i.num_ratings, i.rmp_legacy_id) {
|
||||
(Some(avg_rating), Some(num_ratings), Some(legacy_id)) => Some(RmpRating {
|
||||
avg_rating,
|
||||
num_ratings,
|
||||
legacy_id,
|
||||
is_confident: num_ratings >= RMP_CONFIDENCE_THRESHOLD,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
first_name: i.first_name,
|
||||
last_name: i.last_name,
|
||||
email: i.email,
|
||||
is_primary: i.is_primary,
|
||||
rmp,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Primary = first with is_primary flag, or fall back to first instructor
|
||||
let primary_instructor_id = instructors
|
||||
.iter()
|
||||
.find(|i| i.is_primary)
|
||||
.or(instructors.first())
|
||||
.map(|i| i.instructor_id);
|
||||
|
||||
let meeting_times: Vec<models::DbMeetingTime> =
|
||||
serde_json::from_value(course.meeting_times.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize meeting_times JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let attributes = serde_json::from_value(course.attributes.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize attributes JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_async_online = meeting_times.first().is_some_and(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
&& mt.is_time_tba()
|
||||
});
|
||||
|
||||
let delivery_mode = classify_delivery_mode(
|
||||
course.instructional_method.as_deref(),
|
||||
course.campus.as_deref(),
|
||||
&meeting_times,
|
||||
);
|
||||
|
||||
// Compute primary_location: first non-INT building+room, else "Online" or campus fallback
|
||||
let physical_location = meeting_times
|
||||
.iter()
|
||||
.filter(|mt| mt.location.as_ref().and_then(|loc| loc.building.as_deref()) != Some("INT"))
|
||||
.find_map(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| {
|
||||
loc.building.as_ref().map(|b| match &loc.room {
|
||||
Some(r) => format!("{b} {r}"),
|
||||
None => b.clone(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let has_physical_location = physical_location.is_some();
|
||||
|
||||
let primary_location = physical_location.or_else(|| match delivery_mode {
|
||||
Some(DeliveryMode::Online | DeliveryMode::Internet) => Some("Online".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let enrollment = Enrollment {
|
||||
current: course.enrollment,
|
||||
max: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
};
|
||||
|
||||
let credit_hours = match (
|
||||
course.credit_hours,
|
||||
course.credit_hour_low,
|
||||
course.credit_hour_high,
|
||||
) {
|
||||
(Some(fixed), _, _) => Some(CreditHours::Fixed { hours: fixed }),
|
||||
(None, Some(low), Some(high)) if low != high => Some(CreditHours::Range { low, high }),
|
||||
(None, Some(hours), None) | (None, None, Some(hours)) => Some(CreditHours::Fixed { hours }),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let cross_list = course.cross_list.as_ref().and_then(|identifier| {
|
||||
course.cross_list_capacity.and_then(|capacity| {
|
||||
course.cross_list_count.map(|count| CrossList {
|
||||
identifier: identifier.clone(),
|
||||
capacity,
|
||||
count,
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let section_link = course
|
||||
.link_identifier
|
||||
.clone()
|
||||
.map(|identifier| SectionLink { identifier });
|
||||
|
||||
CourseResponse {
|
||||
crn: course.crn.clone(),
|
||||
subject: course.subject.clone(),
|
||||
@@ -542,21 +718,18 @@ fn build_course_response(
|
||||
sequence_number: course.sequence_number.clone(),
|
||||
instructional_method: course.instructional_method.clone(),
|
||||
campus: course.campus.clone(),
|
||||
enrollment: course.enrollment,
|
||||
max_enrollment: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
credit_hours: course.credit_hours,
|
||||
credit_hour_low: course.credit_hour_low,
|
||||
credit_hour_high: course.credit_hour_high,
|
||||
cross_list: course.cross_list.clone(),
|
||||
cross_list_capacity: course.cross_list_capacity,
|
||||
cross_list_count: course.cross_list_count,
|
||||
link_identifier: course.link_identifier.clone(),
|
||||
is_section_linked: course.is_section_linked,
|
||||
enrollment,
|
||||
credit_hours,
|
||||
cross_list,
|
||||
section_link,
|
||||
part_of_term: course.part_of_term.clone(),
|
||||
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
|
||||
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
|
||||
is_async_online,
|
||||
delivery_mode,
|
||||
primary_location,
|
||||
has_physical_location,
|
||||
primary_instructor_id,
|
||||
meeting_times,
|
||||
attributes,
|
||||
instructors,
|
||||
}
|
||||
}
|
||||
@@ -565,15 +738,11 @@ fn build_course_response(
|
||||
async fn search_courses(
|
||||
State(state): State<AppState>,
|
||||
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
|
||||
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<SearchResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_code = Term::resolve_to_code(¶ms.term).ok_or_else(|| {
|
||||
(
|
||||
AxumStatusCode::BAD_REQUEST,
|
||||
format!("Invalid term: {}", params.term),
|
||||
)
|
||||
})?;
|
||||
let term_code =
|
||||
Term::resolve_to_code(¶ms.term).ok_or_else(|| ApiError::invalid_term(¶ms.term))?;
|
||||
let limit = params.limit.clamp(1, 100);
|
||||
let offset = params.offset.max(0);
|
||||
|
||||
@@ -589,21 +758,44 @@ async fn search_courses(
|
||||
params.course_number_low,
|
||||
params.course_number_high,
|
||||
params.open_only,
|
||||
params.instructional_method.as_deref(),
|
||||
params.campus.as_deref(),
|
||||
if params.instructional_method.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.instructional_method)
|
||||
},
|
||||
if params.campus.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.campus)
|
||||
},
|
||||
params.wait_count_max,
|
||||
if params.days.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.days)
|
||||
},
|
||||
params.time_start.as_deref(),
|
||||
params.time_end.as_deref(),
|
||||
if params.part_of_term.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.part_of_term)
|
||||
},
|
||||
if params.attributes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.attributes)
|
||||
},
|
||||
params.credit_hour_min,
|
||||
params.credit_hour_max,
|
||||
params.instructor.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
params.sort_by,
|
||||
params.sort_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course search failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Search failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Course search", e))?;
|
||||
|
||||
// Batch-fetch all instructors in a single query instead of N+1
|
||||
let course_ids: Vec<i32> = courses.iter().map(|c| c.id).collect();
|
||||
@@ -623,8 +815,6 @@ async fn search_courses(
|
||||
Ok(Json(SearchResponse {
|
||||
courses: course_responses,
|
||||
total_count: total_count as i32,
|
||||
offset,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -632,17 +822,11 @@ async fn search_courses(
|
||||
async fn get_course(
|
||||
State(state): State<AppState>,
|
||||
Path((term, crn)): Path<(String, String)>,
|
||||
) -> Result<Json<CourseResponse>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<CourseResponse>, ApiError> {
|
||||
let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
|
||||
.map_err(|e| db_error("Course lookup", e))?
|
||||
.ok_or_else(|| ApiError::not_found("Course not found"))?;
|
||||
|
||||
let instructors = data::courses::get_course_instructors(&state.db_pool, course.id)
|
||||
.await
|
||||
@@ -650,73 +834,11 @@ async fn get_course(
|
||||
Ok(Json(build_course_response(&course, instructors)))
|
||||
}
|
||||
|
||||
/// `GET /api/terms`
|
||||
async fn get_terms(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<TermResponse>>, (AxumStatusCode, String)> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_codes = data::courses::get_available_terms(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get terms");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to get terms".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let terms: Vec<TermResponse> = term_codes
|
||||
.into_iter()
|
||||
.filter_map(|code| {
|
||||
let term: Term = code.parse().ok()?;
|
||||
Some(TermResponse {
|
||||
code,
|
||||
slug: term.slug(),
|
||||
description: term.description(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(terms))
|
||||
}
|
||||
|
||||
/// `GET /api/subjects?term=202620`
|
||||
async fn get_subjects(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SubjectsParams>,
|
||||
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_code = Term::resolve_to_code(¶ms.term).ok_or_else(|| {
|
||||
(
|
||||
AxumStatusCode::BAD_REQUEST,
|
||||
format!("Invalid term: {}", params.term),
|
||||
)
|
||||
})?;
|
||||
let rows = data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get subjects");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to get subjects".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let subjects: Vec<CodeDescription> = rows
|
||||
.into_iter()
|
||||
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||
.collect();
|
||||
|
||||
Ok(Json(subjects))
|
||||
}
|
||||
|
||||
/// `GET /api/reference/:category`
|
||||
async fn get_reference(
|
||||
State(state): State<AppState>,
|
||||
Path(category): Path<String>,
|
||||
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<Vec<CodeDescription>>, ApiError> {
|
||||
let cache = state.reference_cache.read().await;
|
||||
let entries = cache.entries_for_category(&category);
|
||||
|
||||
@@ -725,13 +847,7 @@ async fn get_reference(
|
||||
drop(cache);
|
||||
let rows = data::reference::get_by_category(&category, &state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, category = %category, "Reference lookup failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error(&format!("Reference lookup for {}", category), e))?;
|
||||
|
||||
return Ok(Json(
|
||||
rows.into_iter()
|
||||
@@ -753,3 +869,106 @@ async fn get_reference(
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/search-options?term={slug}` (term optional, defaults to latest)
|
||||
async fn get_search_options(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchOptionsParams>,
|
||||
) -> Result<Json<SearchOptionsResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
use std::time::Instant;
|
||||
|
||||
// If no term specified, get the latest term
|
||||
let term_slug = if let Some(ref t) = params.term {
|
||||
t.clone()
|
||||
} else {
|
||||
// Fetch available terms to get the default (latest)
|
||||
let term_codes = data::courses::get_available_terms(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("Get terms for default", e))?;
|
||||
|
||||
let first_term: Term = term_codes
|
||||
.first()
|
||||
.and_then(|code| code.parse().ok())
|
||||
.ok_or_else(|| ApiError::new(ApiErrorCode::NoTerms, "No terms available"))?;
|
||||
|
||||
first_term.slug()
|
||||
};
|
||||
|
||||
let term_code =
|
||||
Term::resolve_to_code(&term_slug).ok_or_else(|| ApiError::invalid_term(&term_slug))?;
|
||||
|
||||
// Check cache (10-minute TTL)
|
||||
if let Some(entry) = state.search_options_cache.get(&term_code) {
|
||||
let (cached_at, ref cached_value) = *entry;
|
||||
if cached_at.elapsed() < Duration::from_secs(600) {
|
||||
let response: SearchOptionsResponse = serde_json::from_value(cached_value.clone())
|
||||
.map_err(|e| {
|
||||
ApiError::internal_error(format!("Cache deserialization error: {e}"))
|
||||
})?;
|
||||
return Ok(Json(response));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all data in parallel
|
||||
let (term_codes, subject_rows, ranges) = tokio::try_join!(
|
||||
data::courses::get_available_terms(&state.db_pool),
|
||||
data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code),
|
||||
data::courses::get_filter_ranges(&state.db_pool, &term_code),
|
||||
)
|
||||
.map_err(|e| db_error("Search options", e))?;
|
||||
|
||||
// Build terms
|
||||
let terms: Vec<TermResponse> = term_codes
|
||||
.into_iter()
|
||||
.filter_map(|code| {
|
||||
let term: Term = code.parse().ok()?;
|
||||
Some(TermResponse {
|
||||
code,
|
||||
slug: term.slug(),
|
||||
description: term.description(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build subjects
|
||||
let subjects: Vec<CodeDescription> = subject_rows
|
||||
.into_iter()
|
||||
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||
.collect();
|
||||
|
||||
// Build reference data from in-memory cache
|
||||
let ref_cache = state.reference_cache.read().await;
|
||||
let build_ref = |category: &str| -> Vec<CodeDescription> {
|
||||
ref_cache
|
||||
.entries_for_category(category)
|
||||
.into_iter()
|
||||
.map(|(code, desc)| CodeDescription {
|
||||
code: code.to_string(),
|
||||
description: desc.to_string(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let reference = SearchOptionsReference {
|
||||
instructional_methods: build_ref("instructional_method"),
|
||||
campuses: build_ref("campus"),
|
||||
parts_of_term: build_ref("part_of_term"),
|
||||
attributes: build_ref("attribute"),
|
||||
};
|
||||
|
||||
let response = SearchOptionsResponse {
|
||||
terms,
|
||||
subjects,
|
||||
reference,
|
||||
ranges,
|
||||
};
|
||||
|
||||
// Cache the response
|
||||
let cached_value = serde_json::to_value(&response).unwrap_or_default();
|
||||
state
|
||||
.search_options_cache
|
||||
.insert(term_code, (Instant::now(), cached_value));
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@@ -238,9 +238,11 @@ fn parse_hhmm(s: &str) -> Option<u16> {
|
||||
Some(hours * 60 + mins)
|
||||
}
|
||||
|
||||
/// Parse "MM/DD/YYYY" → NaiveDate.
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
// ── Slot matching ───────────────────────────────────────────────────
|
||||
|
||||
+23
-42
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatMeetingTimeSummary } from "$lib/course";
|
||||
import type { CourseResponse, DbMeetingTime } from "$lib/api";
|
||||
|
||||
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
|
||||
const mt: DbMeetingTime = {
|
||||
timeRange: null,
|
||||
dateRange: { start: "2025-01-13", end: "2025-05-08" },
|
||||
days: [],
|
||||
location: null,
|
||||
meetingType: "CLAS",
|
||||
meetingScheduleType: "LEC",
|
||||
...overrides,
|
||||
};
|
||||
return mt;
|
||||
}
|
||||
|
||||
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
|
||||
return {
|
||||
crn: "12345",
|
||||
subject: "CS",
|
||||
courseNumber: "1234",
|
||||
title: "Test Course",
|
||||
termCode: "202510",
|
||||
sequenceNumber: null,
|
||||
instructionalMethod: null,
|
||||
campus: null,
|
||||
enrollment: { current: 10, max: 30, waitCount: 0, waitCapacity: 0 },
|
||||
creditHours: { type: "fixed", hours: 3 },
|
||||
crossList: null,
|
||||
sectionLink: null,
|
||||
partOfTerm: null,
|
||||
isAsyncOnline: false,
|
||||
deliveryMode: null,
|
||||
primaryLocation: null,
|
||||
hasPhysicalLocation: false,
|
||||
primaryInstructorId: null,
|
||||
meetingTimes: [],
|
||||
attributes: [],
|
||||
instructors: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatMeetingTimeSummary", () => {
|
||||
it("returns 'Async' for async online courses", () => {
|
||||
const course = makeCourse({
|
||||
isAsyncOnline: true,
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
location: { building: "INT", buildingDescription: null, room: null, campus: null },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("Async");
|
||||
});
|
||||
|
||||
it("returns 'TBA' for courses with no meeting times", () => {
|
||||
const course = makeCourse({ meetingTimes: [] });
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TBA");
|
||||
});
|
||||
|
||||
it("returns 'TBA' when days and times are all TBA", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [makeMeetingTime()],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TBA");
|
||||
});
|
||||
|
||||
it("returns formatted days and time for normal meeting", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
days: ["monday", "wednesday", "friday"],
|
||||
timeRange: { start: "09:00:00", end: "09:50:00" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("MWF 9:00–9:50 AM");
|
||||
});
|
||||
|
||||
it("returns formatted days with TBA time", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
days: ["tuesday", "thursday"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
// Days are set but time is TBA — not both TBA, so it enters the final branch
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TTh TBA");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -60,15 +58,15 @@ describe("BannerApiClient", () => {
|
||||
|
||||
const result = await apiClient.searchCourses({
|
||||
term: "202420",
|
||||
subjects: ["CS"],
|
||||
subject: ["CS"],
|
||||
q: "data",
|
||||
open_only: true,
|
||||
openOnly: true,
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
|
||||
"/api/courses/search?term=202420&subject=CS&q=data&openOnly=true&limit=25&offset=50"
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
@@ -77,8 +75,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -91,40 +87,6 @@ describe("BannerApiClient", () => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
|
||||
});
|
||||
|
||||
it("should fetch terms", async () => {
|
||||
const mockTerms = [
|
||||
{ code: "202420", description: "Fall 2024" },
|
||||
{ code: "202510", description: "Spring 2025" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTerms),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getTerms();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/terms");
|
||||
expect(result).toEqual(mockTerms);
|
||||
});
|
||||
|
||||
it("should fetch subjects for a term", async () => {
|
||||
const mockSubjects = [
|
||||
{ code: "CS", description: "Computer Science" },
|
||||
{ code: "MAT", description: "Mathematics" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSubjects),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getSubjects("202420");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
|
||||
expect(result).toEqual(mockSubjects);
|
||||
});
|
||||
|
||||
it("should fetch reference data", async () => {
|
||||
const mockRef = [
|
||||
{ code: "F", description: "Face to Face" },
|
||||
|
||||
+160
-141
@@ -1,27 +1,53 @@
|
||||
import { authStore } from "$lib/auth.svelte";
|
||||
import type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
InstructorResponse,
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsParams as ListInstructorsParamsGenerated,
|
||||
ListInstructorsResponse,
|
||||
MatchBody,
|
||||
MetricEntry,
|
||||
MetricsParams as MetricsParamsGenerated,
|
||||
MetricsResponse,
|
||||
RejectCandidateBody,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
SearchParams as SearchParamsGenerated,
|
||||
SearchResponse as SearchResponseGenerated,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimeRange,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeseriesPoint,
|
||||
TimeseriesResponse,
|
||||
TopCandidateResponse,
|
||||
@@ -32,10 +58,18 @@ const API_BASE_URL = "/api";
|
||||
|
||||
// Re-export generated types under their canonical names
|
||||
export type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
@@ -43,16 +77,29 @@ export type {
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
MetricEntry,
|
||||
MetricsResponse,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeRange,
|
||||
TimeseriesPoint,
|
||||
TimeseriesResponse,
|
||||
TopCandidateResponse,
|
||||
@@ -63,122 +110,69 @@ export type Term = TermResponse;
|
||||
export type Subject = CodeDescription;
|
||||
export type ReferenceEntry = CodeDescription;
|
||||
|
||||
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
|
||||
// Re-export with simplified names
|
||||
export type SearchResponse = SearchResponseGenerated;
|
||||
export type SearchParams = SearchParamsGenerated;
|
||||
export type MetricsParams = MetricsParamsGenerated;
|
||||
export type ListInstructorsParams = ListInstructorsParamsGenerated;
|
||||
|
||||
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
|
||||
// Client-side only — not generated from Rust
|
||||
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
/**
|
||||
* Converts a typed object to URLSearchParams, preserving camelCase keys.
|
||||
* Handles arrays, optional values, and primitives.
|
||||
*/
|
||||
function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
export interface AdminStatus {
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
courseCount: number;
|
||||
scrapeJobCount: number;
|
||||
services: { name: string; status: string }[];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === undefined || value === null) {
|
||||
continue; // Skip undefined/null values
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Append each array element
|
||||
for (const item of value) {
|
||||
if (item !== undefined && item !== null) {
|
||||
params.append(key, String(item));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Convert primitives to string
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface ScrapeJob {
|
||||
id: number;
|
||||
targetType: string;
|
||||
targetPayload: unknown;
|
||||
priority: string;
|
||||
executeAt: string;
|
||||
createdAt: string;
|
||||
lockedAt: string | null;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
queuedAt: string;
|
||||
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
|
||||
}
|
||||
/**
|
||||
* API error class that wraps the structured ApiError response from the backend.
|
||||
*/
|
||||
export class ApiErrorClass extends Error {
|
||||
public readonly code: ApiErrorCode;
|
||||
public readonly details: unknown | null;
|
||||
|
||||
export interface ScrapeJobsResponse {
|
||||
jobs: ScrapeJob[];
|
||||
}
|
||||
constructor(apiError: ApiError) {
|
||||
super(apiError.message);
|
||||
this.name = "ApiError";
|
||||
this.code = apiError.code;
|
||||
this.details = apiError.details;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
fieldChanged: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
subject: string | null;
|
||||
courseNumber: string | null;
|
||||
crn: string | null;
|
||||
courseTitle: string | null;
|
||||
}
|
||||
isNotFound(): boolean {
|
||||
return this.code === "NOT_FOUND";
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
}
|
||||
isBadRequest(): boolean {
|
||||
return (
|
||||
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
|
||||
);
|
||||
}
|
||||
|
||||
export interface MetricEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
enrollment: number;
|
||||
waitCount: number;
|
||||
seatsAvailable: number;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
metrics: MetricEntry[];
|
||||
count: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MetricsParams {
|
||||
course_id?: number;
|
||||
term?: string;
|
||||
crn?: string;
|
||||
range?: "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** A time range for timeline queries (ISO-8601 strings). */
|
||||
export interface TimelineRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** Request body for POST /api/timeline. */
|
||||
export interface TimelineRequest {
|
||||
ranges: TimelineRange[];
|
||||
}
|
||||
|
||||
/** A single 15-minute slot returned by the timeline API. */
|
||||
export interface TimelineSlot {
|
||||
time: string;
|
||||
subjects: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Response from POST /api/timeline. */
|
||||
export interface TimelineResponse {
|
||||
slots: TimelineSlot[];
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
term: string;
|
||||
subjects?: string[];
|
||||
q?: string;
|
||||
open_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort_by?: SortColumn;
|
||||
sort_dir?: SortDirection;
|
||||
}
|
||||
|
||||
// Admin instructor query params (client-only, not generated)
|
||||
export interface AdminInstructorListParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
sort?: string;
|
||||
isInternalError(): boolean {
|
||||
return this.code === "INTERNAL_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
export class BannerApiClient {
|
||||
@@ -220,7 +214,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
@@ -241,7 +245,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,20 +263,8 @@ export class BannerApiClient {
|
||||
return this.request<StatusResponse>("/status");
|
||||
}
|
||||
|
||||
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("term", params.term);
|
||||
if (params.subjects) {
|
||||
for (const s of params.subjects) {
|
||||
query.append("subject", s);
|
||||
}
|
||||
}
|
||||
if (params.q) query.set("q", params.q);
|
||||
if (params.open_only) query.set("open_only", "true");
|
||||
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
||||
if (params.sort_by) query.set("sort_by", params.sort_by);
|
||||
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
|
||||
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -278,9 +280,28 @@ export class BannerApiClient {
|
||||
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
|
||||
}
|
||||
|
||||
// In-memory cache for search options per term
|
||||
private searchOptionsCache = new Map<
|
||||
string,
|
||||
{ data: SearchOptionsResponse; fetchedAt: number }
|
||||
>();
|
||||
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
|
||||
const cacheKey = term || "__default__";
|
||||
const cached = this.searchOptionsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
|
||||
const data = await this.request<SearchOptionsResponse>(url);
|
||||
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async getAdminStatus(): Promise<AdminStatus> {
|
||||
return this.request<AdminStatus>("/admin/status");
|
||||
async getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return this.request<AdminStatusResponse>("/admin/status");
|
||||
}
|
||||
|
||||
async getAdminUsers(): Promise<User[]> {
|
||||
@@ -331,33 +352,31 @@ export class BannerApiClient {
|
||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||
private _auditLastModified: string | null = null;
|
||||
|
||||
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
|
||||
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
|
||||
return this.request<TimelineResponse>("/timeline", {
|
||||
method: "POST",
|
||||
body: { ranges } satisfies TimelineRequest,
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||
if (params?.term) query.set("term", params.term);
|
||||
if (params?.crn) query.set("crn", params.crn);
|
||||
if (params?.range) query.set("range", params.range);
|
||||
if (params?.limit !== undefined) query.set("limit", String(params.limit));
|
||||
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
|
||||
if (!params) {
|
||||
return this.request<MetricsResponse>("/metrics");
|
||||
}
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
const qs = query.toString();
|
||||
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// Admin instructor endpoints
|
||||
|
||||
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set("status", params.status);
|
||||
if (params?.search) query.set("search", params.search);
|
||||
if (params?.page !== undefined) query.set("page", String(params.page));
|
||||
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
|
||||
if (params?.sort) query.set("sort", params.sort);
|
||||
async getAdminInstructors(
|
||||
params?: Partial<ListInstructorsParams>
|
||||
): Promise<ListInstructorsResponse> {
|
||||
if (!params) {
|
||||
return this.request<ListInstructorsResponse>("/admin/instructors");
|
||||
}
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
const qs = query.toString();
|
||||
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
@@ -369,14 +388,14 @@ export class BannerApiClient {
|
||||
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
|
||||
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
body: { rmpLegacyId } satisfies MatchBody,
|
||||
});
|
||||
}
|
||||
|
||||
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
body: { rmpLegacyId } satisfies RejectCandidateBody,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,7 +408,7 @@ export class BannerApiClient {
|
||||
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
|
||||
method: "POST",
|
||||
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
|
||||
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } satisfies MatchBody } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>, };
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ApiErrorCode } from "./ApiErrorCode";
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
/**
|
||||
* Standardized error response for all API endpoints.
|
||||
*/
|
||||
export type ApiError = {
|
||||
/**
|
||||
* Machine-readable error code
|
||||
*/
|
||||
code: ApiErrorCode,
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
message: string,
|
||||
/**
|
||||
* Optional additional details (validation errors, field info, etc.)
|
||||
*/
|
||||
details: JsonValue | null, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Machine-readable error code for API responses.
|
||||
*/
|
||||
export type ApiErrorCode = "NOT_FOUND" | "BAD_REQUEST" | "INTERNAL_ERROR" | "INVALID_TERM" | "INVALID_RANGE" | "UNAUTHORIZED" | "FORBIDDEN" | "NO_TERMS";
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -1,5 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CreditHours } from "./CreditHours";
|
||||
import type { CrossList } from "./CrossList";
|
||||
import type { DbMeetingTime } from "./DbMeetingTime";
|
||||
import type { DeliveryMode } from "./DeliveryMode";
|
||||
import type { Enrollment } from "./Enrollment";
|
||||
import type { InstructorResponse } from "./InstructorResponse";
|
||||
import type { SectionLink } from "./SectionLink";
|
||||
|
||||
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: number, maxEnrollment: number, waitCount: number, waitCapacity: number, creditHours: number | null, creditHourLow: number | null, creditHourHigh: number | null, crossList: string | null, crossListCapacity: number | null, crossListCount: number | null, linkIdentifier: string | null, isSectionLinked: boolean | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, instructors: Array<InstructorResponse>, };
|
||||
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: Enrollment, creditHours: CreditHours | null, crossList: CrossList | null, sectionLink: SectionLink | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, isAsyncOnline: boolean, deliveryMode: DeliveryMode | null,
|
||||
/**
|
||||
* Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
*/
|
||||
primaryLocation: string | null,
|
||||
/**
|
||||
* Whether a physical (non-INT) building was found in meeting times.
|
||||
*/
|
||||
hasPhysicalLocation: boolean, primaryInstructorId: number | null, instructors: Array<InstructorResponse>, };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Credit hours for a course section — either a fixed value or a range.
|
||||
*/
|
||||
export type CreditHours = { "type": "fixed", hours: number, } | { "type": "range", low: number, high: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Cross-listed section information.
|
||||
*/
|
||||
export type CrossList = { identifier: string, capacity: number, count: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* An inclusive date range with the invariant that `start <= end`.
|
||||
*/
|
||||
export type DateRange = { start: string, end: string, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Day of the week.
|
||||
*/
|
||||
export type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
|
||||
@@ -1,6 +1,26 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DateRange } from "./DateRange";
|
||||
import type { DayOfWeek } from "./DayOfWeek";
|
||||
import type { MeetingLocation } from "./MeetingLocation";
|
||||
import type { TimeRange } from "./TimeRange";
|
||||
|
||||
/**
|
||||
* Represents a meeting time stored as JSONB in the courses table.
|
||||
*/
|
||||
export type DbMeetingTime = { begin_time: string | null, end_time: string | null, start_date: string, end_date: string, monday: boolean, tuesday: boolean, wednesday: boolean, thursday: boolean, friday: boolean, saturday: boolean, sunday: boolean, building: string | null, building_description: string | null, room: string | null, campus: string | null, meeting_type: string, meeting_schedule_type: string, };
|
||||
export type DbMeetingTime = {
|
||||
/**
|
||||
* Time range for the meeting; `None` means TBA.
|
||||
*/
|
||||
timeRange: TimeRange | null,
|
||||
/**
|
||||
* Date range over which the meeting recurs.
|
||||
*/
|
||||
dateRange: DateRange,
|
||||
/**
|
||||
* Active days of the week. Empty means days are TBA.
|
||||
*/
|
||||
days: Array<DayOfWeek>,
|
||||
/**
|
||||
* Physical location; `None` when all location fields are absent.
|
||||
*/
|
||||
location: MeetingLocation | null, meetingType: string, meetingScheduleType: string, };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Delivery mode classification for visual accents on location cells.
|
||||
*
|
||||
* `None` means normal in-person on the main campus (no accent needed).
|
||||
*/
|
||||
export type DeliveryMode = "online" | "internet" | "hybrid" | "off-campus";
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Enrollment counts for a course section.
|
||||
*/
|
||||
export type Enrollment = { current: number, max: number, waitCount: number, waitCapacity: number, };
|
||||
@@ -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, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RmpRating } from "./RmpRating";
|
||||
|
||||
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, email: string, isPrimary: boolean, rmpRating: number | null, rmpNumRatings: number | null, rmpLegacyId: number | null, };
|
||||
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, firstName: string | null, lastName: string | null, email: string, isPrimary: boolean, rmp: RmpRating | null, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ListInstructorsParams = { status: string | null, search: string | null, page: number | null, perPage: number | null, sort: string | null, };
|
||||
@@ -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, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Physical location where a course section meets.
|
||||
*/
|
||||
export type MeetingLocation = { building: string | null, buildingDescription: string | null, room: string | null, campus: string | null, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* RateMyProfessors rating summary for an instructor.
|
||||
*/
|
||||
export type RmpRating = { avgRating: number, numRatings: number, legacyId: number, isConfident: boolean, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A linked section reference (e.g. lab linked to a lecture).
|
||||
*/
|
||||
export type SectionLink = { identifier: string, };
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Time range for meetings
|
||||
*/
|
||||
export type TimeRange = { start: string, end: string, };
|
||||
@@ -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,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>, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -1,28 +1,64 @@
|
||||
export type { AdminServiceInfo } from "./AdminServiceInfo";
|
||||
export type { AdminStatusResponse } from "./AdminStatusResponse";
|
||||
export type { ApiError } from "./ApiError";
|
||||
export type { ApiErrorCode } from "./ApiErrorCode";
|
||||
export type { AuditLogEntry } from "./AuditLogEntry";
|
||||
export type { AuditLogResponse } from "./AuditLogResponse";
|
||||
export type { CandidateResponse } from "./CandidateResponse";
|
||||
export type { CodeDescription } from "./CodeDescription";
|
||||
export type { CourseResponse } from "./CourseResponse";
|
||||
export type { CreditHours } from "./CreditHours";
|
||||
export type { CrossList } from "./CrossList";
|
||||
export type { DateRange } from "./DateRange";
|
||||
export type { DayOfWeek } from "./DayOfWeek";
|
||||
export type { DbMeetingTime } from "./DbMeetingTime";
|
||||
export type { DeliveryMode } from "./DeliveryMode";
|
||||
export type { Enrollment } from "./Enrollment";
|
||||
export type { FilterRanges } from "./FilterRanges";
|
||||
export type { InstructorDetail } from "./InstructorDetail";
|
||||
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
|
||||
export type { InstructorListItem } from "./InstructorListItem";
|
||||
export type { InstructorResponse } from "./InstructorResponse";
|
||||
export type { InstructorStats } from "./InstructorStats";
|
||||
export type { LinkedRmpProfile } from "./LinkedRmpProfile";
|
||||
export type { ListInstructorsParams } from "./ListInstructorsParams";
|
||||
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
||||
export type { MatchBody } from "./MatchBody";
|
||||
export type { MeetingLocation } from "./MeetingLocation";
|
||||
export type { MetricEntry } from "./MetricEntry";
|
||||
export type { MetricsParams } from "./MetricsParams";
|
||||
export type { MetricsResponse } from "./MetricsResponse";
|
||||
export type { OkResponse } from "./OkResponse";
|
||||
export type { RejectCandidateBody } from "./RejectCandidateBody";
|
||||
export type { RescoreResponse } from "./RescoreResponse";
|
||||
export type { RmpRating } from "./RmpRating";
|
||||
export type { ScrapeJobDto } from "./ScrapeJobDto";
|
||||
export type { ScrapeJobEvent } from "./ScrapeJobEvent";
|
||||
export type { ScrapeJobStatus } from "./ScrapeJobStatus";
|
||||
export type { ScrapeJobsResponse } from "./ScrapeJobsResponse";
|
||||
export type { ScraperStatsResponse } from "./ScraperStatsResponse";
|
||||
export type { SearchOptionsReference } from "./SearchOptionsReference";
|
||||
export type { SearchOptionsResponse } from "./SearchOptionsResponse";
|
||||
export type { SearchParams } from "./SearchParams";
|
||||
export type { SearchResponse } from "./SearchResponse";
|
||||
export type { SectionLink } from "./SectionLink";
|
||||
export type { ServiceInfo } from "./ServiceInfo";
|
||||
export type { ServiceStatus } from "./ServiceStatus";
|
||||
export type { SortColumn } from "./SortColumn";
|
||||
export type { SortDirection } from "./SortDirection";
|
||||
export type { StatsParams } from "./StatsParams";
|
||||
export type { StatusResponse } from "./StatusResponse";
|
||||
export type { SubjectDetailParams } from "./SubjectDetailParams";
|
||||
export type { SubjectDetailResponse } from "./SubjectDetailResponse";
|
||||
export type { SubjectResultEntry } from "./SubjectResultEntry";
|
||||
export type { SubjectSummary } from "./SubjectSummary";
|
||||
export type { SubjectsResponse } from "./SubjectsResponse";
|
||||
export type { TermResponse } from "./TermResponse";
|
||||
export type { TimeRange } from "./TimeRange";
|
||||
export type { TimelineRequest } from "./TimelineRequest";
|
||||
export type { TimelineResponse } from "./TimelineResponse";
|
||||
export type { TimelineSlot } from "./TimelineSlot";
|
||||
export type { TimeseriesParams } from "./TimeseriesParams";
|
||||
export type { TimeseriesPoint } from "./TimeseriesPoint";
|
||||
export type { TimeseriesResponse } from "./TimeseriesResponse";
|
||||
export type { TopCandidateResponse } from "./TopCandidateResponse";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import type { CodeDescription } from "$lib/bindings";
|
||||
import { toggleValue } from "$lib/filters";
|
||||
import FilterPopover from "./FilterPopover.svelte";
|
||||
|
||||
let {
|
||||
instructionalMethod = $bindable<string[]>([]),
|
||||
campus = $bindable<string[]>([]),
|
||||
partOfTerm = $bindable<string[]>([]),
|
||||
attributes = $bindable<string[]>([]),
|
||||
referenceData,
|
||||
}: {
|
||||
instructionalMethod: string[];
|
||||
campus: string[];
|
||||
partOfTerm: string[];
|
||||
attributes: string[];
|
||||
referenceData: {
|
||||
instructionalMethods: CodeDescription[];
|
||||
campuses: CodeDescription[];
|
||||
partsOfTerm: CodeDescription[];
|
||||
attributes: CodeDescription[];
|
||||
};
|
||||
} = $props();
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
instructionalMethod.length > 0 ||
|
||||
campus.length > 0 ||
|
||||
partOfTerm.length > 0 ||
|
||||
attributes.length > 0
|
||||
);
|
||||
|
||||
const sections: {
|
||||
label: string;
|
||||
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
|
||||
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
|
||||
}[] = [
|
||||
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
|
||||
{ label: "Campus", key: "campus", dataKey: "campuses" },
|
||||
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
|
||||
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
|
||||
];
|
||||
|
||||
function getSelected(
|
||||
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
|
||||
): string[] {
|
||||
if (key === "instructionalMethod") return instructionalMethod;
|
||||
if (key === "campus") return campus;
|
||||
if (key === "partOfTerm") return partOfTerm;
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function toggle(key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes", code: string) {
|
||||
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
|
||||
else if (key === "campus") campus = toggleValue(campus, code);
|
||||
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
|
||||
else attributes = toggleValue(attributes, code);
|
||||
}
|
||||
</script>
|
||||
|
||||
<FilterPopover label="Attributes" active={hasActiveFilters} width="w-80 max-h-96 overflow-y-auto">
|
||||
{#snippet content()}
|
||||
{#each sections as { label, key, dataKey }, i (key)}
|
||||
{#if i > 0}
|
||||
<div class="h-px bg-border"></div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground select-none">{label}</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each referenceData[dataKey] as item (item.code)}
|
||||
{@const selected = getSelected(key)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected.includes(item.code)}
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer select-none
|
||||
{selected.includes(item.code)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => toggle(key, item.code)}
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</FilterPopover>
|
||||
@@ -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}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
abbreviateInstructor,
|
||||
formatMeetingTimeSummary,
|
||||
getPrimaryInstructor,
|
||||
seatsColor,
|
||||
seatsDotColor,
|
||||
} from "$lib/course";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import { slide } from "svelte/transition";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
|
||||
let {
|
||||
course,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
course: CourseResponse;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-border bg-card overflow-hidden transition-colors
|
||||
{expanded ? 'border-border/80' : 'hover:bg-muted/30'}"
|
||||
>
|
||||
<button
|
||||
class="w-full text-left p-3 cursor-pointer"
|
||||
aria-expanded={expanded}
|
||||
onclick={onToggle}
|
||||
>
|
||||
<!-- Line 1: Course code + title + seats -->
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
{#snippet seatsDisplay()}
|
||||
{@const openSeats = course.enrollment.max - course.enrollment.current}
|
||||
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(openSeats)} shrink-0"></span>
|
||||
<span class="{seatsColor(openSeats)} font-medium tabular-nums">
|
||||
{#if openSeats === 0}Full{:else}{openSeats}/{formatNumber(course.enrollment.max)}{/if}
|
||||
</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
<div class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span class="font-mono font-semibold text-sm tracking-tight shrink-0">
|
||||
{course.subject} {course.courseNumber}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground truncate">{course.title}</span>
|
||||
</div>
|
||||
{@render seatsDisplay()}
|
||||
</div>
|
||||
|
||||
<!-- Line 2: Instructor + time -->
|
||||
<div class="flex items-center justify-between gap-2 mt-1">
|
||||
<span class="text-xs text-muted-foreground truncate">
|
||||
{abbreviateInstructor(getPrimaryInstructor(course.instructors, course.primaryInstructorId)?.displayName ?? "Staff")}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">
|
||||
{formatMeetingTimeSummary(course)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<CourseDetail {course} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,18 +2,15 @@
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import {
|
||||
RMP_CONFIDENCE_THRESHOLD,
|
||||
formatCreditHours,
|
||||
formatDate,
|
||||
formatMeetingDaysLong,
|
||||
formatTime,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
ratingStyle,
|
||||
rmpUrl,
|
||||
} from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
@@ -24,7 +21,7 @@ import {
|
||||
Star,
|
||||
Triangle,
|
||||
} from "@lucide/svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import RichTooltip from "./RichTooltip.svelte";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
@@ -40,73 +37,68 @@ const clipboard = useClipboard();
|
||||
{#if course.instructors.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each course.instructors as instructor}
|
||||
<Tooltip.Root delayDuration={200}>
|
||||
<Tooltip.Trigger>
|
||||
<RichTooltip delay={200} contentClass="px-3 py-2">
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||
>
|
||||
{instructor.displayName}
|
||||
{#if instructor.rmpRating != null}
|
||||
{@const rating = instructor.rmpRating}
|
||||
{@const lowConfidence =
|
||||
(instructor.rmpNumRatings ?? 0) <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<span
|
||||
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
|
||||
style={ratingStyle(
|
||||
rating,
|
||||
themeStore.isDark,
|
||||
)}
|
||||
>
|
||||
{rating.toFixed(1)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{instructor.displayName}
|
||||
{#if instructor.rmp != null}
|
||||
{@const rating = instructor.rmp.avgRating}
|
||||
{@const lowConfidence = !instructor.rmp.isConfident}
|
||||
<span
|
||||
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
|
||||
style={ratingStyle(
|
||||
rating,
|
||||
themeStore.isDark,
|
||||
)}
|
||||
>
|
||||
{rating.toFixed(1)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-y-1.5">
|
||||
<div class="font-medium">
|
||||
{instructor.displayName}
|
||||
</div>
|
||||
{#if instructor.isPrimary}
|
||||
<div class="text-muted-foreground">
|
||||
Primary instructor
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmpRating != null}
|
||||
<div class="text-muted-foreground">
|
||||
{instructor.rmpRating.toFixed(1)}/5
|
||||
· {instructor.rmpNumRatings ?? 0} ratings
|
||||
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
|
||||
(low)
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmpLegacyId != null}
|
||||
<a
|
||||
href={rmpUrl(
|
||||
instructor.rmpLegacyId,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink class="size-3" />
|
||||
<span>View on RMP</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if instructor.isPrimary}
|
||||
<div class="text-muted-foreground">
|
||||
Primary instructor
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmp != null}
|
||||
<div class="text-muted-foreground">
|
||||
{instructor.rmp.avgRating.toFixed(1)}/5
|
||||
· {instructor.rmp.numRatings} ratings
|
||||
{#if !instructor.rmp.isConfident}
|
||||
(low)
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmp?.legacyId != null}
|
||||
<a
|
||||
href={rmpUrl(
|
||||
instructor.rmp.legacyId,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink class="size-3" />
|
||||
<span>View on RMP</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<button
|
||||
onclick={(e) =>
|
||||
@@ -126,8 +118,8 @@ const clipboard = useClipboard();
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -139,56 +131,56 @@ const clipboard = useClipboard();
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
|
||||
{#if course.meetingTimes.length > 0}
|
||||
<ul class="space-y-2">
|
||||
{#each course.meetingTimes as mt}
|
||||
<li>
|
||||
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
|
||||
<span class="italic text-muted-foreground"
|
||||
>TBA</span
|
||||
>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{formatMeetingDaysLong(mt)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span class="text-muted-foreground">
|
||||
{formatTime(
|
||||
mt.begin_time,
|
||||
)}–{formatTime(mt.end_time)}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="italic text-muted-foreground"
|
||||
>Time TBA</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mt.building || mt.room}
|
||||
<div
|
||||
class="text-xs text-muted-foreground mt-0.5"
|
||||
>
|
||||
{mt.building_description ??
|
||||
mt.building}{mt.room
|
||||
? ` ${mt.room}`
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{formatDate(mt.start_date)} – {formatDate(
|
||||
mt.end_date,
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
{#each course.meetingTimes as mt}
|
||||
<li>
|
||||
{#if mt.days.length === 0 && mt.timeRange === null}
|
||||
<span class="italic text-muted-foreground"
|
||||
>TBA</span
|
||||
>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if mt.days.length > 0}
|
||||
<span
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{formatMeetingDaysLong(mt)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if mt.timeRange !== null}
|
||||
<span class="text-muted-foreground">
|
||||
{formatTime(
|
||||
mt.timeRange.start,
|
||||
)}–{formatTime(mt.timeRange.end)}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="italic text-muted-foreground"
|
||||
>Time TBA</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mt.location?.building || mt.location?.room}
|
||||
<div
|
||||
class="text-xs text-muted-foreground mt-0.5"
|
||||
>
|
||||
{mt.location.buildingDescription ??
|
||||
mt.location.building}{mt.location.room
|
||||
? ` ${mt.location.room}`
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{formatDate(mt.dateRange.start)} – {formatDate(
|
||||
mt.dateRange.end,
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span class="italic text-muted-foreground">TBA</span>
|
||||
{/if}
|
||||
@@ -259,6 +251,7 @@ const clipboard = useClipboard();
|
||||
|
||||
<!-- Cross-list -->
|
||||
{#if course.crossList}
|
||||
{@const crossList = course.crossList}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
@@ -272,45 +265,45 @@ const clipboard = useClipboard();
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<Tooltip.Root delayDuration={150} disableHoverableContent>
|
||||
<Tooltip.Trigger>
|
||||
<RichTooltip passthrough>
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-foreground font-mono"
|
||||
>
|
||||
<span
|
||||
class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{course.crossList}
|
||||
{crossList.identifier}
|
||||
</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
{#if crossList.count != null && crossList.capacity != null}
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{formatNumber(course.crossListCount)}/{formatNumber(course.crossListCapacity)}
|
||||
{formatNumber(crossList.count)}/{formatNumber(crossList.capacity)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
Group <span class="font-mono font-medium"
|
||||
>{course.crossList}</span
|
||||
>{crossList.identifier}</span
|
||||
>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
— {formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
|
||||
{#if crossList.count != null && crossList.capacity != null}
|
||||
— {formatNumber(crossList.count)} enrolled across {formatNumber(crossList.capacity)}
|
||||
shared seats
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist -->
|
||||
{#if course.waitCapacity > 0}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
|
||||
<span class="text-2foreground"
|
||||
>{formatNumber(course.waitCount)} / {formatNumber(course.waitCapacity)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Waitlist -->
|
||||
{#if course.enrollment.waitCapacity > 0}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
|
||||
<span class="text-2foreground"
|
||||
>{formatNumber(course.enrollment.waitCount)} / {formatNumber(course.enrollment.waitCapacity)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Calendar Export -->
|
||||
{#if course.meetingTimes.length > 0}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}–{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)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
<span class="text-muted-foreground select-none tabular-nums md:hidden">
|
||||
{formatNumber(start)}–{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)}–{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)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
<span class="text-muted-foreground select-none tabular-nums md:hidden">
|
||||
{formatNumber(start)}–{formatNumber(end)} / {formatNumber(totalCount)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import LibRangeSlider from "svelte-range-slider-pips";
|
||||
|
||||
type LibProps = ComponentProps<LibRangeSlider>;
|
||||
|
||||
/**
|
||||
* Two modes:
|
||||
* - `dual` (default): bind `valueLow` and `valueHigh` for a two-thumb range.
|
||||
* - `dual={false}`: bind `value` for a single-thumb slider. `valueLow`/`valueHigh` are ignored.
|
||||
*
|
||||
* All three are null when at their default (boundary) position.
|
||||
*/
|
||||
type Props = Omit<
|
||||
LibProps,
|
||||
| "values"
|
||||
| "value"
|
||||
| "formatter"
|
||||
| "range"
|
||||
| "min"
|
||||
| "max"
|
||||
| "float"
|
||||
| "hoverable"
|
||||
| "springValues"
|
||||
> & {
|
||||
min: number;
|
||||
max: number;
|
||||
label: string;
|
||||
formatValue?: (v: number) => string;
|
||||
dual?: boolean;
|
||||
float?: boolean;
|
||||
hoverable?: boolean;
|
||||
springValues?: { stiffness?: number; damping?: number };
|
||||
valueLow?: number | null;
|
||||
valueHigh?: number | null;
|
||||
value?: number | null;
|
||||
};
|
||||
|
||||
let {
|
||||
min,
|
||||
max,
|
||||
valueLow = $bindable(null),
|
||||
valueHigh = $bindable(null),
|
||||
value = $bindable(null),
|
||||
label,
|
||||
formatValue = (v: number) => String(v),
|
||||
dual = true,
|
||||
float = true,
|
||||
hoverable = true,
|
||||
// Intentionally snappier than library defaults (0.15/0.4)
|
||||
springValues = { stiffness: 0.3, damping: 0.7 },
|
||||
...libProps
|
||||
}: Props = $props();
|
||||
|
||||
let internalValues = $state<number[]>([min, max]);
|
||||
let internalValue = $state(max);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
$effect(() => {
|
||||
if (min >= max) {
|
||||
console.warn(`RangeSlider "${label}": min (${min}) must be less than max (${max})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sync external -> internal (equality guards prevent loops)
|
||||
$effect(() => {
|
||||
if (dual) {
|
||||
const nextLow = valueLow ?? min;
|
||||
const nextHigh = valueHigh ?? max;
|
||||
if (internalValues[0] !== nextLow || internalValues[1] !== nextHigh) {
|
||||
internalValues = [nextLow, nextHigh];
|
||||
}
|
||||
} else {
|
||||
const next = value ?? max;
|
||||
if (internalValue !== next) {
|
||||
internalValue = next;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const isDefault = $derived(dual ? valueLow === null && valueHigh === null : value === null);
|
||||
|
||||
function handleDualChange(event: CustomEvent<{ values: number[] }>) {
|
||||
const [low, high] = event.detail.values;
|
||||
const nextLow = low === min && high === max ? null : low;
|
||||
const nextHigh = low === min && high === max ? null : high;
|
||||
if (nextLow === valueLow && nextHigh === valueHigh) return;
|
||||
valueLow = nextLow;
|
||||
valueHigh = nextHigh;
|
||||
}
|
||||
|
||||
function handleSingleChange(event: CustomEvent<{ value: number }>) {
|
||||
const next = event.detail.value === max ? null : event.detail.value;
|
||||
if (next === value) return;
|
||||
value = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="range-slider-wrapper flex flex-col gap-1.5" role="group" aria-label={label}>
|
||||
<div class="flex items-center justify-between select-none">
|
||||
<span class="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{#if !isDefault}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{#if dual}
|
||||
{formatValue(valueLow ?? min)} – {formatValue(valueHigh ?? max)}
|
||||
{:else}
|
||||
≤ {formatValue(value ?? max)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-0.5">
|
||||
{#if dual}
|
||||
<LibRangeSlider
|
||||
bind:values={internalValues}
|
||||
{min}
|
||||
{max}
|
||||
{float}
|
||||
{hoverable}
|
||||
{springValues}
|
||||
range
|
||||
formatter={formatValue}
|
||||
{...libProps}
|
||||
on:change={handleDualChange}
|
||||
/>
|
||||
{:else}
|
||||
<LibRangeSlider
|
||||
bind:value={internalValue}
|
||||
{min}
|
||||
{max}
|
||||
{float}
|
||||
{hoverable}
|
||||
{springValues}
|
||||
formatter={formatValue}
|
||||
{...libProps}
|
||||
on:change={handleSingleChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Theme color mapping */
|
||||
.range-slider-wrapper :global(.rangeSlider) {
|
||||
--range-slider: var(--border);
|
||||
--range-handle-inactive: var(--muted-foreground);
|
||||
--range-handle: var(--muted-foreground);
|
||||
--range-handle-focus: var(--foreground);
|
||||
--range-handle-border: var(--muted-foreground);
|
||||
--range-range-inactive: var(--muted-foreground);
|
||||
--range-range: var(--foreground);
|
||||
--range-range-hover: var(--foreground);
|
||||
--range-range-press: var(--foreground);
|
||||
--range-float-inactive: var(--card);
|
||||
--range-float: var(--card);
|
||||
--range-float-text: var(--card-foreground);
|
||||
--range-range-limit: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin: 0.5em;
|
||||
height: 0.375em;
|
||||
}
|
||||
|
||||
/* Smaller handles, plain circles */
|
||||
.range-slider-wrapper :global(.rangeSlider .rangeHandle) {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.range-slider-wrapper :global(.rangeSlider.rsRange:not(.rsMin):not(.rsMax) .rangeNub) {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.range-slider-wrapper :global(.rangeSlider.rsRange .rangeHandle .rangeNub) {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Hover / press effects */
|
||||
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle:hover::before) {
|
||||
box-shadow: 0 0 0 6px var(--handle-border);
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress::before),
|
||||
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress:hover::before) {
|
||||
box-shadow: 0 0 0 8px var(--handle-border);
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Track bar */
|
||||
.range-slider-wrapper :global(.rangeSlider .rangeBar),
|
||||
.range-slider-wrapper :global(.rangeSlider .rangeLimit) {
|
||||
height: 0.375em;
|
||||
}
|
||||
|
||||
/* Float label */
|
||||
.range-slider-wrapper :global(.rangeSlider .rangeFloat) {
|
||||
font-size: 0.7em;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
padding: 0.25em 0.4em 0.35em;
|
||||
border-radius: 0.375em;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Pip labels */
|
||||
.range-slider-wrapper :global(.rangeSlider .rangePip .pipVal) {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.6em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Pip spacing */
|
||||
.range-slider-wrapper :global(.rangeSlider.rsPips) {
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
|
||||
.range-slider-wrapper :global(.rangeSlider.rsPipLabels) {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user