From 669dec023519643f9b587e1ad0993cc570cf675d Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 30 Jan 2026 10:56:11 -0600 Subject: [PATCH] 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. --- .../20260131200000_timeline_indexes.sql | 13 + src/app.rs | 5 + src/state.rs | 4 + src/web/mod.rs | 2 + src/web/routes.rs | 2 + src/web/schedule_cache.rs | 444 ++++++++++++++++++ src/web/timeline.rs | 259 ++++++++++ web/src/lib/api.ts | 30 ++ web/src/lib/components/TimelineCanvas.svelte | 36 +- web/src/lib/components/TimelineDrawer.svelte | 13 +- web/src/lib/components/TimelineTooltip.svelte | 6 +- web/src/lib/timeline/animation.ts | 9 +- web/src/lib/timeline/data.ts | 162 +++---- web/src/lib/timeline/renderer.ts | 23 +- web/src/lib/timeline/store.svelte.ts | 69 ++- web/src/lib/timeline/types.ts | 12 +- web/src/lib/timeline/viewport.ts | 16 +- 17 files changed, 940 insertions(+), 165 deletions(-) create mode 100644 migrations/20260131200000_timeline_indexes.sql create mode 100644 src/web/schedule_cache.rs create mode 100644 src/web/timeline.rs diff --git a/migrations/20260131200000_timeline_indexes.sql b/migrations/20260131200000_timeline_indexes.sql new file mode 100644 index 0000000..602ff15 --- /dev/null +++ b/migrations/20260131200000_timeline_indexes.sql @@ -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); diff --git a/src/app.rs b/src/app.rs index f3f44fe..63723ff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -85,6 +85,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) diff --git a/src/state.rs b/src/state.rs index e2f546d..6b2118e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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>, pub session_cache: SessionCache, pub oauth_state_store: OAuthStateStore, + pub schedule_cache: ScheduleCache, pub scrape_job_tx: broadcast::Sender, } impl AppState { pub fn new(banner_api: Arc, 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, } } diff --git a/src/web/mod.rs b/src/web/mod.rs index b232f67..6d06916 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -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::*; diff --git a/src/web/routes.rs b/src/web/routes.rs index 74740ab..7b8a56c 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -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() diff --git a/src/web/schedule_cache.rs b/src/web/schedule_cache.rs new file mode 100644 index 0000000..dc9d719 --- /dev/null +++ b/src/web/schedule_cache.rs @@ -0,0 +1,444 @@ +//! 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::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +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, +} + +/// The immutable snapshot of all courses, swapped atomically on refresh. +#[derive(Debug, Clone)] +pub(crate) struct ScheduleSnapshot { + pub(crate) courses: Vec, + 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>, + /// Sender side, held to push new snapshots. + tx: Arc>>, + /// Singleflight guard — true while a refresh task is in flight. + refreshing: Arc, + /// 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 { + 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 { + let start = std::time::Instant::now(); + + let rows: Vec = sqlx::query_as( + "SELECT subject, enrollment, meeting_times FROM courses", + ) + .fetch_all(pool) + .await?; + + let courses: Vec = 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 { + let Value::Array(arr) = value else { + return Vec::new(); + }; + + arr.iter().filter_map(parse_one_meeting).collect() +} + +fn parse_one_meeting(mt: &Value) -> Option { + 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 { + 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::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)); + } +} diff --git a/src/web/timeline.rs b/src/web/timeline.rs new file mode 100644 index 0000000..7f9aaaf --- /dev/null +++ b/src/web/timeline.rs @@ -0,0 +1,259 @@ +//! 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, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TimeRange { + start: DateTime, + end: DateTime, +} + +#[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, + /// All subject codes present in the returned data. + subjects: Vec, +} + +#[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, + /// Subject code → total enrollment in this slot. + subjects: BTreeMap, +} + +// ── Error type ────────────────────────────────────────────────────── + +pub(crate) struct TimelineError { + status: StatusCode, + message: String, +} + +impl TimelineError { + fn bad_request(msg: impl Into) -> 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) -> DateTime { + 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) -> DateTime { + 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, + end: DateTime, +} + +/// Merge overlapping/adjacent ranges into a minimal set. +fn merge_ranges(mut ranges: Vec) -> Vec { + if ranges.is_empty() { + return ranges; + } + ranges.sort_by_key(|r| r.start); + let mut merged: Vec = 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> { + 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, + Json(body): Json, +) -> Result, 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 = 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 = BTreeSet::new(); + + let slots: Vec = 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 = 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 = 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 +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9d8d15a..0edc023 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -135,6 +135,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; +} + +/** Response from POST /api/timeline. */ +export interface TimelineResponse { + slots: TimelineSlot[]; + subjects: string[]; +} + export interface SearchParams { term: string; subjects?: string[]; @@ -297,6 +320,13 @@ export class BannerApiClient { /** Stored `Last-Modified` value for audit log conditional requests. */ private _auditLastModified: string | null = null; + async getTimeline(ranges: TimelineRange[]): Promise { + return this.request("/timeline", { + method: "POST", + body: { ranges } satisfies TimelineRequest, + }); + } + async getMetrics(params?: MetricsParams): Promise { const query = new URLSearchParams(); if (params?.course_id !== undefined) query.set("course_id", String(params.course_id)); diff --git a/web/src/lib/components/TimelineCanvas.svelte b/web/src/lib/components/TimelineCanvas.svelte index 576cacf..ffba789 100644 --- a/web/src/lib/components/TimelineCanvas.svelte +++ b/web/src/lib/components/TimelineCanvas.svelte @@ -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 = $state(new Set(SUBJECTS)); +// Start with an empty set — subjects are populated dynamically from the API. +let enabledSubjects: Set = $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(() => { 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; + subjects: readonly string[]; + enabledSubjects: Set; 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) {
- {#each SUBJECTS as subject} + {#each subjects as subject} {@const enabled = enabledSubjects.has(subject)} + {@const color = getSubjectColor(subject)}