9 Commits

Author SHA1 Message Date
b02a0738e2 chore: bump to v0.6.0, update roadmap & changelog 2026-01-30 20:37:31 -06:00
5d7d60cd96 fix: prevent session pool deadlock on acquire cancellation
Replace is_creating mutex with atomic flag and RAII guard to ensure
proper cleanup when acquire() futures are cancelled mid-creation,
preventing permanent deadlock for subsequent callers.
2026-01-30 20:19:10 -06:00
1954166db6 feat: add name parsing and normalization for instructor-RMP matching 2026-01-30 20:02:59 -06:00
a2a9116b7a fix: avoid clipping page content 2026-01-30 19:32:05 -06:00
a103f0643a feat: refactor admin instructor UI with component extraction and optimistic updates 2026-01-30 19:31:31 -06:00
474d519b9d feat: add auto-format recovery when formatting is sole check failure
Enhances check recipe to detect when only formatting checks fail while
peers pass, automatically applies formatters, then re-verifies. Supports
both Rust (rustfmt + cargo-check) and web (biome + svelte-check)
domains. Displays results eagerly as checks complete instead of in
original order.
2026-01-30 16:01:56 -06:00
fb27bdc119 feat: implement session expiry extension and 401 recovery 2026-01-30 16:01:17 -06:00
669dec0235 feat: add timeline API with schedule-aware enrollment aggregation
Implements POST /api/timeline endpoint that aggregates enrollment by
subject over 15-minute slots, filtering courses by their actual meeting
times. Includes ISR-style schedule cache with hourly background refresh
using stale-while-revalidate pattern, database indexes for efficient
queries, and frontend refactor to dynamically discover subjects from API.
2026-01-30 10:56:11 -06:00
67ba63339a fix: instructor/course mismatching, build order-independent map for association 2026-01-30 09:53:03 -06:00
48 changed files with 3383 additions and 847 deletions
Generated
+64 -5
View File
@@ -272,7 +272,7 @@ dependencies = [
[[package]]
name = "banner"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"async-trait",
@@ -292,6 +292,7 @@ dependencies = [
"futures",
"governor",
"html-escape",
"htmlize",
"http 1.3.1",
"mime_guess",
"num-format",
@@ -315,6 +316,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ts-rs",
"unicode-normalization",
"url",
"urlencoding",
"yansi",
@@ -492,7 +494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
"phf 0.12.1",
]
[[package]]
@@ -1348,6 +1350,19 @@ dependencies = [
"utf8-width",
]
[[package]]
name = "htmlize"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d347c0de239be20ba0982e4822de3124404281e119ae3e11f5d7425a414e1935"
dependencies = [
"memchr",
"pastey",
"phf 0.11.3",
"phf_codegen",
"serde_json",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -2100,6 +2115,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pear"
version = "0.2.9"
@@ -2138,13 +2159,51 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
"phf_shared 0.12.1",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.5",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
@@ -3982,9 +4041,9 @@ checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-normalization"
version = "0.1.24"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "banner"
version = "0.5.0"
version = "0.6.0"
edition = "2024"
default-run = "banner"
@@ -60,6 +60,8 @@ html-escape = "0.2.13"
axum-extra = { version = "0.12.5", features = ["query"] }
urlencoding = "2.1.3"
chrono-tz = "0.10.4"
htmlize = { version = "1.0.6", features = ["unescape"] }
unicode-normalization = "0.1.25"
[dev-dependencies]
+87 -6
View File
@@ -27,8 +27,28 @@ check *flags:
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
}
// Domain groups: format check name → { peers (other checks), formatter, sanity re-check }
const domains = {
rustfmt: {
peers: ["clippy", "rust-test"],
format: () => run(["cargo", "fmt", "--all"]),
recheck: [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
],
},
biome: {
peers: ["svelte-check", "web-test"],
format: () => run(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
],
},
};
const checks = [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"], terse: true },
{ 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"] },
@@ -60,16 +80,23 @@ check *flags:
process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
}, 100) : null;
// Phase 1: collect all results, eagerly displaying whichever finishes first
const results = {};
let anyFailed = false;
for (const promise of promises) {
const r = await promise;
const tagged = promises.map((p, i) => p.then(r => ({ i, r })));
for (let n = 0; n < checks.length; n++) {
const { i, r } = await Promise.race(tagged);
tagged[i] = new Promise(() => {}); // sentinel: never resolves
results[r.name] = r;
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);
if (!r.terse) {
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`);
}
@@ -77,7 +104,61 @@ check *flags:
if (interval) clearInterval(interval);
if (isTTY) process.stderr.write(`\r\x1b[K`);
process.exit(anyFailed ? 1 : 0);
// Phase 2: auto-fix formatting if it's the only failure in a domain
let autoFixed = false;
for (const [fmtName, domain] of Object.entries(domains)) {
const fmtResult = results[fmtName];
if (!fmtResult || fmtResult.exitCode === 0) continue;
const peersAllPassed = domain.peers.every(p => results[p]?.exitCode === 0);
if (!peersAllPassed) continue;
process.stdout.write(`\n\x1b[1;36m→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...\x1b[0m\n`);
domain.format();
// Re-verify format + sanity check in parallel
const recheckStart = Date.now();
const recheckPromises = domain.recheck.map(async (check) => {
const proc = Bun.spawn(check.cmd, {
env: { ...process.env, FORCE_COLOR: "1" },
stdout: "pipe", stderr: "pipe",
});
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() - recheckStart) / 1000).toFixed(1) };
});
let recheckFailed = false;
for (const p of recheckPromises) {
const r = await p;
if (r.exitCode !== 0) {
recheckFailed = 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 (!recheckFailed) {
process.stdout.write(`\x1b[32m ✓ ${fmtName} auto-fix succeeded\x1b[0m\n`);
results[fmtName].exitCode = 0;
autoFixed = true;
} else {
process.stdout.write(`\x1b[31m ✗ ${fmtName} auto-fix failed sanity check\x1b[0m\n`);
}
}
const finalFailed = Object.values(results).some(r => r.exitCode !== 0);
if (autoFixed && !finalFailed) {
process.stdout.write(`\n\x1b[1;32m✓ All checks passed (formatting was auto-fixed)\x1b[0m\n`);
}
process.exit(finalFailed ? 1 : 0);
# Format all Rust and TypeScript code
format:
+43
View File
@@ -6,6 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.6.0] - 2026-01-30
### Added
- User authentication system with Discord OAuth, sessions, admin roles, and login page with FAQ.
- Interactive timeline visualization with D3 canvas, pan/zoom, touch gestures, and enrollment aggregation API.
- Scraper analytics dashboard with timeseries charts, subject monitoring, and per-subject detail views.
- Adaptive scraper scheduling with admin endpoints for monitoring and configuration.
- Scrape job result persistence for effectiveness tracking.
- WebSocket support for real-time scrape job monitoring with connection status indicators.
- Course change auditing with field-level tracking and time-series metrics endpoint.
- Audit log UI with smart JSON diffing, conditional request caching, and auto-refresh.
- Calendar export web endpoints for ICS download and Google Calendar redirect.
- Confidence-based RMP matching with manual review workflow and admin instructor UI.
- RMP profile links and confidence-aware rating display.
- Name parsing and normalization for improved instructor-RMP matching.
- Mobile touch controls with gesture detection for timeline.
- Worker timeout protection and crash recovery for job queue.
- Build-time asset compression with encoding negotiation (gzip, brotli, zstd).
- Smart page transitions with theme-aware element transitions.
- Search duration and result count feedback.
- Root error page handling.
- Login page with FAQ section and improved styling.
### Changed
- Consolidated navigation with top nav bar and route groups.
- Centralized number formatting with locale-aware utility.
- Modernized Justfile commands and simplified service management.
- Persisted audit log state in module scope for cross-navigation caching.
- Relative time feedback and improved tooltip customization.
### Fixed
- Instructor/course mismatching via build-order-independent map for association.
- Page content clipping.
- Backend startup delays with retry logic in auth.
- Banner API timeouts increased to handle slow responses.
- i64 serialization for JavaScript compatibility, fixing avatar URL display.
- Frontend build ordering with `-e` embed flag in Justfile.
- Login page centering and unnecessary scrollbar.
- ts-rs serde warnings.
## [0.5.0] - 2026-01-29
### Added
+23 -14
View File
@@ -2,34 +2,43 @@
## Now
- **Notification and subscription system** - Subscribe to courses and get alerts on seat availability, waitlist movement, and detail changes (time, location, professor, seats). DB schema exists.
- **Discord bot revival** - Audit and fix all existing commands (search, terms, ics, gcal) against the current data model. Add test coverage. Bot has been untouched since ~0.3.4 and commands may be broken.
- **Notification and subscription system** - Subscribe to courses and get alerts on seat availability, waitlist movement, and detail changes (time, location, professor, seats). Deliver via Discord bot and web dashboard.
- **Mobile/responsive redesign** - Hamburger nav for sidebar, responsive table column hiding, mobile-friendly admin pages. Timeline is the only area with solid mobile support; most pages need work.
- **Professor name search filter** - Filter search results by instructor. Backend code exists but is commented out.
- **Autocomplete for search fields** - Typeahead for course titles, course numbers, professors, and terms.
- **Test coverage expansion** - Broaden coverage with session/rate-limiter tests and more DB integration tests.
- **Search field autocomplete** - Typeahead for course titles, course numbers, professors, and terms.
- **Large component extraction** - Break down CourseTable, Instructors page, and TimelineCanvas into smaller, testable subcomponents.
## Soon
- **Smart time-of-day search parsing** - Support natural queries like "2 PM", "2-3 PM", "ends by 2 PM", "after 2 PM", "before 2 PM" mapped to time ranges.
- **Section-based lookup** - Search by full section identifier, e.g. "CS 4393 001".
- **Search result pagination** - Paginated embeds for large result sets in Discord.
- **Bot slash command parity** - Keep Discord bot commands in sync with web features: timeline summaries, RMP lookups, audit log highlights, notification management via bot.
- **E2E test suite** - Playwright tests for critical user flows: search, login, admin pages, timeline interaction.
- **Settings page** - Replace placeholder with theme preferences, notification settings, default term/subject selection.
- **Profile enhancements** - Expand from read-only stub to subscription management, saved searches, and course watchlists.
- **Smart time-of-day search parsing** - Support natural queries like "2 PM", "ends by 2 PM", "after 2 PM" mapped to time ranges.
- **Multi-term querying** - Query across multiple terms in a single search instead of one at a time.
- **Historical analytics** - Track seat availability over time and visualize fill-rate trends per course or professor.
- **Schedule builder** - Visual weekly schedule tool for assembling a conflict-free course lineup.
- **Professor stats** - Aggregate data views: average class size, typical waitlist length, schedule patterns across semesters.
- **Historical analytics visualization** - Build trend UI on top of existing course metrics and timeline API. Fill-rate charts per course or professor.
- **Schedule builder** - Visual weekly schedule tool for assembling a conflict-free course lineup. Timeline visualization serves as a foundation.
## Eventually
- **API rate limiting** - Rate limiter on public API endpoints. Needed before any public or external exposure.
- **Bulk admin operations** - Batch RMP match/reject, bulk user management, data export from admin pages.
- **Degree audit helper** - Map available courses to degree requirements and suggest what to take next.
- **Dynamic scraper scheduling** - Adjust scrape intervals based on change frequency and course count (e.g. 2 hours per 500 courses, shorter intervals when changes are detected).
- **DM support** - Allow the Discord bot to respond in direct messages, not just guild channels.
- **"Classes Now" command** - Find classes currently in session based on the current day and time.
- **CRN direct lookup** - Look up a course by its CRN without going through search.
- **Metrics dashboard** - Surface scraper and service metrics visually on the web dashboard.
- **Privileged error feedback** - Detailed error information surfaced to bot admins when commands fail.
## Done
- **Interactive timeline visualization** - D3 canvas with pan/zoom, touch gestures, and enrollment aggregation API. *(0.6.0)*
- **Scraper analytics dashboard** - Timeseries charts, subject monitoring, adaptive scheduling, and admin endpoints. *(0.6.0)*
- **WebSocket job monitoring** - Real-time scrape job queue with live connection status indicators. *(0.6.0)*
- **Course change audit log** - Field-level change tracking with smart diffing, conditional caching, and auto-refresh. *(0.6.0)*
- **User authentication system** - Discord OAuth, sessions, admin roles, and login page. *(0.6.0)*
- **Dynamic scraper scheduling** - Adaptive scrape intervals based on change frequency and course volume. *(0.6.0)*
- **Metrics dashboard** - Scraper and service metrics surfaced on the web dashboard. *(0.6.0)*
- **Subject/major search filter** - Multi-select subject filtering with searchable comboboxes. *(0.5.0)*
- **Web course search UI** - Browser-based course search with interactive data table, sorting, pagination, and column controls. *(0.4.0)*
- **RateMyProfessor integration** - Bulk professor sync via GraphQL with inline ratings in search results. *(0.4.0)*
- **Subject/major search filter** - Multi-select subject filtering with searchable comboboxes. *(0.5.0)*
- **Test coverage expansion** - Unit tests for course formatting, API client, query builder, CLI args, and config parsing. *(0.3.40.4.0)*
- **Test coverage expansion** - Unit tests for course formatting, API client, query builder, CLI args, and config parsing. *(0.3.4--0.4.0)*
@@ -0,0 +1,13 @@
-- Indexes for the timeline aggregation endpoint.
-- The query buckets course_metrics by 15-minute intervals, joins to courses
-- for subject, and aggregates enrollment. These indexes support efficient
-- time-range scans and the join.
-- Primary access pattern: scan course_metrics by timestamp range
CREATE INDEX IF NOT EXISTS idx_course_metrics_timestamp
ON course_metrics (timestamp);
-- Composite index for the DISTINCT ON (bucket, course_id) ordered by timestamp DESC
-- to efficiently pick the latest metric per course per bucket.
CREATE INDEX IF NOT EXISTS idx_course_metrics_course_timestamp
ON course_metrics (course_id, timestamp DESC);
@@ -0,0 +1,5 @@
-- Add structured first/last name columns to instructors.
-- Populated by Rust-side backfill (parse_banner_name) since we need
-- HTML entity decoding and suffix extraction that SQL can't handle well.
ALTER TABLE instructors ADD COLUMN first_name VARCHAR;
ALTER TABLE instructors ADD COLUMN last_name VARCHAR;
+11 -1
View File
@@ -14,7 +14,7 @@ use sqlx::postgres::PgPoolOptions;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info};
use tracing::{error, info, warn};
/// Main application struct containing all necessary components
pub struct App {
@@ -70,6 +70,11 @@ impl App {
.context("Failed to run database migrations")?;
info!("Database migrations completed successfully");
// Backfill structured name columns for existing instructors
if let Err(e) = crate::data::names::backfill_instructor_names(&db_pool).await {
warn!(error = ?e, "Failed to backfill instructor names (non-fatal)");
}
// Create BannerApi and AppState
let banner_api = BannerApi::new_with_config(
config.banner_base_url.clone(),
@@ -85,6 +90,11 @@ impl App {
info!(error = ?e, "Could not load reference cache on startup (may be empty)");
}
// Load schedule cache for timeline enrollment queries
if let Err(e) = app_state.schedule_cache.load().await {
info!(error = ?e, "Could not load schedule cache on startup (may be empty)");
}
// Seed the initial admin user if configured
if let Some(admin_id) = config.admin_discord_id {
let user = crate::data::users::ensure_seed_admin(&db_pool, admin_id as i64)
+4
View File
@@ -325,6 +325,7 @@ mod tests {
fn test_parse_json_with_context_null_value() {
#[derive(Debug, Deserialize)]
struct TestStruct {
#[allow(dead_code)]
name: String,
}
@@ -363,12 +364,14 @@ mod tests {
#[allow(dead_code)]
#[serde(rename = "courseTitle")]
course_title: String,
#[allow(dead_code)]
faculty: Vec<Faculty>,
}
#[derive(Debug, Deserialize)]
struct Faculty {
#[serde(rename = "displayName")]
#[allow(dead_code)]
display_name: String,
#[allow(dead_code)]
email: String,
@@ -376,6 +379,7 @@ mod tests {
#[derive(Debug, Deserialize)]
struct SearchResult {
#[allow(dead_code)]
data: Vec<Course>,
}
+112 -64
View File
@@ -11,7 +11,9 @@ use rand::distr::{Alphanumeric, SampleString};
use reqwest_middleware::ClientWithMiddleware;
use std::collections::{HashMap, VecDeque};
use std::mem::ManuallyDrop;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, Notify};
@@ -121,6 +123,64 @@ impl BannerSession {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// Verifies that cancelling `acquire()` mid-session-creation resets `is_creating`,
/// allowing subsequent callers to proceed rather than deadlocking.
#[tokio::test]
async fn test_acquire_not_deadlocked_after_cancellation() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel::<()>(10);
// Local server: /registration signals arrival via `tx`, then hangs forever.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = axum::Router::new().route(
"/StudentRegistrationSsb/registration",
axum::routing::get(move || {
let tx = tx.clone();
async move {
let _ = tx.send(()).await;
std::future::pending::<&str>().await
}
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base_url = format!("http://{}/StudentRegistrationSsb", addr);
let client = reqwest_middleware::ClientBuilder::new(
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.unwrap(),
)
.build();
let pool = SessionPool::new(client, base_url);
let term: Term = "202620".parse().unwrap();
// First acquire: cancel once the request reaches the server.
tokio::select! {
_ = pool.acquire(term) => panic!("server hangs — acquire should never complete"),
_ = rx.recv() => {} // Request arrived; dropping the future simulates timeout cancellation.
}
// Second acquire: verify it reaches the server (i.e., is_creating was reset).
// The global rate limiter has a 10s period, so allow 15s for the second attempt.
tokio::select! {
_ = pool.acquire(term) => {}
result = tokio::time::timeout(Duration::from_secs(15), rx.recv()) => {
assert!(
result.is_ok(),
"acquire() deadlocked — is_creating was not reset after cancellation"
);
}
}
}
#[test]
fn test_new_session_creates_session() {
@@ -200,50 +260,53 @@ mod tests {
}
}
/// A smart pointer that returns a BannerSession to the pool when dropped.
/// A smart pointer that returns a `BannerSession` to the pool when dropped.
pub struct PooledSession {
session: Option<BannerSession>,
// This Arc points directly to the term-specific pool.
session: ManuallyDrop<BannerSession>,
pool: Arc<TermPool>,
}
impl PooledSession {
pub fn been_used(&self) -> bool {
self.session.as_ref().unwrap().been_used()
}
}
impl Deref for PooledSession {
type Target = BannerSession;
fn deref(&self) -> &Self::Target {
// The option is only ever None after drop is called, so this is safe.
self.session.as_ref().unwrap()
&self.session
}
}
impl DerefMut for PooledSession {
fn deref_mut(&mut self) -> &mut Self::Target {
self.session.as_mut().unwrap()
&mut self.session
}
}
/// The magic happens here: when the guard goes out of scope, this is called.
impl Drop for PooledSession {
fn drop(&mut self) {
if let Some(session) = self.session.take() {
let pool = self.pool.clone();
// Since drop() cannot be async, we spawn a task to return the session.
tokio::spawn(async move {
pool.release(session).await;
});
}
// SAFETY: `drop` is called exactly once by Rust's drop semantics,
// so `ManuallyDrop::take` is guaranteed to see a valid value.
let session = unsafe { ManuallyDrop::take(&mut self.session) };
let pool = self.pool.clone();
tokio::spawn(async move {
pool.release(session).await;
});
}
}
pub struct TermPool {
sessions: Mutex<VecDeque<BannerSession>>,
notifier: Notify,
is_creating: Mutex<bool>,
is_creating: AtomicBool,
}
/// RAII guard ensuring `is_creating` is reset on drop for cancellation safety.
/// Without this, a cancelled `acquire()` future would leave the flag set permanently,
/// deadlocking all subsequent callers.
struct CreatingGuard(Arc<TermPool>);
impl Drop for CreatingGuard {
fn drop(&mut self) {
self.0.is_creating.store(false, Ordering::Release);
self.0.notifier.notify_waiters();
}
}
impl TermPool {
@@ -251,7 +314,7 @@ impl TermPool {
Self {
sessions: Mutex::new(VecDeque::new()),
notifier: Notify::new(),
is_creating: Mutex::new(false),
is_creating: AtomicBool::new(false),
}
}
@@ -308,7 +371,7 @@ impl SessionPool {
if let Some(session) = queue.pop_front() {
if !session.is_expired() {
return Ok(PooledSession {
session: Some(session),
session: ManuallyDrop::new(session),
pool: Arc::clone(&term_pool),
});
} else {
@@ -317,45 +380,38 @@ impl SessionPool {
}
} // MutexGuard is dropped, lock is released.
// Slow path: No sessions available. We must either wait or become the creator.
let mut is_creating_guard = term_pool.is_creating.lock().await;
if *is_creating_guard {
// Another task is already creating a session. Release the lock and wait.
drop(is_creating_guard);
// Slow path: wait for an in-progress creation, or become the creator.
if term_pool.is_creating.load(Ordering::Acquire) {
if !waited_for_creation {
trace!("Waiting for another task to create session");
waited_for_creation = true;
}
term_pool.notifier.notified().await;
// Loop back to the top to try the fast path again.
continue;
}
// This task is now the designated creator.
*is_creating_guard = true;
drop(is_creating_guard);
// CAS to become the designated creator.
if term_pool
.is_creating
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
continue; // Lost the race — loop back and wait.
}
// Guard resets is_creating on drop (including cancellation).
let creating_guard = CreatingGuard(Arc::clone(&term_pool));
// Race: wait for a session to be returned OR for the rate limiter to allow a new one.
trace!("Pool empty, creating new session");
tokio::select! {
_ = term_pool.notifier.notified() => {
// A session was returned while we were waiting!
// We are no longer the creator. Reset the flag and loop to race for the new session.
let mut guard = term_pool.is_creating.lock().await;
*guard = false;
drop(guard);
// A session was returned — release creator role and race for it.
drop(creating_guard);
continue;
}
_ = SESSION_CREATION_RATE_LIMITER.until_ready() => {
// The rate limit has elapsed. It's our job to create the session.
let new_session_result = self.create_session(&term).await;
// After creation, we are no longer the creator. Reset the flag
// and notify all other waiting tasks.
let mut guard = term_pool.is_creating.lock().await;
*guard = false;
drop(guard);
term_pool.notifier.notify_waiters();
drop(creating_guard);
match new_session_result {
Ok(new_session) => {
@@ -366,12 +422,11 @@ impl SessionPool {
"Created new session"
);
return Ok(PooledSession {
session: Some(new_session),
session: ManuallyDrop::new(new_session),
pool: term_pool,
});
}
Err(e) => {
// Propagate the error if session creation failed.
return Err(e.context("Failed to create new session in pool"));
}
}
@@ -380,8 +435,8 @@ impl SessionPool {
}
}
/// Sets up initial session cookies by making required Banner API requests
pub async fn create_session(&self, term: &Term) -> Result<BannerSession> {
/// Sets up initial session cookies by making required Banner API requests.
async fn create_session(&self, term: &Term) -> Result<BannerSession> {
info!(term = %term, "setting up banner session");
// The 'register' or 'search' registration page
@@ -392,22 +447,15 @@ impl SessionPool {
.await?;
// TODO: Validate success
let cookies = initial_registration
let cookies: HashMap<String, String> = initial_registration
.headers()
.get_all("Set-Cookie")
.iter()
.filter_map(|header_value| {
if let Ok(cookie_str) = header_value.to_str() {
if let Ok(cookie) = Cookie::parse(cookie_str) {
Some((cookie.name().to_string(), cookie.value().to_string()))
} else {
None
}
} else {
None
}
.filter_map(|v| {
let c = Cookie::parse(v.to_str().ok()?).ok()?;
Some((c.name().to_string(), c.value().to_string()))
})
.collect::<HashMap<String, String>>();
.collect();
let jsessionid = cookies
.get("JSESSIONID")
@@ -494,8 +542,8 @@ impl SessionPool {
Ok(terms)
}
/// Selects a term for the current session
pub async fn select_term(
/// Selects a term for the current session.
async fn select_term(
&self,
term: &str,
unique_session_id: &str,
+43 -8
View File
@@ -2,6 +2,7 @@
use crate::banner::Course;
use crate::data::models::{DbMeetingTime, UpsertCounts};
use crate::data::names::parse_banner_name;
use crate::error::Result;
use sqlx::PgConnection;
use sqlx::PgPool;
@@ -68,6 +69,8 @@ fn extract_campus_code(course: &Course) -> Option<String> {
struct UpsertDiffRow {
id: i32,
old_id: Option<i32>,
crn: String,
term_code: String,
// enrollment fields
old_enrollment: Option<i32>,
@@ -382,8 +385,14 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
// Step 1: Upsert courses with CTE, returning diff rows
let diff_rows = upsert_courses(courses, &mut tx).await?;
// Step 2: Extract course IDs for instructor linking
let course_ids: Vec<i32> = diff_rows.iter().map(|r| r.id).collect();
// Step 2: Build (crn, term_code) → course_id map for instructor linking.
// RETURNING order from INSERT ... ON CONFLICT is not guaranteed to match
// the input array order, so we must key by (crn, term_code) rather than
// relying on positional correspondence.
let crn_term_to_id: HashMap<(&str, &str), i32> = diff_rows
.iter()
.map(|r| ((r.crn.as_str(), r.term_code.as_str()), r.id))
.collect();
// Step 3: Compute audit/metric diffs
let (audits, metrics) = compute_diffs(&diff_rows);
@@ -409,7 +418,7 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
let email_to_id = upsert_instructors(courses, &mut tx).await?;
// Step 6: Link courses to instructors via junction table
upsert_course_instructors(courses, &course_ids, &email_to_id, &mut tx).await?;
upsert_course_instructors(courses, &crn_term_to_id, &email_to_id, &mut tx).await?;
tx.commit().await?;
@@ -556,6 +565,7 @@ async fn upsert_courses(courses: &[Course], conn: &mut PgConnection) -> Result<V
)
SELECT u.id,
o.id AS old_id,
u.crn, u.term_code,
o.enrollment AS old_enrollment, u.enrollment AS new_enrollment,
o.max_enrollment AS old_max_enrollment, u.max_enrollment AS new_max_enrollment,
o.wait_count AS old_wait_count, u.wait_count AS new_wait_count,
@@ -619,6 +629,8 @@ async fn upsert_instructors(
) -> Result<HashMap<String, i32>> {
let mut seen = HashSet::new();
let mut display_names: Vec<&str> = Vec::new();
let mut first_names: Vec<Option<String>> = Vec::new();
let mut last_names: Vec<Option<String>> = Vec::new();
let mut emails_lower: Vec<String> = Vec::new();
let mut skipped_no_email = 0u32;
@@ -627,7 +639,10 @@ async fn upsert_instructors(
if let Some(email) = &faculty.email_address {
let email_lower = email.to_lowercase();
if seen.insert(email_lower.clone()) {
let parts = parse_banner_name(&faculty.display_name);
display_names.push(faculty.display_name.as_str());
first_names.push(parts.as_ref().map(|p| p.first.clone()));
last_names.push(parts.as_ref().map(|p| p.last.clone()));
emails_lower.push(email_lower);
}
} else {
@@ -648,18 +663,25 @@ async fn upsert_instructors(
}
let email_refs: Vec<&str> = emails_lower.iter().map(|s| s.as_str()).collect();
let first_name_refs: Vec<Option<&str>> = first_names.iter().map(|s| s.as_deref()).collect();
let last_name_refs: Vec<Option<&str>> = last_names.iter().map(|s| s.as_deref()).collect();
let rows: Vec<(i32, String)> = sqlx::query_as(
r#"
INSERT INTO instructors (display_name, email)
SELECT * FROM UNNEST($1::text[], $2::text[])
INSERT INTO instructors (display_name, email, first_name, last_name)
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[])
ON CONFLICT (email)
DO UPDATE SET display_name = EXCLUDED.display_name
DO UPDATE SET
display_name = EXCLUDED.display_name,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name
RETURNING id, email
"#,
)
.bind(&display_names)
.bind(&email_refs)
.bind(&first_name_refs)
.bind(&last_name_refs)
.fetch_all(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert instructors: {}", e))?;
@@ -670,7 +692,7 @@ async fn upsert_instructors(
/// Link courses to their instructors via the junction table.
async fn upsert_course_instructors(
courses: &[Course],
course_ids: &[i32],
crn_term_to_id: &HashMap<(&str, &str), i32>,
email_to_id: &HashMap<String, i32>,
conn: &mut PgConnection,
) -> Result<()> {
@@ -679,7 +701,20 @@ async fn upsert_course_instructors(
let mut banner_ids: Vec<&str> = Vec::new();
let mut primaries = Vec::new();
for (course, &course_id) in courses.iter().zip(course_ids) {
for course in courses {
let key = (
course.course_reference_number.as_str(),
course.term.as_str(),
);
let Some(&course_id) = crn_term_to_id.get(&key) else {
tracing::warn!(
crn = %course.course_reference_number,
term = %course.term,
"No course_id found for CRN/term pair during instructor linking"
);
continue;
};
for faculty in &course.faculty {
if let Some(email) = &faculty.email_address {
let email_lower = email.to_lowercase();
+1
View File
@@ -3,6 +3,7 @@
pub mod batch;
pub mod courses;
pub mod models;
pub mod names;
pub mod reference;
pub mod rmp;
pub mod rmp_matching;
+2
View File
@@ -103,6 +103,8 @@ pub struct Instructor {
pub display_name: String,
pub email: String,
pub rmp_match_status: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
}
#[allow(dead_code)]
+728
View File
@@ -0,0 +1,728 @@
//! Name parsing, normalization, and matching utilities.
//!
//! Handles the mismatch between Banner's single `display_name` ("Last, First Middle")
//! and RMP's separate `first_name`/`last_name` fields, plus data quality issues
//! from both sources (HTML entities, accents, nicknames, suffixes, junk).
use sqlx::PgPool;
use tracing::{info, warn};
use unicode_normalization::UnicodeNormalization;
/// Known name suffixes to extract from the last-name portion.
const SUFFIXES: &[&str] = &["iv", "iii", "ii", "jr", "sr"];
/// Parsed, cleaned name components.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NameParts {
/// Cleaned display-quality first name(s): "H. Paul", "María"
pub first: String,
/// Cleaned display-quality last name: "O'Brien", "LeBlanc"
pub last: String,
/// Middle name/initial if detected: "Manuel", "L."
pub middle: Option<String>,
/// Suffix if detected: "III", "Jr"
pub suffix: Option<String>,
/// Nicknames extracted from parentheses: ["Ken"], ["Qian"]
pub nicknames: Vec<String>,
}
/// Decode common HTML entities found in Banner data.
///
/// Handles both named entities (`&amp;`, `&uuml;`) and numeric references
/// (`&#39;`, `&#x27;`).
fn decode_html_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
htmlize::unescape(s).to_string()
}
/// Extract parenthesized nicknames from a name string.
///
/// `"William (Ken)"` → `("William", vec!["Ken"])`
/// `"Guenevere (Qian)"` → `("Guenevere", vec!["Qian"])`
/// `"John (jack) C."` → `("John C.", vec!["jack"])`
fn extract_nicknames(s: &str) -> (String, Vec<String>) {
let mut nicknames = Vec::new();
let mut cleaned = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '(' {
let mut nick = String::new();
for inner in chars.by_ref() {
if inner == ')' {
break;
}
nick.push(inner);
}
let nick = nick.trim().to_string();
if !nick.is_empty() {
nicknames.push(nick);
}
} else if ch == '"' || ch == '\u{201C}' || ch == '\u{201D}' {
// Extract quoted nicknames: Thomas "Butch" → nickname "Butch"
let mut nick = String::new();
for inner in chars.by_ref() {
if inner == '"' || inner == '\u{201C}' || inner == '\u{201D}' {
break;
}
nick.push(inner);
}
let nick = nick.trim().to_string();
if !nick.is_empty() {
nicknames.push(nick);
}
} else {
cleaned.push(ch);
}
}
// Collapse multiple spaces left by extraction
let cleaned = collapse_whitespace(&cleaned);
(cleaned, nicknames)
}
/// Extract a suffix (Jr, Sr, II, III, IV) from the last-name portion.
///
/// `"LeBlanc III"` → `("LeBlanc", Some("III"))`
/// `"Smith Jr."` → `("Smith", Some("Jr."))`
fn extract_suffix(last: &str) -> (String, Option<String>) {
// Try to match the last token as a suffix
let tokens: Vec<&str> = last.split_whitespace().collect();
if tokens.len() < 2 {
return (last.to_string(), None);
}
let candidate = tokens.last().unwrap();
let candidate_normalized = candidate.to_lowercase().trim_end_matches('.').to_string();
if SUFFIXES.contains(&candidate_normalized.as_str()) {
let name_part = tokens[..tokens.len() - 1].join(" ");
return (name_part, Some(candidate.to_string()));
}
(last.to_string(), None)
}
/// Strip junk commonly found in RMP name fields.
///
/// - Trailing commas: `"Cronenberger,"` → `"Cronenberger"`
/// - Email addresses: `"Neel.Baumgardner@utsa.edu"` → `""` (returns empty)
fn strip_junk(s: &str) -> String {
let s = s.trim();
// If the string looks like an email, return empty
if s.contains('@') && s.contains('.') && !s.contains(' ') {
return String::new();
}
// Strip trailing commas
s.trim_end_matches(',').trim().to_string()
}
/// Collapse runs of whitespace into single spaces and trim.
fn collapse_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Parse a Banner `display_name` ("Last, First Middle") into structured parts.
///
/// Handles HTML entities, suffixes, and multi-token names.
///
/// # Examples
///
/// ```
/// use banner::data::names::parse_banner_name;
///
/// let parts = parse_banner_name("O&#39;Brien, Erin").unwrap();
/// assert_eq!(parts.first, "Erin");
/// assert_eq!(parts.last, "O'Brien");
/// ```
pub fn parse_banner_name(display_name: &str) -> Option<NameParts> {
// 1. Decode HTML entities
let decoded = decode_html_entities(display_name);
// 2. Split on first comma
let (last_part, first_part) = decoded.split_once(',')?;
let last_part = last_part.trim();
let first_part = first_part.trim();
if last_part.is_empty() || first_part.is_empty() {
return None;
}
// 3. Extract suffix from last name
let (last_clean, suffix) = extract_suffix(last_part);
// 4. Parse first-name portion: first token(s) + optional middle
// Banner format is "First Middle", so we keep all tokens as first_name
// to support "H. Paul" style names
let first_clean = collapse_whitespace(first_part);
Some(NameParts {
first: first_clean,
last: last_clean,
middle: None, // Banner doesn't clearly delineate middle vs first
suffix,
nicknames: Vec::new(), // Banner doesn't include nicknames
})
}
/// Parse RMP professor name fields into structured parts.
///
/// Handles junk data, nicknames in parentheses/quotes, and suffixes.
///
/// # Examples
///
/// ```
/// use banner::data::names::parse_rmp_name;
///
/// let parts = parse_rmp_name("William (Ken)", "Burchenal").unwrap();
/// assert_eq!(parts.first, "William");
/// assert_eq!(parts.nicknames, vec!["Ken"]);
/// ```
pub fn parse_rmp_name(first_name: &str, last_name: &str) -> Option<NameParts> {
let first_cleaned = strip_junk(first_name);
let last_cleaned = strip_junk(last_name);
if first_cleaned.is_empty() || last_cleaned.is_empty() {
return None;
}
// Extract nicknames from parens/quotes in first name
let (first_no_nicks, nicknames) = extract_nicknames(&first_cleaned);
let first_final = collapse_whitespace(&first_no_nicks);
// Extract suffix from last name
let (last_final, suffix) = extract_suffix(&last_cleaned);
if first_final.is_empty() || last_final.is_empty() {
return None;
}
Some(NameParts {
first: first_final,
last: last_final,
middle: None,
suffix,
nicknames,
})
}
/// Normalize a name string for matching comparison.
///
/// Pipeline: lowercase → NFD decompose → strip combining marks →
/// strip punctuation/hyphens → collapse whitespace → trim.
///
/// # Examples
///
/// ```
/// use banner::data::names::normalize_for_matching;
///
/// assert_eq!(normalize_for_matching("García"), "garcia");
/// assert_eq!(normalize_for_matching("O'Brien"), "obrien");
/// assert_eq!(normalize_for_matching("Aguirre-Mesa"), "aguirremesa");
/// ```
/// Normalize a name string for matching index keys.
///
/// Pipeline: lowercase → NFD decompose → strip combining marks →
/// strip ALL punctuation, hyphens, and whitespace.
///
/// This produces a compact, space-free string so that "Aguirre Mesa" (Banner)
/// and "Aguirre-Mesa" (RMP) both become "aguirremesa".
///
/// # Examples
///
/// ```
/// use banner::data::names::normalize_for_matching;
///
/// assert_eq!(normalize_for_matching("García"), "garcia");
/// assert_eq!(normalize_for_matching("O'Brien"), "obrien");
/// assert_eq!(normalize_for_matching("Aguirre-Mesa"), "aguirremesa");
/// assert_eq!(normalize_for_matching("Aguirre Mesa"), "aguirremesa");
/// ```
pub fn normalize_for_matching(s: &str) -> String {
s.to_lowercase()
.nfd()
.filter(|c| {
// Keep only non-combining alphabetic characters — strip everything else
c.is_alphabetic() && !unicode_normalization::char::is_combining_mark(*c)
})
.collect()
}
/// Generate all matching index keys for a parsed name.
///
/// For a name like "H. Paul" / "LeBlanc" with no nicknames, generates:
/// - `("leblanc", "h paul")` — full normalized first
/// - `("leblanc", "paul")` — individual token (if multi-token)
/// - `("leblanc", "h")` — individual token (if multi-token)
///
/// For a name like "William" / "Burchenal" with nickname "Ken":
/// - `("burchenal", "william")` — primary
/// - `("burchenal", "ken")` — nickname variant
pub fn matching_keys(parts: &NameParts) -> Vec<(String, String)> {
let norm_last = normalize_for_matching(&parts.last);
if norm_last.is_empty() {
return Vec::new();
}
let mut keys = Vec::new();
let mut seen = std::collections::HashSet::new();
// Primary key: full first name (all spaces stripped)
let norm_first_full = normalize_for_matching(&parts.first);
if !norm_first_full.is_empty() && seen.insert(norm_first_full.clone()) {
keys.push((norm_last.clone(), norm_first_full));
}
// Individual tokens from the display-form first name
// (split before full normalization so we can generate per-token keys)
let first_tokens: Vec<&str> = parts.first.split_whitespace().collect();
if first_tokens.len() > 1 {
for token in &first_tokens {
let norm_token = normalize_for_matching(token);
if !norm_token.is_empty() && seen.insert(norm_token.clone()) {
keys.push((norm_last.clone(), norm_token));
}
}
}
// Nickname variants
for nick in &parts.nicknames {
let norm_nick = normalize_for_matching(nick);
if !norm_nick.is_empty() && seen.insert(norm_nick.clone()) {
keys.push((norm_last.clone(), norm_nick));
}
}
keys
}
/// Backfill `first_name`/`last_name` columns for all instructors that have
/// a `display_name` but NULL structured name fields.
///
/// Parses each `display_name` using [`parse_banner_name`] and updates the row.
/// Logs warnings for any names that fail to parse.
pub async fn backfill_instructor_names(db_pool: &PgPool) -> crate::error::Result<()> {
let rows: Vec<(i32, String)> = sqlx::query_as(
"SELECT id, display_name FROM instructors WHERE first_name IS NULL OR last_name IS NULL",
)
.fetch_all(db_pool)
.await?;
if rows.is_empty() {
return Ok(());
}
let total = rows.len();
let mut ids: Vec<i32> = Vec::with_capacity(total);
let mut firsts: Vec<String> = Vec::with_capacity(total);
let mut lasts: Vec<String> = Vec::with_capacity(total);
let mut unparseable = 0usize;
for (id, display_name) in &rows {
match parse_banner_name(display_name) {
Some(parts) => {
ids.push(*id);
firsts.push(parts.first);
lasts.push(parts.last);
}
None => {
warn!(
id,
display_name, "Failed to parse instructor display_name during backfill"
);
unparseable += 1;
}
}
}
if !ids.is_empty() {
let first_refs: Vec<&str> = firsts.iter().map(|s| s.as_str()).collect();
let last_refs: Vec<&str> = lasts.iter().map(|s| s.as_str()).collect();
sqlx::query(
r#"
UPDATE instructors i
SET first_name = v.first_name, last_name = v.last_name
FROM UNNEST($1::int4[], $2::text[], $3::text[])
AS v(id, first_name, last_name)
WHERE i.id = v.id
"#,
)
.bind(&ids)
.bind(&first_refs)
.bind(&last_refs)
.execute(db_pool)
.await?;
}
info!(
total,
updated = ids.len(),
unparseable,
"Instructor name backfill complete"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// -----------------------------------------------------------------------
// HTML entity decoding
// -----------------------------------------------------------------------
#[test]
fn decode_apostrophe_entity() {
assert_eq!(decode_html_entities("O&#39;Brien"), "O'Brien");
}
#[test]
fn decode_umlaut_entity() {
assert_eq!(decode_html_entities("B&uuml;lent"), "Bülent");
}
#[test]
fn decode_no_entities() {
assert_eq!(decode_html_entities("Smith"), "Smith");
}
// -----------------------------------------------------------------------
// Nickname extraction
// -----------------------------------------------------------------------
#[test]
fn extract_paren_nickname() {
let (cleaned, nicks) = extract_nicknames("William (Ken)");
assert_eq!(cleaned, "William");
assert_eq!(nicks, vec!["Ken"]);
}
#[test]
fn extract_quoted_nickname() {
let (cleaned, nicks) = extract_nicknames("Thomas \"Butch\"");
assert_eq!(cleaned, "Thomas");
assert_eq!(nicks, vec!["Butch"]);
}
#[test]
fn extract_paren_with_extra_text() {
let (cleaned, nicks) = extract_nicknames("John (jack) C.");
assert_eq!(cleaned, "John C.");
assert_eq!(nicks, vec!["jack"]);
}
#[test]
fn extract_no_nicknames() {
let (cleaned, nicks) = extract_nicknames("Maria Elena");
assert_eq!(cleaned, "Maria Elena");
assert!(nicks.is_empty());
}
// -----------------------------------------------------------------------
// Suffix extraction
// -----------------------------------------------------------------------
#[test]
fn extract_suffix_iii() {
let (name, suffix) = extract_suffix("LeBlanc III");
assert_eq!(name, "LeBlanc");
assert_eq!(suffix, Some("III".to_string()));
}
#[test]
fn extract_suffix_jr_period() {
let (name, suffix) = extract_suffix("Smith Jr.");
assert_eq!(name, "Smith");
assert_eq!(suffix, Some("Jr.".to_string()));
}
#[test]
fn extract_no_suffix() {
let (name, suffix) = extract_suffix("García");
assert_eq!(name, "García");
assert_eq!(suffix, None);
}
// -----------------------------------------------------------------------
// Junk stripping
// -----------------------------------------------------------------------
#[test]
fn strip_trailing_comma() {
assert_eq!(strip_junk("Cronenberger,"), "Cronenberger");
}
#[test]
fn strip_email_address() {
assert_eq!(strip_junk("Neel.Baumgardner@utsa.edu"), "");
}
#[test]
fn strip_clean_name() {
assert_eq!(strip_junk(" Maria "), "Maria");
}
// -----------------------------------------------------------------------
// normalize_for_matching
// -----------------------------------------------------------------------
#[test]
fn normalize_strips_accents() {
assert_eq!(normalize_for_matching("García"), "garcia");
}
#[test]
fn normalize_strips_apostrophe() {
assert_eq!(normalize_for_matching("O'Brien"), "obrien");
}
#[test]
fn normalize_strips_hyphen() {
assert_eq!(normalize_for_matching("Aguirre-Mesa"), "aguirremesa");
}
#[test]
fn normalize_tilde_n() {
assert_eq!(normalize_for_matching("Muñoz"), "munoz");
}
#[test]
fn normalize_umlaut() {
assert_eq!(normalize_for_matching("Müller"), "muller");
}
#[test]
fn normalize_period() {
assert_eq!(normalize_for_matching("H. Paul"), "hpaul");
}
#[test]
fn normalize_strips_spaces() {
assert_eq!(normalize_for_matching("Mary Lou"), "marylou");
}
// -----------------------------------------------------------------------
// parse_banner_name
// -----------------------------------------------------------------------
#[test]
fn banner_standard_name() {
let p = parse_banner_name("Smith, John").unwrap();
assert_eq!(p.first, "John");
assert_eq!(p.last, "Smith");
assert_eq!(p.suffix, None);
}
#[test]
fn banner_html_entity_apostrophe() {
let p = parse_banner_name("O&#39;Brien, Erin").unwrap();
assert_eq!(p.first, "Erin");
assert_eq!(p.last, "O'Brien");
}
#[test]
fn banner_html_entity_umlaut() {
let p = parse_banner_name("Temel, B&uuml;lent").unwrap();
assert_eq!(p.first, "Bülent");
assert_eq!(p.last, "Temel");
}
#[test]
fn banner_suffix_iii() {
let p = parse_banner_name("LeBlanc III, H. Paul").unwrap();
assert_eq!(p.first, "H. Paul");
assert_eq!(p.last, "LeBlanc");
assert_eq!(p.suffix, Some("III".to_string()));
}
#[test]
fn banner_suffix_ii() {
let p = parse_banner_name("Ellis II, Ronald").unwrap();
assert_eq!(p.first, "Ronald");
assert_eq!(p.last, "Ellis");
assert_eq!(p.suffix, Some("II".to_string()));
}
#[test]
fn banner_multi_word_last() {
let p = parse_banner_name("Aguirre Mesa, Andres").unwrap();
assert_eq!(p.first, "Andres");
assert_eq!(p.last, "Aguirre Mesa");
}
#[test]
fn banner_hyphenated_last() {
let p = parse_banner_name("Abu-Lail, Nehal").unwrap();
assert_eq!(p.first, "Nehal");
assert_eq!(p.last, "Abu-Lail");
}
#[test]
fn banner_with_middle_name() {
let p = parse_banner_name("Smith, John David").unwrap();
assert_eq!(p.first, "John David");
assert_eq!(p.last, "Smith");
}
#[test]
fn banner_no_comma() {
assert!(parse_banner_name("SingleName").is_none());
}
#[test]
fn banner_empty_first() {
assert!(parse_banner_name("Smith,").is_none());
}
#[test]
fn banner_empty_last() {
assert!(parse_banner_name(", John").is_none());
}
// -----------------------------------------------------------------------
// parse_rmp_name
// -----------------------------------------------------------------------
#[test]
fn rmp_standard_name() {
let p = parse_rmp_name("John", "Smith").unwrap();
assert_eq!(p.first, "John");
assert_eq!(p.last, "Smith");
}
#[test]
fn rmp_with_nickname() {
let p = parse_rmp_name("William (Ken)", "Burchenal").unwrap();
assert_eq!(p.first, "William");
assert_eq!(p.nicknames, vec!["Ken"]);
}
#[test]
fn rmp_trailing_comma_last() {
let p = parse_rmp_name("J.", "Cronenberger,").unwrap();
assert_eq!(p.last, "Cronenberger");
}
#[test]
fn rmp_email_in_first() {
assert!(parse_rmp_name("Neel.Baumgardner@utsa.edu", "Baumgardner").is_none());
}
#[test]
fn rmp_suffix_in_last() {
let p = parse_rmp_name("H. Paul", "LeBlanc III").unwrap();
assert_eq!(p.first, "H. Paul");
assert_eq!(p.last, "LeBlanc");
assert_eq!(p.suffix, Some("III".to_string()));
}
#[test]
fn rmp_quoted_nickname() {
let p = parse_rmp_name("Thomas \"Butch\"", "Matjeka").unwrap();
assert_eq!(p.first, "Thomas");
assert_eq!(p.nicknames, vec!["Butch"]);
}
#[test]
fn rmp_accented_last() {
let p = parse_rmp_name("Liliana", "Saldaña").unwrap();
assert_eq!(p.last, "Saldaña");
}
// -----------------------------------------------------------------------
// matching_keys
// -----------------------------------------------------------------------
#[test]
fn keys_simple_name() {
let parts = NameParts {
first: "John".into(),
last: "Smith".into(),
middle: None,
suffix: None,
nicknames: vec![],
};
let keys = matching_keys(&parts);
assert_eq!(keys, vec![("smith".into(), "john".into())]);
}
#[test]
fn keys_multi_token_first() {
let parts = NameParts {
first: "H. Paul".into(),
last: "LeBlanc".into(),
middle: None,
suffix: Some("III".into()),
nicknames: vec![],
};
let keys = matching_keys(&parts);
assert!(keys.contains(&("leblanc".into(), "hpaul".into())));
assert!(keys.contains(&("leblanc".into(), "paul".into())));
assert!(keys.contains(&("leblanc".into(), "h".into())));
assert_eq!(keys.len(), 3);
}
#[test]
fn keys_with_nickname() {
let parts = NameParts {
first: "William".into(),
last: "Burchenal".into(),
middle: None,
suffix: None,
nicknames: vec!["Ken".into()],
};
let keys = matching_keys(&parts);
assert!(keys.contains(&("burchenal".into(), "william".into())));
assert!(keys.contains(&("burchenal".into(), "ken".into())));
assert_eq!(keys.len(), 2);
}
#[test]
fn keys_hyphenated_last() {
let parts = parse_banner_name("Aguirre-Mesa, Andres").unwrap();
let keys = matching_keys(&parts);
// Hyphen removed: "aguirremesa"
assert!(keys.contains(&("aguirremesa".into(), "andres".into())));
}
#[test]
fn keys_accented_name() {
let parts = parse_rmp_name("Liliana", "Saldaña").unwrap();
let keys = matching_keys(&parts);
assert!(keys.contains(&("saldana".into(), "liliana".into())));
}
#[test]
fn keys_cross_source_match() {
// Banner: "Aguirre Mesa, Andres" → last="Aguirre Mesa"
let banner = parse_banner_name("Aguirre Mesa, Andres").unwrap();
let banner_keys = matching_keys(&banner);
// RMP: "Andres" / "Aguirre-Mesa" → last="Aguirre-Mesa"
let rmp = parse_rmp_name("Andres", "Aguirre-Mesa").unwrap();
let rmp_keys = matching_keys(&rmp);
// Both should normalize to ("aguirremesa", "andres")
assert!(banner_keys.iter().any(|k| rmp_keys.contains(k)));
}
#[test]
fn keys_accent_cross_match() {
// Banner: "García, José" (if Banner ever has accents)
let banner = parse_banner_name("Garcia, Jose").unwrap();
let banner_keys = matching_keys(&banner);
// RMP: "José" / "García"
let rmp = parse_rmp_name("José", "García").unwrap();
let rmp_keys = matching_keys(&rmp);
// Both normalize to ("garcia", "jose")
assert!(banner_keys.iter().any(|k| rmp_keys.contains(k)));
}
}
+65 -82
View File
@@ -91,25 +91,6 @@ pub async fn batch_upsert_rmp_professors(
Ok(())
}
/// Normalize a name for matching: lowercase, trim, strip trailing periods.
pub(crate) fn normalize(s: &str) -> String {
s.trim().to_lowercase().trim_end_matches('.').to_string()
}
/// Parse Banner's "Last, First Middle" display name into (last, first) tokens.
///
/// Returns `None` if the format is unparseable (no comma, empty parts).
pub(crate) fn parse_display_name(display_name: &str) -> Option<(String, String)> {
let (last_part, first_part) = display_name.split_once(',')?;
let last = normalize(last_part);
// Take only the first token of the first-name portion to drop middle names/initials.
let first = normalize(first_part.split_whitespace().next()?);
if last.is_empty() || first.is_empty() {
return None;
}
Some((last, first))
}
/// Retrieve RMP rating data for an instructor by instructor id.
///
/// Returns `(avg_rating, num_ratings)` for the best linked RMP profile
@@ -136,74 +117,76 @@ pub async fn get_instructor_rmp_data(
Ok(row)
}
#[cfg(test)]
mod tests {
use super::*;
/// Unmatch an instructor from an RMP profile.
///
/// Removes the link from `instructor_rmp_links` and updates the instructor's
/// `rmp_match_status` to 'unmatched' if no links remain.
///
/// If `rmp_legacy_id` is `Some`, removes only that specific link.
/// If `None`, removes all links for the instructor.
pub async fn unmatch_instructor(
db_pool: &PgPool,
instructor_id: i32,
rmp_legacy_id: Option<i32>,
) -> Result<()> {
let mut tx = db_pool.begin().await?;
#[test]
fn parse_standard_name() {
assert_eq!(
parse_display_name("Smith, John"),
Some(("smith".into(), "john".into()))
);
// Delete specific link or all links
if let Some(legacy_id) = rmp_legacy_id {
sqlx::query(
"DELETE FROM instructor_rmp_links WHERE instructor_id = $1 AND rmp_legacy_id = $2",
)
.bind(instructor_id)
.bind(legacy_id)
.execute(&mut *tx)
.await?;
} else {
sqlx::query("DELETE FROM instructor_rmp_links WHERE instructor_id = $1")
.bind(instructor_id)
.execute(&mut *tx)
.await?;
}
#[test]
fn parse_name_with_middle() {
assert_eq!(
parse_display_name("Smith, John David"),
Some(("smith".into(), "john".into()))
);
// Check if any links remain
let (remaining,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM instructor_rmp_links WHERE instructor_id = $1")
.bind(instructor_id)
.fetch_one(&mut *tx)
.await?;
// Update instructor status if no links remain
if remaining == 0 {
sqlx::query("UPDATE instructors SET rmp_match_status = 'unmatched' WHERE id = $1")
.bind(instructor_id)
.execute(&mut *tx)
.await?;
}
#[test]
fn parse_name_with_middle_initial() {
assert_eq!(
parse_display_name("Garcia, Maria L."),
Some(("garcia".into(), "maria".into()))
);
// Reset accepted candidates back to pending when unmatching
// This allows the candidates to be re-matched later
if let Some(legacy_id) = rmp_legacy_id {
// Reset only the specific candidate
sqlx::query(
"UPDATE rmp_match_candidates
SET status = 'pending', resolved_at = NULL, resolved_by = NULL
WHERE instructor_id = $1 AND rmp_legacy_id = $2 AND status = 'accepted'",
)
.bind(instructor_id)
.bind(legacy_id)
.execute(&mut *tx)
.await?;
} else {
// Reset all accepted candidates for this instructor
sqlx::query(
"UPDATE rmp_match_candidates
SET status = 'pending', resolved_at = NULL, resolved_by = NULL
WHERE instructor_id = $1 AND status = 'accepted'",
)
.bind(instructor_id)
.execute(&mut *tx)
.await?;
}
#[test]
fn parse_name_with_suffix_in_last() {
// Banner may encode "Jr." as part of the last name.
// normalize() strips trailing periods so "Jr." becomes "jr".
assert_eq!(
parse_display_name("Smith Jr., James"),
Some(("smith jr".into(), "james".into()))
);
}
#[test]
fn parse_no_comma_returns_none() {
assert_eq!(parse_display_name("SingleName"), None);
}
#[test]
fn parse_empty_first_returns_none() {
assert_eq!(parse_display_name("Smith,"), None);
}
#[test]
fn parse_empty_last_returns_none() {
assert_eq!(parse_display_name(", John"), None);
}
#[test]
fn parse_extra_whitespace() {
assert_eq!(
parse_display_name(" Doe , Jane Marie "),
Some(("doe".into(), "jane".into()))
);
}
#[test]
fn normalize_trims_and_lowercases() {
assert_eq!(normalize(" FOO "), "foo");
}
#[test]
fn normalize_strips_trailing_period() {
assert_eq!(normalize("Jr."), "jr");
}
tx.commit().await?;
Ok(())
}
+229 -47
View File
@@ -1,6 +1,6 @@
//! Confidence scoring and candidate generation for RMP instructor matching.
use crate::data::rmp::{normalize, parse_display_name};
use crate::data::names::{matching_keys, parse_banner_name, parse_rmp_name};
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -14,6 +14,7 @@ use tracing::{debug, info};
/// Breakdown of individual scoring signals.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScoreBreakdown {
pub name: f32,
pub department: f32,
pub uniqueness: f32,
pub volume: f32,
@@ -37,12 +38,13 @@ const MIN_CANDIDATE_THRESHOLD: f32 = 0.40;
const AUTO_ACCEPT_THRESHOLD: f32 = 0.85;
// ---------------------------------------------------------------------------
// Weights
// Weights (must sum to 1.0)
// ---------------------------------------------------------------------------
const WEIGHT_DEPARTMENT: f32 = 0.50;
const WEIGHT_UNIQUENESS: f32 = 0.30;
const WEIGHT_VOLUME: f32 = 0.20;
const WEIGHT_NAME: f32 = 0.50;
const WEIGHT_DEPARTMENT: f32 = 0.25;
const WEIGHT_UNIQUENESS: f32 = 0.15;
const WEIGHT_VOLUME: f32 = 0.10;
// ---------------------------------------------------------------------------
// Pure scoring functions
@@ -78,8 +80,9 @@ fn department_similarity(subjects: &[String], rmp_department: Option<&str>) -> f
/// Expand common subject abbreviations used at UTSA and check for overlap.
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
const MAPPINGS: &[(&str, &[&str])] = &[
// Core subjects (original mappings, corrected)
("cs", &["computer science"]),
("ece", &["electrical", "computer engineering"]),
("ece", &["early childhood education", "early childhood"]),
("ee", &["electrical engineering", "electrical"]),
("me", &["mechanical engineering", "mechanical"]),
("ce", &["civil engineering", "civil"]),
@@ -105,6 +108,85 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
("ms", &["management science"]),
("kin", &["kinesiology"]),
("com", &["communication"]),
// Architecture & Design
("arc", &["architecture"]),
("ide", &["interior design", "design"]),
// Anthropology & Ethnic Studies
("ant", &["anthropology"]),
("aas", &["african american studies", "ethnic studies"]),
("mas", &["mexican american studies", "ethnic studies"]),
("regs", &["ethnic studies", "gender"]),
// Languages
("lng", &["linguistics", "applied linguistics"]),
("spn", &["spanish"]),
("frn", &["french"]),
("ger", &["german"]),
("chn", &["chinese"]),
("jpn", &["japanese"]),
("kor", &["korean"]),
("itl", &["italian"]),
("rus", &["russian"]),
("lat", &["latin"]),
("grk", &["greek"]),
("asl", &["american sign language", "sign language"]),
(
"fl",
&["foreign languages", "languages", "modern languages"],
),
// Education
("edu", &["education"]),
("ci", &["curriculum", "education"]),
("edl", &["educational leadership", "education"]),
("edp", &["educational psychology", "education"]),
("bbl", &["bilingual education"]),
("spe", &["special education", "education"]),
// Business
("ent", &["entrepreneurship"]),
("gba", &["general business", "business"]),
("blw", &["business law", "law"]),
("rfd", &["real estate"]),
("mot", &["management of technology", "management"]),
// Engineering
("egr", &["engineering"]),
("bme", &["biomedical engineering", "engineering"]),
("cme", &["chemical engineering", "engineering"]),
("cpe", &["computer engineering", "engineering"]),
("ise", &["industrial", "systems engineering", "engineering"]),
("mate", &["materials engineering", "engineering"]),
// Sciences
("che", &["chemistry"]),
("bch", &["biochemistry", "chemistry"]),
("geo", &["geology"]),
("phy", &["physics"]),
("ast", &["astronomy"]),
("es", &["environmental science"]),
// Social Sciences
("crj", &["criminal justice"]),
("swk", &["social work"]),
("pad", &["public administration"]),
("grg", &["geography"]),
("ges", &["geography"]),
// Humanities
("cla", &["classics"]),
("hum", &["humanities"]),
("wgss", &["women's studies"]),
// Health
("hth", &["health"]),
("hcp", &["health science", "health"]),
("ntr", &["nutrition"]),
// Military
("msc", &["military science"]),
("asc", &["aerospace"]),
// Arts
("dan", &["dance"]),
("thr", &["theater"]),
("ahc", &["art history"]),
// Other
("cou", &["counseling"]),
("hon", &["honors"]),
("csm", &["construction"]),
("wrc", &["writing"]),
("set", &["tourism management", "tourism"]),
];
for &(abbr, expansions) in MAPPINGS {
@@ -119,35 +201,39 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
/// Compute match confidence score (0.01.0) for an instructorRMP pair.
///
/// Name matching is handled by the caller via pre-filtering on exact
/// normalized `(last, first)`, so only department, uniqueness, and volume
/// signals are scored here.
/// The name signal is always 1.0 since candidates are only generated for
/// exact normalized name matches. The effective score range is 0.501.0.
pub fn compute_match_score(
instructor_subjects: &[String],
rmp_department: Option<&str>,
candidate_count: usize,
rmp_num_ratings: i32,
) -> MatchScore {
// --- Department (0.50) ---
// --- Name (0.50) — always 1.0, candidates only exist for exact matches ---
let name_score = 1.0;
// --- Department (0.25) ---
let dept_score = department_similarity(instructor_subjects, rmp_department);
// --- Uniqueness (0.30) ---
// --- Uniqueness (0.15) ---
let uniqueness_score = match candidate_count {
0 | 1 => 1.0,
2 => 0.5,
_ => 0.2,
};
// --- Volume (0.20) ---
// --- Volume (0.10) ---
let volume_score = ((rmp_num_ratings as f32).ln_1p() / 5.0_f32.ln_1p()).clamp(0.0, 1.0);
let composite = dept_score * WEIGHT_DEPARTMENT
let composite = name_score * WEIGHT_NAME
+ dept_score * WEIGHT_DEPARTMENT
+ uniqueness_score * WEIGHT_UNIQUENESS
+ volume_score * WEIGHT_VOLUME;
MatchScore {
score: composite,
breakdown: ScoreBreakdown {
name: name_score,
department: dept_score,
uniqueness: uniqueness_score,
volume: volume_score,
@@ -164,6 +250,7 @@ pub fn compute_match_score(
pub struct MatchingStats {
pub total_unmatched: usize,
pub candidates_created: usize,
pub candidates_rescored: usize,
pub auto_matched: usize,
pub skipped_unparseable: usize,
pub skipped_no_candidates: usize,
@@ -179,8 +266,8 @@ struct RmpProfForMatching {
/// Generate match candidates for all unmatched instructors.
///
/// For each unmatched instructor:
/// 1. Parse `display_name` into (last, first).
/// 2. Find RMP professors with matching normalized name.
/// 1. Parse `display_name` into [`NameParts`] and generate matching keys.
/// 2. Find RMP professors with matching normalized name keys.
/// 3. Score each candidate.
/// 4. Store candidates scoring above [`MIN_CANDIDATE_THRESHOLD`].
/// 5. Auto-accept if the top candidate scores ≥ [`AUTO_ACCEPT_THRESHOLD`]
@@ -200,6 +287,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
return Ok(MatchingStats {
total_unmatched: 0,
candidates_created: 0,
candidates_rescored: 0,
auto_matched: 0,
skipped_unparseable: 0,
skipped_no_candidates: 0,
@@ -227,7 +315,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
subject_map.entry(iid).or_default().push(subject);
}
// 3. Load all RMP professors
// 3. Load all RMP professors and build multi-key name index
let prof_rows: Vec<(i32, String, String, Option<String>, i32)> = sqlx::query_as(
"SELECT legacy_id, first_name, last_name, department, num_ratings FROM rmp_professors",
)
@@ -235,40 +323,72 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
.await?;
// Build name index: (normalized_last, normalized_first) -> Vec<RmpProfForMatching>
// Each professor may appear under multiple keys (nicknames, token variants).
let mut name_index: HashMap<(String, String), Vec<RmpProfForMatching>> = HashMap::new();
for (legacy_id, first_name, last_name, department, num_ratings) in prof_rows {
let key = (normalize(&last_name), normalize(&first_name));
name_index.entry(key).or_default().push(RmpProfForMatching {
legacy_id,
department,
num_ratings,
});
let mut rmp_parse_failures = 0usize;
for (legacy_id, first_name, last_name, department, num_ratings) in &prof_rows {
match parse_rmp_name(first_name, last_name) {
Some(parts) => {
let keys = matching_keys(&parts);
for key in keys {
name_index.entry(key).or_default().push(RmpProfForMatching {
legacy_id: *legacy_id,
department: department.clone(),
num_ratings: *num_ratings,
});
}
}
None => {
rmp_parse_failures += 1;
debug!(
legacy_id,
first_name, last_name, "Unparseable RMP professor name, skipping"
);
}
}
}
// 4. Load existing candidate pairs (and rejected subset) in a single query
if rmp_parse_failures > 0 {
debug!(
count = rmp_parse_failures,
"RMP professors with unparseable names"
);
}
// 4. Load existing candidate pairs — only skip resolved (accepted/rejected) pairs.
// Pending candidates are rescored so updated mappings take effect.
let candidate_rows: Vec<(i32, i32, String)> =
sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates")
.fetch_all(db_pool)
.await?;
let mut existing_pairs: HashSet<(i32, i32)> = HashSet::with_capacity(candidate_rows.len());
let mut resolved_pairs: HashSet<(i32, i32)> = HashSet::new();
let mut pending_pairs: HashSet<(i32, i32)> = HashSet::new();
let mut rejected_pairs: HashSet<(i32, i32)> = HashSet::new();
for (iid, lid, status) in candidate_rows {
existing_pairs.insert((iid, lid));
if status == "rejected" {
rejected_pairs.insert((iid, lid));
match status.as_str() {
"accepted" | "rejected" => {
resolved_pairs.insert((iid, lid));
if status == "rejected" {
rejected_pairs.insert((iid, lid));
}
}
_ => {
pending_pairs.insert((iid, lid));
}
}
}
// 5. Score and collect candidates
// 5. Score and collect candidates (new + rescored pending)
let empty_subjects: Vec<String> = Vec::new();
let mut candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut new_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut rescored_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
let mut auto_accept: Vec<(i32, i32)> = Vec::new(); // (instructor_id, legacy_id)
let mut skipped_unparseable = 0usize;
let mut skipped_no_candidates = 0usize;
for (instructor_id, display_name) in &instructors {
let Some((norm_last, norm_first)) = parse_display_name(display_name) else {
let Some(instructor_parts) = parse_banner_name(display_name) else {
skipped_unparseable += 1;
debug!(
instructor_id,
@@ -279,18 +399,33 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
let subjects = subject_map.get(instructor_id).unwrap_or(&empty_subjects);
let key = (norm_last.clone(), norm_first.clone());
let Some(rmp_candidates) = name_index.get(&key) else {
// Generate all matching keys for this instructor and collect candidate
// RMP professors across all key variants (deduplicated by legacy_id).
let instructor_keys = matching_keys(&instructor_parts);
let mut seen_profs: HashSet<i32> = HashSet::new();
let mut matched_profs: Vec<&RmpProfForMatching> = Vec::new();
for key in &instructor_keys {
if let Some(profs) = name_index.get(key) {
for prof in profs {
if seen_profs.insert(prof.legacy_id) {
matched_profs.push(prof);
}
}
}
}
if matched_profs.is_empty() {
skipped_no_candidates += 1;
continue;
};
}
let candidate_count = rmp_candidates.len();
let candidate_count = matched_profs.len();
let mut best: Option<(f32, i32)> = None;
for prof in rmp_candidates {
for prof in &matched_profs {
let pair = (*instructor_id, prof.legacy_id);
if existing_pairs.contains(&pair) {
if resolved_pairs.contains(&pair) {
continue;
}
@@ -308,7 +443,16 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
let breakdown_json =
serde_json::to_value(&ms.breakdown).unwrap_or_else(|_| serde_json::json!({}));
candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
if pending_pairs.contains(&pair) {
rescored_candidates.push((
*instructor_id,
prof.legacy_id,
ms.score,
breakdown_json,
));
} else {
new_candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
}
match best {
Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)),
@@ -327,19 +471,20 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
}
}
// 67. Write candidates and auto-accept within a single transaction
let candidates_created = candidates.len();
// 67. Write candidates, rescore, and auto-accept within a single transaction
let candidates_created = new_candidates.len();
let candidates_rescored = rescored_candidates.len();
let auto_matched = auto_accept.len();
let mut tx = db_pool.begin().await?;
// 6. Batch-insert candidates
if !candidates.is_empty() {
let c_instructor_ids: Vec<i32> = candidates.iter().map(|(iid, _, _, _)| *iid).collect();
let c_legacy_ids: Vec<i32> = candidates.iter().map(|(_, lid, _, _)| *lid).collect();
let c_scores: Vec<f32> = candidates.iter().map(|(_, _, s, _)| *s).collect();
// 6a. Batch-insert new candidates
if !new_candidates.is_empty() {
let c_instructor_ids: Vec<i32> = new_candidates.iter().map(|(iid, _, _, _)| *iid).collect();
let c_legacy_ids: Vec<i32> = new_candidates.iter().map(|(_, lid, _, _)| *lid).collect();
let c_scores: Vec<f32> = new_candidates.iter().map(|(_, _, s, _)| *s).collect();
let c_breakdowns: Vec<serde_json::Value> =
candidates.into_iter().map(|(_, _, _, b)| b).collect();
new_candidates.into_iter().map(|(_, _, _, b)| b).collect();
sqlx::query(
r#"
@@ -358,6 +503,40 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
.await?;
}
// 6b. Batch-update rescored pending candidates
if !rescored_candidates.is_empty() {
let r_instructor_ids: Vec<i32> = rescored_candidates
.iter()
.map(|(iid, _, _, _)| *iid)
.collect();
let r_legacy_ids: Vec<i32> = rescored_candidates
.iter()
.map(|(_, lid, _, _)| *lid)
.collect();
let r_scores: Vec<f32> = rescored_candidates.iter().map(|(_, _, s, _)| *s).collect();
let r_breakdowns: Vec<serde_json::Value> = rescored_candidates
.into_iter()
.map(|(_, _, _, b)| b)
.collect();
sqlx::query(
r#"
UPDATE rmp_match_candidates mc
SET score = v.score, score_breakdown = v.score_breakdown
FROM UNNEST($1::int4[], $2::int4[], $3::real[], $4::jsonb[])
AS v(instructor_id, rmp_legacy_id, score, score_breakdown)
WHERE mc.instructor_id = v.instructor_id
AND mc.rmp_legacy_id = v.rmp_legacy_id
"#,
)
.bind(&r_instructor_ids)
.bind(&r_legacy_ids)
.bind(&r_scores)
.bind(&r_breakdowns)
.execute(&mut *tx)
.await?;
}
// 7. Auto-accept top candidates
if !auto_accept.is_empty() {
let aa_instructor_ids: Vec<i32> = auto_accept.iter().map(|(iid, _)| *iid).collect();
@@ -411,6 +590,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
let stats = MatchingStats {
total_unmatched,
candidates_created,
candidates_rescored,
auto_matched,
skipped_unparseable,
skipped_no_candidates,
@@ -419,6 +599,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
info!(
total_unmatched = stats.total_unmatched,
candidates_created = stats.candidates_created,
candidates_rescored = stats.candidates_rescored,
auto_matched = stats.auto_matched,
skipped_unparseable = stats.skipped_unparseable,
skipped_no_candidates = stats.skipped_no_candidates,
@@ -444,8 +625,9 @@ mod tests {
1, // unique candidate
50, // decent ratings
);
// dept 1.0*0.50 + unique 1.0*0.30 + volume ~0.97*0.20 ≈ 0.99
// name 1.0*0.50 + dept 1.0*0.25 + unique 1.0*0.15 + volume ~0.97*0.10 ≈ 0.997
assert!(ms.score >= 0.85, "Expected score >= 0.85, got {}", ms.score);
assert_eq!(ms.breakdown.name, 1.0);
assert_eq!(ms.breakdown.uniqueness, 1.0);
assert_eq!(ms.breakdown.department, 1.0);
}
+17 -7
View File
@@ -7,6 +7,9 @@ use sqlx::PgPool;
use super::models::UserSession;
use crate::error::Result;
/// Session lifetime: 7 days (in seconds).
pub const SESSION_DURATION_SECS: u64 = 7 * 24 * 3600;
/// Generate a cryptographically random 32-byte hex token.
fn generate_token() -> String {
let bytes: [u8; 32] = rand::rng().random();
@@ -48,13 +51,21 @@ pub async fn get_session(pool: &PgPool, token: &str) -> Result<Option<UserSessio
.context("failed to get session")
}
/// Update the last-active timestamp for a session.
/// Update the last-active timestamp and extend session expiry (sliding window).
pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1")
.bind(token)
.execute(pool)
.await
.context("failed to touch session")?;
sqlx::query(
r#"
UPDATE user_sessions
SET last_active_at = now(),
expires_at = now() + make_interval(secs => $2::double precision)
WHERE id = $1
"#,
)
.bind(token)
.bind(SESSION_DURATION_SECS as f64)
.execute(pool)
.await
.context("failed to touch session")?;
Ok(())
}
@@ -80,7 +91,6 @@ pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result<u64> {
}
/// Delete all expired sessions. Returns the number of sessions cleaned up.
#[allow(dead_code)] // Called by SessionCache::cleanup_expired (not yet wired to periodic task)
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()")
.execute(pool)
+1
View File
@@ -310,6 +310,7 @@ impl Scheduler {
total,
stats.total_unmatched,
stats.candidates_created,
stats.candidates_rescored,
stats.auto_matched,
stats.skipped_unparseable,
stats.skipped_no_candidates,
+34
View File
@@ -51,6 +51,33 @@ impl WebService {
}
}
}
/// Periodically cleans up expired sessions from the database and in-memory cache.
async fn session_cleanup_loop(state: AppState, mut shutdown_rx: broadcast::Receiver<()>) {
use std::time::Duration;
// Run every hour
let mut interval = tokio::time::interval(Duration::from_secs(3600));
loop {
tokio::select! {
_ = interval.tick() => {
match state.session_cache.cleanup_expired().await {
Ok(deleted) => {
if deleted > 0 {
info!(deleted, "cleaned up expired sessions");
}
}
Err(e) => {
warn!(error = %e, "session cleanup failed");
}
}
}
_ = shutdown_rx.recv() => {
break;
}
}
}
}
}
#[async_trait::async_trait]
@@ -87,6 +114,13 @@ impl Service for WebService {
Self::db_health_check_loop(health_state, health_shutdown_rx).await;
});
// Spawn session cleanup task
let cleanup_state = self.app_state.clone();
let cleanup_shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
Self::session_cleanup_loop(cleanup_state, cleanup_shutdown_rx).await;
});
// Use axum's graceful shutdown with the internal shutdown signal
axum::serve(listener, app)
.with_graceful_shutdown(async move {
+4
View File
@@ -4,6 +4,7 @@ use crate::banner::BannerApi;
use crate::banner::Course;
use crate::data::models::ReferenceData;
use crate::status::ServiceStatusRegistry;
use crate::web::schedule_cache::ScheduleCache;
use crate::web::session_cache::{OAuthStateStore, SessionCache};
use crate::web::ws::ScrapeJobEvent;
use anyhow::Result;
@@ -76,12 +77,14 @@ pub struct AppState {
pub reference_cache: Arc<RwLock<ReferenceCache>>,
pub session_cache: SessionCache,
pub oauth_state_store: OAuthStateStore,
pub schedule_cache: ScheduleCache,
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
}
impl AppState {
pub fn new(banner_api: Arc<BannerApi>, db_pool: PgPool) -> Self {
let (scrape_job_tx, _) = broadcast::channel(64);
let schedule_cache = ScheduleCache::new(db_pool.clone());
Self {
session_cache: SessionCache::new(db_pool.clone()),
oauth_state_store: OAuthStateStore::new(),
@@ -89,6 +92,7 @@ impl AppState {
db_pool,
service_statuses: ServiceStatusRegistry::new(),
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
schedule_cache,
scrape_job_tx,
}
}
+12 -50
View File
@@ -180,6 +180,7 @@ pub struct InstructorDetailResponse {
pub struct RescoreResponse {
pub total_unmatched: usize,
pub candidates_created: usize,
pub candidates_rescored: usize,
pub auto_matched: usize,
pub skipped_unparseable: usize,
pub skipped_no_candidates: usize,
@@ -768,16 +769,10 @@ pub async fn unmatch_instructor(
) -> Result<Json<OkResponse>, (StatusCode, Json<Value>)> {
let rmp_legacy_id = body.and_then(|b| b.rmp_legacy_id);
let mut tx = state
.db_pool
.begin()
.await
.map_err(|e| db_error("failed to begin transaction", e))?;
// Verify instructor exists
let exists: Option<(i32,)> = sqlx::query_as("SELECT id FROM instructors WHERE id = $1")
.bind(id)
.fetch_optional(&mut *tx)
.fetch_optional(&state.db_pool)
.await
.map_err(|e| db_error("failed to check instructor", e))?;
@@ -788,50 +783,16 @@ pub async fn unmatch_instructor(
));
}
// Delete specific link or all links
if let Some(legacy_id) = rmp_legacy_id {
let result = sqlx::query(
"DELETE FROM instructor_rmp_links WHERE instructor_id = $1 AND rmp_legacy_id = $2",
)
.bind(id)
.bind(legacy_id)
.execute(&mut *tx)
// Use the data layer function to perform the unmatch
crate::data::rmp::unmatch_instructor(&state.db_pool, id, rmp_legacy_id)
.await
.map_err(|e| db_error("failed to remove rmp link", e))?;
if result.rows_affected() == 0 {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"error": "link not found for this instructor"})),
));
}
} else {
sqlx::query("DELETE FROM instructor_rmp_links WHERE instructor_id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| db_error("failed to remove rmp links", e))?;
}
// Check if any links remain; update status accordingly
let (remaining,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM instructor_rmp_links WHERE instructor_id = $1")
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| db_error("failed to count remaining links", e))?;
if remaining == 0 {
sqlx::query("UPDATE instructors SET rmp_match_status = 'unmatched' WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| db_error("failed to update instructor status", e))?;
}
tx.commit()
.await
.map_err(|e| db_error("failed to commit transaction", e))?;
.map_err(|e| {
tracing::error!(error = %e, "failed to unmatch instructor");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to unmatch instructor"})),
)
})?;
Ok(Json(OkResponse { ok: true }))
}
@@ -858,6 +819,7 @@ pub async fn rescore(
Ok(Json(RescoreResponse {
total_unmatched: stats.total_unmatched,
candidates_created: stats.candidates_created,
candidates_rescored: stats.candidates_rescored,
auto_matched: stats.auto_matched,
skipped_unparseable: stats.skipped_unparseable,
skipped_no_candidates: stats.skipped_no_candidates,
+6 -2
View File
@@ -235,7 +235,7 @@ pub async fn auth_callback(
let session = crate::data::sessions::create_session(
&state.db_pool,
discord_id,
Duration::from_secs(7 * 24 * 3600),
Duration::from_secs(crate::data::sessions::SESSION_DURATION_SECS),
)
.await
.map_err(|e| {
@@ -248,7 +248,11 @@ pub async fn auth_callback(
// 6. Build response with session cookie
let secure = redirect_uri.starts_with("https://");
let cookie = session_cookie(&session.id, 604800, secure);
let cookie = session_cookie(
&session.id,
crate::data::sessions::SESSION_DURATION_SECS as i64,
secure,
);
let redirect_to = if user.is_admin { "/admin" } else { "/" };
+2
View File
@@ -11,7 +11,9 @@ pub mod calendar;
pub mod encoding;
pub mod extractors;
pub mod routes;
pub mod schedule_cache;
pub mod session_cache;
pub mod timeline;
pub mod ws;
pub use routes::*;
+2
View File
@@ -14,6 +14,7 @@ use crate::web::admin_rmp;
use crate::web::admin_scraper;
use crate::web::auth::{self, AuthConfig};
use crate::web::calendar;
use crate::web::timeline;
use crate::web::ws;
#[cfg(feature = "embed-assets")]
use axum::{
@@ -54,6 +55,7 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
.route("/terms", get(get_terms))
.route("/subjects", get(get_subjects))
.route("/reference/{category}", get(get_reference))
.route("/timeline", post(timeline::timeline))
.with_state(app_state.clone());
let auth_router = Router::new()
+443
View File
@@ -0,0 +1,443 @@
//! ISR-style schedule cache for timeline enrollment queries.
//!
//! Loads all courses with their meeting times from the database, parses the
//! JSONB meeting times into a compact in-memory representation, and caches
//! the result. The cache is refreshed in the background every hour using a
//! stale-while-revalidate pattern with singleflight deduplication — readers
//! always get the current cached value instantly, never blocking on a refresh.
use chrono::NaiveDate;
use serde_json::Value;
use sqlx::PgPool;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
use tracing::{debug, error, info};
/// How often the cache is considered fresh (1 hour).
const REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
// ── Compact schedule representation ─────────────────────────────────
/// A single meeting time block, pre-parsed for fast filtering.
#[derive(Debug, Clone)]
pub(crate) struct ParsedSchedule {
/// Bitmask of days: bit 0 = Monday, bit 6 = Sunday.
days: u8,
/// Minutes since midnight for start (e.g. 600 = 10:00).
begin_minutes: u16,
/// Minutes since midnight for end (e.g. 650 = 10:50).
end_minutes: u16,
/// First day the meeting pattern is active.
start_date: NaiveDate,
/// Last day the meeting pattern is active.
end_date: NaiveDate,
}
/// A course with its enrollment and pre-parsed schedule blocks.
#[derive(Debug, Clone)]
pub(crate) struct CachedCourse {
pub(crate) subject: String,
pub(crate) enrollment: i32,
pub(crate) schedules: Vec<ParsedSchedule>,
}
/// The immutable snapshot of all courses, swapped atomically on refresh.
#[derive(Debug, Clone)]
pub(crate) struct ScheduleSnapshot {
pub(crate) courses: Vec<CachedCourse>,
refreshed_at: std::time::Instant,
}
// ── Cache handle ────────────────────────────────────────────────────
/// Shared schedule cache. Clone-cheap (all `Arc`-wrapped internals).
#[derive(Clone)]
pub struct ScheduleCache {
/// Current snapshot, updated via `watch` channel for lock-free reads.
rx: watch::Receiver<Arc<ScheduleSnapshot>>,
/// Sender side, held to push new snapshots.
tx: Arc<watch::Sender<Arc<ScheduleSnapshot>>>,
/// Singleflight guard — true while a refresh task is in flight.
refreshing: Arc<AtomicBool>,
/// Database pool for refresh queries.
pool: PgPool,
}
impl ScheduleCache {
/// Create a new cache with an empty initial snapshot.
pub(crate) fn new(pool: PgPool) -> Self {
let empty = Arc::new(ScheduleSnapshot {
courses: Vec::new(),
refreshed_at: std::time::Instant::now(),
});
let (tx, rx) = watch::channel(empty);
Self {
rx,
tx: Arc::new(tx),
refreshing: Arc::new(AtomicBool::new(false)),
pool,
}
}
/// Get the current snapshot. Never blocks on refresh.
pub(crate) fn snapshot(&self) -> Arc<ScheduleSnapshot> {
self.rx.borrow().clone()
}
/// Check freshness and trigger a background refresh if stale.
/// Always returns immediately — the caller uses the current snapshot.
pub(crate) fn ensure_fresh(&self) {
let snap = self.rx.borrow();
if snap.refreshed_at.elapsed() < REFRESH_INTERVAL {
return;
}
// Singleflight: only one refresh at a time.
if self
.refreshing
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
debug!("Schedule cache refresh already in flight, skipping");
return;
}
let cache = self.clone();
tokio::spawn(async move {
match load_snapshot(&cache.pool).await {
Ok(snap) => {
let count = snap.courses.len();
let _ = cache.tx.send(Arc::new(snap));
info!(courses = count, "Schedule cache refreshed");
}
Err(e) => {
error!(error = %e, "Failed to refresh schedule cache");
}
}
cache.refreshing.store(false, Ordering::Release);
});
}
/// Force an initial load (blocking). Call once at startup.
pub(crate) async fn load(&self) -> anyhow::Result<()> {
let snap = load_snapshot(&self.pool).await?;
let count = snap.courses.len();
let _ = self.tx.send(Arc::new(snap));
info!(courses = count, "Schedule cache initially loaded");
Ok(())
}
}
// ── Database loading ────────────────────────────────────────────────
/// Row returned from the lightweight schedule query.
#[derive(sqlx::FromRow)]
struct ScheduleRow {
subject: String,
enrollment: i32,
meeting_times: Value,
}
/// Load all courses and parse their meeting times into a snapshot.
async fn load_snapshot(pool: &PgPool) -> anyhow::Result<ScheduleSnapshot> {
let start = std::time::Instant::now();
let rows: Vec<ScheduleRow> =
sqlx::query_as("SELECT subject, enrollment, meeting_times FROM courses")
.fetch_all(pool)
.await?;
let courses: Vec<CachedCourse> = rows
.into_iter()
.map(|row| {
let schedules = parse_meeting_times(&row.meeting_times);
CachedCourse {
subject: row.subject,
enrollment: row.enrollment,
schedules,
}
})
.collect();
debug!(
courses = courses.len(),
elapsed_ms = start.elapsed().as_millis(),
"Schedule snapshot built"
);
Ok(ScheduleSnapshot {
courses,
refreshed_at: std::time::Instant::now(),
})
}
// ── Meeting time parsing ────────────────────────────────────────────
/// Parse the JSONB `meeting_times` array into compact `ParsedSchedule` values.
fn parse_meeting_times(value: &Value) -> Vec<ParsedSchedule> {
let Value::Array(arr) = value else {
return Vec::new();
};
arr.iter().filter_map(parse_one_meeting).collect()
}
fn parse_one_meeting(mt: &Value) -> Option<ParsedSchedule> {
let begin_time = mt.get("begin_time")?.as_str()?;
let end_time = mt.get("end_time")?.as_str()?;
let begin_minutes = parse_hhmm(begin_time)?;
let end_minutes = parse_hhmm(end_time)?;
if end_minutes <= begin_minutes {
return None;
}
let start_date = parse_date(mt.get("start_date")?.as_str()?)?;
let end_date = parse_date(mt.get("end_date")?.as_str()?)?;
const DAY_KEYS: [&str; 7] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
let mut days: u8 = 0;
for (bit, key) in DAY_KEYS.iter().enumerate() {
if mt.get(*key).and_then(Value::as_bool).unwrap_or(false) {
days |= 1 << bit;
}
}
// Skip meetings with no days (online async, etc.)
if days == 0 {
return None;
}
Some(ParsedSchedule {
days,
begin_minutes,
end_minutes,
start_date,
end_date,
})
}
/// Parse "HHMM" → minutes since midnight.
fn parse_hhmm(s: &str) -> Option<u16> {
if s.len() != 4 {
return None;
}
let hours: u16 = s[..2].parse().ok()?;
let mins: u16 = s[2..].parse().ok()?;
if hours >= 24 || mins >= 60 {
return None;
}
Some(hours * 60 + mins)
}
/// Parse "MM/DD/YYYY" → NaiveDate.
fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
}
// ── Slot matching ───────────────────────────────────────────────────
/// Day-of-week as our bitmask index (Monday = 0 .. Sunday = 6).
/// Chrono's `weekday().num_days_from_monday()` already gives 0=Mon..6=Sun.
pub(crate) fn weekday_bit(day: chrono::Weekday) -> u8 {
1 << day.num_days_from_monday()
}
impl ParsedSchedule {
/// Check if this schedule is active during a given slot.
///
/// `slot_date` is the calendar date of the slot.
/// `slot_start` / `slot_end` are minutes since midnight for the 15-min window.
#[inline]
pub(crate) fn active_during(
&self,
slot_date: NaiveDate,
slot_weekday_bit: u8,
slot_start_minutes: u16,
slot_end_minutes: u16,
) -> bool {
// Day-of-week check
if self.days & slot_weekday_bit == 0 {
return false;
}
// Date range check
if slot_date < self.start_date || slot_date > self.end_date {
return false;
}
// Time overlap: meeting [begin, end) overlaps slot [start, end)
self.begin_minutes < slot_end_minutes && self.end_minutes > slot_start_minutes
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use serde_json::json;
#[test]
fn parse_hhmm_valid() {
assert_eq!(parse_hhmm("0000"), Some(0));
assert_eq!(parse_hhmm("0930"), Some(570));
assert_eq!(parse_hhmm("1350"), Some(830));
assert_eq!(parse_hhmm("2359"), Some(1439));
}
#[test]
fn parse_hhmm_invalid() {
assert_eq!(parse_hhmm(""), None);
assert_eq!(parse_hhmm("abc"), None);
assert_eq!(parse_hhmm("2500"), None);
assert_eq!(parse_hhmm("0060"), None);
}
#[test]
fn parse_date_valid() {
assert_eq!(
parse_date("08/26/2025"),
Some(NaiveDate::from_ymd_opt(2025, 8, 26).unwrap())
);
}
#[test]
fn parse_meeting_times_basic() {
let json = json!([{
"begin_time": "1000",
"end_time": "1050",
"start_date": "08/26/2025",
"end_date": "12/13/2025",
"monday": true,
"tuesday": false,
"wednesday": true,
"thursday": false,
"friday": true,
"saturday": false,
"sunday": false,
"building": "NPB",
"building_description": "North Paseo Building",
"room": "1.238",
"campus": "11",
"meeting_type": "FF",
"meeting_schedule_type": "AFF"
}]);
let schedules = parse_meeting_times(&json);
assert_eq!(schedules.len(), 1);
let s = &schedules[0];
assert_eq!(s.begin_minutes, 600); // 10:00
assert_eq!(s.end_minutes, 650); // 10:50
assert_eq!(s.days, 0b0010101); // Mon, Wed, Fri
}
#[test]
fn parse_meeting_times_skips_null_times() {
let json = json!([{
"begin_time": null,
"end_time": null,
"start_date": "08/26/2025",
"end_date": "12/13/2025",
"monday": false,
"tuesday": false,
"wednesday": false,
"thursday": false,
"friday": false,
"saturday": false,
"sunday": false,
"meeting_type": "OS",
"meeting_schedule_type": "AFF"
}]);
let schedules = parse_meeting_times(&json);
assert!(schedules.is_empty());
}
#[test]
fn active_during_matching_slot() {
let sched = ParsedSchedule {
days: 0b0000001, // Monday
begin_minutes: 600,
end_minutes: 650,
start_date: NaiveDate::from_ymd_opt(2025, 8, 26).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 12, 13).unwrap(),
};
// Monday Sept 1 2025, 10:00-10:15 slot
let date = NaiveDate::from_ymd_opt(2025, 9, 1).unwrap();
assert!(sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 600, 615));
}
#[test]
fn active_during_wrong_day() {
let sched = ParsedSchedule {
days: 0b0000001, // Monday only
begin_minutes: 600,
end_minutes: 650,
start_date: NaiveDate::from_ymd_opt(2025, 8, 26).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 12, 13).unwrap(),
};
// Tuesday Sept 2 2025
let date = NaiveDate::from_ymd_opt(2025, 9, 2).unwrap();
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Tue), 600, 615));
}
#[test]
fn active_during_no_time_overlap() {
let sched = ParsedSchedule {
days: 0b0000001,
begin_minutes: 600, // 10:00
end_minutes: 650, // 10:50
start_date: NaiveDate::from_ymd_opt(2025, 8, 26).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 12, 13).unwrap(),
};
let date = NaiveDate::from_ymd_opt(2025, 9, 1).unwrap(); // Monday
// Slot 11:00-11:15 — after the meeting ends
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 660, 675));
// Slot 9:45-10:00 — just before meeting starts (end=600, begin=600 → no overlap)
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 585, 600));
}
#[test]
fn active_during_outside_date_range() {
let sched = ParsedSchedule {
days: 0b0000001,
begin_minutes: 600,
end_minutes: 650,
start_date: NaiveDate::from_ymd_opt(2025, 8, 26).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 12, 13).unwrap(),
};
// Monday Jan 6 2025 — before semester
let date = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 600, 615));
}
#[test]
fn active_during_edge_overlap() {
let sched = ParsedSchedule {
days: 0b0000001,
begin_minutes: 600,
end_minutes: 650,
start_date: NaiveDate::from_ymd_opt(2025, 8, 26).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 12, 13).unwrap(),
};
let date = NaiveDate::from_ymd_opt(2025, 9, 1).unwrap();
// Slot 10:45-11:00 — overlaps last 5 minutes of meeting
assert!(sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 645, 660));
// Slot 9:45-10:00 — ends exactly when meeting starts, no overlap
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 585, 600));
// Slot 10:50-11:05 — starts exactly when meeting ends, no overlap
assert!(!sched.active_during(date, weekday_bit(chrono::Weekday::Mon), 650, 665));
}
}
-1
View File
@@ -108,7 +108,6 @@ impl SessionCache {
/// Delete expired sessions from the database and sweep the in-memory cache.
///
/// Returns the number of sessions deleted from the database.
#[allow(dead_code)] // Intended for periodic cleanup task (not yet wired)
pub async fn cleanup_expired(&self) -> anyhow::Result<u64> {
let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?;
+258
View File
@@ -0,0 +1,258 @@
//! Timeline API endpoint for enrollment aggregation by subject over time.
//!
//! Accepts multiple time ranges, merges overlaps, aligns to 15-minute
//! slot boundaries, and returns per-subject enrollment totals for each slot.
//! Only courses whose meeting times overlap a given slot contribute to that
//! slot's totals — so the chart reflects the actual class schedule rhythm.
//!
//! Course data is served from an ISR-style in-memory cache (see
//! [`ScheduleCache`]) that refreshes hourly in the background with
//! stale-while-revalidate semantics.
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc};
use chrono_tz::US::Central;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use ts_rs::TS;
use crate::state::AppState;
use crate::web::schedule_cache::weekday_bit;
/// 15 minutes in seconds, matching the frontend `SLOT_INTERVAL_MS`.
const SLOT_SECONDS: i64 = 15 * 60;
const SLOT_MINUTES: u16 = 15;
/// Maximum number of ranges in a single request.
const MAX_RANGES: usize = 20;
/// Maximum span of a single range (72 hours).
const MAX_RANGE_SPAN: Duration = Duration::hours(72);
/// Maximum total span across all ranges to prevent excessive queries.
const MAX_TOTAL_SPAN: Duration = Duration::hours(168); // 1 week
// ── Request / Response types ────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub(crate) struct TimelineRequest {
ranges: Vec<TimeRange>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct TimeRange {
start: DateTime<Utc>,
end: DateTime<Utc>,
}
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimelineResponse {
/// 15-minute slots with per-subject enrollment totals, sorted by time.
slots: Vec<TimelineSlot>,
/// All subject codes present in the returned data.
subjects: Vec<String>,
}
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TimelineSlot {
/// ISO-8601 timestamp at the start of this 15-minute bucket.
time: DateTime<Utc>,
/// Subject code → total enrollment in this slot.
subjects: BTreeMap<String, i64>,
}
// ── Error type ──────────────────────────────────────────────────────
pub(crate) struct TimelineError {
status: StatusCode,
message: String,
}
impl TimelineError {
fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
}
impl IntoResponse for TimelineError {
fn into_response(self) -> Response {
(
self.status,
Json(serde_json::json!({ "error": self.message })),
)
.into_response()
}
}
// ── Alignment helpers ───────────────────────────────────────────────
/// Floor a timestamp to the nearest 15-minute boundary.
fn align_floor(ts: DateTime<Utc>) -> DateTime<Utc> {
let secs = ts.timestamp();
let aligned = (secs / SLOT_SECONDS) * SLOT_SECONDS;
DateTime::from_timestamp(aligned, 0).unwrap_or(ts)
}
/// Ceil a timestamp to the nearest 15-minute boundary.
fn align_ceil(ts: DateTime<Utc>) -> DateTime<Utc> {
let secs = ts.timestamp();
let aligned = ((secs + SLOT_SECONDS - 1) / SLOT_SECONDS) * SLOT_SECONDS;
DateTime::from_timestamp(aligned, 0).unwrap_or(ts)
}
// ── Range merging ───────────────────────────────────────────────────
/// Aligned, validated range.
#[derive(Debug, Clone, Copy)]
struct AlignedRange {
start: DateTime<Utc>,
end: DateTime<Utc>,
}
/// Merge overlapping/adjacent ranges into a minimal set.
fn merge_ranges(mut ranges: Vec<AlignedRange>) -> Vec<AlignedRange> {
if ranges.is_empty() {
return ranges;
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<AlignedRange> = vec![ranges[0]];
for r in &ranges[1..] {
let last = merged.last_mut().unwrap();
if r.start <= last.end {
last.end = last.end.max(r.end);
} else {
merged.push(*r);
}
}
merged
}
/// Generate all aligned slot timestamps within the merged ranges.
fn generate_slots(merged: &[AlignedRange]) -> BTreeSet<DateTime<Utc>> {
let mut slots = BTreeSet::new();
for range in merged {
let mut t = range.start;
while t < range.end {
slots.insert(t);
t += Duration::seconds(SLOT_SECONDS);
}
}
slots
}
// ── Handler ─────────────────────────────────────────────────────────
/// `POST /api/timeline`
///
/// Accepts a JSON body with multiple time ranges. Returns per-subject
/// enrollment totals bucketed into 15-minute slots. Only courses whose
/// meeting schedule overlaps a slot contribute to that slot's count.
pub(crate) async fn timeline(
State(state): State<AppState>,
Json(body): Json<TimelineRequest>,
) -> Result<Json<TimelineResponse>, TimelineError> {
// ── Validate ────────────────────────────────────────────────────
if body.ranges.is_empty() {
return Err(TimelineError::bad_request("At least one range is required"));
}
if body.ranges.len() > MAX_RANGES {
return Err(TimelineError::bad_request(format!(
"Too many ranges (max {MAX_RANGES})"
)));
}
let mut aligned: Vec<AlignedRange> = Vec::with_capacity(body.ranges.len());
for r in &body.ranges {
if r.end <= r.start {
return Err(TimelineError::bad_request(format!(
"Range end ({}) must be after start ({})",
r.end, r.start
)));
}
let span = r.end - r.start;
if span > MAX_RANGE_SPAN {
return Err(TimelineError::bad_request(format!(
"Range span ({} hours) exceeds maximum ({} hours)",
span.num_hours(),
MAX_RANGE_SPAN.num_hours()
)));
}
aligned.push(AlignedRange {
start: align_floor(r.start),
end: align_ceil(r.end),
});
}
let merged = merge_ranges(aligned);
// Validate total span
let total_span: Duration = merged.iter().map(|r| r.end - r.start).sum();
if total_span > MAX_TOTAL_SPAN {
return Err(TimelineError::bad_request(format!(
"Total time span ({} hours) exceeds maximum ({} hours)",
total_span.num_hours(),
MAX_TOTAL_SPAN.num_hours()
)));
}
// ── Get cached schedule data (ISR: stale-while-revalidate) ───────
state.schedule_cache.ensure_fresh();
let snapshot = state.schedule_cache.snapshot();
// ── Build per-slot enrollment by filtering on meeting times ──────
let slot_times = generate_slots(&merged);
let mut all_subjects: BTreeSet<String> = BTreeSet::new();
let slots: Vec<TimelineSlot> = slot_times
.into_iter()
.map(|utc_time| {
// Convert UTC slot to Central time for local day-of-week and time-of-day
let local = utc_time.with_timezone(&Central);
let local_date = local.date_naive();
let local_time = local.time();
let weekday = local.weekday();
let wday_bit = weekday_bit(weekday);
let slot_start_minutes = time_to_minutes(local_time);
let slot_end_minutes = slot_start_minutes + SLOT_MINUTES;
let mut subject_totals: BTreeMap<String, i64> = BTreeMap::new();
for course in &snapshot.courses {
let active = course.schedules.iter().any(|s| {
s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes)
});
if active {
*subject_totals.entry(course.subject.clone()).or_default() +=
course.enrollment as i64;
}
}
all_subjects.extend(subject_totals.keys().cloned());
TimelineSlot {
time: utc_time,
subjects: subject_totals,
}
})
.collect();
let subjects: Vec<String> = all_subjects.into_iter().collect();
Ok(Json(TimelineResponse { slots, subjects }))
}
/// Convert a `NaiveTime` to minutes since midnight.
fn time_to_minutes(t: NaiveTime) -> u16 {
(t.hour() * 60 + t.minute()) as u16
}
+103
View File
@@ -0,0 +1,103 @@
#[allow(dead_code)]
mod helpers;
use banner::data::rmp::unmatch_instructor;
use sqlx::PgPool;
/// Test that unmatching an instructor resets accepted candidates back to pending.
///
/// When a user unmatches an instructor, accepted candidates should be reset to
/// 'pending' so they can be re-matched later. This prevents the bug where
/// candidates remain 'accepted' but have no corresponding link.
#[sqlx::test]
async fn unmatch_resets_accepted_candidates_to_pending(pool: PgPool) {
// ARRANGE: Create an instructor
let (instructor_id,): (i32,) = sqlx::query_as(
"INSERT INTO instructors (display_name, email)
VALUES ('Test, Instructor', 'test@utsa.edu')
RETURNING id",
)
.fetch_one(&pool)
.await
.expect("failed to create instructor");
// ARRANGE: Create an RMP professor
let (rmp_legacy_id,): (i32,) = sqlx::query_as(
"INSERT INTO rmp_professors (legacy_id, graphql_id, first_name, last_name, num_ratings)
VALUES (9999999, 'test-graphql-id', 'Test', 'Professor', 10)
RETURNING legacy_id",
)
.fetch_one(&pool)
.await
.expect("failed to create rmp professor");
// ARRANGE: Create a match candidate with 'accepted' status
sqlx::query(
"INSERT INTO rmp_match_candidates (instructor_id, rmp_legacy_id, score, status)
VALUES ($1, $2, 0.85, 'accepted')",
)
.bind(instructor_id)
.bind(rmp_legacy_id)
.execute(&pool)
.await
.expect("failed to create candidate");
// ARRANGE: Create a link in instructor_rmp_links
sqlx::query(
"INSERT INTO instructor_rmp_links (instructor_id, rmp_legacy_id, source)
VALUES ($1, $2, 'manual')",
)
.bind(instructor_id)
.bind(rmp_legacy_id)
.execute(&pool)
.await
.expect("failed to create link");
// ARRANGE: Update instructor status to 'confirmed'
sqlx::query("UPDATE instructors SET rmp_match_status = 'confirmed' WHERE id = $1")
.bind(instructor_id)
.execute(&pool)
.await
.expect("failed to update instructor status");
// ACT: Unmatch the specific RMP profile
unmatch_instructor(&pool, instructor_id, Some(rmp_legacy_id))
.await
.expect("unmatch should succeed");
// ASSERT: Candidate should be reset to pending
let (candidate_status,): (String,) = sqlx::query_as(
"SELECT status FROM rmp_match_candidates
WHERE instructor_id = $1 AND rmp_legacy_id = $2",
)
.bind(instructor_id)
.bind(rmp_legacy_id)
.fetch_one(&pool)
.await
.expect("failed to fetch candidate status");
assert_eq!(
candidate_status, "pending",
"candidate should be reset to pending after unmatch"
);
// ASSERT: Link should be deleted
let (link_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM instructor_rmp_links WHERE instructor_id = $1")
.bind(instructor_id)
.fetch_one(&pool)
.await
.expect("failed to count links");
assert_eq!(link_count, 0, "link should be deleted");
// ASSERT: Instructor status should be unmatched
let (instructor_status,): (String,) =
sqlx::query_as("SELECT rmp_match_status FROM instructors WHERE id = $1")
.bind(instructor_id)
.fetch_one(&pool)
.await
.expect("failed to fetch instructor status");
assert_eq!(
instructor_status, "unmatched",
"instructor should be unmatched"
);
}
+1
View File
@@ -1,3 +1,4 @@
#[allow(dead_code)]
mod helpers;
use banner::data::batch::batch_upsert_courses;
+1
View File
@@ -1,3 +1,4 @@
#[allow(dead_code)]
mod helpers;
use banner::data::models::{ScrapePriority, TargetType};
+39
View File
@@ -1,3 +1,4 @@
import { authStore } from "$lib/auth.svelte";
import type {
CandidateResponse,
CodeDescription,
@@ -135,6 +136,29 @@ export interface MetricsParams {
limit?: number;
}
/** A time range for timeline queries (ISO-8601 strings). */
export interface TimelineRange {
start: string;
end: string;
}
/** Request body for POST /api/timeline. */
export interface TimelineRequest {
ranges: TimelineRange[];
}
/** A single 15-minute slot returned by the timeline API. */
export interface TimelineSlot {
time: string;
subjects: Record<string, number>;
}
/** Response from POST /api/timeline. */
export interface TimelineResponse {
slots: TimelineSlot[];
subjects: string[];
}
export interface SearchParams {
term: string;
subjects?: string[];
@@ -189,6 +213,10 @@ export class BannerApiClient {
const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
@@ -206,6 +234,10 @@ export class BannerApiClient {
const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
@@ -297,6 +329,13 @@ export class BannerApiClient {
/** Stored `Last-Modified` value for audit log conditional requests. */
private _auditLastModified: string | null = null;
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
return this.request<TimelineResponse>("/timeline", {
method: "POST",
body: { ranges } satisfies TimelineRequest,
});
}
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
+7
View File
@@ -60,6 +60,13 @@ class AuthStore {
}
}
/** Idempotently mark the session as lost. Called by apiFetch on 401. */
handleUnauthorized() {
if (this.state.mode !== "unauthenticated") {
this.state = { mode: "unauthenticated" };
}
}
login() {
window.location.href = "/api/auth/login";
}
+10 -6
View File
@@ -11,11 +11,15 @@ const staticTabs = [
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
let profileTab = $derived({
href: authStore.isAuthenticated ? "/profile" : "/login",
label: authStore.isAuthenticated ? "Account" : "Login",
icon: User,
});
let profileTab = $derived(
authStore.isLoading
? { href: "/login" as const, label: null, icon: User }
: {
href: authStore.isAuthenticated ? ("/profile" as const) : ("/login" as const),
label: authStore.isAuthenticated ? "Account" : "Login",
icon: User,
}
);
function isActive(tabHref: string): boolean {
if (tabHref === "/") return page.url.pathname === "/";
@@ -50,7 +54,7 @@ function isActive(tabHref: string): boolean {
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<User size={15} strokeWidth={2} />
{profileTab.label}
{#if profileTab.label}{profileTab.label}{/if}
</a>
<ThemeToggle />
</div>
+1 -1
View File
@@ -67,7 +67,7 @@ function outTransition(_node: HTMLElement): TransitionConfig {
}
</script>
<div class="relative flex flex-1 flex-col overflow-hidden">
<div class="relative flex flex-1 flex-col">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
{@render children()}
+28 -8
View File
@@ -2,7 +2,6 @@
import { onMount } from "svelte";
import { scaleTime, scaleLinear } from "d3-scale";
import { SUBJECTS, type Subject } from "$lib/timeline/data";
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
import {
PADDING,
@@ -125,12 +124,31 @@ let pointerOverCanvas = false;
// ── Drawer ──────────────────────────────────────────────────────────
let drawerOpen = $state(false);
let enabledSubjects: Set<Subject> = $state(new Set(SUBJECTS));
// Start with an empty set — subjects are populated dynamically from the API.
let enabledSubjects: Set<string> = $state(new Set());
// ── Data store ──────────────────────────────────────────────────────
const store = createTimelineStore();
let data: TimeSlot[] = $derived(store.data);
let activeSubjects = $derived(SUBJECTS.filter((s) => enabledSubjects.has(s)));
let allSubjects: string[] = $derived(store.subjects);
// Auto-enable newly discovered subjects.
$effect(() => {
const storeSubjects = store.subjects;
const next = new Set(enabledSubjects);
let changed = false;
for (const s of storeSubjects) {
if (!next.has(s)) {
next.add(s);
changed = true;
}
}
if (changed) {
enabledSubjects = next;
}
});
let activeSubjects = $derived(allSubjects.filter((s) => enabledSubjects.has(s)));
// ── Derived layout ──────────────────────────────────────────────────
let viewStart = $derived(viewCenter - viewSpan / 2);
@@ -151,7 +169,7 @@ let yScale = scaleLinear()
.range([0, 1]);
// ── Subject toggling ────────────────────────────────────────────────
function toggleSubject(subject: Subject) {
function toggleSubject(subject: string) {
const next = new Set(enabledSubjects);
if (next.has(subject)) next.delete(subject);
else next.add(subject);
@@ -159,7 +177,7 @@ function toggleSubject(subject: Subject) {
}
function enableAll() {
enabledSubjects = new Set(SUBJECTS);
enabledSubjects = new Set(allSubjects);
}
function disableAll() {
@@ -192,7 +210,7 @@ function render() {
};
const visible = getVisibleSlots(data, viewStart, viewEnd);
const visibleStack = stackVisibleSlots(visible, enabledSubjects, animMap);
const visibleStack = stackVisibleSlots(visible, allSubjects, enabledSubjects, animMap);
drawGrid(chart);
drawHoverColumn(chart, visibleStack, hoverSlotTime);
@@ -585,8 +603,9 @@ function tick(timestamp: number) {
// ── Animation sync ──────────────────────────────────────────────────
$effect(() => {
const slots = data;
const subs = allSubjects;
const enabled = enabledSubjects;
syncAnimTargets(animMap, slots, enabled);
syncAnimTargets(animMap, slots, subs, enabled);
});
// Request data whenever the visible window changes.
@@ -625,7 +644,7 @@ onMount(() => {
class:cursor-grabbing={isDragging}
style="display: block; touch-action: none;"
tabindex="0"
aria-label="Interactive class schedule timeline chart"
aria-label="Interactive enrollment timeline chart"
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
@@ -638,6 +657,7 @@ onMount(() => {
<TimelineDrawer
bind:open={drawerOpen}
subjects={allSubjects}
{enabledSubjects}
{followEnabled}
onToggleSubject={toggleSubject}
+8 -5
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import { Filter, X } from "@lucide/svelte";
import { SUBJECTS, SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import { getSubjectColor } from "$lib/timeline/data";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
interface Props {
open: boolean;
enabledSubjects: Set<Subject>;
subjects: readonly string[];
enabledSubjects: Set<string>;
followEnabled: boolean;
onToggleSubject: (subject: Subject) => void;
onToggleSubject: (subject: string) => void;
onEnableAll: () => void;
onDisableAll: () => void;
onResumeFollow: () => void;
@@ -15,6 +16,7 @@ interface Props {
let {
open = $bindable(),
subjects,
enabledSubjects,
followEnabled,
onToggleSubject,
@@ -109,8 +111,9 @@ function onKeyDown(e: KeyboardEvent) {
</div>
</div>
<div class="space-y-0.5">
{#each SUBJECTS as subject}
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
@@ -118,7 +121,7 @@ function onKeyDown(e: KeyboardEvent) {
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {SUBJECT_COLORS[subject]}; opacity: {enabled ? 1 : 0.2};"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
@@ -1,6 +1,6 @@
<script lang="ts">
import { timeFormat } from "d3-time-format";
import { SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import { getSubjectColor } from "$lib/timeline/data";
import type { TimeSlot } from "$lib/timeline/types";
import { enabledTotalClasses } from "$lib/timeline/viewport";
@@ -9,7 +9,7 @@ interface Props {
x: number;
y: number;
slot: TimeSlot | null;
activeSubjects: readonly Subject[];
activeSubjects: readonly string[];
}
let { visible, x, y, slot, activeSubjects }: Props = $props();
@@ -35,7 +35,7 @@ const fmtTime = timeFormat("%-I:%M %p");
<div class="flex items-center gap-1.5">
<span
class="inline-block w-2 h-2 rounded-sm"
style="background: {SUBJECT_COLORS[subject]}"
style="background: {getSubjectColor(subject)}"
></span>
<span class="text-muted-foreground">{subject}</span>
</div>
+6 -3
View File
@@ -5,7 +5,6 @@
* targets. This module owns the AnimMap lifecycle: syncing targets,
* stepping current values, and pruning offscreen entries.
*/
import { SUBJECTS, type Subject } from "./data";
import { VALUE_EASE, MAXY_EASE, SETTLE_THRESHOLD, MIN_MAXY } from "./constants";
import type { AnimEntry, TimeSlot } from "./types";
@@ -20,11 +19,15 @@ export function createAnimMap(): AnimMap {
* Sync animMap targets from data + filter state.
* New slots start at current=0 so they animate in from the baseline.
* Disabled subjects get target=0 so they animate out.
*
* @param subjects - the full list of known subject codes
* @param enabledSubjects - subjects currently toggled on
*/
export function syncAnimTargets(
animMap: AnimMap,
slots: TimeSlot[],
enabledSubjects: Set<Subject>
subjects: readonly string[],
enabledSubjects: Set<string>
): void {
for (const slot of slots) {
const timeMs = slot.time.getTime();
@@ -34,7 +37,7 @@ export function syncAnimTargets(
animMap.set(timeMs, subjectMap);
}
for (const subject of SUBJECTS) {
for (const subject of subjects) {
const realValue = enabledSubjects.has(subject) ? slot.subjects[subject] || 0 : 0;
const entry = subjectMap.get(subject);
if (entry) {
+59 -103
View File
@@ -1,122 +1,78 @@
/**
* Data types, constants, and deterministic slot generation for the class timeline.
* Each 15-minute slot is seeded by its timestamp, so the same slot always produces
* identical data regardless of when or in what order it's fetched.
* Subject color palette for the timeline chart.
*
* Subjects are dynamic (coming from the API), so we assign colors from
* a fixed palette based on a deterministic hash of the subject code.
* Known high-enrollment subjects get hand-picked colors for familiarity.
*/
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
export type { TimeSlot };
export const SUBJECTS = [
"CS",
"MATH",
"BIO",
"ENG",
"PHYS",
"HIST",
"CHEM",
"PSY",
"ECE",
"ART",
] as const;
export type Subject = (typeof SUBJECTS)[number];
/** Subject colors — distinct, accessible palette */
export const SUBJECT_COLORS: Record<Subject, string> = {
/** Hand-picked colors for common UTSA subject codes. */
const KNOWN_SUBJECT_COLORS: Record<string, string> = {
CS: "#6366f1", // indigo
MATH: "#f59e0b", // amber
MAT: "#f59e0b", // amber
BIO: "#10b981", // emerald
ENG: "#ef4444", // red
PHYS: "#3b82f6", // blue
HIST: "#8b5cf6", // violet
CHEM: "#f97316", // orange
PHY: "#3b82f6", // blue
HIS: "#8b5cf6", // violet
CHE: "#f97316", // orange
PSY: "#ec4899", // pink
ECE: "#14b8a6", // teal
ART: "#a855f7", // purple
ACC: "#84cc16", // lime
FIN: "#06b6d4", // cyan
MUS: "#e11d48", // rose
POL: "#d946ef", // fuchsia
SOC: "#22d3ee", // sky
KIN: "#4ade80", // green
IS: "#fb923c", // light orange
STA: "#818cf8", // light indigo
MGT: "#fbbf24", // yellow
MKT: "#2dd4bf", // teal-light
};
/**
* Bell-curve-like distribution centered at a given hour.
* Returns a value 0..1 representing relative class density.
* Extended palette for subjects that don't have a hand-picked color.
* These are chosen to be visually distinct from each other.
*/
function bellCurve(hour: number, center: number, spread: number): number {
const x = (hour - center) / spread;
return Math.exp(-0.5 * x * x);
}
const FALLBACK_PALETTE = [
"#f472b6", // pink-400
"#60a5fa", // blue-400
"#34d399", // emerald-400
"#fbbf24", // amber-400
"#a78bfa", // violet-400
"#fb7185", // rose-400
"#38bdf8", // sky-400
"#4ade80", // green-400
"#facc15", // yellow-400
"#c084fc", // purple-400
"#f87171", // red-400
"#2dd4bf", // teal-400
"#fb923c", // orange-400
"#818cf8", // indigo-400
"#a3e635", // lime-400
"#22d3ee", // cyan-400
];
/**
* Each subject has characteristic scheduling patterns:
* peak hours, relative popularity, and spread.
*/
const SUBJECT_PROFILES: Record<Subject, { peaks: number[]; weight: number; spread: number }> = {
CS: { peaks: [10, 14, 16], weight: 12, spread: 2.0 },
MATH: { peaks: [8, 10, 13], weight: 10, spread: 1.8 },
BIO: { peaks: [9, 11, 14], weight: 8, spread: 1.5 },
ENG: { peaks: [9, 11, 14, 16], weight: 7, spread: 2.2 },
PHYS: { peaks: [8, 13, 15], weight: 6, spread: 1.6 },
HIST: { peaks: [10, 13, 15], weight: 5, spread: 2.0 },
CHEM: { peaks: [8, 10, 14], weight: 6, spread: 1.5 },
PSY: { peaks: [11, 14, 16], weight: 7, spread: 2.0 },
ECE: { peaks: [9, 13, 15], weight: 5, spread: 1.8 },
ART: { peaks: [10, 14, 17], weight: 4, spread: 2.5 },
};
/**
* Seeded pseudo-random number generator (LCG) for reproducible data.
*/
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
/**
* Integer hash so adjacent slot timestamps produce very different seeds.
*/
function hashTimestamp(ms: number): number {
let h = ms | 0;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = (h >> 16) ^ h;
return h >>> 0;
}
/** Generate a single TimeSlot for the given aligned timestamp. */
function generateSlot(timeMs: number): TimeSlot {
const rand = seededRandom(hashTimestamp(timeMs));
const time = new Date(timeMs);
const hour = time.getHours() + time.getMinutes() / 60;
const subjects = {} as Record<Subject, number>;
for (const subject of SUBJECTS) {
const profile = SUBJECT_PROFILES[subject];
let density = 0;
for (const peak of profile.peaks) {
density += bellCurve(hour, peak, profile.spread);
}
const base = density * profile.weight;
const noise = (rand() - 0.5) * 2;
subjects[subject] = Math.max(0, Math.round(base + noise));
/** Simple string hash for deterministic color assignment. */
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return { time, subjects };
return Math.abs(hash);
}
/**
* Generate TimeSlots covering [startMs, endMs], aligned to 15-minute boundaries.
* Each slot is deterministically seeded by its timestamp.
*/
export function generateSlots(startMs: number, endMs: number): TimeSlot[] {
const alignedStart = Math.floor(startMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
const alignedEnd = Math.ceil(endMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
/** Cache of assigned colors to avoid re-computing. */
const colorCache = new Map<string, string>();
const slots: TimeSlot[] = [];
for (let t = alignedStart; t <= alignedEnd; t += SLOT_INTERVAL_MS) {
slots.push(generateSlot(t));
}
return slots;
/** Get a consistent color for any subject code. */
export function getSubjectColor(subject: string): string {
const cached = colorCache.get(subject);
if (cached) return cached;
const color =
KNOWN_SUBJECT_COLORS[subject] ?? FALLBACK_PALETTE[hashCode(subject) % FALLBACK_PALETTE.length];
colorCache.set(subject, color);
return color;
}
+16 -7
View File
@@ -7,7 +7,7 @@
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
import { timeFormat } from "d3-time-format";
import { SUBJECT_COLORS, type Subject } from "./data";
import { getSubjectColor } from "./data";
import type { AnimMap } from "./animation";
import { getStackSubjects } from "./viewport";
import type { ChartContext, TimeSlot } from "./types";
@@ -55,22 +55,31 @@ export function chooseTickCount(viewSpan: number): number {
* Stack only the visible slice using *animated* values so transitions
* between filter/data states are smooth. Includes subjects that are
* still animating out so removal is gradual.
*
* @param allSubjects - full set of known subject codes
*/
export function stackVisibleSlots(
visible: TimeSlot[],
enabledSubjects: Set<Subject>,
allSubjects: readonly string[],
enabledSubjects: Set<string>,
animMap: AnimMap
): VisibleStack {
if (visible.length === 0) return [];
const stackKeys = getStackSubjects(visible, enabledSubjects, animMap, SETTLE_THRESHOLD);
const stackKeys = getStackSubjects(
visible,
allSubjects,
enabledSubjects,
animMap,
SETTLE_THRESHOLD
);
if (stackKeys.length === 0) return [];
// Build synthetic slots with animated current values.
const animatedSlots: TimeSlot[] = visible.map((slot) => {
const timeMs = slot.time.getTime();
const subjectMap = animMap.get(timeMs);
const subjects = {} as Record<Subject, number>;
const subjects: Record<string, number> = {};
for (const subject of stackKeys) {
const entry = subjectMap?.get(subject);
subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
@@ -80,7 +89,7 @@ export function stackVisibleSlots(
const gen = stack<TimeSlot>()
.keys(stackKeys)
.value((d, key) => d.subjects[key as Subject] || 0);
.value((d, key) => d.subjects[key] || 0);
return gen(animatedSlots);
}
@@ -187,8 +196,8 @@ export function drawStackedArea(chart: ChartContext, visibleStack: VisibleStack)
for (let i = visibleStack.length - 1; i >= 0; i--) {
const layer = visibleStack[i];
const subject = layer.key as Subject;
const color = SUBJECT_COLORS[subject];
const subject = layer.key;
const color = getSubjectColor(subject);
ctx.beginPath();
area<StackPoint>()
+46 -23
View File
@@ -3,10 +3,9 @@
*
* Tracks which time ranges have already been fetched and only requests
* the missing segments when the view expands into unloaded territory.
* Fetches are throttled so rapid panning/zooming doesn't flood the
* (currently mock) API.
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
*/
import { generateSlots } from "./data";
import { client, type TimelineRange } from "$lib/api";
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
@@ -16,20 +15,6 @@ type Range = [start: number, end: number];
const FETCH_THROTTLE_MS = 500;
const BUFFER_RATIO = 0.15;
// Mock network latency bounds (ms).
const MOCK_DELAY_MIN = 40;
const MOCK_DELAY_MAX = 120;
/**
* Simulate an API call that returns slots for an arbitrary time range.
* The delay makes loading behaviour visible during development.
*/
async function mockFetch(startMs: number, endMs: number): Promise<TimeSlot[]> {
const delay = MOCK_DELAY_MIN + Math.random() * (MOCK_DELAY_MAX - MOCK_DELAY_MIN);
await new Promise((r) => setTimeout(r, delay));
return generateSlots(startMs, endMs);
}
/** Align a timestamp down to the nearest slot boundary. */
function alignFloor(ms: number): number {
return Math.floor(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
@@ -84,6 +69,24 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
return merged;
}
/**
* Fetch timeline data for the given gap ranges from the API.
* Converts gap ranges into the API request format.
*/
async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
const ranges: TimelineRange[] = gaps.map(([start, end]) => ({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}));
const response = await client.getTimeline(ranges);
return response.slots.map((slot) => ({
time: new Date(slot.time),
subjects: slot.subjects,
}));
}
/**
* Create a reactive timeline store.
*
@@ -93,6 +96,9 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
*
* The `data` getter returns a sorted `TimeSlot[]` that reactively
* updates as new segments arrive.
*
* The `subjects` getter returns the sorted list of all subject codes
* seen so far across all fetched data.
*/
export function createTimelineStore() {
// All loaded slots keyed by aligned timestamp (ms).
@@ -101,6 +107,9 @@ export function createTimelineStore() {
// Sorted, non-overlapping list of fetched ranges.
let loadedRanges: Range[] = [];
// All subject codes observed across all fetched data.
let knownSubjects: Set<string> = $state(new Set());
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
let pendingStart = 0;
let pendingEnd = 0;
@@ -112,18 +121,28 @@ export function createTimelineStore() {
[...slotMap.values()].sort((a, b) => a.time.getTime() - b.time.getTime())
);
// Sorted subject list derived from the known subjects set.
const subjects: string[] = $derived([...knownSubjects].sort());
async function fetchGaps(start: number, end: number): Promise<void> {
const gaps = findGaps(start, end, loadedRanges);
if (gaps.length === 0) return;
// Fetch all gap segments in parallel.
const results = await Promise.all(gaps.map(([gs, ge]) => mockFetch(gs, ge)));
let slots: TimeSlot[];
try {
slots = await fetchFromApi(gaps);
} catch (err) {
console.error("Timeline fetch failed:", err);
return;
}
// Merge results into the slot map.
const next = new Map(slotMap);
for (const slots of results) {
for (const slot of slots) {
next.set(slot.time.getTime(), slot);
const nextSubjects = new Set(knownSubjects);
for (const slot of slots) {
next.set(slot.time.getTime(), slot);
for (const subject of Object.keys(slot.subjects)) {
nextSubjects.add(subject);
}
}
@@ -132,8 +151,9 @@ export function createTimelineStore() {
loadedRanges = mergeRange(loadedRanges, gap);
}
// Single reactive assignment.
// Single reactive assignments.
slotMap = next;
knownSubjects = nextSubjects;
}
/**
@@ -173,6 +193,9 @@ export function createTimelineStore() {
get data() {
return data;
},
get subjects() {
return subjects;
},
requestRange,
dispose,
};
+6 -6
View File
@@ -1,16 +1,16 @@
/**
* Shared types for the timeline feature.
*
* Subjects are dynamic strings (actual Banner subject codes like "CS",
* "MAT", "BIO") rather than a fixed enum — the set of subjects comes
* from the API response.
*/
import type { ScaleLinear, ScaleTime } from "d3-scale";
import type { Subject } from "./data";
export type { Subject };
/** A single 15-minute time slot with per-subject class counts. */
/** A single 15-minute time slot with per-subject enrollment totals. */
export interface TimeSlot {
time: Date;
subjects: Record<Subject, number>;
subjects: Record<string, number>;
}
/** Lerped animation entry for a single subject within a slot. */
+9 -7
View File
@@ -3,7 +3,6 @@
* hit-testing, and snapping for the timeline canvas.
*/
import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
import { SUBJECTS, type Subject } from "./data";
import type { TimeSlot } from "./types";
/**
@@ -55,8 +54,8 @@ export function snapToSlot(timeMs: number): number {
return Math.floor(timeMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
}
/** Sum of class counts for enabled subjects in a slot. */
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Subject[]): number {
/** Sum of enrollment counts for enabled subjects in a slot. */
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly string[]): number {
let sum = 0;
for (const s of activeSubjects) {
sum += slot.subjects[s] || 0;
@@ -67,15 +66,18 @@ export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Sub
/**
* Determine which subjects to include in the stack: all enabled subjects
* plus any disabled subjects still animating out (current > threshold).
*
* @param allSubjects - the full set of known subject codes
*/
export function getStackSubjects(
visible: TimeSlot[],
enabledSubjects: Set<Subject>,
allSubjects: readonly string[],
enabledSubjects: Set<string>,
animMap: Map<number, Map<string, { current: number }>>,
settleThreshold: number
): Subject[] {
const subjects: Subject[] = [];
for (const subject of SUBJECTS) {
): string[] {
const subjects: string[] = [];
for (const subject of allSubjects) {
if (enabledSubjects.has(subject)) {
subjects.push(subject);
continue;
+1 -7
View File
@@ -15,7 +15,7 @@ import {
User,
Users,
} from "@lucide/svelte";
import { onMount, tick } from "svelte";
import { tick } from "svelte";
let { children } = $props();
@@ -41,12 +41,6 @@ $effect(() => {
}
});
onMount(async () => {
if (authStore.isLoading) {
await authStore.init();
}
});
$effect(() => {
if (authStore.state.mode === "unauthenticated") {
goto("/login");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
<script lang="ts">
import type { CandidateResponse } from "$lib/api";
import { isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
import { Check, ExternalLink, LoaderCircle, X, XCircle } from "@lucide/svelte";
import ScoreBreakdown from "./ScoreBreakdown.svelte";
let {
candidate,
isMatched = false,
isRejected = false,
disabled = false,
actionLoading = null,
isDark = false,
onmatch,
onreject,
onunmatch,
}: {
candidate: CandidateResponse;
isMatched?: boolean;
isRejected?: boolean;
disabled?: boolean;
actionLoading?: string | null;
isDark?: boolean;
onmatch?: () => void;
onreject?: () => void;
onunmatch?: () => void;
} = $props();
const isPending = $derived(!isMatched && !isRejected);
const isMatchLoading = $derived(actionLoading === `match-${candidate.rmpLegacyId}`);
const isRejectLoading = $derived(actionLoading === `reject-${candidate.rmpLegacyId}`);
const isUnmatchLoading = $derived(actionLoading === `unmatch-${candidate.rmpLegacyId}`);
</script>
<div
class="rounded-md border p-3 transition-all duration-200
{isMatched
? 'border-l-4 border-l-green-500 bg-green-500/5 border-border'
: isRejected
? 'border-border bg-card opacity-50'
: 'border-border bg-card hover:shadow-sm'}"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground text-sm">
{candidate.firstName} {candidate.lastName}
</span>
{#if isMatched}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium"
>
Matched
</span>
{:else if isRejected}
<span
class="text-[10px] rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 font-medium"
>
Rejected
</span>
{/if}
</div>
{#if candidate.department}
<div class="text-xs text-muted-foreground mt-0.5">{candidate.department}</div>
{/if}
</div>
<div class="flex items-center gap-0.5 shrink-0">
{#if isMatched}
<button
onclick={(e) => {
e.stopPropagation();
onunmatch?.();
}}
{disabled}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Remove match"
>
{#if isUnmatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<XCircle size={14} />
{/if}
Unmatch
</button>
{:else if isPending}
<button
onclick={(e) => {
e.stopPropagation();
onmatch?.();
}}
{disabled}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept match"
>
{#if isMatchLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<Check size={14} />
{/if}
</button>
<button
onclick={(e) => {
e.stopPropagation();
onreject?.();
}}
{disabled}
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Reject candidate"
>
{#if isRejectLoading}
<LoaderCircle size={14} class="animate-spin" />
{:else}
<X size={14} />
{/if}
</button>
{/if}
<a
href={rmpUrl(candidate.rmpLegacyId)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title="View on RateMyProfessors"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<!-- Rating stats -->
<div class="mt-2 flex items-center gap-3 text-xs flex-wrap">
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
<span
class="font-semibold tabular-nums"
style={ratingStyle(candidate.avgRating!, isDark)}
>
{candidate.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-muted-foreground">No rating</span>
{/if}
{#if candidate.avgDifficulty !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.avgDifficulty.toFixed(1)} diff</span
>
{/if}
<span class="text-muted-foreground tabular-nums">{candidate.numRatings} ratings</span>
{#if candidate.wouldTakeAgainPct !== null}
<span class="text-muted-foreground tabular-nums"
>{candidate.wouldTakeAgainPct.toFixed(0)}% again</span
>
{/if}
</div>
<!-- Score breakdown -->
<div class="mt-2">
<ScoreBreakdown breakdown={candidate.scoreBreakdown} score={candidate.score ?? 0} />
</div>
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
let {
breakdown = null,
score = 0,
}: {
breakdown?: { [key in string]?: number } | null;
score?: number;
} = $props();
const weights: Record<string, number> = {
name: 0.5,
department: 0.25,
uniqueness: 0.15,
volume: 0.1,
};
const colors: Record<string, string> = {
name: "bg-blue-500",
department: "bg-purple-500",
uniqueness: "bg-amber-500",
volume: "bg-emerald-500",
};
const labels: Record<string, string> = {
name: "Name",
department: "Dept",
uniqueness: "Unique",
volume: "Volume",
};
function fmt(v: number): string {
return (v * 100).toFixed(0);
}
const segments = $derived(
Object.entries(breakdown ?? {})
.filter(([_, value]) => value != null)
.map(([key, value]) => ({
key,
label: labels[key] ?? key,
color: colors[key] ?? "bg-primary",
weight: weights[key] ?? 0,
raw: value!,
pct: value! * (weights[key] ?? 0) * 100,
}))
);
const tooltipText = $derived(
segments.map((s) => `${s.label}: ${fmt(s.raw)}% \u00d7 ${fmt(s.weight)}%`).join("\n") +
`\nTotal: ${fmt(score)}%`
);
</script>
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">Score:</span>
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
{#each segments as seg (seg.key)}
<div
class="{seg.color} h-full transition-all duration-300"
style="width: {seg.pct}%"
></div>
{/each}
</div>
<SimpleTooltip text={tooltipText} side="top">
<span
class="tabular-nums font-medium text-foreground cursor-help border-b border-dotted border-muted-foreground/40"
>
{fmt(score)}%
</span>
</SimpleTooltip>
</div>
+2
View File
@@ -7,6 +7,7 @@ import NavBar from "$lib/components/NavBar.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { authStore } from "$lib/auth.svelte";
import { Tooltip } from "bits-ui";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import { onMount } from "svelte";
@@ -34,6 +35,7 @@ useOverlayScrollbars(() => document.body, {
onMount(() => {
themeStore.init();
authStore.init();
});
</script>