mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 12:23:33 -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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user