7 Commits

Author SHA1 Message Date
e41b970d6e fix: implement i64 serialization for JavaScript compatibility, fixing avatar URL display 2026-01-29 15:51:19 -06:00
e880126281 feat: implement worker timeout protection and crash recovery for job queue
Add JOB_TIMEOUT constant to fail stuck jobs after 5 minutes, and
LOCK_EXPIRY to reclaim abandoned locks after 10 minutes. Introduce
force_unlock_all to recover orphaned jobs at startup. Fix retry limit
off-by-one error and update deduplication to include locked jobs.
2026-01-29 15:50:09 -06:00
db0ec1e69d feat: add rmp profile links and confidence-aware rating display 2026-01-29 15:43:39 -06:00
2947face06 fix: run frontend build first with -e embed flag in Justfile 2026-01-29 15:00:13 -06:00
36bcc27d7f feat: setup smart page transitions, fix laggy theme-aware element transitions 2026-01-29 14:59:47 -06:00
9e403e5043 refactor: modernize Justfile commands and simplify service management 2026-01-29 14:33:16 -06:00
98a6d978c6 feat: implement course change auditing with time-series metrics endpoint 2026-01-29 14:19:36 -06:00
27 changed files with 1716 additions and 625 deletions
+2 -2
View File
@@ -115,5 +115,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
ENV HOSTS=0.0.0.0,[::] ENV HOSTS=0.0.0.0,[::]
# Implicitly uses PORT environment variable # Implicitly uses PORT environment variable
# temporary: running without 'scraper' service # Runs all services: web, bot, and scraper
CMD ["sh", "-c", "exec ./banner --services web,bot"] CMD ["sh", "-c", "exec ./banner"]
+272 -111
View File
@@ -1,51 +1,292 @@
set dotenv-load set dotenv-load
default_services := "bot,web,scraper"
default: default:
just --list just --list
# Run all checks (format, clippy, tests, lint) # Run all checks in parallel. Pass -f/--fix to auto-format and fix first.
check: [script("bun")]
cargo fmt --all -- --check check *flags:
cargo clippy --all-features -- --deny warnings const args = "{{flags}}".split(/\s+/).filter(Boolean);
cargo nextest run -E 'not test(export_bindings)' let fix = false;
bun run --cwd web check for (const arg of args) {
bun run --cwd web test if (arg === "-f" || arg === "--fix") fix = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
}
# Generate TypeScript bindings from Rust types (ts-rs) const run = (cmd) => {
bindings: const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
cargo test export_bindings if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
# Run all tests (Rust + frontend) if (fix) {
test: test-rust test-web console.log("\x1b[1;36m→ Fixing...\x1b[0m");
run(["cargo", "fmt", "--all"]);
run(["bun", "run", "--cwd", "web", "format"]);
run(["cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged",
"--", "--deny", "warnings"]);
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
}
# Run only Rust tests (excludes ts-rs bindings generation) const checks = [
test-rust *ARGS: { name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
cargo nextest run -E 'not test(export_bindings)' {{ARGS}} { name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ 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: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
// { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] },
];
# Run only frontend tests const isTTY = process.stderr.isTTY;
test-web: const start = Date.now();
bun run --cwd web test const remaining = new Set(checks.map(c => c.name));
# Quick check: clippy + tests + typecheck (skips formatting) const promises = checks.map(async (check) => {
check-quick: const proc = Bun.spawn(check.cmd, {
cargo clippy --all-features -- --deny warnings env: { ...process.env, FORCE_COLOR: "1" },
cargo nextest run -E 'not test(export_bindings)' stdout: "pipe", stderr: "pipe",
bun run --cwd web check });
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return { ...check, stdout, stderr, exitCode: proc.exitCode,
elapsed: ((Date.now() - start) / 1000).toFixed(1) };
});
# Run the Banner API search demo (hits live UTSA API, ~20s) const interval = isTTY ? setInterval(() => {
search *ARGS: const elapsed = ((Date.now() - start) / 1000).toFixed(1);
cargo run -q --bin search -- {{ARGS}} process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
}, 100) : null;
let anyFailed = false;
for (const promise of promises) {
const r = await promise;
remaining.delete(r.name);
if (isTTY) process.stderr.write(`\r\x1b[K`);
if (r.exitCode !== 0) {
anyFailed = true;
process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
} else {
process.stdout.write(`\x1b[32m✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
}
}
if (interval) clearInterval(interval);
if (isTTY) process.stderr.write(`\r\x1b[K`);
process.exit(anyFailed ? 1 : 0);
# Format all Rust and TypeScript code # Format all Rust and TypeScript code
format: format:
cargo fmt --all cargo fmt --all
bun run --cwd web format bun run --cwd web format
# Check formatting without modifying (CI-friendly) # Run tests. Usage: just test [rust|web|<nextest filter args>]
format-check: [script("bun")]
cargo fmt --all -- --check test *args:
bun run --cwd web format:check const input = "{{args}}".trim();
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
if (input === "web") {
run(["bun", "run", "--cwd", "web", "test"]);
} else if (input === "rust") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
} else if (input === "") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
run(["bun", "run", "--cwd", "web", "test"]);
} else {
run(["cargo", "nextest", "run", ...input.split(/\s+/)]);
}
# Generate TypeScript bindings from Rust types (ts-rs)
bindings:
cargo test export_bindings
# Run the Banner API search demo (hits live UTSA API, ~20s)
search *ARGS:
cargo run -q --bin search -- {{ARGS}}
# Pass args to binary after --: just dev -n -- --some-flag
# Dev server. Flags: -f(rontend) -b(ackend) -W(no-watch) -n(o-build) -r(elease) -e(mbed) --tracing <fmt>
[script("bun")]
dev *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let frontendOnly = false, backendOnly = false;
let noWatch = false, noBuild = false, release = false, embed = false;
let tracing = "pretty";
const passthrough = [];
let i = 0;
let seenDashDash = false;
while (i < argv.length) {
const arg = argv[i];
if (seenDashDash) { passthrough.push(arg); i++; continue; }
if (arg === "--") { seenDashDash = true; i++; continue; }
if (arg.startsWith("--")) {
if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else if (arg === "--no-watch") noWatch = true;
else if (arg === "--no-build") noBuild = true;
else if (arg === "--release") release = true;
else if (arg === "--embed") embed = true;
else if (arg === "--tracing") { tracing = argv[++i] || "pretty"; }
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else if (c === "W") noWatch = true;
else if (c === "n") noBuild = true;
else if (c === "r") release = true;
else if (c === "e") embed = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
i++;
}
// -e implies -b (no point running Vite if assets are embedded)
if (embed) backendOnly = true;
// -n implies -W (no build means no watch)
if (noBuild) noWatch = true;
// Validate conflicting flags
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together (or -e implies -b)");
process.exit(1);
}
const runFrontend = !backendOnly;
const runBackend = !frontendOnly;
const profile = release ? "release" : "dev";
const profileDir = release ? "release" : "debug";
const procs = [];
const cleanup = async () => {
for (const p of procs) p.kill();
await Promise.all(procs.map(p => p.exited));
};
process.on("SIGINT", async () => { await cleanup(); process.exit(0); });
process.on("SIGTERM", async () => { await cleanup(); process.exit(0); });
// Build frontend first when embedding assets (backend will bake them in)
if (embed && !noBuild) {
console.log(`\x1b[1;36m→ Building frontend (for embedding)...\x1b[0m`);
const fb = Bun.spawnSync(["bun", "run", "--cwd", "web", "build"], {
stdio: ["inherit", "inherit", "inherit"],
});
if (fb.exitCode !== 0) process.exit(fb.exitCode);
}
// Frontend: Vite dev server
if (runFrontend) {
const proc = Bun.spawn(["bun", "run", "--cwd", "web", "dev"], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
// Backend
if (runBackend) {
const backendArgs = [`--tracing`, tracing, ...passthrough];
const bin = `target/${profileDir}/banner`;
if (noWatch) {
// Build first unless -n (skip build)
if (!noBuild) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cargoArgs = ["cargo", "build", "--bin", "banner"];
if (!embed) cargoArgs.push("--no-default-features");
if (release) cargoArgs.push("--release");
const build = Bun.spawnSync(cargoArgs, { stdio: ["inherit", "inherit", "inherit"] });
if (build.exitCode !== 0) { cleanup(); process.exit(build.exitCode); }
}
// Run the binary directly (no watch)
const { existsSync } = await import("fs");
if (!existsSync(bin)) {
console.error(`Binary not found: ${bin}`);
console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`);
cleanup();
process.exit(1);
}
console.log(`\x1b[1;36m→ Running ${bin} (no watch)\x1b[0m`);
const proc = Bun.spawn([bin, ...backendArgs], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
} else {
// Bacon watch mode
const baconArgs = ["bacon", "--headless", "run", "--"];
if (!embed) baconArgs.push("--no-default-features");
if (release) baconArgs.push("--profile", "release");
baconArgs.push("--", ...backendArgs);
const proc = Bun.spawn(baconArgs, {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
}
// Wait for any process to exit, then kill the rest
const results = procs.map((p, i) => p.exited.then(code => ({ i, code })));
const first = await Promise.race(results);
cleanup();
process.exit(first.code);
# Production build. Flags: -d(ebug) -f(rontend-only) -b(ackend-only)
[script("bun")]
build *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let debug = false, frontendOnly = false, backendOnly = false;
for (const arg of argv) {
if (arg.startsWith("--")) {
if (arg === "--debug") debug = true;
else if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "d") debug = true;
else if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
}
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together");
process.exit(1);
}
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
const buildFrontend = !backendOnly;
const buildBackend = !frontendOnly;
const profile = debug ? "debug" : "release";
if (buildFrontend) {
console.log("\x1b[1;36m→ Building frontend...\x1b[0m");
run(["bun", "run", "--cwd", "web", "build"]);
}
if (buildBackend) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cmd = ["cargo", "build", "--bin", "banner"];
if (!debug) cmd.push("--release");
run(cmd);
}
# Start PostgreSQL in Docker and update .env with connection string # Start PostgreSQL in Docker and update .env with connection string
# Commands: start (default), reset, rm # Commands: start (default), reset, rm
@@ -115,86 +356,6 @@ db cmd="start":
await updateEnv(); await updateEnv();
} }
# Auto-reloading frontend server
frontend:
bun run --cwd web dev
# Production build of frontend
build-frontend:
bun run --cwd web build
# Auto-reloading backend server (with embedded assets)
backend *ARGS:
bacon --headless run -- -- {{ARGS}}
# Auto-reloading backend server (no embedded assets, for dev proxy mode)
backend-dev *ARGS:
bacon --headless run -- --no-default-features -- {{ARGS}}
# Production build
build:
bun run --cwd web build
cargo build --release --bin banner
# Run auto-reloading development build with release characteristics
dev-build *ARGS='--services web --tracing pretty': build-frontend
bacon --headless run -- --profile dev-release -- {{ARGS}}
# Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite)
[parallel]
dev *ARGS='--services web,bot': frontend (backend-dev ARGS)
# Smoke test: start web server, hit API endpoints, verify responses
[script("bash")]
test-smoke port="18080":
set -euo pipefail
PORT={{port}}
cleanup() { kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null; }
# Start server in background
PORT=$PORT cargo run -q --no-default-features -- --services web --tracing json &
SERVER_PID=$!
trap cleanup EXIT
# Wait for server to be ready (up to 15s)
for i in $(seq 1 30); do
if curl -sf "http://localhost:$PORT/api/health" >/dev/null 2>&1; then break; fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "FAIL: server exited early"; exit 1; fi
sleep 0.5
done
PASS=0; FAIL=0
check() {
local label="$1" url="$2" expected="$3"
body=$(curl -sf "$url") || { echo "FAIL: $label - request failed"; FAIL=$((FAIL+1)); return; }
if echo "$body" | grep -q "$expected"; then
echo "PASS: $label"
PASS=$((PASS+1))
else
echo "FAIL: $label - expected '$expected' in: $body"
FAIL=$((FAIL+1))
fi
}
check "GET /api/health" "http://localhost:$PORT/api/health" '"status":"healthy"'
check "GET /api/status" "http://localhost:$PORT/api/status" '"version"'
check "GET /api/metrics" "http://localhost:$PORT/api/metrics" '"banner_api"'
# Test 404
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/nonexistent")
if [ "$STATUS" = "404" ]; then
echo "PASS: 404 on unknown route"
PASS=$((PASS+1))
else
echo "FAIL: expected 404, got $STATUS"
FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
alias b := bun alias b := bun
bun *ARGS: bun *ARGS:
cd web && bun {{ ARGS }} cd web && bun {{ ARGS }}
+1 -2
View File
@@ -29,8 +29,7 @@ The application consists of three modular services that can be run independently
bun install --cwd web # Install frontend dependencies bun install --cwd web # Install frontend dependencies
cargo build # Build the backend cargo build # Build the backend
just dev # Runs auto-reloading dev build just dev # Runs auto-reloading dev build with all services
just dev --services bot,web # Runs auto-reloading dev build, running only the bot and web services
just dev-build # Development build with release characteristics (frontend is embedded, non-auto-reloading) just dev-build # Development build with release characteristics (frontend is embedded, non-auto-reloading)
just build # Production build that embeds assets just build # Production build that embeds assets
+1 -105
View File
@@ -2,34 +2,16 @@ use clap::Parser;
/// Banner Discord Bot - Course availability monitoring /// Banner Discord Bot - Course availability monitoring
/// ///
/// This application runs multiple services that can be controlled via CLI arguments: /// This application runs all services:
/// - bot: Discord bot for course monitoring commands /// - bot: Discord bot for course monitoring commands
/// - web: HTTP server for web interface and API /// - web: HTTP server for web interface and API
/// - scraper: Background service for scraping course data /// - scraper: Background service for scraping course data
///
/// Use --services to specify which services to run, or --disable-services to exclude specific services.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Args { pub struct Args {
/// Log formatter to use /// Log formatter to use
#[arg(long, value_enum, default_value_t = default_tracing_format())] #[arg(long, value_enum, default_value_t = default_tracing_format())]
pub tracing: TracingFormat, pub tracing: TracingFormat,
/// Services to run (comma-separated). Default: all services
///
/// Examples:
/// --services bot,web # Run only bot and web services
/// --services scraper # Run only the scraper service
#[arg(long, value_delimiter = ',', conflicts_with = "disable_services")]
pub services: Option<Vec<ServiceName>>,
/// Services to disable (comma-separated)
///
/// Examples:
/// --disable-services bot # Run web and scraper only
/// --disable-services bot,web # Run only the scraper service
#[arg(long, value_delimiter = ',', conflicts_with = "services")]
pub disable_services: Option<Vec<ServiceName>>,
} }
#[derive(clap::ValueEnum, Clone, Debug)] #[derive(clap::ValueEnum, Clone, Debug)]
@@ -66,34 +48,6 @@ impl ServiceName {
} }
} }
/// Determine which services should be enabled based on CLI arguments
pub fn determine_enabled_services(args: &Args) -> Result<Vec<ServiceName>, anyhow::Error> {
match (&args.services, &args.disable_services) {
(Some(services), None) => {
// User specified which services to run
Ok(services.clone())
}
(None, Some(disabled)) => {
// User specified which services to disable
let enabled: Vec<ServiceName> = ServiceName::all()
.into_iter()
.filter(|s| !disabled.contains(s))
.collect();
Ok(enabled)
}
(None, None) => {
// Default: run all services
Ok(ServiceName::all())
}
(Some(_), Some(_)) => {
// This should be prevented by clap's conflicts_with, but just in case
Err(anyhow::anyhow!(
"Cannot specify both --services and --disable-services"
))
}
}
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Pretty; const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Pretty;
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
@@ -107,64 +61,6 @@ fn default_tracing_format() -> TracingFormat {
mod tests { mod tests {
use super::*; use super::*;
fn args_with_services(
services: Option<Vec<ServiceName>>,
disable: Option<Vec<ServiceName>>,
) -> Args {
Args {
tracing: TracingFormat::Pretty,
services,
disable_services: disable,
}
}
#[test]
fn test_default_enables_all_services() {
let result = determine_enabled_services(&args_with_services(None, None)).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_explicit_services_only_those() {
let result =
determine_enabled_services(&args_with_services(Some(vec![ServiceName::Web]), None))
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].as_str(), "web");
}
#[test]
fn test_disable_bot_leaves_web_and_scraper() {
let result =
determine_enabled_services(&args_with_services(None, Some(vec![ServiceName::Bot])))
.unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|s| s.as_str() != "bot"));
}
#[test]
fn test_disable_all_leaves_empty() {
let result = determine_enabled_services(&args_with_services(
None,
Some(vec![
ServiceName::Bot,
ServiceName::Web,
ServiceName::Scraper,
]),
))
.unwrap();
assert!(result.is_empty());
}
#[test]
fn test_both_specified_returns_error() {
let result = determine_enabled_services(&args_with_services(
Some(vec![ServiceName::Web]),
Some(vec![ServiceName::Bot]),
));
assert!(result.is_err());
}
#[test] #[test]
fn test_service_name_as_str() { fn test_service_name_as_str() {
assert_eq!(ServiceName::Bot.as_str(), "bot"); assert_eq!(ServiceName::Bot.as_str(), "bot");
+438 -78
View File
@@ -3,6 +3,7 @@
use crate::banner::Course; use crate::banner::Course;
use crate::data::models::DbMeetingTime; use crate::data::models::DbMeetingTime;
use crate::error::Result; use crate::error::Result;
use sqlx::PgConnection;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Instant; use std::time::Instant;
@@ -57,15 +58,315 @@ fn extract_campus_code(course: &Course) -> Option<String> {
.and_then(|mf| mf.meeting_time.campus.clone()) .and_then(|mf| mf.meeting_time.campus.clone())
} }
// ---------------------------------------------------------------------------
// Task 1: UpsertDiffRow — captures pre- and post-upsert state for diffing
// ---------------------------------------------------------------------------
/// Row returned by the CTE-based upsert query, carrying both old and new values
/// for every auditable field. `old_id` is `None` for fresh inserts.
#[derive(sqlx::FromRow, Debug)]
struct UpsertDiffRow {
id: i32,
old_id: Option<i32>,
// enrollment fields
old_enrollment: Option<i32>,
new_enrollment: i32,
old_max_enrollment: Option<i32>,
new_max_enrollment: i32,
old_wait_count: Option<i32>,
new_wait_count: i32,
old_wait_capacity: Option<i32>,
new_wait_capacity: i32,
// text fields (non-nullable in DB)
old_subject: Option<String>,
new_subject: String,
old_course_number: Option<String>,
new_course_number: String,
old_title: Option<String>,
new_title: String,
// nullable text fields
old_sequence_number: Option<String>,
new_sequence_number: Option<String>,
old_part_of_term: Option<String>,
new_part_of_term: Option<String>,
old_instructional_method: Option<String>,
new_instructional_method: Option<String>,
old_campus: Option<String>,
new_campus: Option<String>,
// nullable int fields
old_credit_hours: Option<i32>,
new_credit_hours: Option<i32>,
old_credit_hour_low: Option<i32>,
new_credit_hour_low: Option<i32>,
old_credit_hour_high: Option<i32>,
new_credit_hour_high: Option<i32>,
// cross-list fields
old_cross_list: Option<String>,
new_cross_list: Option<String>,
old_cross_list_capacity: Option<i32>,
new_cross_list_capacity: Option<i32>,
old_cross_list_count: Option<i32>,
new_cross_list_count: Option<i32>,
// link fields
old_link_identifier: Option<String>,
new_link_identifier: Option<String>,
old_is_section_linked: Option<bool>,
new_is_section_linked: Option<bool>,
// JSONB fields
old_meeting_times: Option<serde_json::Value>,
new_meeting_times: serde_json::Value,
old_attributes: Option<serde_json::Value>,
new_attributes: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Task 3: Entry types and diff logic
// ---------------------------------------------------------------------------
struct AuditEntry {
course_id: i32,
field_changed: &'static str,
old_value: String,
new_value: String,
}
struct MetricEntry {
course_id: i32,
enrollment: i32,
wait_count: i32,
seats_available: i32,
}
/// Compare old vs new for a single field, pushing an `AuditEntry` when they differ.
///
/// Three variants:
/// - `diff_field!(audits, row, field_name, old_field, new_field)` — `Option<T>` old vs `T` new
/// - `diff_field!(opt audits, row, field_name, old_field, new_field)` — `Option<T>` old vs `Option<T>` new
/// - `diff_field!(json audits, row, field_name, old_field, new_field)` — `Option<Value>` old vs `Value` new
///
/// All variants skip when `old_id` is None (fresh insert).
macro_rules! diff_field {
// Standard: Option<T> old vs T new (non-nullable columns)
($audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_str = $row
.$old
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
let new_str = $row.$new.to_string();
if old_str != new_str {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_str,
new_value: new_str,
});
}
}
};
// Nullable: Option<T> old vs Option<T> new
(opt $audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_str = $row
.$old
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
let new_str = $row
.$new
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
if old_str != new_str {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_str,
new_value: new_str,
});
}
}
};
// JSONB: Option<Value> old vs Value new
(json $audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_val = $row
.$old
.as_ref()
.cloned()
.unwrap_or(serde_json::Value::Null);
let new_val = &$row.$new;
if old_val != *new_val {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_val.to_string(),
new_value: new_val.to_string(),
});
}
}
};
}
/// Compute audit entries (field-level diffs) and metric entries from upsert diff rows.
fn compute_diffs(rows: &[UpsertDiffRow]) -> (Vec<AuditEntry>, Vec<MetricEntry>) {
let mut audits = Vec::new();
let mut metrics = Vec::new();
for row in rows {
// Non-nullable fields
diff_field!(audits, row, "enrollment", old_enrollment, new_enrollment);
diff_field!(
audits,
row,
"max_enrollment",
old_max_enrollment,
new_max_enrollment
);
diff_field!(audits, row, "wait_count", old_wait_count, new_wait_count);
diff_field!(
audits,
row,
"wait_capacity",
old_wait_capacity,
new_wait_capacity
);
diff_field!(audits, row, "subject", old_subject, new_subject);
diff_field!(
audits,
row,
"course_number",
old_course_number,
new_course_number
);
diff_field!(audits, row, "title", old_title, new_title);
// Nullable text fields
diff_field!(opt audits, row, "sequence_number", old_sequence_number, new_sequence_number);
diff_field!(opt audits, row, "part_of_term", old_part_of_term, new_part_of_term);
diff_field!(opt audits, row, "instructional_method", old_instructional_method, new_instructional_method);
diff_field!(opt audits, row, "campus", old_campus, new_campus);
// Nullable int fields
diff_field!(opt audits, row, "credit_hours", old_credit_hours, new_credit_hours);
diff_field!(opt audits, row, "credit_hour_low", old_credit_hour_low, new_credit_hour_low);
diff_field!(opt audits, row, "credit_hour_high", old_credit_hour_high, new_credit_hour_high);
// Cross-list fields
diff_field!(opt audits, row, "cross_list", old_cross_list, new_cross_list);
diff_field!(opt audits, row, "cross_list_capacity", old_cross_list_capacity, new_cross_list_capacity);
diff_field!(opt audits, row, "cross_list_count", old_cross_list_count, new_cross_list_count);
// Link fields
diff_field!(opt audits, row, "link_identifier", old_link_identifier, new_link_identifier);
diff_field!(opt audits, row, "is_section_linked", old_is_section_linked, new_is_section_linked);
// JSONB fields
diff_field!(json audits, row, "meeting_times", old_meeting_times, new_meeting_times);
diff_field!(json audits, row, "attributes", old_attributes, new_attributes);
// Emit a metric entry when enrollment/wait_count/max_enrollment changed
// Skip fresh inserts (no old data to compare against)
let enrollment_changed = row.old_id.is_some()
&& (row.old_enrollment != Some(row.new_enrollment)
|| row.old_wait_count != Some(row.new_wait_count)
|| row.old_max_enrollment != Some(row.new_max_enrollment));
if enrollment_changed {
metrics.push(MetricEntry {
course_id: row.id,
enrollment: row.new_enrollment,
wait_count: row.new_wait_count,
seats_available: row.new_max_enrollment - row.new_enrollment,
});
}
}
(audits, metrics)
}
// ---------------------------------------------------------------------------
// Task 4: Batch insert functions for audits and metrics
// ---------------------------------------------------------------------------
async fn insert_audits(audits: &[AuditEntry], conn: &mut PgConnection) -> Result<()> {
if audits.is_empty() {
return Ok(());
}
let course_ids: Vec<i32> = audits.iter().map(|a| a.course_id).collect();
let fields: Vec<&str> = audits.iter().map(|a| a.field_changed).collect();
let old_values: Vec<&str> = audits.iter().map(|a| a.old_value.as_str()).collect();
let new_values: Vec<&str> = audits.iter().map(|a| a.new_value.as_str()).collect();
sqlx::query(
r#"
INSERT INTO course_audits (course_id, timestamp, field_changed, old_value, new_value)
SELECT v.course_id, NOW(), v.field_changed, v.old_value, v.new_value
FROM UNNEST($1::int4[], $2::text[], $3::text[], $4::text[])
AS v(course_id, field_changed, old_value, new_value)
"#,
)
.bind(&course_ids)
.bind(&fields)
.bind(&old_values)
.bind(&new_values)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch insert course_audits: {}", e))?;
Ok(())
}
async fn insert_metrics(metrics: &[MetricEntry], conn: &mut PgConnection) -> Result<()> {
if metrics.is_empty() {
return Ok(());
}
let course_ids: Vec<i32> = metrics.iter().map(|m| m.course_id).collect();
let enrollments: Vec<i32> = metrics.iter().map(|m| m.enrollment).collect();
let wait_counts: Vec<i32> = metrics.iter().map(|m| m.wait_count).collect();
let seats_available: Vec<i32> = metrics.iter().map(|m| m.seats_available).collect();
sqlx::query(
r#"
INSERT INTO course_metrics (course_id, timestamp, enrollment, wait_count, seats_available)
SELECT v.course_id, NOW(), v.enrollment, v.wait_count, v.seats_available
FROM UNNEST($1::int4[], $2::int4[], $3::int4[], $4::int4[])
AS v(course_id, enrollment, wait_count, seats_available)
"#,
)
.bind(&course_ids)
.bind(&enrollments)
.bind(&wait_counts)
.bind(&seats_available)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch insert course_metrics: {}", e))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Core upsert functions (updated to use &mut PgConnection)
// ---------------------------------------------------------------------------
/// Batch upsert courses in a single database query. /// Batch upsert courses in a single database query.
/// ///
/// Performs a bulk INSERT...ON CONFLICT DO UPDATE for all courses, including /// Performs a bulk INSERT...ON CONFLICT DO UPDATE for all courses, including
/// new fields (meeting times, attributes, instructor data). Returns the /// new fields (meeting times, attributes, instructor data). Captures pre-update
/// database IDs for all upserted courses (in input order) so instructors /// state for audit/metric tracking, all within a single transaction.
/// can be linked.
/// ///
/// # Performance /// # Performance
/// - Reduces N database round-trips to 3 (courses, instructors, junction) /// - Reduces N database round-trips to 5 (old-data CTE + upsert, audits, metrics, instructors, junction)
/// - Typical usage: 50-200 courses per batch /// - Typical usage: 50-200 courses per batch
pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<()> { pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<()> {
if courses.is_empty() { if courses.is_empty() {
@@ -76,27 +377,47 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
let start = Instant::now(); let start = Instant::now();
let course_count = courses.len(); let course_count = courses.len();
// Step 1: Upsert courses with all fields, returning IDs let mut tx = db_pool.begin().await?;
let course_ids = upsert_courses(courses, db_pool).await?;
// Step 2: Upsert instructors (deduplicated across batch) // Step 1: Upsert courses with CTE, returning diff rows
upsert_instructors(courses, db_pool).await?; let diff_rows = upsert_courses(courses, &mut tx).await?;
// Step 3: Link courses to instructors via junction table // Step 2: Extract course IDs for instructor linking
upsert_course_instructors(courses, &course_ids, db_pool).await?; let course_ids: Vec<i32> = diff_rows.iter().map(|r| r.id).collect();
// Step 3: Compute audit/metric diffs
let (audits, metrics) = compute_diffs(&diff_rows);
// Step 4: Insert audits and metrics
insert_audits(&audits, &mut tx).await?;
insert_metrics(&metrics, &mut tx).await?;
// Step 5: Upsert instructors (deduplicated across batch)
upsert_instructors(courses, &mut tx).await?;
// Step 6: Link courses to instructors via junction table
upsert_course_instructors(courses, &course_ids, &mut tx).await?;
tx.commit().await?;
let duration = start.elapsed(); let duration = start.elapsed();
info!( info!(
courses_count = course_count, courses_count = course_count,
audit_entries = audits.len(),
metric_entries = metrics.len(),
duration_ms = duration.as_millis(), duration_ms = duration.as_millis(),
"Batch upserted courses with instructors" "Batch upserted courses with instructors, audits, and metrics"
); );
Ok(()) Ok(())
} }
/// Upsert all courses and return their database IDs in input order. // ---------------------------------------------------------------------------
async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>> { // Task 2: CTE-based upsert returning old+new values
// ---------------------------------------------------------------------------
/// Upsert all courses and return diff rows with old and new values for auditing.
async fn upsert_courses(courses: &[Course], conn: &mut PgConnection) -> Result<Vec<UpsertDiffRow>> {
let crns: Vec<&str> = courses let crns: Vec<&str> = courses
.iter() .iter()
.map(|c| c.course_reference_number.as_str()) .map(|c| c.course_reference_number.as_str())
@@ -143,67 +464,106 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
courses.iter().map(to_db_meeting_times).collect(); courses.iter().map(to_db_meeting_times).collect();
let attributes_json: Vec<serde_json::Value> = courses.iter().map(to_db_attributes).collect(); let attributes_json: Vec<serde_json::Value> = courses.iter().map(to_db_attributes).collect();
let rows = sqlx::query_scalar::<_, i32>( let rows = sqlx::query_as::<_, UpsertDiffRow>(
r#" r#"
INSERT INTO courses ( WITH old_data AS (
crn, subject, course_number, title, term_code, SELECT id, enrollment, max_enrollment, wait_count, wait_capacity,
enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at, subject, course_number, title,
sequence_number, part_of_term, instructional_method, campus, sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high, credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count, cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked, link_identifier, is_section_linked,
meeting_times, attributes meeting_times, attributes,
crn, term_code
FROM courses
WHERE (crn, term_code) IN (SELECT * FROM UNNEST($1::text[], $5::text[]))
),
upserted AS (
INSERT INTO courses (
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
)
SELECT
v.crn, v.subject, v.course_number, v.title, v.term_code,
v.enrollment, v.max_enrollment, v.wait_count, v.wait_capacity, NOW(),
v.sequence_number, v.part_of_term, v.instructional_method, v.campus,
v.credit_hours, v.credit_hour_low, v.credit_hour_high,
v.cross_list, v.cross_list_capacity, v.cross_list_count,
v.link_identifier, v.is_section_linked,
v.meeting_times, v.attributes
FROM UNNEST(
$1::text[], $2::text[], $3::text[], $4::text[], $5::text[],
$6::int4[], $7::int4[], $8::int4[], $9::int4[],
$10::text[], $11::text[], $12::text[], $13::text[],
$14::int4[], $15::int4[], $16::int4[],
$17::text[], $18::int4[], $19::int4[],
$20::text[], $21::bool[],
$22::jsonb[], $23::jsonb[]
) AS v(
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
)
ON CONFLICT (crn, term_code)
DO UPDATE SET
subject = EXCLUDED.subject,
course_number = EXCLUDED.course_number,
title = EXCLUDED.title,
enrollment = EXCLUDED.enrollment,
max_enrollment = EXCLUDED.max_enrollment,
wait_count = EXCLUDED.wait_count,
wait_capacity = EXCLUDED.wait_capacity,
last_scraped_at = EXCLUDED.last_scraped_at,
sequence_number = EXCLUDED.sequence_number,
part_of_term = EXCLUDED.part_of_term,
instructional_method = EXCLUDED.instructional_method,
campus = EXCLUDED.campus,
credit_hours = EXCLUDED.credit_hours,
credit_hour_low = EXCLUDED.credit_hour_low,
credit_hour_high = EXCLUDED.credit_hour_high,
cross_list = EXCLUDED.cross_list,
cross_list_capacity = EXCLUDED.cross_list_capacity,
cross_list_count = EXCLUDED.cross_list_count,
link_identifier = EXCLUDED.link_identifier,
is_section_linked = EXCLUDED.is_section_linked,
meeting_times = EXCLUDED.meeting_times,
attributes = EXCLUDED.attributes
RETURNING *
) )
SELECT SELECT u.id,
v.crn, v.subject, v.course_number, v.title, v.term_code, o.id AS old_id,
v.enrollment, v.max_enrollment, v.wait_count, v.wait_capacity, NOW(), o.enrollment AS old_enrollment, u.enrollment AS new_enrollment,
v.sequence_number, v.part_of_term, v.instructional_method, v.campus, o.max_enrollment AS old_max_enrollment, u.max_enrollment AS new_max_enrollment,
v.credit_hours, v.credit_hour_low, v.credit_hour_high, o.wait_count AS old_wait_count, u.wait_count AS new_wait_count,
v.cross_list, v.cross_list_capacity, v.cross_list_count, o.wait_capacity AS old_wait_capacity, u.wait_capacity AS new_wait_capacity,
v.link_identifier, v.is_section_linked, o.subject AS old_subject, u.subject AS new_subject,
v.meeting_times, v.attributes o.course_number AS old_course_number, u.course_number AS new_course_number,
FROM UNNEST( o.title AS old_title, u.title AS new_title,
$1::text[], $2::text[], $3::text[], $4::text[], $5::text[], o.sequence_number AS old_sequence_number, u.sequence_number AS new_sequence_number,
$6::int4[], $7::int4[], $8::int4[], $9::int4[], o.part_of_term AS old_part_of_term, u.part_of_term AS new_part_of_term,
$10::text[], $11::text[], $12::text[], $13::text[], o.instructional_method AS old_instructional_method, u.instructional_method AS new_instructional_method,
$14::int4[], $15::int4[], $16::int4[], o.campus AS old_campus, u.campus AS new_campus,
$17::text[], $18::int4[], $19::int4[], o.credit_hours AS old_credit_hours, u.credit_hours AS new_credit_hours,
$20::text[], $21::bool[], o.credit_hour_low AS old_credit_hour_low, u.credit_hour_low AS new_credit_hour_low,
$22::jsonb[], $23::jsonb[] o.credit_hour_high AS old_credit_hour_high, u.credit_hour_high AS new_credit_hour_high,
) AS v( o.cross_list AS old_cross_list, u.cross_list AS new_cross_list,
crn, subject, course_number, title, term_code, o.cross_list_capacity AS old_cross_list_capacity, u.cross_list_capacity AS new_cross_list_capacity,
enrollment, max_enrollment, wait_count, wait_capacity, o.cross_list_count AS old_cross_list_count, u.cross_list_count AS new_cross_list_count,
sequence_number, part_of_term, instructional_method, campus, o.link_identifier AS old_link_identifier, u.link_identifier AS new_link_identifier,
credit_hours, credit_hour_low, credit_hour_high, o.is_section_linked AS old_is_section_linked, u.is_section_linked AS new_is_section_linked,
cross_list, cross_list_capacity, cross_list_count, o.meeting_times AS old_meeting_times, u.meeting_times AS new_meeting_times,
link_identifier, is_section_linked, o.attributes AS old_attributes, u.attributes AS new_attributes
meeting_times, attributes FROM upserted u
) LEFT JOIN old_data o ON u.crn = o.crn AND u.term_code = o.term_code
ON CONFLICT (crn, term_code)
DO UPDATE SET
subject = EXCLUDED.subject,
course_number = EXCLUDED.course_number,
title = EXCLUDED.title,
enrollment = EXCLUDED.enrollment,
max_enrollment = EXCLUDED.max_enrollment,
wait_count = EXCLUDED.wait_count,
wait_capacity = EXCLUDED.wait_capacity,
last_scraped_at = EXCLUDED.last_scraped_at,
sequence_number = EXCLUDED.sequence_number,
part_of_term = EXCLUDED.part_of_term,
instructional_method = EXCLUDED.instructional_method,
campus = EXCLUDED.campus,
credit_hours = EXCLUDED.credit_hours,
credit_hour_low = EXCLUDED.credit_hour_low,
credit_hour_high = EXCLUDED.credit_hour_high,
cross_list = EXCLUDED.cross_list,
cross_list_capacity = EXCLUDED.cross_list_capacity,
cross_list_count = EXCLUDED.cross_list_count,
link_identifier = EXCLUDED.link_identifier,
is_section_linked = EXCLUDED.is_section_linked,
meeting_times = EXCLUDED.meeting_times,
attributes = EXCLUDED.attributes
RETURNING id
"#, "#,
) )
.bind(&crns) .bind(&crns)
@@ -229,7 +589,7 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
.bind(&is_section_linkeds) .bind(&is_section_linkeds)
.bind(&meeting_times_json) .bind(&meeting_times_json)
.bind(&attributes_json) .bind(&attributes_json)
.fetch_all(db_pool) .fetch_all(&mut *conn)
.await .await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert courses: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to batch upsert courses: {}", e))?;
@@ -237,7 +597,7 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
} }
/// Deduplicate and upsert all instructors from the batch. /// Deduplicate and upsert all instructors from the batch.
async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()> { async fn upsert_instructors(courses: &[Course], conn: &mut PgConnection) -> Result<()> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut banner_ids = Vec::new(); let mut banner_ids = Vec::new();
let mut display_names = Vec::new(); let mut display_names = Vec::new();
@@ -270,7 +630,7 @@ async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()>
.bind(&banner_ids) .bind(&banner_ids)
.bind(&display_names) .bind(&display_names)
.bind(&emails) .bind(&emails)
.execute(db_pool) .execute(&mut *conn)
.await .await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert instructors: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to batch upsert instructors: {}", e))?;
@@ -281,7 +641,7 @@ async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()>
async fn upsert_course_instructors( async fn upsert_course_instructors(
courses: &[Course], courses: &[Course],
course_ids: &[i32], course_ids: &[i32],
db_pool: &PgPool, conn: &mut PgConnection,
) -> Result<()> { ) -> Result<()> {
let mut cids = Vec::new(); let mut cids = Vec::new();
let mut iids = Vec::new(); let mut iids = Vec::new();
@@ -303,7 +663,7 @@ async fn upsert_course_instructors(
// This handles instructor changes cleanly. // This handles instructor changes cleanly.
sqlx::query("DELETE FROM course_instructors WHERE course_id = ANY($1)") sqlx::query("DELETE FROM course_instructors WHERE course_id = ANY($1)")
.bind(&cids) .bind(&cids)
.execute(db_pool) .execute(&mut *conn)
.await?; .await?;
sqlx::query( sqlx::query(
@@ -317,7 +677,7 @@ async fn upsert_course_instructors(
.bind(&cids) .bind(&cids)
.bind(&iids) .bind(&iids)
.bind(&primaries) .bind(&primaries)
.execute(db_pool) .execute(&mut *conn)
.await .await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert course_instructors: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to batch upsert course_instructors: {}", e))?;
+2 -2
View File
@@ -148,7 +148,7 @@ pub async fn get_course_instructors(
let rows = sqlx::query_as::<_, CourseInstructorDetail>( let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary, SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings, rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id ci.course_id
FROM course_instructors ci FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id JOIN instructors i ON i.banner_id = ci.instructor_id
@@ -177,7 +177,7 @@ pub async fn get_instructors_for_courses(
let rows = sqlx::query_as::<_, CourseInstructorDetail>( let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary, SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings, rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id ci.course_id
FROM course_instructors ci FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id JOIN instructors i ON i.banner_id = ci.instructor_id
+43 -1
View File
@@ -1,10 +1,46 @@
//! `sqlx` models for the database schema. //! `sqlx` models for the database schema.
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value; use serde_json::Value;
use ts_rs::TS; use ts_rs::TS;
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&value.to_string())
}
/// Deserialize an `i64` from either a number or a string.
fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<i64, D::Error> {
use serde::de;
struct I64OrStringVisitor;
impl<'de> de::Visitor<'de> for I64OrStringVisitor {
type Value = i64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an integer or a string containing an integer")
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<i64, E> {
Ok(value)
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<i64, E> {
i64::try_from(value).map_err(|_| E::custom(format!("u64 {value} out of i64 range")))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<i64, E> {
value.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(I64OrStringVisitor)
}
/// Represents a meeting time stored as JSONB in the courses table. /// Represents a meeting time stored as JSONB in the courses table.
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)] #[ts(export)]
@@ -85,6 +121,7 @@ pub struct CourseInstructorDetail {
pub is_primary: bool, pub is_primary: bool,
pub avg_rating: Option<f32>, pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>, pub num_ratings: Option<i32>,
pub rmp_legacy_id: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries. /// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>, pub course_id: Option<i32>,
} }
@@ -161,6 +198,11 @@ pub struct ScrapeJob {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct User { pub struct User {
#[serde(
serialize_with = "serialize_i64_as_string",
deserialize_with = "deserialize_i64_from_string"
)]
#[ts(type = "string")]
pub discord_id: i64, pub discord_id: i64,
pub discord_username: String, pub discord_username: String,
pub discord_avatar_hash: Option<String>, pub discord_avatar_hash: Option<String>,
+38 -9
View File
@@ -5,11 +5,33 @@ use crate::error::Result;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashSet; use std::collections::HashSet;
/// Force-unlock all jobs that have a non-NULL `locked_at`.
///
/// Intended to be called once at startup to recover jobs left locked by
/// a previous unclean shutdown (crash, OOM kill, etc.).
///
/// # Returns
/// The number of jobs that were unlocked.
pub async fn force_unlock_all(db_pool: &PgPool) -> Result<u64> {
let result = sqlx::query("UPDATE scrape_jobs SET locked_at = NULL WHERE locked_at IS NOT NULL")
.execute(db_pool)
.await?;
Ok(result.rows_affected())
}
/// How long a lock can be held before it is considered expired and reclaimable.
///
/// This acts as a safety net for cases where a worker dies without unlocking
/// (OOM kill, crash, network partition). Under normal operation, the worker's
/// own job timeout fires well before this threshold.
const LOCK_EXPIRY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
/// Atomically fetch and lock the next available scrape job. /// Atomically fetch and lock the next available scrape job.
/// ///
/// Uses `FOR UPDATE SKIP LOCKED` to allow multiple workers to poll the queue /// Uses `FOR UPDATE SKIP LOCKED` to allow multiple workers to poll the queue
/// concurrently without conflicts. Only jobs that are unlocked and ready to /// concurrently without conflicts. Considers jobs that are:
/// execute (based on `execute_at`) are considered. /// - Unlocked and ready to execute, OR
/// - Locked but past [`LOCK_EXPIRY`] (abandoned by a dead worker)
/// ///
/// # Arguments /// # Arguments
/// * `db_pool` - PostgreSQL connection pool /// * `db_pool` - PostgreSQL connection pool
@@ -20,9 +42,16 @@ use std::collections::HashSet;
pub async fn fetch_and_lock_job(db_pool: &PgPool) -> Result<Option<ScrapeJob>> { pub async fn fetch_and_lock_job(db_pool: &PgPool) -> Result<Option<ScrapeJob>> {
let mut tx = db_pool.begin().await?; let mut tx = db_pool.begin().await?;
let lock_expiry_secs = LOCK_EXPIRY.as_secs() as i32;
let job = sqlx::query_as::<_, ScrapeJob>( let job = sqlx::query_as::<_, ScrapeJob>(
"SELECT * FROM scrape_jobs WHERE locked_at IS NULL AND execute_at <= NOW() ORDER BY priority DESC, execute_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED" "SELECT * FROM scrape_jobs \
WHERE (locked_at IS NULL OR locked_at < NOW() - make_interval(secs => $1::double precision)) \
AND execute_at <= NOW() \
ORDER BY priority DESC, execute_at ASC \
LIMIT 1 \
FOR UPDATE SKIP LOCKED"
) )
.bind(lock_expiry_secs)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await?; .await?;
@@ -90,7 +119,7 @@ pub async fn unlock_and_increment_retry(
"UPDATE scrape_jobs "UPDATE scrape_jobs
SET locked_at = NULL, retry_count = retry_count + 1 SET locked_at = NULL, retry_count = retry_count + 1
WHERE id = $1 WHERE id = $1
RETURNING CASE WHEN retry_count < $2 THEN retry_count ELSE NULL END", RETURNING CASE WHEN retry_count <= $2 THEN retry_count ELSE NULL END",
) )
.bind(job_id) .bind(job_id)
.bind(max_retries) .bind(max_retries)
@@ -100,10 +129,10 @@ pub async fn unlock_and_increment_retry(
Ok(result.is_some()) Ok(result.is_some())
} }
/// Find existing unlocked job payloads matching the given target type and candidates. /// Find existing job payloads matching the given target type and candidates.
/// ///
/// Returns a set of stringified JSON payloads that already exist in the queue, /// Returns a set of stringified JSON payloads that already exist in the queue
/// used for deduplication when scheduling new jobs. /// (both locked and unlocked), used for deduplication when scheduling new jobs.
/// ///
/// # Arguments /// # Arguments
/// * `target_type` - The target type to filter by /// * `target_type` - The target type to filter by
@@ -111,7 +140,7 @@ pub async fn unlock_and_increment_retry(
/// * `db_pool` - PostgreSQL connection pool /// * `db_pool` - PostgreSQL connection pool
/// ///
/// # Returns /// # Returns
/// A `HashSet` of stringified JSON payloads that already have pending jobs /// A `HashSet` of stringified JSON payloads that already have pending or in-progress jobs
pub async fn find_existing_job_payloads( pub async fn find_existing_job_payloads(
target_type: TargetType, target_type: TargetType,
candidate_payloads: &[serde_json::Value], candidate_payloads: &[serde_json::Value],
@@ -119,7 +148,7 @@ pub async fn find_existing_job_payloads(
) -> Result<HashSet<String>> { ) -> Result<HashSet<String>> {
let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as( let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT target_payload FROM scrape_jobs "SELECT target_payload FROM scrape_jobs
WHERE target_type = $1 AND target_payload = ANY($2) AND locked_at IS NULL", WHERE target_type = $1 AND target_payload = ANY($2)",
) )
.bind(target_type) .bind(target_type)
.bind(candidate_payloads) .bind(candidate_payloads)
+3 -4
View File
@@ -1,5 +1,5 @@
use crate::app::App; use crate::app::App;
use crate::cli::{Args, ServiceName, determine_enabled_services}; use crate::cli::{Args, ServiceName};
use crate::logging::setup_logging; use crate::logging::setup_logging;
use clap::Parser; use clap::Parser;
use std::process::ExitCode; use std::process::ExitCode;
@@ -29,9 +29,8 @@ async fn main() -> ExitCode {
// Parse CLI arguments // Parse CLI arguments
let args = Args::parse(); let args = Args::parse();
// Determine which services should be enabled // Always run all services
let enabled_services: Vec<ServiceName> = let enabled_services = ServiceName::all();
determine_enabled_services(&args).expect("Failed to determine enabled services");
// Create and initialize the application // Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application"); let mut app = App::new().await.expect("Failed to initialize application");
+13 -2
View File
@@ -3,6 +3,7 @@ pub mod scheduler;
pub mod worker; pub mod worker;
use crate::banner::BannerApi; use crate::banner::BannerApi;
use crate::data::scrape_jobs;
use crate::services::Service; use crate::services::Service;
use crate::state::ReferenceCache; use crate::state::ReferenceCache;
use crate::status::{ServiceStatus, ServiceStatusRegistry}; use crate::status::{ServiceStatus, ServiceStatusRegistry};
@@ -49,7 +50,17 @@ impl ScraperService {
} }
/// Starts the scheduler and a pool of workers. /// Starts the scheduler and a pool of workers.
pub fn start(&mut self) { ///
/// Force-unlocks any jobs left locked by a previous unclean shutdown before
/// spawning workers, so those jobs re-enter the queue immediately.
pub async fn start(&mut self) {
// Recover jobs left locked by a previous crash/unclean shutdown
match scrape_jobs::force_unlock_all(&self.db_pool).await {
Ok(0) => {}
Ok(count) => warn!(count, "Force-unlocked stale jobs from previous run"),
Err(e) => warn!(error = ?e, "Failed to force-unlock stale jobs"),
}
info!("ScraperService starting"); info!("ScraperService starting");
// Create shutdown channel // Create shutdown channel
@@ -92,7 +103,7 @@ impl Service for ScraperService {
} }
async fn run(&mut self) -> Result<(), anyhow::Error> { async fn run(&mut self) -> Result<(), anyhow::Error> {
self.start(); self.start().await;
std::future::pending::<()>().await; std::future::pending::<()>().await;
Ok(()) Ok(())
} }
+15 -2
View File
@@ -10,6 +10,9 @@ use tokio::sync::broadcast;
use tokio::time; use tokio::time;
use tracing::{Instrument, debug, error, info, trace, warn}; use tracing::{Instrument, debug, error, info, trace, warn};
/// Maximum time a single job is allowed to run before being considered stuck.
const JOB_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// A single worker instance. /// A single worker instance.
/// ///
/// Each worker runs in its own asynchronous task and continuously polls the /// Each worker runs in its own asynchronous task and continuously polls the
@@ -62,13 +65,23 @@ impl Worker {
let max_retries = job.max_retries; let max_retries = job.max_retries;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// Process the job, racing against shutdown signal // Process the job, racing against shutdown signal and timeout
let process_result = tokio::select! { let process_result = tokio::select! {
_ = shutdown_rx.recv() => { _ = shutdown_rx.recv() => {
self.handle_shutdown_during_processing(job_id).await; self.handle_shutdown_during_processing(job_id).await;
break; break;
} }
result = self.process_job(job) => result result = async {
match time::timeout(JOB_TIMEOUT, self.process_job(job)).await {
Ok(result) => result,
Err(_elapsed) => {
Err(JobError::Recoverable(anyhow::anyhow!(
"job timed out after {}s",
JOB_TIMEOUT.as_secs()
)))
}
}
} => result
}; };
let duration = start.elapsed(); let duration = start.elapsed();
+120 -8
View File
@@ -244,20 +244,130 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
} }
/// Metrics endpoint for monitoring /// Metrics endpoint for monitoring
async fn metrics() -> Json<Value> { async fn metrics(
// For now, return basic metrics structure State(state): State<AppState>,
Json(json!({ Query(params): Query<MetricsParams>,
"banner_api": { ) -> Result<Json<Value>, (AxumStatusCode, String)> {
"status": "connected" let limit = params.limit.clamp(1, 5000);
},
"timestamp": chrono::Utc::now().to_rfc3339() // Parse range shorthand, defaulting to 24h
})) let range_str = params.range.as_deref().unwrap_or("24h");
let duration = match range_str {
"1h" => chrono::Duration::hours(1),
"6h" => chrono::Duration::hours(6),
"24h" => chrono::Duration::hours(24),
"7d" => chrono::Duration::days(7),
"30d" => chrono::Duration::days(30),
_ => {
return Err((
AxumStatusCode::BAD_REQUEST,
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
));
}
};
let since = chrono::Utc::now() - duration;
// Resolve course_id: explicit param takes priority, then term+crn lookup
let course_id = if let Some(id) = params.course_id {
Some(id)
} else if let (Some(term), Some(crn)) = (params.term.as_deref(), params.crn.as_deref()) {
let row: Option<(i32,)> =
sqlx::query_as("SELECT id FROM courses WHERE term_code = $1 AND crn = $2")
.bind(term)
.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(),
)
})?;
row.map(|(id,)| id)
} else {
None
};
// Build query dynamically based on filters
let metrics: Vec<(i32, i32, chrono::DateTime<chrono::Utc>, i32, i32, i32)> =
if let Some(cid) = course_id {
sqlx::query_as(
"SELECT id, course_id, timestamp, enrollment, wait_count, seats_available \
FROM course_metrics \
WHERE course_id = $1 AND timestamp >= $2 \
ORDER BY timestamp DESC \
LIMIT $3",
)
.bind(cid)
.bind(since)
.bind(limit)
.fetch_all(&state.db_pool)
.await
} else {
sqlx::query_as(
"SELECT id, course_id, timestamp, enrollment, wait_count, seats_available \
FROM course_metrics \
WHERE timestamp >= $1 \
ORDER BY timestamp DESC \
LIMIT $2",
)
.bind(since)
.bind(limit)
.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(),
)
})?;
let count = metrics.len();
let metrics_json: Vec<Value> = 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,
})
},
)
.collect();
Ok(Json(json!({
"metrics": metrics_json,
"count": count,
"timestamp": chrono::Utc::now().to_rfc3339(),
})))
} }
// ============================================================ // ============================================================
// Course search & detail API // 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>,
#[serde(default = "default_metrics_limit")]
limit: i32,
}
fn default_metrics_limit() -> i32 {
500
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SubjectsParams { struct SubjectsParams {
term: String, term: String,
@@ -329,6 +439,7 @@ pub struct InstructorResponse {
is_primary: bool, is_primary: bool,
rmp_rating: Option<f32>, rmp_rating: Option<f32>,
rmp_num_ratings: Option<i32>, rmp_num_ratings: Option<i32>,
rmp_legacy_id: Option<i32>,
} }
#[derive(Serialize, TS)] #[derive(Serialize, TS)]
@@ -363,6 +474,7 @@ fn build_course_response(
is_primary: i.is_primary, is_primary: i.is_primary,
rmp_rating: i.avg_rating, rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings, rmp_num_ratings: i.num_ratings,
rmp_legacy_id: i.rmp_legacy_id,
}) })
.collect(); .collect();
+113
View File
@@ -210,3 +210,116 @@ async fn test_batch_upsert_unique_constraint_crn_term(pool: PgPool) {
assert_eq!(rows[1].0, "202520"); assert_eq!(rows[1].0, "202520");
assert_eq!(rows[1].1, 10); assert_eq!(rows[1].1, 10);
} }
#[sqlx::test]
async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
// Insert initial data — should NOT create audits/metrics (it's a fresh insert)
let initial = vec![helpers::make_course(
"50001",
"202510",
"CS",
"3443",
"App Programming",
10,
35,
0,
5,
)];
batch_upsert_courses(&initial, &pool).await.unwrap();
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
audit_count, 0,
"initial insert should not create audit entries"
);
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
metric_count, 0,
"initial insert should not create metric entries"
);
// Update enrollment and wait_count
let updated = vec![helpers::make_course(
"50001",
"202510",
"CS",
"3443",
"App Programming",
20,
35,
2,
5,
)];
batch_upsert_courses(&updated, &pool).await.unwrap();
// Should have audit entries for enrollment and wait_count changes
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert!(
audit_count >= 2,
"should have audit entries for enrollment and wait_count changes, got {audit_count}"
);
// Should have exactly 1 metric entry
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(metric_count, 1, "should have 1 metric snapshot");
// Verify metric values
let (enrollment, wait_count, seats): (i32, i32, i32) = sqlx::query_as(
"SELECT enrollment, wait_count, seats_available FROM course_metrics LIMIT 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 20);
assert_eq!(wait_count, 2);
assert_eq!(seats, 15); // 35 - 20
}
#[sqlx::test]
async fn test_batch_upsert_no_change_no_audit(pool: PgPool) {
// Insert then re-insert identical data — should produce zero audits/metrics
let course = vec![helpers::make_course(
"60001",
"202510",
"CS",
"1083",
"Intro to CS",
25,
30,
0,
5,
)];
batch_upsert_courses(&course, &pool).await.unwrap();
batch_upsert_courses(&course, &pool).await.unwrap();
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
audit_count, 0,
"identical re-upsert should not create audit entries"
);
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
metric_count, 0,
"identical re-upsert should not create metric entries"
);
}
+8 -5
View File
@@ -241,7 +241,7 @@ async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
json!({"subject": "CS"}), json!({"subject": "CS"}),
ScrapePriority::Medium, ScrapePriority::Medium,
true, true,
2, // retry_count 3, // retry_count (already used all 3 retries)
3, // max_retries 3, // max_retries
) )
.await; .await;
@@ -251,7 +251,7 @@ async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
.unwrap(); .unwrap();
assert!( assert!(
!has_retries, !has_retries,
"should NOT have retries remaining (2→3, max=3)" "should NOT have retries remaining (3→4, max=3)"
); );
let (retry_count,): (i32,) = let (retry_count,): (i32,) =
@@ -260,7 +260,7 @@ async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(retry_count, 3); assert_eq!(retry_count, 4);
} }
#[sqlx::test] #[sqlx::test]
@@ -346,7 +346,7 @@ async fn find_existing_payloads_returns_matching(pool: PgPool) {
} }
#[sqlx::test] #[sqlx::test]
async fn find_existing_payloads_ignores_locked(pool: PgPool) { async fn find_existing_payloads_includes_locked(pool: PgPool) {
let payload = json!({"subject": "CS"}); let payload = json!({"subject": "CS"});
helpers::insert_scrape_job( helpers::insert_scrape_job(
@@ -365,7 +365,10 @@ async fn find_existing_payloads_ignores_locked(pool: PgPool) {
.await .await
.unwrap(); .unwrap();
assert!(existing.is_empty(), "locked jobs should be ignored"); assert!(
existing.contains(&payload.to_string()),
"locked jobs should be included in deduplication"
);
} }
#[sqlx::test] #[sqlx::test]
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" class="no-transition"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+35 -1
View File
@@ -72,6 +72,29 @@ export interface AuditLogResponse {
entries: AuditLogEntry[]; 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;
}
export interface SearchParams { export interface SearchParams {
term: string; term: string;
subjects?: string[]; subjects?: string[];
@@ -144,7 +167,7 @@ export class BannerApiClient {
return this.request<User[]>("/admin/users"); return this.request<User[]>("/admin/users");
} }
async setUserAdmin(discordId: bigint, isAdmin: boolean): Promise<User> { async setUserAdmin(discordId: string, isAdmin: boolean): Promise<User> {
const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, { const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -161,6 +184,17 @@ export class BannerApiClient {
async getAdminAuditLog(): Promise<AuditLogResponse> { async getAdminAuditLog(): Promise<AuditLogResponse> {
return this.request<AuditLogResponse>("/admin/audit-log"); return this.request<AuditLogResponse>("/admin/audit-log");
} }
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
if (params?.term) query.set("term", params.term);
if (params?.crn) query.set("crn", params.crn);
if (params?.range) query.set("range", params.range);
if (params?.limit !== undefined) query.set("limit", String(params.limit));
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
} }
export const client = new BannerApiClient(); export const client = new BannerApiClient();
+281 -201
View File
@@ -7,13 +7,16 @@ import {
formatMeetingDaysLong, formatMeetingDaysLong,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
ratingColor, ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course"; } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte"; import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils"; import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte"; import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte"; import { Info, Copy, Check, Star, Triangle, ExternalLink } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props(); let { course }: { course: CourseResponse } = $props();
@@ -21,206 +24,283 @@ const clipboard = useClipboard();
</script> </script>
<div class="bg-muted/60 p-5 text-sm border-b border-border"> <div class="bg-muted/60 p-5 text-sm border-b border-border">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Instructors --> <!-- Instructors -->
<div> <div>
<h4 class="text-sm text-foreground mb-2"> <h4 class="text-sm text-foreground mb-2">Instructors</h4>
Instructors {#if course.instructors.length > 0}
</h4> <div class="flex flex-wrap gap-1.5">
{#if course.instructors.length > 0} {#each course.instructors as instructor}
<div class="flex flex-wrap gap-1.5"> <Tooltip.Root delayDuration={200}>
{#each course.instructors as instructor} <Tooltip.Trigger>
<Tooltip.Root delayDuration={200}> <span
<Tooltip.Trigger> 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"
<span >
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors" {instructor.displayName}
> {#if instructor.rmpRating != null}
{instructor.displayName} {@const rating = instructor.rmpRating}
{#if instructor.rmpRating != null} {@const lowConfidence =
{@const rating = instructor.rmpRating} (instructor.rmpNumRatings ?? 0) <
<span RMP_CONFIDENCE_THRESHOLD}
class="text-[10px] font-semibold {ratingColor(rating)}" <span
>{rating.toFixed(1)}</span> class="text-[10px] font-semibold inline-flex items-center gap-0.5"
{/if} style={ratingStyle(
</span> rating,
</Tooltip.Trigger> themeStore.isDark,
<Tooltip.Content )}
sideOffset={6} >
class={cn(tooltipContentClass, "px-3 py-2")} {rating.toFixed(1)}
> {#if lowConfidence}
<div class="space-y-1.5"> <Triangle
<div class="font-medium">{instructor.displayName}</div> class="size-2 fill-current"
{#if instructor.isPrimary} />
<div class="text-muted-foreground">Primary instructor</div> {:else}
{/if} <Star
{#if instructor.rmpRating != null} class="size-2.5 fill-current"
<div class="text-muted-foreground"> />
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings) {/if}
</div> </span>
{/if} {/if}
{#if instructor.email} </span>
<button </Tooltip.Trigger>
onclick={(e) => clipboard.copy(instructor.email!, e)} <Tooltip.Content
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" sideOffset={6}
> class={cn(tooltipContentClass, "px-3 py-2")}
{#if clipboard.copiedValue === instructor.email} >
<Check class="size-3" /> <div class="space-y-1.5">
<span>Copied!</span> <div class="font-medium">
{:else} {instructor.displayName}
<Copy class="size-3" /> </div>
<span>{instructor.email}</span> {#if instructor.isPrimary}
{/if} <div class="text-muted-foreground">
</button> Primary instructor
{/if} </div>
{/if}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{instructor.rmpRating.toFixed(1)}/5
· {instructor.rmpNumRatings ?? 0} ratings
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
</div>
{/if}
{#if instructor.rmpLegacyId != null}
<a
href={rmpUrl(
instructor.rmpLegacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="size-3" />
<span>View on RMP</span>
</a>
{/if}
{#if instructor.email}
<button
onclick={(e) =>
clipboard.copy(
instructor.email!,
e,
)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if clipboard.copiedValue === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else}
<Copy class="size-3" />
<span>{instructor.email}</span>
{/if}
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div> </div>
</Tooltip.Content> {:else}
</Tooltip.Root> <span class="text-muted-foreground italic">Staff</span>
{/each}
</div>
{:else}
<span class="text-muted-foreground italic">Staff</span>
{/if}
</div>
<!-- Meeting Times -->
<div>
<h4 class="text-sm text-foreground mb-2">
Meeting Times
</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground">TBA</span>
{:else}
<div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)}
<span class="font-medium text-foreground">
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span class="italic text-muted-foreground">Time TBA</span>
{/if}
</div>
{/if}
{#if mt.building || mt.room}
<div class="text-xs text-muted-foreground mt-0.5">
{mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}
</div>
{/if}
<div class="text-xs text-muted-foreground/70 mt-0.5">
{formatDate(mt.start_date)} &ndash; {formatDate(mt.end_date)}
</div>
</li>
{/each}
</ul>
{:else}
<span class="italic text-muted-foreground">TBA</span>
{/if}
</div>
<!-- Delivery -->
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<SimpleTooltip text="How the course is taught: in-person, online, hybrid, etc." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<span class="text-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
<span class="text-muted-foreground"> · {course.campus}</span>
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="text-sm text-foreground mb-2">
Credits
</h4>
<span class="text-foreground">{formatCreditHours(course)}</span>
</div>
<!-- Attributes -->
{#if course.attributes.length > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<SimpleTooltip text="Course flags for degree requirements, core curriculum, or special designations" delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<SimpleTooltip text="Course attribute code" delay={150} passthrough>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
{attr}
</span>
</SimpleTooltip>
{/each}
</div>
</div>
{/if}
<!-- Cross-list -->
{#if course.crossList}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<SimpleTooltip text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
{course.crossList}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
<span class="text-muted-foreground text-xs">
{course.crossListCount}/{course.crossListCapacity}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={tooltipContentClass}
>
Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
{course.crossListCount} enrolled across {course.crossListCapacity} shared seats
{/if} {/if}
</Tooltip.Content> </div>
</Tooltip.Root>
</div>
{/if}
<!-- Waitlist --> <!-- Meeting Times -->
{#if course.waitCapacity > 0} <div>
<div> <h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
<h4 class="text-sm text-foreground mb-2"> {#if course.meetingTimes.length > 0}
Waitlist <ul class="space-y-2">
</h4> {#each course.meetingTimes as mt}
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span> <li>
</div> {#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
{/if} <span class="italic text-muted-foreground"
</div> >TBA</span
>
{:else}
<div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)}
<span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(
mt.begin_time,
)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if}
</div>
{/if}
{#if mt.building || mt.room}
<div
class="text-xs text-muted-foreground mt-0.5"
>
{mt.building_description ??
mt.building}{mt.room
? ` ${mt.room}`
: ""}
</div>
{/if}
<div
class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.start_date)} &ndash; {formatDate(
mt.end_date,
)}
</div>
</li>
{/each}
</ul>
{:else}
<span class="italic text-muted-foreground">TBA</span>
{/if}
</div>
<!-- Delivery -->
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<SimpleTooltip
text="How the course is taught: in-person, online, hybrid, etc."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<span class="text-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
<span class="text-muted-foreground">
· {course.campus}
</span>
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="text-sm text-foreground mb-2">Credits</h4>
<span class="text-foreground">{formatCreditHours(course)}</span>
</div>
<!-- Attributes -->
{#if course.attributes.length > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<SimpleTooltip
text="Course flags for degree requirements, core curriculum, or special designations"
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<SimpleTooltip
text="Course attribute code"
delay={150}
passthrough
>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
{attr}
</span>
</SimpleTooltip>
{/each}
</div>
</div>
{/if}
<!-- Cross-list -->
{#if course.crossList}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<SimpleTooltip
text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<span
class="inline-flex items-center gap-1.5 text-foreground font-mono"
>
<span
class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"
>
{course.crossList}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
<span class="text-muted-foreground text-xs">
{course.crossListCount}/{course.crossListCapacity}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
Group <span class="font-mono font-medium"
>{course.crossList}</span
>
{#if course.crossListCount != null && course.crossListCapacity != null}
{course.crossListCount} enrolled across {course.crossListCapacity}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
<span class="text-2foreground"
>{course.waitCount} / {course.waitCapacity}</span
>
</div>
{/if}
</div>
</div> </div>
+155 -52
View File
@@ -15,8 +15,11 @@ import {
openSeats, openSeats,
seatsColor, seatsColor,
seatsDotColor, seatsDotColor,
ratingColor, ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course"; } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte"; import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte"; import CourseDetail from "./CourseDetail.svelte";
@@ -31,8 +34,19 @@ import {
type VisibilityState, type VisibilityState,
type Updater, type Updater,
} from "@tanstack/table-core"; } from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte"; import {
import { DropdownMenu, ContextMenu } from "bits-ui"; ArrowUp,
ArrowDown,
ArrowUpDown,
Columns3,
Check,
RotateCcw,
Star,
Triangle,
ExternalLink,
} from "@lucide/svelte";
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
import { cn, tooltipContentClass } from "$lib/utils";
import SimpleTooltip from "./SimpleTooltip.svelte"; import SimpleTooltip from "./SimpleTooltip.svelte";
let { let {
@@ -91,10 +105,16 @@ function primaryInstructorDisplay(course: CourseResponse): string {
return abbreviateInstructor(primary.displayName); return abbreviateInstructor(primary.displayName);
} }
function primaryRating(course: CourseResponse): { rating: number; count: number } | null { function primaryRating(
course: CourseResponse
): { rating: number; count: number; legacyId: number | null } | null {
const primary = getPrimaryInstructor(course.instructors); const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null; if (!primary?.rmpRating) return null;
return { rating: primary.rmpRating, count: primary.rmpNumRatings ?? 0 }; return {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
};
} }
function timeIsTBA(course: CourseResponse): boolean { function timeIsTBA(course: CourseResponse): boolean {
@@ -207,8 +227,7 @@ const table = createSvelteTable({
</GroupHeading> </GroupHeading>
{#each columns as col} {#each columns as col}
{@const id = col.id!} {@const id = col.id!}
{@const label = {@const label = typeof col.header === "string" ? col.header : id}
typeof col.header === "string" ? col.header : id}
<CheckboxItem <CheckboxItem
checked={columnVisibility[id] !== false} checked={columnVisibility[id] !== false}
closeOnSelect={false} closeOnSelect={false}
@@ -269,12 +288,12 @@ const table = createSvelteTable({
transition:fly={{ duration: 150, y: -10 }} transition:fly={{ duration: 150, y: -10 }}
> >
{@render columnVisibilityGroup( {@render columnVisibilityGroup(
DropdownMenu.Group, DropdownMenu.Group,
DropdownMenu.GroupHeading, DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem, DropdownMenu.CheckboxItem,
DropdownMenu.Separator, DropdownMenu.Separator,
DropdownMenu.Item, DropdownMenu.Item,
)} )}
</div> </div>
</div> </div>
{/if} {/if}
@@ -384,7 +403,10 @@ const table = createSvelteTable({
{@const course = row.original} {@const course = row.original}
<tbody <tbody
animate:flip={{ duration: 300 }} animate:flip={{ duration: 300 }}
in:fade={{ duration: 200, delay: Math.min(i * 20, 400) }} in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
}}
> >
<tr <tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
@@ -405,9 +427,15 @@ const table = createSvelteTable({
e, e,
)} )}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault(); e.preventDefault();
clipboard.copy(course.crn, e); clipboard.copy(
course.crn,
e,
);
} }
}} }}
aria-label="Copy CRN {course.crn} to clipboard" aria-label="Copy CRN {course.crn} to clipboard"
@@ -468,9 +496,12 @@ const table = createSvelteTable({
{@const primary = getPrimaryInstructor( {@const primary = getPrimaryInstructor(
course.instructors, course.instructors,
)} )}
{@const display = primaryInstructorDisplay(course)} {@const display =
{@const commaIdx = display.indexOf(", ")} primaryInstructorDisplay(course)}
{@const ratingData = primaryRating(course)} {@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"} {#if display === "Staff"}
<span <span
@@ -486,38 +517,98 @@ const table = createSvelteTable({
passthrough passthrough
> >
{#if commaIdx !== -1} {#if commaIdx !== -1}
<span>{display.slice(0, commaIdx)}, <span
<span class="text-muted-foreground">{display.slice(commaIdx + 1)}</span >{display.slice(
></span> 0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else} {:else}
<span>{display}</span> <span>{display}</span>
{/if} {/if}
</SimpleTooltip> </SimpleTooltip>
{/if} {/if}
{#if ratingData} {#if ratingData}
<SimpleTooltip {@const lowConfidence =
text="{ratingData.rating.toFixed( ratingData.count <
1, RMP_CONFIDENCE_THRESHOLD}
)}/5 ({ratingData.count} ratings on RateMyProfessors)" <Tooltip.Root
delay={150} delayDuration={150}
side="bottom"
passthrough
> >
<span <Tooltip.Trigger>
class="ml-1 text-xs font-medium {ratingColor( <span
ratingData.rating, class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
)}" style={ratingStyle(
>{ratingData.rating.toFixed( ratingData.rating,
1, themeStore.isDark,
)}★</span )}
>
{ratingData.rating.toFixed(
1,
)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
> >
</SimpleTooltip> <span
class="inline-flex items-center gap-1.5 text-xs"
>
{ratingData.rating.toFixed(
1,
)}/5 · {ratingData.count}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(
ratingData.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink
class="size-3"
/>
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/if} {/if}
</td> </td>
{:else if colId === "time"} {:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip <SimpleTooltip
text={formatMeetingTimesTooltip(course.meetingTimes)} text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough passthrough
> >
{#if timeIsTBA(course)} {#if timeIsTBA(course)}
@@ -566,10 +657,14 @@ const table = createSvelteTable({
</SimpleTooltip> </SimpleTooltip>
</td> </td>
{:else if colId === "location"} {:else if colId === "location"}
{@const concern = getDeliveryConcern(course)} {@const concern =
{@const accentColor = concernAccentColor(concern)} getDeliveryConcern(course)}
{@const locTooltip = formatLocationTooltip(course)} {@const accentColor =
{@const locDisplay = formatLocationDisplay(course)} concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip} {#if locTooltip}
<SimpleTooltip <SimpleTooltip
@@ -579,18 +674,26 @@ const table = createSvelteTable({
> >
<span <span
class="text-muted-foreground" class="text-muted-foreground"
class:pl-2={accentColor !== null} class:pl-2={accentColor !==
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined} null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
> >
{locDisplay ?? "—"} {locDisplay ?? "—"}
</span> </span>
</SimpleTooltip> </SimpleTooltip>
{:else if locDisplay} {:else if locDisplay}
<span class="text-muted-foreground"> <span
class="text-muted-foreground"
>
{locDisplay} {locDisplay}
</span> </span>
{:else} {:else}
<span class="text-xs text-muted-foreground/50">—</span> <span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if} {/if}
</td> </td>
{:else if colId === "seats"} {:else if colId === "seats"}
@@ -668,12 +771,12 @@ const table = createSvelteTable({
out:fade={{ duration: 100 }} out:fade={{ duration: 100 }}
> >
{@render columnVisibilityGroup( {@render columnVisibilityGroup(
ContextMenu.Group, ContextMenu.Group,
ContextMenu.GroupHeading, ContextMenu.GroupHeading,
ContextMenu.CheckboxItem, ContextMenu.CheckboxItem,
ContextMenu.Separator, ContextMenu.Separator,
ContextMenu.Item, ContextMenu.Item,
)} )}
</div> </div>
</div> </div>
{/if} {/if}
@@ -0,0 +1,47 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
let { key, children }: { key: string; children: Snippet } = $props();
const DURATION = 250;
const OFFSET = 40;
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `opacity: ${t}` };
}
const x = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
// Outgoing element is positioned absolutely so incoming flows normally
const base = "position: absolute; top: 0; left: 0; width: 100%";
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `${base}; opacity: ${t}` };
}
const x = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
};
}
</script>
<div class="relative overflow-hidden">
{#key key}
<div in:inTransition out:outTransition class="w-full">
{@render children()}
</div>
{/key}
</div>
+17 -13
View File
@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Select } from "bits-ui"; import { Select } from "bits-ui";
import { ChevronUp, ChevronDown } from "@lucide/svelte"; import { ChevronUp, ChevronDown } from "@lucide/svelte";
import { fly } from "svelte/transition"; import type { Action } from "svelte/action";
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
);
}
};
let { let {
totalCount, totalCount,
@@ -21,17 +33,8 @@ const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount)); const end = $derived(Math.min(offset + limit, totalCount));
// Track direction for slide animation // Track direction for slide animation
let prevPage = $state(1);
let direction = $state(0); let direction = $state(0);
$effect(() => {
const page = currentPage;
if (page !== prevPage) {
direction = page > prevPage ? 1 : -1;
prevPage = page;
}
});
// 5 page slots: current-2, current-1, current, current+1, current+2 // 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta)); const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
@@ -40,6 +43,7 @@ function isSlotVisible(page: number): boolean {
} }
function goToPage(page: number) { function goToPage(page: number) {
direction = page > currentPage ? 1 : -1;
onPageChange((page - 1) * limit); onPageChange((page - 1) * limit);
} }
@@ -86,7 +90,7 @@ const selectValue = $derived(String(currentPage));
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" 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" aria-label="Page {currentPage} of {totalPages}, click to select page"
> >
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span> <span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" /> <ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger> </Select.Trigger>
<Select.Portal> <Select.Portal>
@@ -140,8 +144,8 @@ const selectValue = $derived(String(currentPage));
aria-hidden={!isSlotVisible(page)} aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1} tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)} disabled={!isSlotVisible(page)}
in:fly={{ x: direction * 20, duration: 200 }} use:slideIn={direction}
> >
{page} {page}
</button> </button>
{/if} {/if}
+3
View File
@@ -193,6 +193,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false, isPrimary: false,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
{ {
bannerId: "2", bannerId: "2",
@@ -201,6 +202,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: true, isPrimary: true,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
]; ];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B"); expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
@@ -214,6 +216,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false, isPrimary: false,
rmpRating: null, rmpRating: null,
rmpNumRatings: null, rmpNumRatings: null,
rmpLegacyId: null,
}, },
]; ];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A"); expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
+43 -5
View File
@@ -362,11 +362,49 @@ export function seatsDotColor(course: CourseResponse): string {
return "bg-green-500"; return "bg-green-500";
} }
/** Text color class for a RateMyProfessors rating */ /** Minimum number of ratings needed to consider RMP data reliable */
export function ratingColor(rating: number): string { export const RMP_CONFIDENCE_THRESHOLD = 7;
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500"; /** RMP professor page URL from legacy ID */
return "text-status-red"; export function rmpUrl(legacyId: number): string {
return `https://www.ratemyprofessors.com/professor/${legacyId}`;
}
/**
* Smooth OKLCH color + text-shadow for a RateMyProfessors rating.
*
* Three-stop gradient interpolated in OKLCH:
* 1.0 → red, 3.0 → amber, 5.0 → green
* with separate light/dark mode tuning.
*/
export function ratingStyle(rating: number, isDark: boolean): string {
const clamped = Math.max(1, Math.min(5, rating));
// OKLCH stops: [lightness, chroma, hue]
const stops: { light: [number, number, number]; dark: [number, number, number] }[] = [
{ light: [0.63, 0.2, 25], dark: [0.7, 0.19, 25] }, // 1.0 red
{ light: [0.7, 0.16, 85], dark: [0.78, 0.15, 85] }, // 3.0 amber
{ light: [0.65, 0.2, 145], dark: [0.72, 0.19, 145] }, // 5.0 green
];
let t: number;
let fromIdx: number;
if (clamped <= 3) {
t = (clamped - 1) / 2;
fromIdx = 0;
} else {
t = (clamped - 3) / 2;
fromIdx = 1;
}
const from = isDark ? stops[fromIdx].dark : stops[fromIdx].light;
const to = isDark ? stops[fromIdx + 1].dark : stops[fromIdx + 1].light;
const l = from[0] + (to[0] - from[0]) * t;
const c = from[1] + (to[1] - from[1]) * t;
const h = from[2] + (to[2] - from[2]) * t;
return `color: oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)}); text-shadow: 0 0 4px oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)} / 0.3);`;
} }
/** Format credit hours display */ /** Format credit hours display */
+45
View File
@@ -0,0 +1,45 @@
import { beforeNavigate } from "$app/navigation";
export type NavDirection = "left" | "right" | "fade";
/** Admin sidebar order — indexes determine slide direction for same-depth siblings */
const ADMIN_NAV_ORDER = ["/admin", "/admin/scrape-jobs", "/admin/audit-log", "/admin/users"];
function getDepth(path: string): number {
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
}
function getAdminIndex(path: string): number {
return ADMIN_NAV_ORDER.indexOf(path);
}
function computeDirection(from: string, to: string): NavDirection {
const fromDepth = getDepth(from);
const toDepth = getDepth(to);
if (toDepth > fromDepth) return "right";
if (toDepth < fromDepth) return "left";
// Same depth — use admin sidebar ordering if both are admin routes
const fromIdx = getAdminIndex(from);
const toIdx = getAdminIndex(to);
if (fromIdx >= 0 && toIdx >= 0) {
return toIdx > fromIdx ? "right" : "left";
}
return "fade";
}
class NavigationStore {
direction: NavDirection = $state("fade");
}
export const navigationStore = new NavigationStore();
/** Call once from root layout to start tracking navigation direction */
export function initNavigation() {
beforeNavigate(({ from, to }) => {
if (!from?.url || !to?.url) return;
navigationStore.direction = computeDirection(from.url.pathname, to.url.pathname);
});
}
+11 -8
View File
@@ -1,14 +1,19 @@
<script lang="ts"> <script lang="ts">
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css"; import "./layout.css";
import { onMount } from "svelte"; import { page } from "$app/state";
import { Tooltip } from "bits-ui"; import PageTransition from "$lib/components/PageTransition.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { Tooltip } from "bits-ui";
import { onMount } from "svelte";
let { children } = $props(); let { children } = $props();
initNavigation();
useOverlayScrollbars(() => document.body, { useOverlayScrollbars(() => document.body, {
scrollbars: { scrollbars: {
autoHide: "leave", autoHide: "leave",
@@ -18,10 +23,6 @@ useOverlayScrollbars(() => document.body, {
onMount(() => { onMount(() => {
themeStore.init(); themeStore.init();
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition");
});
}); });
</script> </script>
@@ -30,5 +31,7 @@ onMount(() => {
<ThemeToggle /> <ThemeToggle />
</div> </div>
{@render children()} <PageTransition key={page.url.pathname}>
{@render children()}
</PageTransition>
</Tooltip.Provider> </Tooltip.Provider>
+7 -3
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte"; import { authStore } from "$lib/auth.svelte";
import { LayoutDashboard, Users, ClipboardList, FileText, LogOut } from "@lucide/svelte"; import PageTransition from "$lib/components/PageTransition.svelte";
import { ClipboardList, FileText, LayoutDashboard, LogOut, Users } from "@lucide/svelte";
import { onMount } from "svelte";
let { children } = $props(); let { children } = $props();
@@ -68,7 +70,9 @@ const navItems = [
</div> </div>
</aside> </aside>
<main class="flex-1 overflow-auto p-6"> <main class="flex-1 overflow-auto p-6">
{@render children()} <PageTransition key={page.url.pathname}>
{@render children()}
</PageTransition>
</main> </main>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Shield, ShieldOff } from "@lucide/svelte";
let users = $state<User[]>([]); let users = $state<User[]>([]);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let updating = $state<bigint | null>(null); let updating = $state<string | null>(null);
onMount(async () => { onMount(async () => {
try { try {
+1 -9
View File
@@ -57,11 +57,8 @@
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
} }
* {
border-color: var(--border);
}
body { body {
border-color: var(--border);
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -129,11 +126,6 @@ input[type="checkbox"]:checked::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
} }
html:not(.no-transition) body,
html:not(.no-transition) body * {
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
}
/* View Transitions API - disable default cross-fade so JS can animate clip-path */ /* View Transitions API - disable default cross-fade so JS can animate clip-path */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {