mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 14:23:36 -06:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b69c1eec54 | |||
| 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
|
||||
|
||||
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.6.2](https://github.com/Xevion/Banner/compare/v0.6.1...v0.6.2) (2026-01-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **web:** Add dynamic range sliders with consolidated search options API ([f5a639e](https://github.com/Xevion/Banner/commit/f5a639e88bfe03dfc635f25e06fc22208ee0c855))
|
||||
* **web:** Implement aligned course codes with jetbrains mono ([567c4ae](https://github.com/Xevion/Banner/commit/567c4aec3ca7baaeb548fff2005d83f7e6228d79))
|
||||
* **web:** Implement multi-dimensional course filtering system ([106bf23](https://github.com/Xevion/Banner/commit/106bf232c4b53f4ca8902a582f185e146878c54e))
|
||||
* **web:** Implement smooth view transitions for search results ([5729a82](https://github.com/Xevion/Banner/commit/5729a821d54d95a00e9f4ba736a2bd884c0c409b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** Add proper flag validation for check script ([2acf52a](https://github.com/Xevion/Banner/commit/2acf52a63b6dcd24ca826b99061bf7a51a9230b1))
|
||||
* Re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping ([9e825cd](https://github.com/Xevion/Banner/commit/9e825cd113bbc65c10f0386b5300b6aec50bf936))
|
||||
* Separate Biome format and lint checks to enable auto-format ([ac8dbb2](https://github.com/Xevion/Banner/commit/ac8dbb2eefe79ec5d898cfa719e270f4713125d5))
|
||||
* **web:** Prevent duplicate searches and background fetching on navigation ([5dd35ed](https://github.com/Xevion/Banner/commit/5dd35ed215d3d1f3603e67a2aa59eaddf619f5c9))
|
||||
* **web:** Prevent interaction blocking during search transitions ([7f0f087](https://github.com/Xevion/Banner/commit/7f0f08725a668c5ac88c510f43791d90ce2f795e))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* Migrate API responses from manual JSON to type-safe bindings ([0ee4e8a](https://github.com/Xevion/Banner/commit/0ee4e8a8bc1fe0b079fea84ac303674083b43a59))
|
||||
* Standardize error responses with ApiError and ts-rs bindings ([239f7ee](https://github.com/Xevion/Banner/commit/239f7ee38cbc0e49d9041579fc9923fd4a4608bf))
|
||||
* **web:** Consolidate tooltip implementations with shared components ([d91f7ab](https://github.com/Xevion/Banner/commit/d91f7ab34299b26dc12d629bf99d502ee05e7cfa))
|
||||
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* Add aliases to Justfile ([02b18f0](https://github.com/Xevion/Banner/commit/02b18f0c66dc8b876452f35999c027475df52462))
|
||||
* Add dev-build flag for embedded vite builds ([5134ae9](https://github.com/Xevion/Banner/commit/5134ae93881854ac722dc9e7f3f5040aee3e517a))
|
||||
|
||||
## [0.6.1](https://github.com/Xevion/Banner/compare/v0.6.0...v0.6.1) (2026-01-31)
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
+153
-26
@@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail};
|
||||
use crate::error::Result;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Column to sort search results by.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortColumn {
|
||||
CourseCode,
|
||||
Title,
|
||||
@@ -17,16 +19,29 @@ pub enum SortColumn {
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Aggregate min/max ranges for filter sliders, computed per-term.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct FilterRanges {
|
||||
pub course_number_min: i32,
|
||||
pub course_number_max: i32,
|
||||
pub credit_hour_min: i32,
|
||||
pub credit_hour_max: i32,
|
||||
pub wait_count_max: i32,
|
||||
}
|
||||
|
||||
/// Shared WHERE clause for course search filters.
|
||||
///
|
||||
/// Parameters $1-$8 match the bind order in `search_courses`.
|
||||
/// Parameters $1-$17 match the bind order in `search_courses`.
|
||||
const SEARCH_WHERE: &str = r#"
|
||||
WHERE term_code = $1
|
||||
AND ($2::text[] IS NULL OR subject = ANY($2))
|
||||
@@ -34,8 +49,40 @@ const SEARCH_WHERE: &str = r#"
|
||||
AND ($4::int IS NULL OR course_number::int >= $4)
|
||||
AND ($5::int IS NULL OR course_number::int <= $5)
|
||||
AND ($6::bool = false OR max_enrollment > enrollment)
|
||||
AND ($7::text IS NULL OR instructional_method = $7)
|
||||
AND ($8::text IS NULL OR campus = $8)
|
||||
AND ($7::text[] IS NULL OR instructional_method = ANY($7))
|
||||
AND ($8::text[] IS NULL OR campus = ANY($8))
|
||||
AND ($9::int IS NULL OR wait_count <= $9)
|
||||
AND ($10::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (NOT 'monday' = ANY($10) OR (mt->>'monday')::bool)
|
||||
AND (NOT 'tuesday' = ANY($10) OR (mt->>'tuesday')::bool)
|
||||
AND (NOT 'wednesday' = ANY($10) OR (mt->>'wednesday')::bool)
|
||||
AND (NOT 'thursday' = ANY($10) OR (mt->>'thursday')::bool)
|
||||
AND (NOT 'friday' = ANY($10) OR (mt->>'friday')::bool)
|
||||
AND (NOT 'saturday' = ANY($10) OR (mt->>'saturday')::bool)
|
||||
AND (NOT 'sunday' = ANY($10) OR (mt->>'sunday')::bool)
|
||||
))
|
||||
AND ($11::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'begin_time') >= $11
|
||||
))
|
||||
AND ($12::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'end_time') <= $12
|
||||
))
|
||||
AND ($13::text[] IS NULL OR part_of_term = ANY($13))
|
||||
AND ($14::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(attributes) a
|
||||
WHERE a = ANY($14)
|
||||
))
|
||||
AND ($15::int IS NULL OR COALESCE(credit_hours, credit_hour_low, 0) >= $15)
|
||||
AND ($16::int IS NULL OR COALESCE(credit_hours, credit_hour_high, 0) <= $16)
|
||||
AND ($17::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM course_instructors ci
|
||||
JOIN instructors i ON i.id = ci.instructor_id
|
||||
WHERE ci.course_id = courses.id
|
||||
AND i.display_name ILIKE '%' || $17 || '%'
|
||||
))
|
||||
"#;
|
||||
|
||||
/// Build a safe ORDER BY clause from typed sort parameters.
|
||||
@@ -83,8 +130,17 @@ pub async fn search_courses(
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
open_only: bool,
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
instructional_method: Option<&[String]>,
|
||||
campus: Option<&[String]>,
|
||||
wait_count_max: Option<i32>,
|
||||
days: Option<&[String]>,
|
||||
time_start: Option<&str>,
|
||||
time_end: Option<&str>,
|
||||
part_of_term: Option<&[String]>,
|
||||
attributes: Option<&[String]>,
|
||||
credit_hour_min: Option<i32>,
|
||||
credit_hour_max: Option<i32>,
|
||||
instructor: Option<&str>,
|
||||
limit: i32,
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
@@ -93,32 +149,50 @@ pub async fn search_courses(
|
||||
let order_by = sort_clause(sort_by, sort_dir);
|
||||
|
||||
let data_query =
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $18 OFFSET $19");
|
||||
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
|
||||
|
||||
let courses = sqlx::query_as::<_, Course>(&data_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.bind(limit) // $18
|
||||
.bind(offset) // $19
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
let total: (i64,) = sqlx::query_as(&count_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
@@ -247,3 +321,56 @@ pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(tc,)| tc).collect())
|
||||
}
|
||||
|
||||
type RangeRow = (
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
);
|
||||
|
||||
/// Get aggregate filter ranges for a term (course number, credit hours, waitlist).
|
||||
pub async fn get_filter_ranges(db_pool: &PgPool, term_code: &str) -> Result<FilterRanges> {
|
||||
let row: RangeRow = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
MIN(course_number::int),
|
||||
MAX(course_number::int),
|
||||
MIN(COALESCE(credit_hours, credit_hour_low, 0)),
|
||||
MAX(COALESCE(credit_hours, credit_hour_high, 0)),
|
||||
MAX(wait_count)
|
||||
FROM courses
|
||||
WHERE term_code = $1
|
||||
AND course_number ~ '^\d+$'
|
||||
"#,
|
||||
)
|
||||
.bind(term_code)
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
let cn_min = row.0.unwrap_or(1000);
|
||||
let cn_max = row.1.unwrap_or(9000);
|
||||
let ch_min = row.2.unwrap_or(0);
|
||||
let ch_max = row.3.unwrap_or(8);
|
||||
let wc_max_raw = row.4.unwrap_or(0);
|
||||
|
||||
// Round course number to hundreds: floor min, ceil max
|
||||
let cn_min_rounded = (cn_min / 100) * 100;
|
||||
let cn_max_rounded = ((cn_max + 99) / 100) * 100;
|
||||
|
||||
// Waitlist ceiling: (max / 10 + 1) * 10
|
||||
let wc_max = if wc_max_raw > 0 {
|
||||
(wc_max_raw / 10 + 1) * 10
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(FilterRanges {
|
||||
course_number_min: cn_min_rounded,
|
||||
course_number_max: cn_max_rounded,
|
||||
credit_hour_min: ch_min,
|
||||
credit_hour_max: ch_max,
|
||||
wait_count_max: wc_max,
|
||||
})
|
||||
}
|
||||
|
||||
+2
-1
@@ -192,8 +192,9 @@ pub enum TargetType {
|
||||
}
|
||||
|
||||
/// Computed status for a scrape job, derived from existing fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub enum ScrapeJobStatus {
|
||||
Processing,
|
||||
StaleLock,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Standardized API error responses.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Standardized error response for all API endpoints.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ApiError {
|
||||
/// Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM")
|
||||
pub code: String,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional additional details (validation errors, field info, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new("NOT_FOUND", message)
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new("BAD_REQUEST", message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new("INTERNAL_ERROR", message)
|
||||
}
|
||||
|
||||
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
|
||||
Self::new("INVALID_TERM", format!("Invalid term: {}", term))
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.code.as_str() {
|
||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"BAD_REQUEST" | "INVALID_TERM" | "INVALID_RANGE" => StatusCode::BAD_REQUEST,
|
||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
(status, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `(StatusCode, String)` tuple errors to ApiError
|
||||
impl From<(StatusCode, String)> for ApiError {
|
||||
fn from((status, message): (StatusCode, String)) -> Self {
|
||||
let code = match status {
|
||||
StatusCode::NOT_FOUND => "NOT_FOUND",
|
||||
StatusCode::BAD_REQUEST => "BAD_REQUEST",
|
||||
StatusCode::UNAUTHORIZED => "UNAUTHORIZED",
|
||||
StatusCode::FORBIDDEN => "FORBIDDEN",
|
||||
_ => "INTERNAL_ERROR",
|
||||
};
|
||||
Self::new(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for converting database errors to ApiError
|
||||
pub fn db_error(context: &str, error: anyhow::Error) -> ApiError {
|
||||
tracing::error!(error = %error, context = context, "Database error");
|
||||
ApiError::internal_error(format!("{} failed", context))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod auth;
|
||||
pub mod calendar;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod encoding;
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod routes;
|
||||
pub mod schedule_cache;
|
||||
|
||||
+294
-165
@@ -4,7 +4,6 @@ use axum::{
|
||||
Extension, Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode as AxumStatusCode,
|
||||
response::{Json, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
@@ -12,6 +11,7 @@ use axum::{
|
||||
use crate::web::admin_scraper;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::calendar;
|
||||
use crate::web::error::{ApiError, db_error};
|
||||
use crate::web::timeline;
|
||||
use crate::web::ws;
|
||||
use crate::{data, web::admin};
|
||||
@@ -52,9 +52,8 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
||||
get(calendar::course_ics),
|
||||
)
|
||||
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
|
||||
.route("/terms", get(get_terms))
|
||||
.route("/subjects", get(get_subjects))
|
||||
.route("/reference/{category}", get(get_reference))
|
||||
.route("/search-options", get(get_search_options))
|
||||
.route("/timeline", post(timeline::timeline))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
@@ -291,7 +290,7 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
async fn metrics(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MetricsParams>,
|
||||
) -> Result<Json<Value>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<MetricsResponse>, ApiError> {
|
||||
let limit = params.limit.clamp(1, 5000);
|
||||
|
||||
// Parse range shorthand, defaulting to 24h
|
||||
@@ -303,8 +302,8 @@ async fn metrics(
|
||||
"7d" => chrono::Duration::days(7),
|
||||
"30d" => chrono::Duration::days(30),
|
||||
_ => {
|
||||
return Err((
|
||||
AxumStatusCode::BAD_REQUEST,
|
||||
return Err(ApiError::new(
|
||||
"INVALID_RANGE",
|
||||
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
|
||||
));
|
||||
}
|
||||
@@ -321,13 +320,7 @@ async fn metrics(
|
||||
.bind(crn)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup for metrics failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Course lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Course lookup for metrics", e.into()))?;
|
||||
row.map(|(id,)| id)
|
||||
} else {
|
||||
None
|
||||
@@ -361,80 +354,120 @@ async fn metrics(
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Metrics query failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Metrics query failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Metrics query", e.into()))?;
|
||||
|
||||
let count = metrics.len();
|
||||
let metrics_json: Vec<Value> = metrics
|
||||
let metrics_entries: Vec<MetricEntry> = metrics
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"courseId": course_id,
|
||||
"timestamp": timestamp.to_rfc3339(),
|
||||
"enrollment": enrollment,
|
||||
"waitCount": wait_count,
|
||||
"seatsAvailable": seats_available,
|
||||
})
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| MetricEntry {
|
||||
id,
|
||||
course_id,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
enrollment,
|
||||
wait_count,
|
||||
seats_available,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"metrics": metrics_json,
|
||||
"count": count,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})))
|
||||
Ok(Json(MetricsResponse {
|
||||
metrics: metrics_entries,
|
||||
count,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Course search & detail API
|
||||
// ============================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MetricsParams {
|
||||
course_id: Option<i32>,
|
||||
term: Option<String>,
|
||||
crn: Option<String>,
|
||||
/// Shorthand durations: "1h", "6h", "24h", "7d", "30d"
|
||||
range: Option<String>,
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricEntry {
|
||||
pub id: i32,
|
||||
pub course_id: i32,
|
||||
pub timestamp: String,
|
||||
pub enrollment: i32,
|
||||
pub wait_count: i32,
|
||||
pub seats_available: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsResponse {
|
||||
pub metrics: Vec<MetricEntry>,
|
||||
pub count: usize,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub course_id: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub term: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crn: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub range: Option<String>,
|
||||
#[serde(default = "default_metrics_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
fn default_metrics_limit() -> i32 {
|
||||
500
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubjectsParams {
|
||||
term: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchParams {
|
||||
term: String,
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchParams {
|
||||
pub term: String,
|
||||
#[serde(default)]
|
||||
subject: Vec<String>,
|
||||
q: Option<String>,
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
pub subject: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub q: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_low")]
|
||||
pub course_number_low: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_high")]
|
||||
pub course_number_high: Option<i32>,
|
||||
#[serde(default, alias = "open_only")]
|
||||
pub open_only: bool,
|
||||
#[serde(default, alias = "instructional_method")]
|
||||
pub instructional_method: Vec<String>,
|
||||
#[serde(default)]
|
||||
open_only: bool,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
pub campus: Vec<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
#[serde(default)]
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
sort_dir: Option<SortDirection>,
|
||||
pub offset: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_by")]
|
||||
pub sort_by: Option<SortColumn>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_dir")]
|
||||
pub sort_dir: Option<SortDirection>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "wait_count_max")]
|
||||
pub wait_count_max: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub days: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_start")]
|
||||
pub time_start: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_end")]
|
||||
pub time_end: Option<String>,
|
||||
#[serde(default, alias = "part_of_term")]
|
||||
pub part_of_term: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub attributes: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_min")]
|
||||
pub credit_hour_min: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_max")]
|
||||
pub credit_hour_max: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructor: Option<String>,
|
||||
}
|
||||
|
||||
use crate::data::courses::{SortColumn, SortDirection};
|
||||
@@ -493,11 +526,9 @@ pub struct InstructorResponse {
|
||||
pub struct SearchResponse {
|
||||
courses: Vec<CourseResponse>,
|
||||
total_count: i32,
|
||||
offset: i32,
|
||||
limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CodeDescription {
|
||||
@@ -505,7 +536,7 @@ pub struct CodeDescription {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TermResponse {
|
||||
@@ -514,6 +545,32 @@ pub struct TermResponse {
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// Response for the consolidated search-options endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsResponse {
|
||||
pub terms: Vec<TermResponse>,
|
||||
pub subjects: Vec<CodeDescription>,
|
||||
pub reference: SearchOptionsReference,
|
||||
pub ranges: data::courses::FilterRanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsReference {
|
||||
pub instructional_methods: Vec<CodeDescription>,
|
||||
pub campuses: Vec<CodeDescription>,
|
||||
pub parts_of_term: Vec<CodeDescription>,
|
||||
pub attributes: Vec<CodeDescription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchOptionsParams {
|
||||
pub term: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
|
||||
fn build_course_response(
|
||||
course: &models::Course,
|
||||
@@ -533,6 +590,32 @@ fn build_course_response(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let meeting_times = serde_json::from_value(course.meeting_times.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize meeting_times JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let attributes = serde_json::from_value(course.attributes.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize attributes JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
CourseResponse {
|
||||
crn: course.crn.clone(),
|
||||
subject: course.subject.clone(),
|
||||
@@ -555,8 +638,8 @@ fn build_course_response(
|
||||
link_identifier: course.link_identifier.clone(),
|
||||
is_section_linked: course.is_section_linked,
|
||||
part_of_term: course.part_of_term.clone(),
|
||||
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
|
||||
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
|
||||
meeting_times,
|
||||
attributes,
|
||||
instructors,
|
||||
}
|
||||
}
|
||||
@@ -565,15 +648,11 @@ fn build_course_response(
|
||||
async fn search_courses(
|
||||
State(state): State<AppState>,
|
||||
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
|
||||
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<SearchResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_code = Term::resolve_to_code(¶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 +668,44 @@ async fn search_courses(
|
||||
params.course_number_low,
|
||||
params.course_number_high,
|
||||
params.open_only,
|
||||
params.instructional_method.as_deref(),
|
||||
params.campus.as_deref(),
|
||||
if params.instructional_method.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶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 +725,6 @@ async fn search_courses(
|
||||
Ok(Json(SearchResponse {
|
||||
courses: course_responses,
|
||||
total_count: total_count as i32,
|
||||
offset,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -632,17 +732,11 @@ async fn search_courses(
|
||||
async fn get_course(
|
||||
State(state): State<AppState>,
|
||||
Path((term, crn)): Path<(String, String)>,
|
||||
) -> Result<Json<CourseResponse>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<CourseResponse>, ApiError> {
|
||||
let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
|
||||
.map_err(|e| db_error("Course lookup", e))?
|
||||
.ok_or_else(|| ApiError::not_found("Course not found"))?;
|
||||
|
||||
let instructors = data::courses::get_course_instructors(&state.db_pool, course.id)
|
||||
.await
|
||||
@@ -650,73 +744,11 @@ async fn get_course(
|
||||
Ok(Json(build_course_response(&course, instructors)))
|
||||
}
|
||||
|
||||
/// `GET /api/terms`
|
||||
async fn get_terms(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<TermResponse>>, (AxumStatusCode, String)> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_codes = data::courses::get_available_terms(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get terms");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to get terms".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let terms: Vec<TermResponse> = term_codes
|
||||
.into_iter()
|
||||
.filter_map(|code| {
|
||||
let term: Term = code.parse().ok()?;
|
||||
Some(TermResponse {
|
||||
code,
|
||||
slug: term.slug(),
|
||||
description: term.description(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(terms))
|
||||
}
|
||||
|
||||
/// `GET /api/subjects?term=202620`
|
||||
async fn get_subjects(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SubjectsParams>,
|
||||
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_code = Term::resolve_to_code(¶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 +757,7 @@ async fn get_reference(
|
||||
drop(cache);
|
||||
let rows = data::reference::get_by_category(&category, &state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, category = %category, "Reference lookup failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error(&format!("Reference lookup for {}", category), e))?;
|
||||
|
||||
return Ok(Json(
|
||||
rows.into_iter()
|
||||
@@ -753,3 +779,106 @@ async fn get_reference(
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/search-options?term={slug}` (term optional, defaults to latest)
|
||||
async fn get_search_options(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchOptionsParams>,
|
||||
) -> Result<Json<SearchOptionsResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
use std::time::Instant;
|
||||
|
||||
// If no term specified, get the latest term
|
||||
let term_slug = if let Some(ref t) = params.term {
|
||||
t.clone()
|
||||
} else {
|
||||
// Fetch available terms to get the default (latest)
|
||||
let term_codes = data::courses::get_available_terms(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("Get terms for default", e))?;
|
||||
|
||||
let first_term: Term = term_codes
|
||||
.first()
|
||||
.and_then(|code| code.parse().ok())
|
||||
.ok_or_else(|| ApiError::new("NO_TERMS", "No terms available".to_string()))?;
|
||||
|
||||
first_term.slug()
|
||||
};
|
||||
|
||||
let term_code =
|
||||
Term::resolve_to_code(&term_slug).ok_or_else(|| ApiError::invalid_term(&term_slug))?;
|
||||
|
||||
// Check cache (10-minute TTL)
|
||||
if let Some(entry) = state.search_options_cache.get(&term_code) {
|
||||
let (cached_at, ref cached_value) = *entry;
|
||||
if cached_at.elapsed() < Duration::from_secs(600) {
|
||||
let response: SearchOptionsResponse = serde_json::from_value(cached_value.clone())
|
||||
.map_err(|e| {
|
||||
ApiError::internal_error(format!("Cache deserialization error: {e}"))
|
||||
})?;
|
||||
return Ok(Json(response));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all data in parallel
|
||||
let (term_codes, subject_rows, ranges) = tokio::try_join!(
|
||||
data::courses::get_available_terms(&state.db_pool),
|
||||
data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code),
|
||||
data::courses::get_filter_ranges(&state.db_pool, &term_code),
|
||||
)
|
||||
.map_err(|e| db_error("Search options", e))?;
|
||||
|
||||
// Build terms
|
||||
let terms: Vec<TermResponse> = term_codes
|
||||
.into_iter()
|
||||
.filter_map(|code| {
|
||||
let term: Term = code.parse().ok()?;
|
||||
Some(TermResponse {
|
||||
code,
|
||||
slug: term.slug(),
|
||||
description: term.description(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build subjects
|
||||
let subjects: Vec<CodeDescription> = subject_rows
|
||||
.into_iter()
|
||||
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||
.collect();
|
||||
|
||||
// Build reference data from in-memory cache
|
||||
let ref_cache = state.reference_cache.read().await;
|
||||
let build_ref = |category: &str| -> Vec<CodeDescription> {
|
||||
ref_cache
|
||||
.entries_for_category(category)
|
||||
.into_iter()
|
||||
.map(|(code, desc)| CodeDescription {
|
||||
code: code.to_string(),
|
||||
description: desc.to_string(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let reference = SearchOptionsReference {
|
||||
instructional_methods: build_ref("instructional_method"),
|
||||
campuses: build_ref("campus"),
|
||||
parts_of_term: build_ref("part_of_term"),
|
||||
attributes: build_ref("attribute"),
|
||||
};
|
||||
|
||||
let response = SearchOptionsResponse {
|
||||
terms,
|
||||
subjects,
|
||||
reference,
|
||||
ranges,
|
||||
};
|
||||
|
||||
// Cache the response
|
||||
let cached_value = serde_json::to_value(&response).unwrap_or_default();
|
||||
state
|
||||
.search_options_cache
|
||||
.insert(term_code, (Instant::now(), cached_value));
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
+23
-42
@@ -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,
|
||||
},
|
||||
|
||||
+18
-15
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "banner-web",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@icons-pack/svelte-simple-icons": "^6.5.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
@@ -16,27 +17,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@lucide/svelte": "^0.563.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@lucide/svelte": "^0.563.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/node": "^25.1.0",
|
||||
"bits-ui": "^1.3.7",
|
||||
"bits-ui": "^1.8.0",
|
||||
"clsx": "^2.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"svelte": "^5.19.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.0.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"svelte": "^5.49.1",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -137,6 +138,8 @@
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
|
||||
|
||||
"@icons-pack/svelte-simple-icons": ["@icons-pack/svelte-simple-icons@6.5.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.5.0", "svelte": "^4.2.0 || ^5.0.0" } }, "sha512-Xj3PTioiV3TJ1NTKsXY95NFG8FUqw90oeyDZIlslWHs1KkuCheu1HOPrlHb0/IM0b4cldPgx/0TldzxzBlM8Cw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
"test": "vitest run",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format ."
|
||||
@@ -38,6 +38,7 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@icons-pack/svelte-simple-icons": "^6.5.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
|
||||
+2
-40
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -60,9 +58,9 @@ describe("BannerApiClient", () => {
|
||||
|
||||
const result = await apiClient.searchCourses({
|
||||
term: "202420",
|
||||
subjects: ["CS"],
|
||||
subject: ["CS"],
|
||||
q: "data",
|
||||
open_only: true,
|
||||
openOnly: true,
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
});
|
||||
@@ -77,8 +75,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -91,40 +87,6 @@ describe("BannerApiClient", () => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
|
||||
});
|
||||
|
||||
it("should fetch terms", async () => {
|
||||
const mockTerms = [
|
||||
{ code: "202420", description: "Fall 2024" },
|
||||
{ code: "202510", description: "Spring 2025" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTerms),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getTerms();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/terms");
|
||||
expect(result).toEqual(mockTerms);
|
||||
});
|
||||
|
||||
it("should fetch subjects for a term", async () => {
|
||||
const mockSubjects = [
|
||||
{ code: "CS", description: "Computer Science" },
|
||||
{ code: "MAT", description: "Mathematics" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSubjects),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getSubjects("202420");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
|
||||
expect(result).toEqual(mockSubjects);
|
||||
});
|
||||
|
||||
it("should fetch reference data", async () => {
|
||||
const mockRef = [
|
||||
{ code: "F", description: "Face to Face" },
|
||||
|
||||
+167
-118
@@ -1,9 +1,14 @@
|
||||
import { authStore } from "$lib/auth.svelte";
|
||||
import type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DbMeetingTime,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
@@ -11,17 +16,32 @@ import type {
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
MetricEntry,
|
||||
MetricsParams as MetricsParamsGenerated,
|
||||
MetricsResponse,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
SearchParams as SearchParamsGenerated,
|
||||
SearchResponse as SearchResponseGenerated,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimeRange,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeseriesPoint,
|
||||
TimeseriesResponse,
|
||||
TopCandidateResponse,
|
||||
@@ -32,10 +52,15 @@ const API_BASE_URL = "/api";
|
||||
|
||||
// Re-export generated types under their canonical names
|
||||
export type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DbMeetingTime,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
@@ -43,16 +68,29 @@ export type {
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
MetricEntry,
|
||||
MetricsResponse,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeRange,
|
||||
TimeseriesPoint,
|
||||
TimeseriesResponse,
|
||||
TopCandidateResponse,
|
||||
@@ -63,115 +101,13 @@ export type Term = TermResponse;
|
||||
export type Subject = CodeDescription;
|
||||
export type ReferenceEntry = CodeDescription;
|
||||
|
||||
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
|
||||
// Re-export with simplified names
|
||||
export type SearchResponse = SearchResponseGenerated;
|
||||
export type SearchParams = SearchParamsGenerated;
|
||||
export type MetricsParams = MetricsParamsGenerated;
|
||||
|
||||
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
|
||||
// Client-side only — not generated from Rust
|
||||
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface AdminStatus {
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
courseCount: number;
|
||||
scrapeJobCount: number;
|
||||
services: { name: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface ScrapeJob {
|
||||
id: number;
|
||||
targetType: string;
|
||||
targetPayload: unknown;
|
||||
priority: string;
|
||||
executeAt: string;
|
||||
createdAt: string;
|
||||
lockedAt: string | null;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
queuedAt: string;
|
||||
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
|
||||
}
|
||||
|
||||
export interface ScrapeJobsResponse {
|
||||
jobs: ScrapeJob[];
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
fieldChanged: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
subject: string | null;
|
||||
courseNumber: string | null;
|
||||
crn: string | null;
|
||||
courseTitle: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
}
|
||||
|
||||
export interface MetricEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
enrollment: number;
|
||||
waitCount: number;
|
||||
seatsAvailable: number;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
metrics: MetricEntry[];
|
||||
count: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MetricsParams {
|
||||
course_id?: number;
|
||||
term?: string;
|
||||
crn?: string;
|
||||
range?: "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** A time range for timeline queries (ISO-8601 strings). */
|
||||
export interface TimelineRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** Request body for POST /api/timeline. */
|
||||
export interface TimelineRequest {
|
||||
ranges: TimelineRange[];
|
||||
}
|
||||
|
||||
/** A single 15-minute slot returned by the timeline API. */
|
||||
export interface TimelineSlot {
|
||||
time: string;
|
||||
subjects: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Response from POST /api/timeline. */
|
||||
export interface TimelineResponse {
|
||||
slots: TimelineSlot[];
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
term: string;
|
||||
subjects?: string[];
|
||||
q?: string;
|
||||
open_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort_by?: SortColumn;
|
||||
sort_dir?: SortDirection;
|
||||
}
|
||||
|
||||
// Admin instructor query params (client-only, not generated)
|
||||
export interface AdminInstructorListParams {
|
||||
status?: string;
|
||||
@@ -181,6 +117,35 @@ export interface AdminInstructorListParams {
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API error class that wraps the structured ApiError response from the backend.
|
||||
*/
|
||||
export class ApiErrorClass extends Error {
|
||||
public readonly code: string;
|
||||
public readonly details: unknown | null;
|
||||
|
||||
constructor(apiError: ApiError) {
|
||||
super(apiError.message);
|
||||
this.name = "ApiError";
|
||||
this.code = apiError.code;
|
||||
this.details = apiError.details;
|
||||
}
|
||||
|
||||
isNotFound(): boolean {
|
||||
return this.code === "NOT_FOUND";
|
||||
}
|
||||
|
||||
isBadRequest(): boolean {
|
||||
return (
|
||||
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
|
||||
);
|
||||
}
|
||||
|
||||
isInternalError(): boolean {
|
||||
return this.code === "INTERNAL_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
export class BannerApiClient {
|
||||
private baseUrl: string;
|
||||
private fetchFn: typeof fetch;
|
||||
@@ -220,7 +185,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
@@ -241,7 +216,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,20 +234,63 @@ export class BannerApiClient {
|
||||
return this.request<StatusResponse>("/status");
|
||||
}
|
||||
|
||||
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("term", params.term);
|
||||
if (params.subjects) {
|
||||
for (const s of params.subjects) {
|
||||
if (params.subject && params.subject.length > 0) {
|
||||
for (const s of params.subject) {
|
||||
query.append("subject", s);
|
||||
}
|
||||
}
|
||||
if (params.q) query.set("q", params.q);
|
||||
if (params.open_only) query.set("open_only", "true");
|
||||
if (params.openOnly) query.set("open_only", "true");
|
||||
if (params.courseNumberLow !== undefined && params.courseNumberLow !== null) {
|
||||
query.set("course_number_low", String(params.courseNumberLow));
|
||||
}
|
||||
if (params.courseNumberHigh !== undefined && params.courseNumberHigh !== null) {
|
||||
query.set("course_number_high", String(params.courseNumberHigh));
|
||||
}
|
||||
if (params.instructionalMethod && params.instructionalMethod.length > 0) {
|
||||
for (const m of params.instructionalMethod) {
|
||||
query.append("instructional_method", m);
|
||||
}
|
||||
}
|
||||
if (params.campus && params.campus.length > 0) {
|
||||
for (const c of params.campus) {
|
||||
query.append("campus", c);
|
||||
}
|
||||
}
|
||||
if (params.waitCountMax !== undefined && params.waitCountMax !== null) {
|
||||
query.set("wait_count_max", String(params.waitCountMax));
|
||||
}
|
||||
if (params.days && params.days.length > 0) {
|
||||
for (const d of params.days) {
|
||||
query.append("days", d);
|
||||
}
|
||||
}
|
||||
if (params.timeStart) query.set("time_start", params.timeStart);
|
||||
if (params.timeEnd) query.set("time_end", params.timeEnd);
|
||||
if (params.partOfTerm && params.partOfTerm.length > 0) {
|
||||
for (const p of params.partOfTerm) {
|
||||
query.append("part_of_term", p);
|
||||
}
|
||||
}
|
||||
if (params.attributes && params.attributes.length > 0) {
|
||||
for (const a of params.attributes) {
|
||||
query.append("attributes", a);
|
||||
}
|
||||
}
|
||||
if (params.creditHourMin !== undefined && params.creditHourMin !== null) {
|
||||
query.set("credit_hour_min", String(params.creditHourMin));
|
||||
}
|
||||
if (params.creditHourMax !== undefined && params.creditHourMax !== null) {
|
||||
query.set("credit_hour_max", String(params.creditHourMax));
|
||||
}
|
||||
if (params.instructor) query.set("instructor", params.instructor);
|
||||
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
||||
if (params.sort_by) query.set("sort_by", params.sort_by);
|
||||
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
|
||||
if (params.sortBy) query.set("sort_by", params.sortBy);
|
||||
if (params.sortDir) query.set("sort_dir", params.sortDir);
|
||||
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -278,9 +306,28 @@ export class BannerApiClient {
|
||||
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
|
||||
}
|
||||
|
||||
// In-memory cache for search options per term
|
||||
private searchOptionsCache = new Map<
|
||||
string,
|
||||
{ data: SearchOptionsResponse; fetchedAt: number }
|
||||
>();
|
||||
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
|
||||
const cacheKey = term || "__default__";
|
||||
const cached = this.searchOptionsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
|
||||
const data = await this.request<SearchOptionsResponse>(url);
|
||||
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async getAdminStatus(): Promise<AdminStatus> {
|
||||
return this.request<AdminStatus>("/admin/status");
|
||||
async getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return this.request<AdminStatusResponse>("/admin/status");
|
||||
}
|
||||
|
||||
async getAdminUsers(): Promise<User[]> {
|
||||
@@ -331,16 +378,18 @@ export class BannerApiClient {
|
||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||
private _auditLastModified: string | null = null;
|
||||
|
||||
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
|
||||
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
|
||||
return this.request<TimelineResponse>("/timeline", {
|
||||
method: "POST",
|
||||
body: { ranges } satisfies TimelineRequest,
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||
if (params?.courseId !== undefined && params.courseId !== null) {
|
||||
query.set("course_id", String(params.courseId));
|
||||
}
|
||||
if (params?.term) query.set("term", params.term);
|
||||
if (params?.crn) query.set("crn", params.crn);
|
||||
if (params?.range) query.set("range", params.range);
|
||||
|
||||
@@ -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,19 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
/**
|
||||
* Standardized error response for all API endpoints.
|
||||
*/
|
||||
export type ApiError = {
|
||||
/**
|
||||
* Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM")
|
||||
*/
|
||||
code: string,
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
message: string,
|
||||
/**
|
||||
* Optional additional details (validation errors, field info, etc.)
|
||||
*/
|
||||
details: JsonValue | null, };
|
||||
@@ -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>, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Aggregate min/max ranges for filter sliders, computed per-term.
|
||||
*/
|
||||
export type FilterRanges = { courseNumberMin: number, courseNumberMax: number, creditHourMin: number, creditHourMax: number, waitCountMax: number, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ListInstructorsParams = { status: string | null, search: string | null, page: number | null, perPage: number | null, sort: string | null, };
|
||||
@@ -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,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,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.
|
||||
|
||||
/**
|
||||
* 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,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TimeRange = {
|
||||
/**
|
||||
* ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z")
|
||||
*/
|
||||
start: string,
|
||||
/**
|
||||
* ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z")
|
||||
*/
|
||||
end: string, };
|
||||
@@ -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,54 @@
|
||||
export type { AdminServiceInfo } from "./AdminServiceInfo";
|
||||
export type { AdminStatusResponse } from "./AdminStatusResponse";
|
||||
export type { ApiError } from "./ApiError";
|
||||
export type { AuditLogEntry } from "./AuditLogEntry";
|
||||
export type { AuditLogResponse } from "./AuditLogResponse";
|
||||
export type { CandidateResponse } from "./CandidateResponse";
|
||||
export type { CodeDescription } from "./CodeDescription";
|
||||
export type { CourseResponse } from "./CourseResponse";
|
||||
export type { DbMeetingTime } from "./DbMeetingTime";
|
||||
export type { FilterRanges } from "./FilterRanges";
|
||||
export type { InstructorDetail } from "./InstructorDetail";
|
||||
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
|
||||
export type { InstructorListItem } from "./InstructorListItem";
|
||||
export type { InstructorResponse } from "./InstructorResponse";
|
||||
export type { InstructorStats } from "./InstructorStats";
|
||||
export type { LinkedRmpProfile } from "./LinkedRmpProfile";
|
||||
export type { ListInstructorsParams } from "./ListInstructorsParams";
|
||||
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
||||
export type { MatchBody } from "./MatchBody";
|
||||
export type { MetricEntry } from "./MetricEntry";
|
||||
export type { MetricsParams } from "./MetricsParams";
|
||||
export type { MetricsResponse } from "./MetricsResponse";
|
||||
export type { OkResponse } from "./OkResponse";
|
||||
export type { RejectCandidateBody } from "./RejectCandidateBody";
|
||||
export type { RescoreResponse } from "./RescoreResponse";
|
||||
export type { ScrapeJobDto } from "./ScrapeJobDto";
|
||||
export type { ScrapeJobEvent } from "./ScrapeJobEvent";
|
||||
export type { ScrapeJobStatus } from "./ScrapeJobStatus";
|
||||
export type { ScrapeJobsResponse } from "./ScrapeJobsResponse";
|
||||
export type { ScraperStatsResponse } from "./ScraperStatsResponse";
|
||||
export type { SearchOptionsReference } from "./SearchOptionsReference";
|
||||
export type { SearchOptionsResponse } from "./SearchOptionsResponse";
|
||||
export type { SearchParams } from "./SearchParams";
|
||||
export type { SearchResponse } from "./SearchResponse";
|
||||
export type { ServiceInfo } from "./ServiceInfo";
|
||||
export type { ServiceStatus } from "./ServiceStatus";
|
||||
export type { SortColumn } from "./SortColumn";
|
||||
export type { SortDirection } from "./SortDirection";
|
||||
export type { StatsParams } from "./StatsParams";
|
||||
export type { StatusResponse } from "./StatusResponse";
|
||||
export type { SubjectDetailParams } from "./SubjectDetailParams";
|
||||
export type { SubjectDetailResponse } from "./SubjectDetailResponse";
|
||||
export type { SubjectResultEntry } from "./SubjectResultEntry";
|
||||
export type { SubjectSummary } from "./SubjectSummary";
|
||||
export type { SubjectsResponse } from "./SubjectsResponse";
|
||||
export type { TermResponse } from "./TermResponse";
|
||||
export type { TimeRange } from "./TimeRange";
|
||||
export type { TimelineRequest } from "./TimelineRequest";
|
||||
export type { TimelineResponse } from "./TimelineResponse";
|
||||
export type { TimelineSlot } from "./TimelineSlot";
|
||||
export type { TimeseriesParams } from "./TimeseriesParams";
|
||||
export type { TimeseriesPoint } from "./TimeseriesPoint";
|
||||
export type { TimeseriesResponse } from "./TimeseriesResponse";
|
||||
export type { TopCandidateResponse } from "./TopCandidateResponse";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { CodeDescription } from "$lib/bindings";
|
||||
import { ChevronDown } from "@lucide/svelte";
|
||||
import { Popover } from "bits-ui";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
let {
|
||||
instructionalMethod = $bindable<string[]>([]),
|
||||
campus = $bindable<string[]>([]),
|
||||
partOfTerm = $bindable<string[]>([]),
|
||||
attributes = $bindable<string[]>([]),
|
||||
referenceData,
|
||||
}: {
|
||||
instructionalMethod: string[];
|
||||
campus: string[];
|
||||
partOfTerm: string[];
|
||||
attributes: string[];
|
||||
referenceData: {
|
||||
instructionalMethods: CodeDescription[];
|
||||
campuses: CodeDescription[];
|
||||
partsOfTerm: CodeDescription[];
|
||||
attributes: CodeDescription[];
|
||||
};
|
||||
} = $props();
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
instructionalMethod.length > 0 ||
|
||||
campus.length > 0 ||
|
||||
partOfTerm.length > 0 ||
|
||||
attributes.length > 0
|
||||
);
|
||||
|
||||
function toggleValue(arr: string[], code: string): string[] {
|
||||
return arr.includes(code) ? arr.filter((v) => v !== code) : [...arr, code];
|
||||
}
|
||||
|
||||
const sections: {
|
||||
label: string;
|
||||
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes";
|
||||
dataKey: "instructionalMethods" | "campuses" | "partsOfTerm" | "attributes";
|
||||
}[] = [
|
||||
{ label: "Instructional Method", key: "instructionalMethod", dataKey: "instructionalMethods" },
|
||||
{ label: "Campus", key: "campus", dataKey: "campuses" },
|
||||
{ label: "Part of Term", key: "partOfTerm", dataKey: "partsOfTerm" },
|
||||
{ label: "Course Attributes", key: "attributes", dataKey: "attributes" },
|
||||
];
|
||||
|
||||
function getSelected(
|
||||
key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes"
|
||||
): string[] {
|
||||
if (key === "instructionalMethod") return instructionalMethod;
|
||||
if (key === "campus") return campus;
|
||||
if (key === "partOfTerm") return partOfTerm;
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function toggle(key: "instructionalMethod" | "campus" | "partOfTerm" | "attributes", code: string) {
|
||||
if (key === "instructionalMethod") instructionalMethod = toggleValue(instructionalMethod, code);
|
||||
else if (key === "campus") campus = toggleValue(campus, code);
|
||||
else if (key === "partOfTerm") partOfTerm = toggleValue(partOfTerm, code);
|
||||
else attributes = toggleValue(attributes, code);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
|
||||
{hasActiveFilters
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||
>
|
||||
{#if hasActiveFilters}
|
||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
Attributes
|
||||
<ChevronDown class="size-3" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-80 max-h-96 overflow-y-auto"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each sections as { label, key, dataKey }, i (key)}
|
||||
{#if i > 0}
|
||||
<div class="h-px bg-border"></div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each referenceData[dataKey] as item (item.code)}
|
||||
{@const selected = getSelected(key)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer
|
||||
{selected.includes(item.code)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => toggle(key, item.code)}
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
rmpUrl,
|
||||
} from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Star,
|
||||
Triangle,
|
||||
} from "@lucide/svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import RichTooltip from "./RichTooltip.svelte";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
@@ -40,8 +40,8 @@ const clipboard = useClipboard();
|
||||
{#if course.instructors.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each course.instructors as instructor}
|
||||
<Tooltip.Root delayDuration={200}>
|
||||
<Tooltip.Trigger>
|
||||
<RichTooltip delay={200} contentClass="px-3 py-2">
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||
>
|
||||
@@ -71,11 +71,8 @@ const clipboard = useClipboard();
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="space-y-1.5">
|
||||
<div class="font-medium">
|
||||
{instructor.displayName}
|
||||
@@ -126,8 +123,8 @@ const clipboard = useClipboard();
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -272,8 +269,8 @@ const clipboard = useClipboard();
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<Tooltip.Root delayDuration={150} disableHoverableContent>
|
||||
<Tooltip.Trigger>
|
||||
<RichTooltip passthrough>
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-foreground font-mono"
|
||||
>
|
||||
@@ -288,8 +285,8 @@ const clipboard = useClipboard();
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
Group <span class="font-mono font-medium"
|
||||
>{course.crossList}</span
|
||||
>
|
||||
@@ -297,8 +294,8 @@ const clipboard = useClipboard();
|
||||
— {formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
|
||||
shared seats
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -24,13 +24,12 @@ import {
|
||||
seatsDotColor,
|
||||
} from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Columns3,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Star,
|
||||
@@ -44,10 +43,12 @@ import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
|
||||
import { ContextMenu, DropdownMenu } from "bits-ui";
|
||||
import { flip } from "svelte/animate";
|
||||
import { fade, fly, slide } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
import RichTooltip from "./RichTooltip.svelte";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
|
||||
let {
|
||||
@@ -57,6 +58,8 @@ let {
|
||||
onSortingChange,
|
||||
manualSorting = false,
|
||||
subjectMap = {},
|
||||
limit = 25,
|
||||
columnVisibility = $bindable({}),
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
@@ -64,12 +67,35 @@ let {
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualSorting?: boolean;
|
||||
subjectMap?: Record<string, string>;
|
||||
limit?: number;
|
||||
columnVisibility?: VisibilityState;
|
||||
} = $props();
|
||||
|
||||
let expandedCrn: string | null = $state(null);
|
||||
let tableWrapper: HTMLDivElement = undefined!;
|
||||
let tableElement: HTMLTableElement = undefined!;
|
||||
const clipboard = useClipboard(1000);
|
||||
|
||||
// Track previous row count so skeleton matches expected result size
|
||||
let previousRowCount = $state(0);
|
||||
$effect(() => {
|
||||
if (courses.length > 0) {
|
||||
previousRowCount = courses.length;
|
||||
}
|
||||
});
|
||||
let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : limit);
|
||||
|
||||
// Animate container height via ResizeObserver
|
||||
let contentHeight = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (!tableElement) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
contentHeight = entry.contentRect.height;
|
||||
});
|
||||
observer.observe(tableElement);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Collapse expanded row when the dataset changes to avoid stale detail rows
|
||||
// and FLIP position calculation glitches from lingering expanded content
|
||||
$effect(() => {
|
||||
@@ -82,9 +108,6 @@ useOverlayScrollbars(() => tableWrapper, {
|
||||
scrollbars: { autoHide: "never" },
|
||||
});
|
||||
|
||||
// Column visibility state
|
||||
let columnVisibility: VisibilityState = $state({});
|
||||
|
||||
function resetColumnVisibility() {
|
||||
columnVisibility = {};
|
||||
}
|
||||
@@ -124,6 +147,11 @@ function timeIsTBA(course: CourseResponse): boolean {
|
||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||
}
|
||||
|
||||
// Calculate max subject code length for alignment
|
||||
let maxSubjectLength = $derived(
|
||||
courses.length > 0 ? Math.max(...courses.map((c) => c.subject.length)) : 3
|
||||
);
|
||||
|
||||
// Column definitions
|
||||
const columns: ColumnDef<CourseResponse, unknown>[] = [
|
||||
{
|
||||
@@ -265,50 +293,18 @@ const table = createSvelteTable({
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Toolbar: View columns button -->
|
||||
<div class="flex items-center justify-end pb-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<Columns3 class="size-3.5" />
|
||||
View
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div
|
||||
{...props}
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
>
|
||||
{@render columnVisibilityGroup(
|
||||
DropdownMenu.Group,
|
||||
DropdownMenu.GroupHeading,
|
||||
DropdownMenu.CheckboxItem,
|
||||
DropdownMenu.Separator,
|
||||
DropdownMenu.Item,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<!-- Table with context menu on header -->
|
||||
<div bind:this={tableWrapper} class="overflow-x-auto">
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="overflow-x-auto overflow-y-hidden transition-[height] duration-200"
|
||||
style:height={contentHeight != null ? `${contentHeight}px` : undefined}
|
||||
style:view-transition-name="search-results"
|
||||
style:contain="layout"
|
||||
data-search-results
|
||||
>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="contents">
|
||||
<table class="w-full min-w-160 border-collapse text-sm">
|
||||
<table bind:this={tableElement} class="w-full min-w-160 border-collapse text-sm">
|
||||
<thead>
|
||||
{#each table.getHeaderGroups() as headerGroup}
|
||||
<tr
|
||||
@@ -368,7 +364,7 @@ const table = createSvelteTable({
|
||||
</thead>
|
||||
{#if loading && courses.length === 0}
|
||||
<tbody>
|
||||
{#each Array(5) as _}
|
||||
{#each Array(skeletonRowCount) as _}
|
||||
<tr class="border-b border-border">
|
||||
{#each table.getVisibleLeafColumns() as col}
|
||||
<td class="py-2.5 px-2">
|
||||
@@ -387,7 +383,7 @@ const table = createSvelteTable({
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{:else if courses.length === 0}
|
||||
{:else if courses.length === 0 && !loading}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
@@ -403,10 +399,12 @@ const table = createSvelteTable({
|
||||
{#each table.getRowModel().rows as row, i (row.id)}
|
||||
{@const course = row.original}
|
||||
<tbody
|
||||
class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}"
|
||||
animate:flip={{ duration: 300 }}
|
||||
in:fade={{
|
||||
duration: 200,
|
||||
delay: Math.min(i * 20, 400),
|
||||
delay: Math.min(i * 25, 300),
|
||||
easing: cubicOut,
|
||||
}}
|
||||
>
|
||||
<tr
|
||||
@@ -460,6 +458,11 @@ const table = createSvelteTable({
|
||||
{:else if colId === "course_code"}
|
||||
{@const subjectDesc =
|
||||
subjectMap[course.subject]}
|
||||
{@const paddedSubject =
|
||||
course.subject.padStart(
|
||||
maxSubjectLength,
|
||||
" ",
|
||||
)}
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
<SimpleTooltip
|
||||
text={subjectDesc
|
||||
@@ -469,13 +472,7 @@ const table = createSvelteTable({
|
||||
side="bottom"
|
||||
passthrough
|
||||
>
|
||||
<span class="font-semibold"
|
||||
>{course.subject}
|
||||
{course.courseNumber}</span
|
||||
>{#if course.sequenceNumber}<span
|
||||
class="text-muted-foreground"
|
||||
>-{course.sequenceNumber}</span
|
||||
>{/if}
|
||||
<span class="font-semibold font-mono tracking-tight whitespace-pre">{paddedSubject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground font-mono tracking-tight">-{course.sequenceNumber}</span>{/if}
|
||||
</SimpleTooltip>
|
||||
</td>
|
||||
{:else if colId === "title"}
|
||||
@@ -540,10 +537,12 @@ const table = createSvelteTable({
|
||||
{@const lowConfidence =
|
||||
ratingData.count <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<Tooltip.Root
|
||||
delayDuration={150}
|
||||
<RichTooltip
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
contentClass="px-2.5 py-1.5"
|
||||
>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet children()}
|
||||
<span
|
||||
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
|
||||
style={ratingStyle(
|
||||
@@ -564,15 +563,8 @@ const table = createSvelteTable({
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
class={cn(
|
||||
tooltipContentClass,
|
||||
"px-2.5 py-1.5",
|
||||
)}
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
@@ -600,8 +592,8 @@ const table = createSvelteTable({
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if colId === "time"}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { X } from "@lucide/svelte";
|
||||
|
||||
let {
|
||||
label,
|
||||
onRemove,
|
||||
onclick,
|
||||
}: {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
onclick?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground/80 transition-colors cursor-pointer"
|
||||
onclick={onclick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label="Remove {label} filter"
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from "@lucide/svelte";
|
||||
import { Popover } from "bits-ui";
|
||||
import { fly } from "svelte/transition";
|
||||
import RangeSlider from "./RangeSlider.svelte";
|
||||
|
||||
let {
|
||||
creditHourMin = $bindable<number | null>(null),
|
||||
creditHourMax = $bindable<number | null>(null),
|
||||
instructor = $bindable(""),
|
||||
courseNumberLow = $bindable<number | null>(null),
|
||||
courseNumberHigh = $bindable<number | null>(null),
|
||||
ranges,
|
||||
}: {
|
||||
creditHourMin: number | null;
|
||||
creditHourMax: number | null;
|
||||
instructor: string;
|
||||
courseNumberLow: number | null;
|
||||
courseNumberHigh: number | null;
|
||||
ranges: { courseNumber: { min: number; max: number }; creditHours: { min: number; max: number } };
|
||||
} = $props();
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
creditHourMin !== null ||
|
||||
creditHourMax !== null ||
|
||||
instructor !== "" ||
|
||||
courseNumberLow !== null ||
|
||||
courseNumberHigh !== null
|
||||
);
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
|
||||
{hasActiveFilters
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||
>
|
||||
{#if hasActiveFilters}
|
||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
More
|
||||
<ChevronDown class="size-3" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-72"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<RangeSlider
|
||||
min={ranges.creditHours.min}
|
||||
max={ranges.creditHours.max}
|
||||
step={1}
|
||||
bind:valueLow={creditHourMin}
|
||||
bind:valueHigh={creditHourMax}
|
||||
label="Credit hours"
|
||||
/>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="instructor-input" class="text-xs font-medium text-muted-foreground">
|
||||
Instructor
|
||||
</label>
|
||||
<input
|
||||
id="instructor-input"
|
||||
type="text"
|
||||
placeholder="Search by name..."
|
||||
bind:value={instructor}
|
||||
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
<RangeSlider
|
||||
min={ranges.courseNumber.min}
|
||||
max={ranges.courseNumber.max}
|
||||
step={100}
|
||||
bind:valueLow={courseNumberLow}
|
||||
bind:valueHigh={courseNumberHigh}
|
||||
label="Course number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -48,7 +48,8 @@ function inTransition(_node: HTMLElement): TransitionConfig {
|
||||
|
||||
function outTransition(_node: HTMLElement): TransitionConfig {
|
||||
const dir = navigationStore.direction;
|
||||
const base = "position: absolute; top: 0; left: 0; width: 100%; height: 100%";
|
||||
const base =
|
||||
"position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none";
|
||||
if (dir === "fade") {
|
||||
return {
|
||||
duration: DURATION,
|
||||
@@ -67,9 +68,9 @@ function outTransition(_node: HTMLElement): TransitionConfig {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex flex-1 flex-col">
|
||||
<div class="relative flex flex-1 flex-col overflow-hidden p-8">
|
||||
{#key key}
|
||||
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
|
||||
<div in:inTransition out:outTransition class="flex flex-1 flex-col -m-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -8,7 +8,10 @@ const slideIn: Action<HTMLElement, number> = (node, direction) => {
|
||||
if (direction !== 0) {
|
||||
node.animate(
|
||||
[
|
||||
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
|
||||
{
|
||||
transform: `translateX(${direction * 20}px)`,
|
||||
opacity: 0,
|
||||
},
|
||||
{ transform: "translateX(0)", opacity: 1 },
|
||||
],
|
||||
{ duration: 200, easing: "ease-out" }
|
||||
@@ -20,11 +23,13 @@ let {
|
||||
totalCount,
|
||||
offset,
|
||||
limit,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
}: {
|
||||
totalCount: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
loading?: boolean;
|
||||
onPageChange: (newOffset: number) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -60,43 +65,50 @@ const selectValue = $derived(String(currentPage));
|
||||
</script>
|
||||
|
||||
{#if totalCount > 0 && totalPages > 1}
|
||||
<div class="flex items-start text-xs -mt-3 pl-2">
|
||||
<!-- Left zone: result count -->
|
||||
<div class="flex-1">
|
||||
<span class="text-muted-foreground">
|
||||
Showing {formatNumber(start)}–{formatNumber(end)} of {formatNumber(totalCount)} courses
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start text-xs mt-2 pl-2">
|
||||
<!-- Left zone: result count -->
|
||||
<div class="flex-1">
|
||||
<span class="text-muted-foreground">
|
||||
Showing {formatNumber(start)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Center zone: page buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#key currentPage}
|
||||
{#each pageSlots as page, i (i)}
|
||||
{#if i === 2}
|
||||
<!-- Center slot: current page with dropdown trigger -->
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectValue}
|
||||
onValueChange={(v) => {
|
||||
if (v) goToPage(Number(v));
|
||||
}}
|
||||
items={pageItems}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
|
||||
<!-- Center zone: page buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#key currentPage}
|
||||
{#each pageSlots as page, i (i)}
|
||||
{#if i === 2}
|
||||
<!-- Center slot: current page with dropdown trigger -->
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectValue}
|
||||
onValueChange={(v) => {
|
||||
if (v) goToPage(Number(v));
|
||||
}}
|
||||
items={pageItems}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
|
||||
rounded-md text-sm font-medium tabular-nums
|
||||
border border-border bg-card text-foreground
|
||||
hover:bg-muted/50 active:bg-muted transition-colors
|
||||
cursor-pointer select-none outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Page {currentPage} of {totalPages}, click to select page"
|
||||
>
|
||||
<span use:slideIn={direction}>{currentPage}</span>
|
||||
<ChevronUp class="size-3 text-muted-foreground" />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
class="border border-border bg-card shadow-md outline-hidden z-50
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
|
||||
{loading ? 'animate-pulse' : ''}"
|
||||
aria-label="Page {currentPage} of {totalPages}, click to select page"
|
||||
>
|
||||
<span use:slideIn={direction}
|
||||
>{currentPage}</span
|
||||
>
|
||||
<ChevronUp
|
||||
class="size-3 text-muted-foreground"
|
||||
/>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
class="border border-border bg-card shadow-md outline-hidden z-50
|
||||
max-h-72 min-w-16 w-auto
|
||||
select-none rounded-md p-1
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
@@ -104,64 +116,81 @@ const selectValue = $derived(String(currentPage));
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[side=top]:slide-in-from-bottom-2
|
||||
data-[side=bottom]:slide-in-from-top-2"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
|
||||
<ChevronUp class="size-3.5 text-muted-foreground" />
|
||||
</Select.ScrollUpButton>
|
||||
<Select.Viewport class="p-0.5">
|
||||
{#each pageItems as item (item.value)}
|
||||
<Select.Item
|
||||
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.ScrollUpButton
|
||||
class="flex w-full items-center justify-center py-0.5"
|
||||
>
|
||||
<ChevronUp
|
||||
class="size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</Select.ScrollUpButton>
|
||||
<Select.Viewport class="p-0.5">
|
||||
{#each pageItems as item (item.value)}
|
||||
<Select.Item
|
||||
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
|
||||
justify-center px-3 text-sm tabular-nums
|
||||
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
|
||||
data-[selected]:font-semibold"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
|
||||
<ChevronDown class="size-3.5 text-muted-foreground" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<!-- Side slot: navigable page button or invisible placeholder -->
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-9 h-9
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton
|
||||
class="flex w-full items-center justify-center py-0.5"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<!-- Side slot: navigable page button or invisible placeholder -->
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-9 h-9
|
||||
rounded-md text-sm tabular-nums
|
||||
text-muted-foreground
|
||||
hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors
|
||||
cursor-pointer select-none
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
|
||||
{isSlotVisible(page) ? '' : 'invisible pointer-events-none'}"
|
||||
onclick={() => goToPage(page)}
|
||||
aria-label="Go to page {page}"
|
||||
aria-hidden={!isSlotVisible(page)}
|
||||
tabindex={isSlotVisible(page) ? 0 : -1}
|
||||
disabled={!isSlotVisible(page)}
|
||||
use:slideIn={direction}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
{!isSlotVisible(page)
|
||||
? 'invisible'
|
||||
: loading
|
||||
? 'opacity-40'
|
||||
: ''}
|
||||
{!isSlotVisible(page) || loading
|
||||
? 'pointer-events-none'
|
||||
: ''}"
|
||||
onclick={() => goToPage(page)}
|
||||
aria-label="Go to page {page}"
|
||||
aria-hidden={!isSlotVisible(page)}
|
||||
tabindex={isSlotVisible(page) ? 0 : -1}
|
||||
disabled={!isSlotVisible(page) || loading}
|
||||
use:slideIn={direction}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<!-- Right zone: spacer for centering -->
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
<!-- Right zone: spacer for centering -->
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
{:else if totalCount > 0}
|
||||
<!-- Single page: just show the count, no pagination controls -->
|
||||
<div class="flex items-start text-xs -mt-3 pl-2">
|
||||
<span class="text-muted-foreground">
|
||||
Showing {formatNumber(start)}–{formatNumber(end)} of {formatNumber(totalCount)} courses
|
||||
</span>
|
||||
</div>
|
||||
<!-- Single page: just show the count, no pagination controls -->
|
||||
<div class="flex items-start text-xs mt-2 pl-2">
|
||||
<span class="text-muted-foreground">
|
||||
Showing {formatNumber(start)}–{formatNumber(end)} of {formatNumber(
|
||||
totalCount,
|
||||
)} courses
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
valueLow = $bindable<number | null>(null),
|
||||
valueHigh = $bindable<number | null>(null),
|
||||
label,
|
||||
formatValue = (v: number) => String(v),
|
||||
dual = true,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
valueLow: number | null;
|
||||
valueHigh: number | null;
|
||||
label: string;
|
||||
formatValue?: (v: number) => string;
|
||||
dual?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Internal slider values — full range when filter is null (inactive)
|
||||
let internalLow = $state(0);
|
||||
let internalHigh = $state(0);
|
||||
|
||||
// Sync external → internal when props change (e.g., reset)
|
||||
$effect(() => {
|
||||
internalLow = valueLow ?? min;
|
||||
internalHigh = valueHigh ?? max;
|
||||
});
|
||||
|
||||
// Whether the slider is at its default (full range) position
|
||||
const isDefault = $derived(internalLow === min && internalHigh === max);
|
||||
|
||||
function commitLow(value: number) {
|
||||
internalLow = value;
|
||||
// At full range = no filter
|
||||
if (value === min && internalHigh === max) {
|
||||
valueLow = null;
|
||||
valueHigh = null;
|
||||
} else {
|
||||
valueLow = value;
|
||||
if (valueHigh === null) valueHigh = internalHigh;
|
||||
}
|
||||
}
|
||||
|
||||
function commitHigh(value: number) {
|
||||
internalHigh = value;
|
||||
if (internalLow === min && value === max) {
|
||||
valueLow = null;
|
||||
valueHigh = null;
|
||||
} else {
|
||||
valueHigh = value;
|
||||
if (valueLow === null) valueLow = internalLow;
|
||||
}
|
||||
}
|
||||
|
||||
function commitSingle(value: number) {
|
||||
internalHigh = value;
|
||||
valueHigh = value === 0 ? null : value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{#if !isDefault}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{#if dual}
|
||||
{formatValue(internalLow)} – {formatValue(internalHigh)}
|
||||
{:else}
|
||||
≤ {formatValue(internalHigh)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dual}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
{min}
|
||||
max={internalHigh}
|
||||
{step}
|
||||
value={internalLow}
|
||||
oninput={(e) => commitLow(Number(e.currentTarget.value))}
|
||||
class="flex-1 accent-primary h-1.5"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={internalLow}
|
||||
{max}
|
||||
{step}
|
||||
value={internalHigh}
|
||||
oninput={(e) => commitHigh(Number(e.currentTarget.value))}
|
||||
class="flex-1 accent-primary h-1.5"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
{max}
|
||||
{step}
|
||||
value={internalHigh}
|
||||
oninput={(e) => commitSingle(Number(e.currentTarget.value))}
|
||||
class="w-full accent-primary h-1.5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { cn, tooltipContentClass } from "$lib/utils";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
delay,
|
||||
side = "top",
|
||||
sideOffset = 6,
|
||||
passthrough = false,
|
||||
triggerClass = "",
|
||||
contentClass = "",
|
||||
portal = true,
|
||||
avoidCollisions = true,
|
||||
collisionPadding = 8,
|
||||
children,
|
||||
content,
|
||||
}: {
|
||||
delay?: number;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
sideOffset?: number;
|
||||
passthrough?: boolean;
|
||||
triggerClass?: string;
|
||||
contentClass?: string;
|
||||
portal?: boolean;
|
||||
avoidCollisions?: boolean;
|
||||
collisionPadding?: number;
|
||||
children: Snippet;
|
||||
content: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span class={triggerClass} {...props}>
|
||||
{@render children()}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
{#if portal}
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
{sideOffset}
|
||||
{avoidCollisions}
|
||||
{collisionPadding}
|
||||
class={cn(tooltipContentClass, contentClass)}
|
||||
>
|
||||
{@render content()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
{:else}
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
{sideOffset}
|
||||
{avoidCollisions}
|
||||
{collisionPadding}
|
||||
class={cn(tooltipContentClass, contentClass)}
|
||||
>
|
||||
{@render content()}
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from "@lucide/svelte";
|
||||
import { Popover } from "bits-ui";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
let {
|
||||
days = $bindable<string[]>([]),
|
||||
timeStart = $bindable<string | null>(null),
|
||||
timeEnd = $bindable<string | null>(null),
|
||||
}: {
|
||||
days: string[];
|
||||
timeStart: string | null;
|
||||
timeEnd: string | null;
|
||||
} = $props();
|
||||
|
||||
const DAY_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "M", value: "monday" },
|
||||
{ label: "T", value: "tuesday" },
|
||||
{ label: "W", value: "wednesday" },
|
||||
{ label: "Th", value: "thursday" },
|
||||
{ label: "F", value: "friday" },
|
||||
{ label: "Sa", value: "saturday" },
|
||||
{ label: "Su", value: "sunday" },
|
||||
];
|
||||
|
||||
const hasActiveFilters = $derived(days.length > 0 || timeStart !== null || timeEnd !== null);
|
||||
|
||||
function toggleDay(day: string) {
|
||||
if (days.includes(day)) {
|
||||
days = days.filter((d) => d !== day);
|
||||
} else {
|
||||
days = [...days, day];
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert "10:00 AM" or "14:30" input to 24h string like "1000" or "1430" */
|
||||
function parseTimeInput(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
// Try HH:MM AM/PM format
|
||||
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
||||
if (ampmMatch) {
|
||||
let hours = parseInt(ampmMatch[1], 10);
|
||||
const minutes = parseInt(ampmMatch[2], 10);
|
||||
const period = ampmMatch[3].toUpperCase();
|
||||
if (period === "PM" && hours !== 12) hours += 12;
|
||||
if (period === "AM" && hours === 12) hours = 0;
|
||||
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Try HH:MM 24h format
|
||||
const militaryMatch = trimmed.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (militaryMatch) {
|
||||
const hours = parseInt(militaryMatch[1], 10);
|
||||
const minutes = parseInt(militaryMatch[2], 10);
|
||||
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Convert 24h string like "1000" to "10:00 AM" for display */
|
||||
function formatTime(time: string | null): string {
|
||||
if (time === null || time.length !== 4) return "";
|
||||
const hours = parseInt(time.slice(0, 2), 10);
|
||||
const minutes = time.slice(2);
|
||||
const period = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
return `${displayHours}:${minutes} ${period}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
|
||||
{hasActiveFilters
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||
>
|
||||
{#if hasActiveFilters}
|
||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
Schedule
|
||||
<ChevronDown class="size-3" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-72"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">Days of week</span>
|
||||
<div class="flex gap-1">
|
||||
{#each DAY_OPTIONS as { label, value } (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer min-w-[2rem]
|
||||
{days.includes(value)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => toggleDay(value)}
|
||||
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
aria-pressed={days.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">Time range</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="10:00 AM"
|
||||
value={formatTime(timeStart)}
|
||||
onchange={(e) => {
|
||||
timeStart = parseTimeInput(e.currentTarget.value);
|
||||
e.currentTarget.value = formatTime(timeStart);
|
||||
}}
|
||||
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">to</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="3:00 PM"
|
||||
value={formatTime(timeEnd)}
|
||||
onchange={(e) => {
|
||||
timeEnd = parseTimeInput(e.currentTarget.value);
|
||||
e.currentTarget.value = formatTime(timeEnd);
|
||||
}}
|
||||
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Subject, Term } from "$lib/api";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
import type { CodeDescription, Subject, Term } from "$lib/api";
|
||||
import AttributesPopover from "./AttributesPopover.svelte";
|
||||
import MorePopover from "./MorePopover.svelte";
|
||||
import SchedulePopover from "./SchedulePopover.svelte";
|
||||
import StatusPopover from "./StatusPopover.svelte";
|
||||
import SubjectCombobox from "./SubjectCombobox.svelte";
|
||||
import TermCombobox from "./TermCombobox.svelte";
|
||||
|
||||
@@ -11,6 +14,21 @@ let {
|
||||
selectedSubjects = $bindable(),
|
||||
query = $bindable(),
|
||||
openOnly = $bindable(),
|
||||
waitCountMax = $bindable(),
|
||||
days = $bindable(),
|
||||
timeStart = $bindable(),
|
||||
timeEnd = $bindable(),
|
||||
instructionalMethod = $bindable(),
|
||||
campus = $bindable(),
|
||||
partOfTerm = $bindable(),
|
||||
attributes = $bindable(),
|
||||
creditHourMin = $bindable(),
|
||||
creditHourMax = $bindable(),
|
||||
instructor = $bindable(),
|
||||
courseNumberLow = $bindable(),
|
||||
courseNumberHigh = $bindable(),
|
||||
referenceData,
|
||||
ranges,
|
||||
}: {
|
||||
terms: Term[];
|
||||
subjects: Subject[];
|
||||
@@ -18,9 +36,34 @@ let {
|
||||
selectedSubjects: string[];
|
||||
query: string;
|
||||
openOnly: boolean;
|
||||
waitCountMax: number | null;
|
||||
days: string[];
|
||||
timeStart: string | null;
|
||||
timeEnd: string | null;
|
||||
instructionalMethod: string[];
|
||||
campus: string[];
|
||||
partOfTerm: string[];
|
||||
attributes: string[];
|
||||
creditHourMin: number | null;
|
||||
creditHourMax: number | null;
|
||||
instructor: string;
|
||||
courseNumberLow: number | null;
|
||||
courseNumberHigh: number | null;
|
||||
referenceData: {
|
||||
instructionalMethods: CodeDescription[];
|
||||
campuses: CodeDescription[];
|
||||
partsOfTerm: CodeDescription[];
|
||||
attributes: CodeDescription[];
|
||||
};
|
||||
ranges: {
|
||||
courseNumber: { min: number; max: number };
|
||||
creditHours: { min: number; max: number };
|
||||
waitCount: { max: number };
|
||||
};
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- Row 1: Primary filters -->
|
||||
<div class="flex flex-wrap gap-3 items-start">
|
||||
<TermCombobox {terms} bind:value={selectedTerm} />
|
||||
|
||||
@@ -35,11 +78,25 @@ let {
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
|
||||
transition-colors"
|
||||
/>
|
||||
|
||||
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
|
||||
<label class="flex items-center gap-1.5 h-9 text-sm text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" bind:checked={openOnly} />
|
||||
Open only
|
||||
</label>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Category popovers -->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<StatusPopover bind:openOnly bind:waitCountMax waitCountMaxRange={ranges.waitCount.max} />
|
||||
<SchedulePopover bind:days bind:timeStart bind:timeEnd />
|
||||
<AttributesPopover
|
||||
bind:instructionalMethod
|
||||
bind:campus
|
||||
bind:partOfTerm
|
||||
bind:attributes
|
||||
{referenceData}
|
||||
/>
|
||||
<MorePopover
|
||||
bind:creditHourMin
|
||||
bind:creditHourMax
|
||||
bind:instructor
|
||||
bind:courseNumberLow
|
||||
bind:courseNumberHigh
|
||||
ranges={{ courseNumber: ranges.courseNumber, creditHours: ranges.creditHours }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import { relativeTime } from "$lib/time";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import { onMount } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
export interface SearchMeta {
|
||||
totalCount: number;
|
||||
@@ -10,7 +11,7 @@ export interface SearchMeta {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { meta }: { meta: SearchMeta | null } = $props();
|
||||
let { meta, loading = false }: { meta: SearchMeta | null; loading?: boolean } = $props();
|
||||
|
||||
let now = $state(new Date());
|
||||
|
||||
@@ -51,18 +52,26 @@ onMount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<SimpleTooltip
|
||||
text={tooltipText}
|
||||
contentClass="whitespace-nowrap text-[12px] px-2 py-1"
|
||||
triggerClass="self-start"
|
||||
sideOffset={0}
|
||||
>
|
||||
<span
|
||||
class="pl-1 text-xs transition-opacity duration-200"
|
||||
style:opacity={meta ? 1 : 0}
|
||||
{#if meta}
|
||||
<SimpleTooltip
|
||||
text={tooltipText}
|
||||
contentClass="whitespace-nowrap text-[12px]/1 px-2"
|
||||
sideOffset={0}
|
||||
>
|
||||
<span class="text-muted-foreground/70">{countLabel}</span>
|
||||
<span class="text-muted-foreground/35">{resultNoun} in</span>
|
||||
<span class="text-muted-foreground/70">{durationLabel}</span>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
<span
|
||||
class="pl-1 text-xs transition-opacity duration-200 {loading
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
in:fade={{ duration: 300 }}
|
||||
>
|
||||
<span class="text-muted-foreground/70">{countLabel}</span>
|
||||
<span class="text-muted-foreground/35">{resultNoun} in</span>
|
||||
<span class="text-muted-foreground/70">{durationLabel}</span>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
{:else}
|
||||
<!-- Invisible placeholder to maintain layout height -->
|
||||
<span class="pl-1 text-xs opacity-0 pointer-events-none" aria-hidden="true"
|
||||
> </span
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { X } from "@lucide/svelte";
|
||||
|
||||
let {
|
||||
segments,
|
||||
onRemoveSegment,
|
||||
onRemoveAll,
|
||||
}: {
|
||||
segments: string[];
|
||||
onRemoveSegment: (segment: string) => void;
|
||||
onRemoveAll: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if segments.length > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs text-foreground"
|
||||
>
|
||||
{#each segments as segment, i}
|
||||
{#if i > 0}
|
||||
<span class="w-px self-stretch bg-border"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-0.5 hover:bg-muted/60 transition-colors cursor-pointer first:rounded-l-full"
|
||||
onclick={() => onRemoveSegment(segment)}
|
||||
aria-label="Remove {segment} filter"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="w-px self-stretch bg-border"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center px-1.5 py-0.5 rounded-r-full hover:bg-muted/60 transition-colors cursor-pointer"
|
||||
onclick={onRemoveAll}
|
||||
aria-label="Remove all subject filters"
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -11,6 +11,9 @@ let {
|
||||
triggerClass = "",
|
||||
contentClass = "",
|
||||
sideOffset = 6,
|
||||
portal = true,
|
||||
avoidCollisions = true,
|
||||
collisionPadding = 8,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
@@ -20,6 +23,9 @@ let {
|
||||
triggerClass?: string;
|
||||
contentClass?: string;
|
||||
sideOffset?: number;
|
||||
portal?: boolean;
|
||||
avoidCollisions?: boolean;
|
||||
collisionPadding?: number;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -32,11 +38,27 @@ let {
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
{sideOffset}
|
||||
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
|
||||
>
|
||||
{text}
|
||||
</Tooltip.Content>
|
||||
{#if portal}
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
{sideOffset}
|
||||
{avoidCollisions}
|
||||
{collisionPadding}
|
||||
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
|
||||
>
|
||||
{text}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
{:else}
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
{sideOffset}
|
||||
{avoidCollisions}
|
||||
{collisionPadding}
|
||||
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
|
||||
>
|
||||
{text}
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from "@lucide/svelte";
|
||||
import { Popover } from "bits-ui";
|
||||
import { fly } from "svelte/transition";
|
||||
import RangeSlider from "./RangeSlider.svelte";
|
||||
|
||||
let {
|
||||
openOnly = $bindable(false),
|
||||
waitCountMax = $bindable<number | null>(null),
|
||||
waitCountMaxRange = 0,
|
||||
}: {
|
||||
openOnly: boolean;
|
||||
waitCountMax: number | null;
|
||||
waitCountMaxRange: number;
|
||||
} = $props();
|
||||
|
||||
let _dummyLow = $state<number | null>(null);
|
||||
|
||||
const hasActiveFilters = $derived(openOnly || waitCountMax !== null);
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
|
||||
{hasActiveFilters
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||
>
|
||||
{#if hasActiveFilters}
|
||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
Status
|
||||
<ChevronDown class="size-3" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-64"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">Availability</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer
|
||||
{openOnly
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => (openOnly = !openOnly)}
|
||||
>
|
||||
Open only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
{#if waitCountMaxRange > 0}
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={waitCountMaxRange}
|
||||
step={10}
|
||||
bind:valueLow={_dummyLow}
|
||||
bind:valueHigh={waitCountMax}
|
||||
label="Max waitlist"
|
||||
dual={false}
|
||||
formatValue={(v) => v === 0 ? "Off" : String(v)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">Max waitlist</span>
|
||||
<span class="text-xs text-muted-foreground">No waitlisted courses</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Utilities for ISO-8601 date string validation and conversion.
|
||||
*
|
||||
* All DateTime<Utc> fields from Rust are serialized as ISO-8601 strings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid ISO-8601 date string.
|
||||
*
|
||||
* @param value - The string to validate
|
||||
* @returns True if the string is a valid ISO-8601 date
|
||||
*/
|
||||
export function isValidISODate(value: string): boolean {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
return !isNaN(date.getTime()) && date.toISOString() === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO-8601 date string to a Date object.
|
||||
*
|
||||
* @param value - The ISO-8601 string to parse
|
||||
* @returns Date object, or null if invalid
|
||||
*/
|
||||
export function parseISODate(value: string): Date | null {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a string is a valid ISO-8601 date, throwing if not.
|
||||
*
|
||||
* @param value - The string to validate
|
||||
* @param fieldName - Name of the field for error messages
|
||||
* @throws Error if the string is not a valid ISO-8601 date
|
||||
*/
|
||||
export function assertISODate(value: string, fieldName = "date"): void {
|
||||
if (!isValidISODate(value)) {
|
||||
throw new Error(`Invalid ISO-8601 date for ${fieldName}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Date to an ISO-8601 UTC string.
|
||||
*
|
||||
* @param date - The Date object to convert
|
||||
* @returns ISO-8601 string in UTC (e.g., "2024-01-15T10:30:00Z")
|
||||
*/
|
||||
export function toISOString(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* the missing segments when the view expands into unloaded territory.
|
||||
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
|
||||
*/
|
||||
import { type TimelineRange, client } from "$lib/api";
|
||||
import { type TimeRange, client } from "$lib/api";
|
||||
import { SLOT_INTERVAL_MS } from "./constants";
|
||||
import type { TimeSlot } from "./types";
|
||||
|
||||
@@ -74,7 +74,7 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
|
||||
* Converts gap ranges into the API request format.
|
||||
*/
|
||||
async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
|
||||
const ranges: TimelineRange[] = gaps.map(([start, end]) => ({
|
||||
const ranges: TimeRange[] = gaps.map(([start, end]) => ({
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
}));
|
||||
@@ -83,7 +83,9 @@ async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
|
||||
|
||||
return response.slots.map((slot) => ({
|
||||
time: new Date(slot.time),
|
||||
subjects: slot.subjects,
|
||||
subjects: Object.fromEntries(
|
||||
Object.entries(slot.subjects).map(([k, v]) => [k, Number(v)])
|
||||
) as Record<string, number>,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+5
-22
@@ -1,21 +1,4 @@
|
||||
import type { ScrapeJob } from "$lib/api";
|
||||
|
||||
export type ScrapeJobStatus = "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
|
||||
|
||||
export type ScrapeJobEvent =
|
||||
| { type: "init"; jobs: ScrapeJob[] }
|
||||
| { type: "jobCreated"; job: ScrapeJob }
|
||||
| { type: "jobLocked"; id: number; lockedAt: string; status: ScrapeJobStatus }
|
||||
| { type: "jobCompleted"; id: number }
|
||||
| {
|
||||
type: "jobRetried";
|
||||
id: number;
|
||||
retryCount: number;
|
||||
queuedAt: string;
|
||||
status: ScrapeJobStatus;
|
||||
}
|
||||
| { type: "jobExhausted"; id: number }
|
||||
| { type: "jobDeleted"; id: number };
|
||||
import type { ScrapeJobDto, ScrapeJobEvent } from "$lib/bindings";
|
||||
|
||||
export type ConnectionState = "connected" | "reconnecting" | "disconnected";
|
||||
|
||||
@@ -29,7 +12,7 @@ const PRIORITY_ORDER: Record<string, number> = {
|
||||
const MAX_RECONNECT_DELAY = 30_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
|
||||
function sortJobs(jobs: Iterable<ScrapeJob>): ScrapeJob[] {
|
||||
function sortJobs(jobs: Iterable<ScrapeJobDto>): ScrapeJobDto[] {
|
||||
return Array.from(jobs).sort((a, b) => {
|
||||
const pa = PRIORITY_ORDER[a.priority.toLowerCase()] ?? 2;
|
||||
const pb = PRIORITY_ORDER[b.priority.toLowerCase()] ?? 2;
|
||||
@@ -40,7 +23,7 @@ function sortJobs(jobs: Iterable<ScrapeJob>): ScrapeJob[] {
|
||||
|
||||
export class ScrapeJobsStore {
|
||||
private ws: WebSocket | null = null;
|
||||
private jobs = new Map<number, ScrapeJob>();
|
||||
private jobs = new Map<number, ScrapeJobDto>();
|
||||
private _connectionState: ConnectionState = "disconnected";
|
||||
private _initialized = false;
|
||||
private onUpdate: () => void;
|
||||
@@ -49,14 +32,14 @@ export class ScrapeJobsStore {
|
||||
private intentionalClose = false;
|
||||
|
||||
/** Cached sorted array, invalidated on data mutations. */
|
||||
private cachedJobs: ScrapeJob[] = [];
|
||||
private cachedJobs: ScrapeJobDto[] = [];
|
||||
private cacheDirty = false;
|
||||
|
||||
constructor(onUpdate: () => void) {
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
getJobs(): ScrapeJob[] {
|
||||
getJobs(): ScrapeJobDto[] {
|
||||
if (this.cacheDirty) {
|
||||
this.cachedJobs = sortJobs(this.jobs.values());
|
||||
this.cacheDirty = false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { type AdminStatus, client } from "$lib/api";
|
||||
import { type AdminStatusResponse, client } from "$lib/api";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let status = $state<AdminStatus | null>(null);
|
||||
let status = $state<AdminStatusResponse | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { type ScrapeJob, client } from "$lib/api";
|
||||
import { type ScrapeJobDto, client } from "$lib/api";
|
||||
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
|
||||
import { formatAbsoluteDate } from "$lib/date";
|
||||
import { formatDuration } from "$lib/time";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@tanstack/table-core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let jobs = $state<ScrapeJob[]>([]);
|
||||
let jobs = $state<ScrapeJobDto[]>([]);
|
||||
let connectionState = $state<ConnectionState>("disconnected");
|
||||
let initialized = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -97,7 +97,7 @@ function handleSortingChange(updater: Updater<SortingState>) {
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
function formatJobDetails(job: ScrapeJob, subjects: Map<string, string>): string {
|
||||
function formatJobDetails(job: ScrapeJobDto, subjects: Map<string, string>): string {
|
||||
const payload = job.targetPayload as Record<string, unknown>;
|
||||
switch (job.targetType) {
|
||||
case "Subject": {
|
||||
@@ -169,7 +169,7 @@ function overdueDurationColor(ms: number): string {
|
||||
|
||||
// --- Table columns ---
|
||||
|
||||
const columns: ColumnDef<ScrapeJob, unknown>[] = [
|
||||
const columns: ColumnDef<ScrapeJobDto, unknown>[] = [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
@@ -265,7 +265,7 @@ const skeletonWidths: Record<string, string> = {
|
||||
// Unified timing display: shows the most relevant duration for the job's current state.
|
||||
// Uses _tick dependency so Svelte re-evaluates every second.
|
||||
function getTimingDisplay(
|
||||
job: ScrapeJob,
|
||||
job: ScrapeJobDto,
|
||||
_tick: number
|
||||
): { text: string; colorClass: string; icon: "warning" | "none"; tooltip: string } {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -71,12 +71,14 @@ function scheduleTick() {
|
||||
const MIN_INTERVAL = 5_000;
|
||||
const MAX_INTERVAL = 60_000;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let destroyed = false;
|
||||
|
||||
const MIN_SPIN_MS = 700;
|
||||
let spinnerVisible = $state(false);
|
||||
let spinHoldTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function fetchAll() {
|
||||
if (destroyed) return;
|
||||
refreshError = false;
|
||||
spinnerVisible = true;
|
||||
clearTimeout(spinHoldTimer);
|
||||
@@ -88,16 +90,19 @@ async function fetchAll() {
|
||||
client.getScraperTimeseries(selectedPeriod),
|
||||
client.getScraperSubjects(),
|
||||
]);
|
||||
if (destroyed) return;
|
||||
stats = statsRes;
|
||||
timeseries = timeseriesRes;
|
||||
subjects = subjectsRes.subjects;
|
||||
error = null;
|
||||
refreshInterval = MIN_INTERVAL;
|
||||
} catch (e) {
|
||||
if (destroyed) return;
|
||||
error = e instanceof Error ? e.message : "Failed to load scraper data";
|
||||
refreshError = true;
|
||||
refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL);
|
||||
} finally {
|
||||
if (destroyed) return;
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const remaining = MIN_SPIN_MS - elapsed;
|
||||
if (remaining > 0) {
|
||||
@@ -112,6 +117,7 @@ async function fetchAll() {
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (destroyed) return;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(fetchAll, refreshInterval);
|
||||
}
|
||||
@@ -302,20 +308,27 @@ const detailGridCols = "grid-cols-[7fr_5fr_3fr_4fr_4fr_3fr_4fr_minmax(6rem,1fr)]
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMount(() => {
|
||||
destroyed = false;
|
||||
mounted = true;
|
||||
fetchAll();
|
||||
scheduleTick();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
mounted = false;
|
||||
clearTimeout(tickTimer);
|
||||
clearTimeout(refreshTimer);
|
||||
clearTimeout(spinHoldTimer);
|
||||
});
|
||||
|
||||
// Refetch when period changes
|
||||
// Refetch when period changes (skip initial run since onMount handles it)
|
||||
let mounted = false;
|
||||
$effect(() => {
|
||||
void selectedPeriod;
|
||||
fetchAll();
|
||||
if (mounted && !destroyed) {
|
||||
fetchAll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ onMount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Provider delayDuration={150} skipDelayDuration={50}>
|
||||
<div class="relative flex min-h-screen flex-col">
|
||||
<!-- pointer-events-none so the navbar doesn't block canvas interactions;
|
||||
NavBar re-enables pointer-events on its own container. -->
|
||||
|
||||
+682
-102
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
type CodeDescription,
|
||||
type SearchOptionsResponse,
|
||||
type SearchResponse,
|
||||
type SortColumn,
|
||||
type SortDirection,
|
||||
@@ -8,12 +10,17 @@ import {
|
||||
client,
|
||||
} from "$lib/api";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import FilterChip from "$lib/components/FilterChip.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte";
|
||||
import type { SortingState } from "@tanstack/table-core";
|
||||
import { untrack } from "svelte";
|
||||
import SegmentedChip from "$lib/components/SegmentedChip.svelte";
|
||||
import { Check, Columns3, RotateCcw } from "@lucide/svelte";
|
||||
import type { SortingState, VisibilityState } from "@tanstack/table-core";
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import { tick, untrack } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -21,12 +28,23 @@ let { data } = $props();
|
||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||
|
||||
// The default term is the first one returned by the backend (most current)
|
||||
const defaultTermSlug = data.terms[0]?.slug ?? "";
|
||||
const defaultTermSlug = untrack(() => data.searchOptions?.terms[0]?.slug ?? "");
|
||||
|
||||
// Helper to parse a URL param as a number or null
|
||||
function parseNumParam(key: string): number | null {
|
||||
const v = initialParams.get(key);
|
||||
if (v === null || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Default to the first term when no URL param is present
|
||||
const urlTerm = initialParams.get("term");
|
||||
let selectedTerm = $state(
|
||||
urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug
|
||||
untrack(() => {
|
||||
const terms = data.searchOptions?.terms ?? [];
|
||||
return urlTerm && terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug;
|
||||
})
|
||||
);
|
||||
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
|
||||
let query = $state(initialParams.get("q") ?? "");
|
||||
@@ -34,6 +52,52 @@ let openOnly = $state(initialParams.get("open") === "true");
|
||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||
const limit = 25;
|
||||
|
||||
// New filter state from URL
|
||||
let waitCountMax = $state<number | null>(parseNumParam("wait_count_max"));
|
||||
let days: string[] = $state(initialParams.getAll("days"));
|
||||
let timeStart = $state<string | null>(initialParams.get("time_start"));
|
||||
let timeEnd = $state<string | null>(initialParams.get("time_end"));
|
||||
let instructionalMethod: string[] = $state(initialParams.getAll("instructional_method"));
|
||||
let campus: string[] = $state(initialParams.getAll("campus"));
|
||||
let partOfTerm: string[] = $state(initialParams.getAll("part_of_term"));
|
||||
let attributes: string[] = $state(initialParams.getAll("attributes"));
|
||||
let creditHourMin = $state<number | null>(parseNumParam("credit_hour_min"));
|
||||
let creditHourMax = $state<number | null>(parseNumParam("credit_hour_max"));
|
||||
let instructor = $state(initialParams.get("instructor") ?? "");
|
||||
let courseNumberLow = $state<number | null>(parseNumParam("course_number_low"));
|
||||
let courseNumberHigh = $state<number | null>(parseNumParam("course_number_high"));
|
||||
|
||||
let searchOptions = $state<SearchOptionsResponse | null>(null);
|
||||
|
||||
// Sync data prop to local state
|
||||
$effect(() => {
|
||||
searchOptions = data.searchOptions;
|
||||
});
|
||||
|
||||
// Derived from search options
|
||||
const terms = $derived(searchOptions?.terms ?? []);
|
||||
const subjects: Subject[] = $derived(searchOptions?.subjects ?? []);
|
||||
let subjectMap: Record<string, string> = $derived(
|
||||
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
|
||||
);
|
||||
|
||||
const referenceData = $derived({
|
||||
instructionalMethods: searchOptions?.reference.instructionalMethods ?? [],
|
||||
campuses: searchOptions?.reference.campuses ?? [],
|
||||
partsOfTerm: searchOptions?.reference.partsOfTerm ?? [],
|
||||
attributes: searchOptions?.reference.attributes ?? [],
|
||||
});
|
||||
|
||||
const ranges = $derived(
|
||||
searchOptions?.ranges ?? {
|
||||
courseNumberMin: 1000,
|
||||
courseNumberMax: 9000,
|
||||
creditHourMin: 0,
|
||||
creditHourMax: 8,
|
||||
waitCountMax: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Sorting state — maps TanStack column IDs to server sort params
|
||||
const SORT_COLUMN_MAP: Record<string, SortColumn> = {
|
||||
course_code: "course_code",
|
||||
@@ -58,47 +122,92 @@ function handleSortingChange(newSorting: SortingState) {
|
||||
}
|
||||
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let subjectMap: Record<string, string> = $derived(
|
||||
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
|
||||
);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let searchMeta: SearchMeta | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Fetch subjects when term changes
|
||||
// Track if we're validating subjects to prevent cascading search
|
||||
let validatingSubjects = false;
|
||||
|
||||
// Fetch new search options when term changes
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
if (!term) return;
|
||||
client
|
||||
.getSubjects(term)
|
||||
.then((s) => {
|
||||
subjects = s;
|
||||
const validCodes = new Set(s.map((sub) => sub.code));
|
||||
selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
|
||||
.getSearchOptions(term)
|
||||
.then((opts) => {
|
||||
searchOptions = opts;
|
||||
// Validate selected subjects against new term's subjects
|
||||
const validCodes = new Set(opts.subjects.map((s) => s.code));
|
||||
const filtered = selectedSubjects.filter((code) => validCodes.has(code));
|
||||
if (filtered.length !== selectedSubjects.length) {
|
||||
validatingSubjects = true;
|
||||
selectedSubjects = filtered;
|
||||
validatingSubjects = false;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to fetch subjects:", e);
|
||||
console.error("Failed to fetch search options:", e);
|
||||
});
|
||||
});
|
||||
|
||||
// Centralized throttle configuration - maps trigger source to throttle delay (ms)
|
||||
const THROTTLE_MS = {
|
||||
term: 0, // Immediate
|
||||
subjects: 100, // Short delay for combobox selection
|
||||
query: 300, // Standard input debounce
|
||||
openOnly: 0, // Immediate
|
||||
offset: 0, // Immediate (pagination)
|
||||
sorting: 0, // Immediate (column sort)
|
||||
term: 0,
|
||||
subjects: 100,
|
||||
query: 300,
|
||||
openOnly: 0,
|
||||
offset: 0,
|
||||
sorting: 0,
|
||||
waitCountMax: 300,
|
||||
days: 100,
|
||||
timeStart: 300,
|
||||
timeEnd: 300,
|
||||
instructionalMethod: 100,
|
||||
campus: 100,
|
||||
partOfTerm: 100,
|
||||
attributes: 100,
|
||||
creditHourMin: 300,
|
||||
creditHourMax: 300,
|
||||
instructor: 300,
|
||||
courseNumberLow: 300,
|
||||
courseNumberHigh: 300,
|
||||
} as const;
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastSearchKey = "";
|
||||
|
||||
function buildSearchKey(): string {
|
||||
return [
|
||||
selectedTerm,
|
||||
selectedSubjects.join(","),
|
||||
query,
|
||||
openOnly,
|
||||
offset,
|
||||
JSON.stringify(sorting),
|
||||
waitCountMax,
|
||||
days.join(","),
|
||||
timeStart,
|
||||
timeEnd,
|
||||
instructionalMethod.join(","),
|
||||
campus.join(","),
|
||||
partOfTerm.join(","),
|
||||
attributes.join(","),
|
||||
creditHourMin,
|
||||
creditHourMax,
|
||||
instructor,
|
||||
courseNumberLow,
|
||||
courseNumberHigh,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function scheduleSearch(source: keyof typeof THROTTLE_MS) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting);
|
||||
const key = buildSearchKey();
|
||||
if (key === lastSearchKey) return;
|
||||
performSearch();
|
||||
}, THROTTLE_MS[source]);
|
||||
}
|
||||
|
||||
@@ -111,7 +220,9 @@ $effect(() => {
|
||||
|
||||
$effect(() => {
|
||||
selectedSubjects;
|
||||
scheduleSearch("subjects");
|
||||
if (!validatingSubjects) {
|
||||
scheduleSearch("subjects");
|
||||
}
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
@@ -139,66 +250,214 @@ $effect(() => {
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
waitCountMax;
|
||||
scheduleSearch("waitCountMax");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
days;
|
||||
scheduleSearch("days");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
timeStart;
|
||||
scheduleSearch("timeStart");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
timeEnd;
|
||||
scheduleSearch("timeEnd");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
instructionalMethod;
|
||||
scheduleSearch("instructionalMethod");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
campus;
|
||||
scheduleSearch("campus");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
partOfTerm;
|
||||
scheduleSearch("partOfTerm");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
attributes;
|
||||
scheduleSearch("attributes");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
creditHourMin;
|
||||
scheduleSearch("creditHourMin");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
creditHourMax;
|
||||
scheduleSearch("creditHourMax");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
instructor;
|
||||
scheduleSearch("instructor");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
courseNumberLow;
|
||||
scheduleSearch("courseNumberLow");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
courseNumberHigh;
|
||||
scheduleSearch("courseNumberHigh");
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
// Build a filter key that excludes offset/sorting — used to detect filter changes for offset reset
|
||||
function buildFilterKey(): string {
|
||||
return [
|
||||
selectedTerm,
|
||||
selectedSubjects.join(","),
|
||||
query,
|
||||
openOnly,
|
||||
waitCountMax,
|
||||
days.join(","),
|
||||
timeStart,
|
||||
timeEnd,
|
||||
instructionalMethod.join(","),
|
||||
campus.join(","),
|
||||
partOfTerm.join(","),
|
||||
attributes.join(","),
|
||||
creditHourMin,
|
||||
creditHourMax,
|
||||
instructor,
|
||||
courseNumberLow,
|
||||
courseNumberHigh,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
// Reset offset when filters change (not offset itself)
|
||||
let prevFilters = $state("");
|
||||
$effect(() => {
|
||||
const key = `${selectedTerm}|${selectedSubjects.join(",")}|${query}|${openOnly}`;
|
||||
const key = buildFilterKey();
|
||||
if (prevFilters && key !== prevFilters) {
|
||||
offset = 0;
|
||||
}
|
||||
prevFilters = key;
|
||||
});
|
||||
|
||||
async function performSearch(
|
||||
term: string,
|
||||
subjects: string[],
|
||||
q: string,
|
||||
open: boolean,
|
||||
off: number,
|
||||
sort: SortingState
|
||||
) {
|
||||
if (!term) return;
|
||||
async function performSearch() {
|
||||
if (!selectedTerm) return;
|
||||
const key = buildSearchKey();
|
||||
lastSearchKey = key;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
|
||||
const sortBy = sorting.length > 0 ? SORT_COLUMN_MAP[sorting[0].id] : undefined;
|
||||
const sortDir: SortDirection | undefined =
|
||||
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
|
||||
sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : undefined;
|
||||
|
||||
// Build URL params for browser URL sync
|
||||
const params = new URLSearchParams();
|
||||
for (const s of subjects) {
|
||||
for (const s of selectedSubjects) {
|
||||
params.append("subject", s);
|
||||
}
|
||||
if (q) params.set("q", q);
|
||||
if (open) params.set("open", "true");
|
||||
if (off > 0) params.set("offset", String(off));
|
||||
if (query) params.set("q", query);
|
||||
if (openOnly) params.set("open", "true");
|
||||
if (offset > 0) params.set("offset", String(offset));
|
||||
if (sortBy) params.set("sort_by", sortBy);
|
||||
if (sortDir && sortBy) params.set("sort_dir", sortDir);
|
||||
if (waitCountMax !== null) params.set("wait_count_max", String(waitCountMax));
|
||||
for (const d of days) params.append("days", d);
|
||||
if (timeStart) params.set("time_start", timeStart);
|
||||
if (timeEnd) params.set("time_end", timeEnd);
|
||||
for (const m of instructionalMethod) params.append("instructional_method", m);
|
||||
for (const c of campus) params.append("campus", c);
|
||||
for (const p of partOfTerm) params.append("part_of_term", p);
|
||||
for (const a of attributes) params.append("attributes", a);
|
||||
if (creditHourMin !== null) params.set("credit_hour_min", String(creditHourMin));
|
||||
if (creditHourMax !== null) params.set("credit_hour_max", String(creditHourMax));
|
||||
if (instructor) params.set("instructor", instructor);
|
||||
if (courseNumberLow !== null) params.set("course_number_low", String(courseNumberLow));
|
||||
if (courseNumberHigh !== null) params.set("course_number_high", String(courseNumberHigh));
|
||||
|
||||
// Include term in URL only when it differs from the default or other params are active
|
||||
const hasOtherParams = params.size > 0;
|
||||
if (term !== defaultTermSlug || hasOtherParams) {
|
||||
params.set("term", term);
|
||||
if (selectedTerm !== defaultTermSlug || hasOtherParams) {
|
||||
params.set("term", selectedTerm);
|
||||
}
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
goto(`?${params.toString()}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
searchResult = await client.searchCourses({
|
||||
term,
|
||||
subjects: subjects.length > 0 ? subjects : undefined,
|
||||
q: q || undefined,
|
||||
open_only: open || undefined,
|
||||
const result = await client.searchCourses({
|
||||
term: selectedTerm,
|
||||
subject: selectedSubjects.length > 0 ? selectedSubjects : [],
|
||||
q: query || undefined,
|
||||
openOnly: openOnly || false,
|
||||
limit,
|
||||
offset: off,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
offset,
|
||||
sortBy,
|
||||
sortDir,
|
||||
waitCountMax: waitCountMax ?? undefined,
|
||||
days: days.length > 0 ? days : undefined,
|
||||
timeStart: timeStart ?? undefined,
|
||||
timeEnd: timeEnd ?? undefined,
|
||||
instructionalMethod: instructionalMethod.length > 0 ? instructionalMethod : undefined,
|
||||
campus: campus.length > 0 ? campus : undefined,
|
||||
partOfTerm: partOfTerm.length > 0 ? partOfTerm : undefined,
|
||||
attributes: attributes.length > 0 ? attributes : undefined,
|
||||
creditHourMin: creditHourMin ?? undefined,
|
||||
creditHourMax: creditHourMax ?? undefined,
|
||||
instructor: instructor || undefined,
|
||||
courseNumberLow: courseNumberLow ?? undefined,
|
||||
courseNumberHigh: courseNumberHigh ?? undefined,
|
||||
});
|
||||
searchMeta = {
|
||||
totalCount: searchResult.totalCount,
|
||||
durationMs: performance.now() - t0,
|
||||
timestamp: new Date(),
|
||||
|
||||
const applyUpdate = () => {
|
||||
searchResult = result;
|
||||
searchMeta = {
|
||||
totalCount: result.totalCount,
|
||||
durationMs: performance.now() - t0,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
// Scoped view transitions only affect the table element, so filters and
|
||||
// other controls remain fully interactive. Document-level transitions
|
||||
// apply visibility:hidden to the entire page for the transition duration,
|
||||
// blocking all pointer interactions — so we skip those entirely and let
|
||||
// Svelte's animate:flip / in:fade handle the visual update instead.
|
||||
const tableEl = document.querySelector("[data-search-results]") as HTMLElement | null;
|
||||
|
||||
if (tableEl && "startViewTransition" in tableEl) {
|
||||
const transition = (tableEl as any).startViewTransition(async () => {
|
||||
applyUpdate();
|
||||
await tick();
|
||||
});
|
||||
await transition.updateCallbackDone;
|
||||
} else {
|
||||
applyUpdate();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Search failed";
|
||||
} finally {
|
||||
@@ -209,57 +468,378 @@ async function performSearch(
|
||||
function handlePageChange(newOffset: number) {
|
||||
offset = newOffset;
|
||||
}
|
||||
|
||||
// Column visibility state (lifted from CourseTable)
|
||||
let columnVisibility: VisibilityState = $state({});
|
||||
|
||||
function resetColumnVisibility() {
|
||||
columnVisibility = {};
|
||||
}
|
||||
|
||||
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
|
||||
|
||||
// All possible column IDs for the View dropdown
|
||||
const columnDefs = [
|
||||
{ id: "crn", label: "CRN" },
|
||||
{ id: "course_code", label: "Course" },
|
||||
{ id: "title", label: "Title" },
|
||||
{ id: "instructor", label: "Instructor" },
|
||||
{ id: "time", label: "Time" },
|
||||
{ id: "location", label: "Location" },
|
||||
{ id: "seats", label: "Seats" },
|
||||
];
|
||||
|
||||
// Chip helpers
|
||||
const DAY_ABBREV: Record<string, string> = {
|
||||
monday: "M",
|
||||
tuesday: "T",
|
||||
wednesday: "W",
|
||||
thursday: "Th",
|
||||
friday: "F",
|
||||
saturday: "Sa",
|
||||
sunday: "Su",
|
||||
};
|
||||
|
||||
function formatDaysChip(d: string[]): string {
|
||||
return d.map((day) => DAY_ABBREV[day] ?? day).join("");
|
||||
}
|
||||
|
||||
function formatTimeChip(start: string | null, end: string | null): string {
|
||||
const fmt = (t: string) => {
|
||||
if (t.length !== 4) return t;
|
||||
const h = parseInt(t.slice(0, 2), 10);
|
||||
const m = t.slice(2);
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const dh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${dh}:${m} ${period}`;
|
||||
};
|
||||
if (start && end) return `${fmt(start)} – ${fmt(end)}`;
|
||||
if (start) return `After ${fmt(start)}`;
|
||||
if (end) return `Before ${fmt(end)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatMultiChip(codes: string[], refItems: CodeDescription[]): string {
|
||||
const lookup = new Map(refItems.map((r) => [r.code, r.description]));
|
||||
const first = lookup.get(codes[0]) ?? codes[0];
|
||||
if (codes.length === 1) return first;
|
||||
return `${first} + ${codes.length - 1} more`;
|
||||
}
|
||||
|
||||
let activeFilterCount = $derived(
|
||||
(selectedSubjects.length > 0 ? 1 : 0) +
|
||||
(openOnly ? 1 : 0) +
|
||||
(waitCountMax !== null ? 1 : 0) +
|
||||
(days.length > 0 ? 1 : 0) +
|
||||
(timeStart !== null || timeEnd !== null ? 1 : 0) +
|
||||
(instructionalMethod.length > 0 ? 1 : 0) +
|
||||
(campus.length > 0 ? 1 : 0) +
|
||||
(partOfTerm.length > 0 ? 1 : 0) +
|
||||
(attributes.length > 0 ? 1 : 0) +
|
||||
(creditHourMin !== null || creditHourMax !== null ? 1 : 0) +
|
||||
(instructor !== "" ? 1 : 0) +
|
||||
(courseNumberLow !== null || courseNumberHigh !== null ? 1 : 0)
|
||||
);
|
||||
|
||||
function removeSubject(code: string) {
|
||||
selectedSubjects = selectedSubjects.filter((s) => s !== code);
|
||||
}
|
||||
|
||||
function clearAllSubjects() {
|
||||
selectedSubjects = [];
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
selectedSubjects = [];
|
||||
openOnly = false;
|
||||
waitCountMax = null;
|
||||
days = [];
|
||||
timeStart = null;
|
||||
timeEnd = null;
|
||||
instructionalMethod = [];
|
||||
campus = [];
|
||||
partOfTerm = [];
|
||||
attributes = [];
|
||||
creditHourMin = null;
|
||||
creditHourMax = null;
|
||||
instructor = "";
|
||||
courseNumberLow = null;
|
||||
courseNumberHigh = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center px-5 pb-5 pt-20">
|
||||
<div class="w-full max-w-6xl flex flex-col gap-6 pt-2">
|
||||
<div class="w-full max-w-6xl flex flex-col pt-2">
|
||||
<!-- Chips bar: status | chips | view button -->
|
||||
<div class="flex items-end gap-3 min-h-7">
|
||||
<SearchStatus meta={searchMeta} {loading} />
|
||||
|
||||
<!-- Search status + Filters -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<SearchStatus meta={searchMeta} />
|
||||
<!-- Filters -->
|
||||
<SearchFilters
|
||||
terms={data.terms}
|
||||
{subjects}
|
||||
bind:selectedTerm
|
||||
bind:selectedSubjects
|
||||
bind:query
|
||||
bind:openOnly
|
||||
/>
|
||||
<!-- Active filter chips -->
|
||||
<div
|
||||
class="flex items-center gap-1.5 flex-1 min-w-0 flex-wrap pb-1.5"
|
||||
>
|
||||
{#if selectedSubjects.length > 0}
|
||||
<SegmentedChip
|
||||
segments={selectedSubjects}
|
||||
onRemoveSegment={removeSubject}
|
||||
onRemoveAll={clearAllSubjects}
|
||||
/>
|
||||
{/if}
|
||||
{#if openOnly}
|
||||
<FilterChip
|
||||
label="Open only"
|
||||
onRemove={() => (openOnly = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if waitCountMax !== null}
|
||||
<FilterChip
|
||||
label="Waitlist ≤ {waitCountMax}"
|
||||
onRemove={() => (waitCountMax = null)}
|
||||
/>
|
||||
{/if}
|
||||
{#if days.length > 0}
|
||||
<FilterChip
|
||||
label={formatDaysChip(days)}
|
||||
onRemove={() => (days = [])}
|
||||
/>
|
||||
{/if}
|
||||
{#if timeStart !== null || timeEnd !== null}
|
||||
<FilterChip
|
||||
label={formatTimeChip(timeStart, timeEnd)}
|
||||
onRemove={() => {
|
||||
timeStart = null;
|
||||
timeEnd = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if instructionalMethod.length > 0}
|
||||
<FilterChip
|
||||
label={formatMultiChip(
|
||||
instructionalMethod,
|
||||
referenceData.instructionalMethods,
|
||||
)}
|
||||
onRemove={() => (instructionalMethod = [])}
|
||||
/>
|
||||
{/if}
|
||||
{#if campus.length > 0}
|
||||
<FilterChip
|
||||
label={formatMultiChip(campus, referenceData.campuses)}
|
||||
onRemove={() => (campus = [])}
|
||||
/>
|
||||
{/if}
|
||||
{#if partOfTerm.length > 0}
|
||||
<FilterChip
|
||||
label={formatMultiChip(
|
||||
partOfTerm,
|
||||
referenceData.partsOfTerm,
|
||||
)}
|
||||
onRemove={() => (partOfTerm = [])}
|
||||
/>
|
||||
{/if}
|
||||
{#if attributes.length > 0}
|
||||
<FilterChip
|
||||
label={formatMultiChip(
|
||||
attributes,
|
||||
referenceData.attributes,
|
||||
)}
|
||||
onRemove={() => (attributes = [])}
|
||||
/>
|
||||
{/if}
|
||||
{#if creditHourMin !== null || creditHourMax !== null}
|
||||
<FilterChip
|
||||
label={creditHourMin !== null && creditHourMax !== null
|
||||
? `${creditHourMin}–${creditHourMax} credits`
|
||||
: creditHourMin !== null
|
||||
? `≥ ${creditHourMin} credits`
|
||||
: `≤ ${creditHourMax} credits`}
|
||||
onRemove={() => {
|
||||
creditHourMin = null;
|
||||
creditHourMax = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if instructor !== ""}
|
||||
<FilterChip
|
||||
label="Instructor: {instructor}"
|
||||
onRemove={() => (instructor = "")}
|
||||
/>
|
||||
{/if}
|
||||
{#if courseNumberLow !== null || courseNumberHigh !== null}
|
||||
<FilterChip
|
||||
label={courseNumberLow !== null &&
|
||||
courseNumberHigh !== null
|
||||
? `Course ${courseNumberLow}–${courseNumberHigh}`
|
||||
: courseNumberLow !== null
|
||||
? `Course ≥ ${courseNumberLow}`
|
||||
: `Course ≤ ${courseNumberHigh}`}
|
||||
onRemove={() => {
|
||||
courseNumberLow = null;
|
||||
courseNumberHigh = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if activeFilterCount >= 2}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer ml-1"
|
||||
onclick={clearAllFilters}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- View columns dropdown (moved from CourseTable) -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<Columns3 class="size-3.5" />
|
||||
View
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
forceMount
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div
|
||||
{...props}
|
||||
transition:fly={{
|
||||
duration: 150,
|
||||
y: -10,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading
|
||||
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Toggle columns
|
||||
</DropdownMenu.GroupHeading>
|
||||
{#each columnDefs as col}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={columnVisibility[
|
||||
col.id
|
||||
] !== false}
|
||||
closeOnSelect={false}
|
||||
onCheckedChange={(
|
||||
checked,
|
||||
) => {
|
||||
columnVisibility = {
|
||||
...columnVisibility,
|
||||
[col.id]: checked,
|
||||
};
|
||||
}}
|
||||
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
|
||||
>
|
||||
{#snippet children({
|
||||
checked,
|
||||
})}
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded-sm border border-border"
|
||||
>
|
||||
{#if checked}
|
||||
<Check
|
||||
class="size-3"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{col.label}
|
||||
{/snippet}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
{#if hasCustomVisibility}
|
||||
<DropdownMenu.Separator
|
||||
class="mx-1 my-1 h-px bg-border"
|
||||
/>
|
||||
<DropdownMenu.Item
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
|
||||
onSelect={resetColumnVisibility}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
Reset to default
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="flex flex-col gap-2 pb-4">
|
||||
<SearchFilters
|
||||
{terms}
|
||||
{subjects}
|
||||
bind:selectedTerm
|
||||
bind:selectedSubjects
|
||||
bind:query
|
||||
bind:openOnly
|
||||
bind:waitCountMax
|
||||
bind:days
|
||||
bind:timeStart
|
||||
bind:timeEnd
|
||||
bind:instructionalMethod
|
||||
bind:campus
|
||||
bind:partOfTerm
|
||||
bind:attributes
|
||||
bind:creditHourMin
|
||||
bind:creditHourMax
|
||||
bind:instructor
|
||||
bind:courseNumberLow
|
||||
bind:courseNumberHigh
|
||||
{referenceData}
|
||||
ranges={{
|
||||
courseNumber: { min: ranges.courseNumberMin, max: ranges.courseNumberMax },
|
||||
creditHours: { min: ranges.creditHourMin, max: ranges.creditHourMax },
|
||||
waitCount: { max: ranges.waitCountMax },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if error}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-status-red">{error}</p>
|
||||
<button
|
||||
onclick={() => performSearch()}
|
||||
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CourseTable
|
||||
courses={searchResult?.courses ?? []}
|
||||
{loading}
|
||||
{sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
manualSorting={true}
|
||||
{subjectMap}
|
||||
{limit}
|
||||
bind:columnVisibility
|
||||
/>
|
||||
|
||||
{#if searchResult}
|
||||
<Pagination
|
||||
totalCount={searchResult.totalCount}
|
||||
{offset}
|
||||
{limit}
|
||||
{loading}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if error}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-status-red">{error}</p>
|
||||
<button
|
||||
onclick={() => performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting)}
|
||||
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CourseTable
|
||||
courses={searchResult?.courses ?? []}
|
||||
{loading}
|
||||
{sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
manualSorting={true}
|
||||
{subjectMap}
|
||||
/>
|
||||
|
||||
{#if searchResult}
|
||||
<Pagination
|
||||
totalCount={searchResult.totalCount}
|
||||
offset={searchResult.offset}
|
||||
{limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { PageLoad } from "./$types";
|
||||
export const load: PageLoad = async ({ url, fetch }) => {
|
||||
const client = new BannerApiClient(undefined, fetch);
|
||||
try {
|
||||
const terms = await client.getTerms();
|
||||
return { terms, url };
|
||||
const urlTerm = url.searchParams.get("term");
|
||||
// Backend defaults to latest term if not specified
|
||||
const searchOptions = await client.getSearchOptions(urlTerm ?? undefined);
|
||||
return { searchOptions, url };
|
||||
} catch (e) {
|
||||
console.error("Failed to load terms:", e);
|
||||
return { terms: [], url };
|
||||
console.error("Failed to load search options:", e);
|
||||
return { searchOptions: null, url };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -60,6 +61,7 @@
|
||||
--color-surface-100: var(--card);
|
||||
--color-surface-content: var(--foreground);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||
--animate-accordion-down: accordion-down 200ms ease-out;
|
||||
--animate-accordion-up: accordion-up 200ms ease-out;
|
||||
}
|
||||
@@ -133,7 +135,7 @@ input[type="checkbox"]:checked::before {
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
|
||||
/* View Transitions API - disable default cross-fade for scoped table transitions */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
@@ -240,3 +242,14 @@ body::-webkit-scrollbar {
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* View Transitions: scope crossfade to search results table */
|
||||
::view-transition-group(search-results) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(search-results) {
|
||||
animation-duration: 150ms;
|
||||
}
|
||||
::view-transition-new(search-results) {
|
||||
animation-duration: 200ms;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user