mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -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)");
|
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
|
// Seed the initial admin user if configured
|
||||||
if let Some(admin_id) = config.admin_discord_id {
|
if let Some(admin_id) = config.admin_discord_id {
|
||||||
let user = crate::data::users::ensure_seed_admin(&db_pool, admin_id as i64)
|
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::banner::Course;
|
||||||
use crate::data::models::ReferenceData;
|
use crate::data::models::ReferenceData;
|
||||||
use crate::status::ServiceStatusRegistry;
|
use crate::status::ServiceStatusRegistry;
|
||||||
|
use crate::web::schedule_cache::ScheduleCache;
|
||||||
use crate::web::session_cache::{OAuthStateStore, SessionCache};
|
use crate::web::session_cache::{OAuthStateStore, SessionCache};
|
||||||
use crate::web::ws::ScrapeJobEvent;
|
use crate::web::ws::ScrapeJobEvent;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -76,12 +77,14 @@ pub struct AppState {
|
|||||||
pub reference_cache: Arc<RwLock<ReferenceCache>>,
|
pub reference_cache: Arc<RwLock<ReferenceCache>>,
|
||||||
pub session_cache: SessionCache,
|
pub session_cache: SessionCache,
|
||||||
pub oauth_state_store: OAuthStateStore,
|
pub oauth_state_store: OAuthStateStore,
|
||||||
|
pub schedule_cache: ScheduleCache,
|
||||||
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
|
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(banner_api: Arc<BannerApi>, db_pool: PgPool) -> Self {
|
pub fn new(banner_api: Arc<BannerApi>, db_pool: PgPool) -> Self {
|
||||||
let (scrape_job_tx, _) = broadcast::channel(64);
|
let (scrape_job_tx, _) = broadcast::channel(64);
|
||||||
|
let schedule_cache = ScheduleCache::new(db_pool.clone());
|
||||||
Self {
|
Self {
|
||||||
session_cache: SessionCache::new(db_pool.clone()),
|
session_cache: SessionCache::new(db_pool.clone()),
|
||||||
oauth_state_store: OAuthStateStore::new(),
|
oauth_state_store: OAuthStateStore::new(),
|
||||||
@@ -89,6 +92,7 @@ impl AppState {
|
|||||||
db_pool,
|
db_pool,
|
||||||
service_statuses: ServiceStatusRegistry::new(),
|
service_statuses: ServiceStatusRegistry::new(),
|
||||||
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
|
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
|
||||||
|
schedule_cache,
|
||||||
scrape_job_tx,
|
scrape_job_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ pub mod calendar;
|
|||||||
pub mod encoding;
|
pub mod encoding;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod schedule_cache;
|
||||||
pub mod session_cache;
|
pub mod session_cache;
|
||||||
|
pub mod timeline;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
pub use routes::*;
|
pub use routes::*;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::web::admin_rmp;
|
|||||||
use crate::web::admin_scraper;
|
use crate::web::admin_scraper;
|
||||||
use crate::web::auth::{self, AuthConfig};
|
use crate::web::auth::{self, AuthConfig};
|
||||||
use crate::web::calendar;
|
use crate::web::calendar;
|
||||||
|
use crate::web::timeline;
|
||||||
use crate::web::ws;
|
use crate::web::ws;
|
||||||
#[cfg(feature = "embed-assets")]
|
#[cfg(feature = "embed-assets")]
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -54,6 +55,7 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
|||||||
.route("/terms", get(get_terms))
|
.route("/terms", get(get_terms))
|
||||||
.route("/subjects", get(get_subjects))
|
.route("/subjects", get(get_subjects))
|
||||||
.route("/reference/{category}", get(get_reference))
|
.route("/reference/{category}", get(get_reference))
|
||||||
|
.route("/timeline", post(timeline::timeline))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
let auth_router = Router::new()
|
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;
|
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 {
|
export interface SearchParams {
|
||||||
term: string;
|
term: string;
|
||||||
subjects?: string[];
|
subjects?: string[];
|
||||||
@@ -297,6 +320,13 @@ export class BannerApiClient {
|
|||||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||||
private _auditLastModified: string | null = null;
|
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> {
|
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { scaleTime, scaleLinear } from "d3-scale";
|
import { scaleTime, scaleLinear } from "d3-scale";
|
||||||
|
|
||||||
import { SUBJECTS, type Subject } from "$lib/timeline/data";
|
|
||||||
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
|
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
|
||||||
import {
|
import {
|
||||||
PADDING,
|
PADDING,
|
||||||
@@ -125,12 +124,31 @@ let pointerOverCanvas = false;
|
|||||||
|
|
||||||
// ── Drawer ──────────────────────────────────────────────────────────
|
// ── Drawer ──────────────────────────────────────────────────────────
|
||||||
let drawerOpen = $state(false);
|
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 ──────────────────────────────────────────────────────
|
// ── Data store ──────────────────────────────────────────────────────
|
||||||
const store = createTimelineStore();
|
const store = createTimelineStore();
|
||||||
let data: TimeSlot[] = $derived(store.data);
|
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 ──────────────────────────────────────────────────
|
// ── Derived layout ──────────────────────────────────────────────────
|
||||||
let viewStart = $derived(viewCenter - viewSpan / 2);
|
let viewStart = $derived(viewCenter - viewSpan / 2);
|
||||||
@@ -151,7 +169,7 @@ let yScale = scaleLinear()
|
|||||||
.range([0, 1]);
|
.range([0, 1]);
|
||||||
|
|
||||||
// ── Subject toggling ────────────────────────────────────────────────
|
// ── Subject toggling ────────────────────────────────────────────────
|
||||||
function toggleSubject(subject: Subject) {
|
function toggleSubject(subject: string) {
|
||||||
const next = new Set(enabledSubjects);
|
const next = new Set(enabledSubjects);
|
||||||
if (next.has(subject)) next.delete(subject);
|
if (next.has(subject)) next.delete(subject);
|
||||||
else next.add(subject);
|
else next.add(subject);
|
||||||
@@ -159,7 +177,7 @@ function toggleSubject(subject: Subject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enableAll() {
|
function enableAll() {
|
||||||
enabledSubjects = new Set(SUBJECTS);
|
enabledSubjects = new Set(allSubjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableAll() {
|
function disableAll() {
|
||||||
@@ -192,7 +210,7 @@ function render() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const visible = getVisibleSlots(data, viewStart, viewEnd);
|
const visible = getVisibleSlots(data, viewStart, viewEnd);
|
||||||
const visibleStack = stackVisibleSlots(visible, enabledSubjects, animMap);
|
const visibleStack = stackVisibleSlots(visible, allSubjects, enabledSubjects, animMap);
|
||||||
|
|
||||||
drawGrid(chart);
|
drawGrid(chart);
|
||||||
drawHoverColumn(chart, visibleStack, hoverSlotTime);
|
drawHoverColumn(chart, visibleStack, hoverSlotTime);
|
||||||
@@ -585,8 +603,9 @@ function tick(timestamp: number) {
|
|||||||
// ── Animation sync ──────────────────────────────────────────────────
|
// ── Animation sync ──────────────────────────────────────────────────
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const slots = data;
|
const slots = data;
|
||||||
|
const subs = allSubjects;
|
||||||
const enabled = enabledSubjects;
|
const enabled = enabledSubjects;
|
||||||
syncAnimTargets(animMap, slots, enabled);
|
syncAnimTargets(animMap, slots, subs, enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request data whenever the visible window changes.
|
// Request data whenever the visible window changes.
|
||||||
@@ -625,7 +644,7 @@ onMount(() => {
|
|||||||
class:cursor-grabbing={isDragging}
|
class:cursor-grabbing={isDragging}
|
||||||
style="display: block; touch-action: none;"
|
style="display: block; touch-action: none;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Interactive class schedule timeline chart"
|
aria-label="Interactive enrollment timeline chart"
|
||||||
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
|
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
|
||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
@@ -638,6 +657,7 @@ onMount(() => {
|
|||||||
|
|
||||||
<TimelineDrawer
|
<TimelineDrawer
|
||||||
bind:open={drawerOpen}
|
bind:open={drawerOpen}
|
||||||
|
subjects={allSubjects}
|
||||||
{enabledSubjects}
|
{enabledSubjects}
|
||||||
{followEnabled}
|
{followEnabled}
|
||||||
onToggleSubject={toggleSubject}
|
onToggleSubject={toggleSubject}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Filter, X } from "@lucide/svelte";
|
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";
|
import { DRAWER_WIDTH } from "$lib/timeline/constants";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
enabledSubjects: Set<Subject>;
|
subjects: readonly string[];
|
||||||
|
enabledSubjects: Set<string>;
|
||||||
followEnabled: boolean;
|
followEnabled: boolean;
|
||||||
onToggleSubject: (subject: Subject) => void;
|
onToggleSubject: (subject: string) => void;
|
||||||
onEnableAll: () => void;
|
onEnableAll: () => void;
|
||||||
onDisableAll: () => void;
|
onDisableAll: () => void;
|
||||||
onResumeFollow: () => void;
|
onResumeFollow: () => void;
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(),
|
open = $bindable(),
|
||||||
|
subjects,
|
||||||
enabledSubjects,
|
enabledSubjects,
|
||||||
followEnabled,
|
followEnabled,
|
||||||
onToggleSubject,
|
onToggleSubject,
|
||||||
@@ -109,8 +111,9 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each SUBJECTS as subject}
|
{#each subjects as subject}
|
||||||
{@const enabled = enabledSubjects.has(subject)}
|
{@const enabled = enabledSubjects.has(subject)}
|
||||||
|
{@const color = getSubjectColor(subject)}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
|
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"
|
hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||||
@@ -118,7 +121,7 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
|
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>
|
||||||
<span
|
<span
|
||||||
class="transition-opacity {enabled
|
class="transition-opacity {enabled
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { timeFormat } from "d3-time-format";
|
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 type { TimeSlot } from "$lib/timeline/types";
|
||||||
import { enabledTotalClasses } from "$lib/timeline/viewport";
|
import { enabledTotalClasses } from "$lib/timeline/viewport";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
slot: TimeSlot | null;
|
slot: TimeSlot | null;
|
||||||
activeSubjects: readonly Subject[];
|
activeSubjects: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { visible, x, y, slot, activeSubjects }: Props = $props();
|
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">
|
<div class="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
class="inline-block w-2 h-2 rounded-sm"
|
class="inline-block w-2 h-2 rounded-sm"
|
||||||
style="background: {SUBJECT_COLORS[subject]}"
|
style="background: {getSubjectColor(subject)}"
|
||||||
></span>
|
></span>
|
||||||
<span class="text-muted-foreground">{subject}</span>
|
<span class="text-muted-foreground">{subject}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
* targets. This module owns the AnimMap lifecycle: syncing targets,
|
* targets. This module owns the AnimMap lifecycle: syncing targets,
|
||||||
* stepping current values, and pruning offscreen entries.
|
* 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 { VALUE_EASE, MAXY_EASE, SETTLE_THRESHOLD, MIN_MAXY } from "./constants";
|
||||||
import type { AnimEntry, TimeSlot } from "./types";
|
import type { AnimEntry, TimeSlot } from "./types";
|
||||||
|
|
||||||
@@ -20,11 +19,15 @@ export function createAnimMap(): AnimMap {
|
|||||||
* Sync animMap targets from data + filter state.
|
* Sync animMap targets from data + filter state.
|
||||||
* New slots start at current=0 so they animate in from the baseline.
|
* New slots start at current=0 so they animate in from the baseline.
|
||||||
* Disabled subjects get target=0 so they animate out.
|
* 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(
|
export function syncAnimTargets(
|
||||||
animMap: AnimMap,
|
animMap: AnimMap,
|
||||||
slots: TimeSlot[],
|
slots: TimeSlot[],
|
||||||
enabledSubjects: Set<Subject>
|
subjects: readonly string[],
|
||||||
|
enabledSubjects: Set<string>
|
||||||
): void {
|
): void {
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
const timeMs = slot.time.getTime();
|
const timeMs = slot.time.getTime();
|
||||||
@@ -34,7 +37,7 @@ export function syncAnimTargets(
|
|||||||
animMap.set(timeMs, subjectMap);
|
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 realValue = enabledSubjects.has(subject) ? slot.subjects[subject] || 0 : 0;
|
||||||
const entry = subjectMap.get(subject);
|
const entry = subjectMap.get(subject);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
+59
-103
@@ -1,122 +1,78 @@
|
|||||||
/**
|
/**
|
||||||
* Data types, constants, and deterministic slot generation for the class timeline.
|
* Subject color palette for the timeline chart.
|
||||||
* 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.
|
* 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 };
|
/** Hand-picked colors for common UTSA subject codes. */
|
||||||
|
const KNOWN_SUBJECT_COLORS: Record<string, string> = {
|
||||||
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> = {
|
|
||||||
CS: "#6366f1", // indigo
|
CS: "#6366f1", // indigo
|
||||||
MATH: "#f59e0b", // amber
|
MAT: "#f59e0b", // amber
|
||||||
BIO: "#10b981", // emerald
|
BIO: "#10b981", // emerald
|
||||||
ENG: "#ef4444", // red
|
ENG: "#ef4444", // red
|
||||||
PHYS: "#3b82f6", // blue
|
PHY: "#3b82f6", // blue
|
||||||
HIST: "#8b5cf6", // violet
|
HIS: "#8b5cf6", // violet
|
||||||
CHEM: "#f97316", // orange
|
CHE: "#f97316", // orange
|
||||||
PSY: "#ec4899", // pink
|
PSY: "#ec4899", // pink
|
||||||
ECE: "#14b8a6", // teal
|
ECE: "#14b8a6", // teal
|
||||||
ART: "#a855f7", // purple
|
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.
|
* Extended palette for subjects that don't have a hand-picked color.
|
||||||
* Returns a value 0..1 representing relative class density.
|
* These are chosen to be visually distinct from each other.
|
||||||
*/
|
*/
|
||||||
function bellCurve(hour: number, center: number, spread: number): number {
|
const FALLBACK_PALETTE = [
|
||||||
const x = (hour - center) / spread;
|
"#f472b6", // pink-400
|
||||||
return Math.exp(-0.5 * x * x);
|
"#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
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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 Math.abs(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Cache of assigned colors to avoid re-computing. */
|
||||||
* Each subject has characteristic scheduling patterns:
|
const colorCache = new Map<string, string>();
|
||||||
* 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 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/** Get a consistent color for any subject code. */
|
||||||
* Seeded pseudo-random number generator (LCG) for reproducible data.
|
export function getSubjectColor(subject: string): string {
|
||||||
*/
|
const cached = colorCache.get(subject);
|
||||||
function seededRandom(seed: number): () => number {
|
if (cached) return cached;
|
||||||
let s = seed;
|
|
||||||
return () => {
|
|
||||||
s = (s * 1664525 + 1013904223) & 0xffffffff;
|
|
||||||
return (s >>> 0) / 0xffffffff;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const color =
|
||||||
* Integer hash so adjacent slot timestamps produce very different seeds.
|
KNOWN_SUBJECT_COLORS[subject] ?? FALLBACK_PALETTE[hashCode(subject) % FALLBACK_PALETTE.length];
|
||||||
*/
|
|
||||||
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. */
|
colorCache.set(subject, color);
|
||||||
function generateSlot(timeMs: number): TimeSlot {
|
return color;
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { time, subjects };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
const slots: TimeSlot[] = [];
|
|
||||||
for (let t = alignedStart; t <= alignedEnd; t += SLOT_INTERVAL_MS) {
|
|
||||||
slots.push(generateSlot(t));
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
|
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
|
||||||
import { timeFormat } from "d3-time-format";
|
import { timeFormat } from "d3-time-format";
|
||||||
|
|
||||||
import { SUBJECT_COLORS, type Subject } from "./data";
|
import { getSubjectColor } from "./data";
|
||||||
import type { AnimMap } from "./animation";
|
import type { AnimMap } from "./animation";
|
||||||
import { getStackSubjects } from "./viewport";
|
import { getStackSubjects } from "./viewport";
|
||||||
import type { ChartContext, TimeSlot } from "./types";
|
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
|
* Stack only the visible slice using *animated* values so transitions
|
||||||
* between filter/data states are smooth. Includes subjects that are
|
* between filter/data states are smooth. Includes subjects that are
|
||||||
* still animating out so removal is gradual.
|
* still animating out so removal is gradual.
|
||||||
|
*
|
||||||
|
* @param allSubjects - full set of known subject codes
|
||||||
*/
|
*/
|
||||||
export function stackVisibleSlots(
|
export function stackVisibleSlots(
|
||||||
visible: TimeSlot[],
|
visible: TimeSlot[],
|
||||||
enabledSubjects: Set<Subject>,
|
allSubjects: readonly string[],
|
||||||
|
enabledSubjects: Set<string>,
|
||||||
animMap: AnimMap
|
animMap: AnimMap
|
||||||
): VisibleStack {
|
): VisibleStack {
|
||||||
if (visible.length === 0) return [];
|
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 [];
|
if (stackKeys.length === 0) return [];
|
||||||
|
|
||||||
// Build synthetic slots with animated current values.
|
// Build synthetic slots with animated current values.
|
||||||
const animatedSlots: TimeSlot[] = visible.map((slot) => {
|
const animatedSlots: TimeSlot[] = visible.map((slot) => {
|
||||||
const timeMs = slot.time.getTime();
|
const timeMs = slot.time.getTime();
|
||||||
const subjectMap = animMap.get(timeMs);
|
const subjectMap = animMap.get(timeMs);
|
||||||
const subjects = {} as Record<Subject, number>;
|
const subjects: Record<string, number> = {};
|
||||||
for (const subject of stackKeys) {
|
for (const subject of stackKeys) {
|
||||||
const entry = subjectMap?.get(subject);
|
const entry = subjectMap?.get(subject);
|
||||||
subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
|
subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
|
||||||
@@ -80,7 +89,7 @@ export function stackVisibleSlots(
|
|||||||
|
|
||||||
const gen = stack<TimeSlot>()
|
const gen = stack<TimeSlot>()
|
||||||
.keys(stackKeys)
|
.keys(stackKeys)
|
||||||
.value((d, key) => d.subjects[key as Subject] || 0);
|
.value((d, key) => d.subjects[key] || 0);
|
||||||
return gen(animatedSlots);
|
return gen(animatedSlots);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +196,8 @@ export function drawStackedArea(chart: ChartContext, visibleStack: VisibleStack)
|
|||||||
|
|
||||||
for (let i = visibleStack.length - 1; i >= 0; i--) {
|
for (let i = visibleStack.length - 1; i >= 0; i--) {
|
||||||
const layer = visibleStack[i];
|
const layer = visibleStack[i];
|
||||||
const subject = layer.key as Subject;
|
const subject = layer.key;
|
||||||
const color = SUBJECT_COLORS[subject];
|
const color = getSubjectColor(subject);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
area<StackPoint>()
|
area<StackPoint>()
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Tracks which time ranges have already been fetched and only requests
|
* Tracks which time ranges have already been fetched and only requests
|
||||||
* the missing segments when the view expands into unloaded territory.
|
* the missing segments when the view expands into unloaded territory.
|
||||||
* Fetches are throttled so rapid panning/zooming doesn't flood the
|
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
|
||||||
* (currently mock) API.
|
|
||||||
*/
|
*/
|
||||||
import { generateSlots } from "./data";
|
import { client, type TimelineRange } from "$lib/api";
|
||||||
import { SLOT_INTERVAL_MS } from "./constants";
|
import { SLOT_INTERVAL_MS } from "./constants";
|
||||||
import type { TimeSlot } from "./types";
|
import type { TimeSlot } from "./types";
|
||||||
|
|
||||||
@@ -16,20 +15,6 @@ type Range = [start: number, end: number];
|
|||||||
const FETCH_THROTTLE_MS = 500;
|
const FETCH_THROTTLE_MS = 500;
|
||||||
const BUFFER_RATIO = 0.15;
|
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. */
|
/** Align a timestamp down to the nearest slot boundary. */
|
||||||
function alignFloor(ms: number): number {
|
function alignFloor(ms: number): number {
|
||||||
return Math.floor(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
|
return Math.floor(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
|
||||||
@@ -84,6 +69,24 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
|
|||||||
return merged;
|
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.
|
* 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
|
* The `data` getter returns a sorted `TimeSlot[]` that reactively
|
||||||
* updates as new segments arrive.
|
* 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() {
|
export function createTimelineStore() {
|
||||||
// All loaded slots keyed by aligned timestamp (ms).
|
// All loaded slots keyed by aligned timestamp (ms).
|
||||||
@@ -101,6 +107,9 @@ export function createTimelineStore() {
|
|||||||
// Sorted, non-overlapping list of fetched ranges.
|
// Sorted, non-overlapping list of fetched ranges.
|
||||||
let loadedRanges: Range[] = [];
|
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 throttleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let pendingStart = 0;
|
let pendingStart = 0;
|
||||||
let pendingEnd = 0;
|
let pendingEnd = 0;
|
||||||
@@ -112,18 +121,28 @@ export function createTimelineStore() {
|
|||||||
[...slotMap.values()].sort((a, b) => a.time.getTime() - b.time.getTime())
|
[...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> {
|
async function fetchGaps(start: number, end: number): Promise<void> {
|
||||||
const gaps = findGaps(start, end, loadedRanges);
|
const gaps = findGaps(start, end, loadedRanges);
|
||||||
if (gaps.length === 0) return;
|
if (gaps.length === 0) return;
|
||||||
|
|
||||||
// Fetch all gap segments in parallel.
|
let slots: TimeSlot[];
|
||||||
const results = await Promise.all(gaps.map(([gs, ge]) => mockFetch(gs, ge)));
|
try {
|
||||||
|
slots = await fetchFromApi(gaps);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Timeline fetch failed:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge results into the slot map.
|
// Merge results into the slot map.
|
||||||
const next = new Map(slotMap);
|
const next = new Map(slotMap);
|
||||||
for (const slots of results) {
|
const nextSubjects = new Set(knownSubjects);
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
next.set(slot.time.getTime(), slot);
|
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);
|
loadedRanges = mergeRange(loadedRanges, gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single reactive assignment.
|
// Single reactive assignments.
|
||||||
slotMap = next;
|
slotMap = next;
|
||||||
|
knownSubjects = nextSubjects;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +193,9 @@ export function createTimelineStore() {
|
|||||||
get data() {
|
get data() {
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
get subjects() {
|
||||||
|
return subjects;
|
||||||
|
},
|
||||||
requestRange,
|
requestRange,
|
||||||
dispose,
|
dispose,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Shared types for the timeline feature.
|
* 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 { ScaleLinear, ScaleTime } from "d3-scale";
|
||||||
|
|
||||||
import type { Subject } from "./data";
|
/** A single 15-minute time slot with per-subject enrollment totals. */
|
||||||
|
|
||||||
export type { Subject };
|
|
||||||
|
|
||||||
/** A single 15-minute time slot with per-subject class counts. */
|
|
||||||
export interface TimeSlot {
|
export interface TimeSlot {
|
||||||
time: Date;
|
time: Date;
|
||||||
subjects: Record<Subject, number>;
|
subjects: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lerped animation entry for a single subject within a slot. */
|
/** Lerped animation entry for a single subject within a slot. */
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* hit-testing, and snapping for the timeline canvas.
|
* hit-testing, and snapping for the timeline canvas.
|
||||||
*/
|
*/
|
||||||
import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
|
import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
|
||||||
import { SUBJECTS, type Subject } from "./data";
|
|
||||||
import type { TimeSlot } from "./types";
|
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;
|
return Math.floor(timeMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of class counts for enabled subjects in a slot. */
|
/** Sum of enrollment counts for enabled subjects in a slot. */
|
||||||
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Subject[]): number {
|
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly string[]): number {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (const s of activeSubjects) {
|
for (const s of activeSubjects) {
|
||||||
sum += slot.subjects[s] || 0;
|
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
|
* Determine which subjects to include in the stack: all enabled subjects
|
||||||
* plus any disabled subjects still animating out (current > threshold).
|
* plus any disabled subjects still animating out (current > threshold).
|
||||||
|
*
|
||||||
|
* @param allSubjects - the full set of known subject codes
|
||||||
*/
|
*/
|
||||||
export function getStackSubjects(
|
export function getStackSubjects(
|
||||||
visible: TimeSlot[],
|
visible: TimeSlot[],
|
||||||
enabledSubjects: Set<Subject>,
|
allSubjects: readonly string[],
|
||||||
|
enabledSubjects: Set<string>,
|
||||||
animMap: Map<number, Map<string, { current: number }>>,
|
animMap: Map<number, Map<string, { current: number }>>,
|
||||||
settleThreshold: number
|
settleThreshold: number
|
||||||
): Subject[] {
|
): string[] {
|
||||||
const subjects: Subject[] = [];
|
const subjects: string[] = [];
|
||||||
for (const subject of SUBJECTS) {
|
for (const subject of allSubjects) {
|
||||||
if (enabledSubjects.has(subject)) {
|
if (enabledSubjects.has(subject)) {
|
||||||
subjects.push(subject);
|
subjects.push(subject);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user