mirror of
https://github.com/Xevion/banner.git
synced 2026-01-30 18:23:30 -06:00
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.
This commit is contained in:
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
/** 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<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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user