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:
2026-01-30 10:56:11 -06:00
parent 67ba63339a
commit 669dec0235
17 changed files with 940 additions and 165 deletions
+30
View File
@@ -135,6 +135,29 @@ export interface MetricsParams {
limit?: number;
}
/** A time range for timeline queries (ISO-8601 strings). */
export interface TimelineRange {
start: string;
end: string;
}
/** Request body for POST /api/timeline. */
export interface TimelineRequest {
ranges: TimelineRange[];
}
/** A single 15-minute slot returned by the timeline API. */
export interface TimelineSlot {
time: string;
subjects: Record<string, number>;
}
/** Response from POST /api/timeline. */
export interface TimelineResponse {
slots: TimelineSlot[];
subjects: string[];
}
export interface SearchParams {
term: string;
subjects?: string[];
@@ -297,6 +320,13 @@ export class BannerApiClient {
/** Stored `Last-Modified` value for audit log conditional requests. */
private _auditLastModified: string | null = null;
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
return this.request<TimelineResponse>("/timeline", {
method: "POST",
body: { ranges } satisfies TimelineRequest,
});
}
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
const query = new URLSearchParams();
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
+28 -8
View File
@@ -2,7 +2,6 @@
import { onMount } from "svelte";
import { scaleTime, scaleLinear } from "d3-scale";
import { SUBJECTS, type Subject } from "$lib/timeline/data";
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
import {
PADDING,
@@ -125,12 +124,31 @@ let pointerOverCanvas = false;
// ── Drawer ──────────────────────────────────────────────────────────
let drawerOpen = $state(false);
let enabledSubjects: Set<Subject> = $state(new Set(SUBJECTS));
// Start with an empty set — subjects are populated dynamically from the API.
let enabledSubjects: Set<string> = $state(new Set());
// ── Data store ──────────────────────────────────────────────────────
const store = createTimelineStore();
let data: TimeSlot[] = $derived(store.data);
let activeSubjects = $derived(SUBJECTS.filter((s) => enabledSubjects.has(s)));
let allSubjects: string[] = $derived(store.subjects);
// Auto-enable newly discovered subjects.
$effect(() => {
const storeSubjects = store.subjects;
const next = new Set(enabledSubjects);
let changed = false;
for (const s of storeSubjects) {
if (!next.has(s)) {
next.add(s);
changed = true;
}
}
if (changed) {
enabledSubjects = next;
}
});
let activeSubjects = $derived(allSubjects.filter((s) => enabledSubjects.has(s)));
// ── Derived layout ──────────────────────────────────────────────────
let viewStart = $derived(viewCenter - viewSpan / 2);
@@ -151,7 +169,7 @@ let yScale = scaleLinear()
.range([0, 1]);
// ── Subject toggling ────────────────────────────────────────────────
function toggleSubject(subject: Subject) {
function toggleSubject(subject: string) {
const next = new Set(enabledSubjects);
if (next.has(subject)) next.delete(subject);
else next.add(subject);
@@ -159,7 +177,7 @@ function toggleSubject(subject: Subject) {
}
function enableAll() {
enabledSubjects = new Set(SUBJECTS);
enabledSubjects = new Set(allSubjects);
}
function disableAll() {
@@ -192,7 +210,7 @@ function render() {
};
const visible = getVisibleSlots(data, viewStart, viewEnd);
const visibleStack = stackVisibleSlots(visible, enabledSubjects, animMap);
const visibleStack = stackVisibleSlots(visible, allSubjects, enabledSubjects, animMap);
drawGrid(chart);
drawHoverColumn(chart, visibleStack, hoverSlotTime);
@@ -585,8 +603,9 @@ function tick(timestamp: number) {
// ── Animation sync ──────────────────────────────────────────────────
$effect(() => {
const slots = data;
const subs = allSubjects;
const enabled = enabledSubjects;
syncAnimTargets(animMap, slots, enabled);
syncAnimTargets(animMap, slots, subs, enabled);
});
// Request data whenever the visible window changes.
@@ -625,7 +644,7 @@ onMount(() => {
class:cursor-grabbing={isDragging}
style="display: block; touch-action: none;"
tabindex="0"
aria-label="Interactive class schedule timeline chart"
aria-label="Interactive enrollment timeline chart"
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
@@ -638,6 +657,7 @@ onMount(() => {
<TimelineDrawer
bind:open={drawerOpen}
subjects={allSubjects}
{enabledSubjects}
{followEnabled}
onToggleSubject={toggleSubject}
+8 -5
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import { Filter, X } from "@lucide/svelte";
import { SUBJECTS, SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import { getSubjectColor } from "$lib/timeline/data";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
interface Props {
open: boolean;
enabledSubjects: Set<Subject>;
subjects: readonly string[];
enabledSubjects: Set<string>;
followEnabled: boolean;
onToggleSubject: (subject: Subject) => void;
onToggleSubject: (subject: string) => void;
onEnableAll: () => void;
onDisableAll: () => void;
onResumeFollow: () => void;
@@ -15,6 +16,7 @@ interface Props {
let {
open = $bindable(),
subjects,
enabledSubjects,
followEnabled,
onToggleSubject,
@@ -109,8 +111,9 @@ function onKeyDown(e: KeyboardEvent) {
</div>
</div>
<div class="space-y-0.5">
{#each SUBJECTS as subject}
{#each subjects as subject}
{@const enabled = enabledSubjects.has(subject)}
{@const color = getSubjectColor(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
@@ -118,7 +121,7 @@ function onKeyDown(e: KeyboardEvent) {
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {SUBJECT_COLORS[subject]}; opacity: {enabled ? 1 : 0.2};"
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
@@ -1,6 +1,6 @@
<script lang="ts">
import { timeFormat } from "d3-time-format";
import { SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import { getSubjectColor } from "$lib/timeline/data";
import type { TimeSlot } from "$lib/timeline/types";
import { enabledTotalClasses } from "$lib/timeline/viewport";
@@ -9,7 +9,7 @@ interface Props {
x: number;
y: number;
slot: TimeSlot | null;
activeSubjects: readonly Subject[];
activeSubjects: readonly string[];
}
let { visible, x, y, slot, activeSubjects }: Props = $props();
@@ -35,7 +35,7 @@ const fmtTime = timeFormat("%-I:%M %p");
<div class="flex items-center gap-1.5">
<span
class="inline-block w-2 h-2 rounded-sm"
style="background: {SUBJECT_COLORS[subject]}"
style="background: {getSubjectColor(subject)}"
></span>
<span class="text-muted-foreground">{subject}</span>
</div>
+6 -3
View File
@@ -5,7 +5,6 @@
* targets. This module owns the AnimMap lifecycle: syncing targets,
* stepping current values, and pruning offscreen entries.
*/
import { SUBJECTS, type Subject } from "./data";
import { VALUE_EASE, MAXY_EASE, SETTLE_THRESHOLD, MIN_MAXY } from "./constants";
import type { AnimEntry, TimeSlot } from "./types";
@@ -20,11 +19,15 @@ export function createAnimMap(): AnimMap {
* Sync animMap targets from data + filter state.
* New slots start at current=0 so they animate in from the baseline.
* Disabled subjects get target=0 so they animate out.
*
* @param subjects - the full list of known subject codes
* @param enabledSubjects - subjects currently toggled on
*/
export function syncAnimTargets(
animMap: AnimMap,
slots: TimeSlot[],
enabledSubjects: Set<Subject>
subjects: readonly string[],
enabledSubjects: Set<string>
): void {
for (const slot of slots) {
const timeMs = slot.time.getTime();
@@ -34,7 +37,7 @@ export function syncAnimTargets(
animMap.set(timeMs, subjectMap);
}
for (const subject of SUBJECTS) {
for (const subject of subjects) {
const realValue = enabledSubjects.has(subject) ? slot.subjects[subject] || 0 : 0;
const entry = subjectMap.get(subject);
if (entry) {
+59 -103
View File
@@ -1,122 +1,78 @@
/**
* Data types, constants, and deterministic slot generation for the class timeline.
* Each 15-minute slot is seeded by its timestamp, so the same slot always produces
* identical data regardless of when or in what order it's fetched.
* Subject color palette for the timeline chart.
*
* Subjects are dynamic (coming from the API), so we assign colors from
* a fixed palette based on a deterministic hash of the subject code.
* Known high-enrollment subjects get hand-picked colors for familiarity.
*/
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
export type { TimeSlot };
export const SUBJECTS = [
"CS",
"MATH",
"BIO",
"ENG",
"PHYS",
"HIST",
"CHEM",
"PSY",
"ECE",
"ART",
] as const;
export type Subject = (typeof SUBJECTS)[number];
/** Subject colors — distinct, accessible palette */
export const SUBJECT_COLORS: Record<Subject, string> = {
/** Hand-picked colors for common UTSA subject codes. */
const KNOWN_SUBJECT_COLORS: Record<string, string> = {
CS: "#6366f1", // indigo
MATH: "#f59e0b", // amber
MAT: "#f59e0b", // amber
BIO: "#10b981", // emerald
ENG: "#ef4444", // red
PHYS: "#3b82f6", // blue
HIST: "#8b5cf6", // violet
CHEM: "#f97316", // orange
PHY: "#3b82f6", // blue
HIS: "#8b5cf6", // violet
CHE: "#f97316", // orange
PSY: "#ec4899", // pink
ECE: "#14b8a6", // teal
ART: "#a855f7", // purple
ACC: "#84cc16", // lime
FIN: "#06b6d4", // cyan
MUS: "#e11d48", // rose
POL: "#d946ef", // fuchsia
SOC: "#22d3ee", // sky
KIN: "#4ade80", // green
IS: "#fb923c", // light orange
STA: "#818cf8", // light indigo
MGT: "#fbbf24", // yellow
MKT: "#2dd4bf", // teal-light
};
/**
* Bell-curve-like distribution centered at a given hour.
* Returns a value 0..1 representing relative class density.
* Extended palette for subjects that don't have a hand-picked color.
* These are chosen to be visually distinct from each other.
*/
function bellCurve(hour: number, center: number, spread: number): number {
const x = (hour - center) / spread;
return Math.exp(-0.5 * x * x);
}
const FALLBACK_PALETTE = [
"#f472b6", // pink-400
"#60a5fa", // blue-400
"#34d399", // emerald-400
"#fbbf24", // amber-400
"#a78bfa", // violet-400
"#fb7185", // rose-400
"#38bdf8", // sky-400
"#4ade80", // green-400
"#facc15", // yellow-400
"#c084fc", // purple-400
"#f87171", // red-400
"#2dd4bf", // teal-400
"#fb923c", // orange-400
"#818cf8", // indigo-400
"#a3e635", // lime-400
"#22d3ee", // cyan-400
];
/**
* Each subject has characteristic scheduling patterns:
* peak hours, relative popularity, and spread.
*/
const SUBJECT_PROFILES: Record<Subject, { peaks: number[]; weight: number; spread: number }> = {
CS: { peaks: [10, 14, 16], weight: 12, spread: 2.0 },
MATH: { peaks: [8, 10, 13], weight: 10, spread: 1.8 },
BIO: { peaks: [9, 11, 14], weight: 8, spread: 1.5 },
ENG: { peaks: [9, 11, 14, 16], weight: 7, spread: 2.2 },
PHYS: { peaks: [8, 13, 15], weight: 6, spread: 1.6 },
HIST: { peaks: [10, 13, 15], weight: 5, spread: 2.0 },
CHEM: { peaks: [8, 10, 14], weight: 6, spread: 1.5 },
PSY: { peaks: [11, 14, 16], weight: 7, spread: 2.0 },
ECE: { peaks: [9, 13, 15], weight: 5, spread: 1.8 },
ART: { peaks: [10, 14, 17], weight: 4, spread: 2.5 },
};
/**
* Seeded pseudo-random number generator (LCG) for reproducible data.
*/
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
/**
* Integer hash so adjacent slot timestamps produce very different seeds.
*/
function hashTimestamp(ms: number): number {
let h = ms | 0;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = (h >> 16) ^ h;
return h >>> 0;
}
/** Generate a single TimeSlot for the given aligned timestamp. */
function generateSlot(timeMs: number): TimeSlot {
const rand = seededRandom(hashTimestamp(timeMs));
const time = new Date(timeMs);
const hour = time.getHours() + time.getMinutes() / 60;
const subjects = {} as Record<Subject, number>;
for (const subject of SUBJECTS) {
const profile = SUBJECT_PROFILES[subject];
let density = 0;
for (const peak of profile.peaks) {
density += bellCurve(hour, peak, profile.spread);
}
const base = density * profile.weight;
const noise = (rand() - 0.5) * 2;
subjects[subject] = Math.max(0, Math.round(base + noise));
/** Simple string hash for deterministic color assignment. */
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return { time, subjects };
return Math.abs(hash);
}
/**
* Generate TimeSlots covering [startMs, endMs], aligned to 15-minute boundaries.
* Each slot is deterministically seeded by its timestamp.
*/
export function generateSlots(startMs: number, endMs: number): TimeSlot[] {
const alignedStart = Math.floor(startMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
const alignedEnd = Math.ceil(endMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
/** Cache of assigned colors to avoid re-computing. */
const colorCache = new Map<string, string>();
const slots: TimeSlot[] = [];
for (let t = alignedStart; t <= alignedEnd; t += SLOT_INTERVAL_MS) {
slots.push(generateSlot(t));
}
return slots;
/** Get a consistent color for any subject code. */
export function getSubjectColor(subject: string): string {
const cached = colorCache.get(subject);
if (cached) return cached;
const color =
KNOWN_SUBJECT_COLORS[subject] ?? FALLBACK_PALETTE[hashCode(subject) % FALLBACK_PALETTE.length];
colorCache.set(subject, color);
return color;
}
+16 -7
View File
@@ -7,7 +7,7 @@
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
import { timeFormat } from "d3-time-format";
import { SUBJECT_COLORS, type Subject } from "./data";
import { getSubjectColor } from "./data";
import type { AnimMap } from "./animation";
import { getStackSubjects } from "./viewport";
import type { ChartContext, TimeSlot } from "./types";
@@ -55,22 +55,31 @@ export function chooseTickCount(viewSpan: number): number {
* Stack only the visible slice using *animated* values so transitions
* between filter/data states are smooth. Includes subjects that are
* still animating out so removal is gradual.
*
* @param allSubjects - full set of known subject codes
*/
export function stackVisibleSlots(
visible: TimeSlot[],
enabledSubjects: Set<Subject>,
allSubjects: readonly string[],
enabledSubjects: Set<string>,
animMap: AnimMap
): VisibleStack {
if (visible.length === 0) return [];
const stackKeys = getStackSubjects(visible, enabledSubjects, animMap, SETTLE_THRESHOLD);
const stackKeys = getStackSubjects(
visible,
allSubjects,
enabledSubjects,
animMap,
SETTLE_THRESHOLD
);
if (stackKeys.length === 0) return [];
// Build synthetic slots with animated current values.
const animatedSlots: TimeSlot[] = visible.map((slot) => {
const timeMs = slot.time.getTime();
const subjectMap = animMap.get(timeMs);
const subjects = {} as Record<Subject, number>;
const subjects: Record<string, number> = {};
for (const subject of stackKeys) {
const entry = subjectMap?.get(subject);
subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
@@ -80,7 +89,7 @@ export function stackVisibleSlots(
const gen = stack<TimeSlot>()
.keys(stackKeys)
.value((d, key) => d.subjects[key as Subject] || 0);
.value((d, key) => d.subjects[key] || 0);
return gen(animatedSlots);
}
@@ -187,8 +196,8 @@ export function drawStackedArea(chart: ChartContext, visibleStack: VisibleStack)
for (let i = visibleStack.length - 1; i >= 0; i--) {
const layer = visibleStack[i];
const subject = layer.key as Subject;
const color = SUBJECT_COLORS[subject];
const subject = layer.key;
const color = getSubjectColor(subject);
ctx.beginPath();
area<StackPoint>()
+46 -23
View File
@@ -3,10 +3,9 @@
*
* Tracks which time ranges have already been fetched and only requests
* the missing segments when the view expands into unloaded territory.
* Fetches are throttled so rapid panning/zooming doesn't flood the
* (currently mock) API.
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
*/
import { generateSlots } from "./data";
import { client, type TimelineRange } from "$lib/api";
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
@@ -16,20 +15,6 @@ type Range = [start: number, end: number];
const FETCH_THROTTLE_MS = 500;
const BUFFER_RATIO = 0.15;
// Mock network latency bounds (ms).
const MOCK_DELAY_MIN = 40;
const MOCK_DELAY_MAX = 120;
/**
* Simulate an API call that returns slots for an arbitrary time range.
* The delay makes loading behaviour visible during development.
*/
async function mockFetch(startMs: number, endMs: number): Promise<TimeSlot[]> {
const delay = MOCK_DELAY_MIN + Math.random() * (MOCK_DELAY_MAX - MOCK_DELAY_MIN);
await new Promise((r) => setTimeout(r, delay));
return generateSlots(startMs, endMs);
}
/** Align a timestamp down to the nearest slot boundary. */
function alignFloor(ms: number): number {
return Math.floor(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
@@ -84,6 +69,24 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
return merged;
}
/**
* Fetch timeline data for the given gap ranges from the API.
* Converts gap ranges into the API request format.
*/
async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
const ranges: TimelineRange[] = gaps.map(([start, end]) => ({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}));
const response = await client.getTimeline(ranges);
return response.slots.map((slot) => ({
time: new Date(slot.time),
subjects: slot.subjects,
}));
}
/**
* Create a reactive timeline store.
*
@@ -93,6 +96,9 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
*
* The `data` getter returns a sorted `TimeSlot[]` that reactively
* updates as new segments arrive.
*
* The `subjects` getter returns the sorted list of all subject codes
* seen so far across all fetched data.
*/
export function createTimelineStore() {
// All loaded slots keyed by aligned timestamp (ms).
@@ -101,6 +107,9 @@ export function createTimelineStore() {
// Sorted, non-overlapping list of fetched ranges.
let loadedRanges: Range[] = [];
// All subject codes observed across all fetched data.
let knownSubjects: Set<string> = $state(new Set());
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
let pendingStart = 0;
let pendingEnd = 0;
@@ -112,18 +121,28 @@ export function createTimelineStore() {
[...slotMap.values()].sort((a, b) => a.time.getTime() - b.time.getTime())
);
// Sorted subject list derived from the known subjects set.
const subjects: string[] = $derived([...knownSubjects].sort());
async function fetchGaps(start: number, end: number): Promise<void> {
const gaps = findGaps(start, end, loadedRanges);
if (gaps.length === 0) return;
// Fetch all gap segments in parallel.
const results = await Promise.all(gaps.map(([gs, ge]) => mockFetch(gs, ge)));
let slots: TimeSlot[];
try {
slots = await fetchFromApi(gaps);
} catch (err) {
console.error("Timeline fetch failed:", err);
return;
}
// Merge results into the slot map.
const next = new Map(slotMap);
for (const slots of results) {
for (const slot of slots) {
next.set(slot.time.getTime(), slot);
const nextSubjects = new Set(knownSubjects);
for (const slot of slots) {
next.set(slot.time.getTime(), slot);
for (const subject of Object.keys(slot.subjects)) {
nextSubjects.add(subject);
}
}
@@ -132,8 +151,9 @@ export function createTimelineStore() {
loadedRanges = mergeRange(loadedRanges, gap);
}
// Single reactive assignment.
// Single reactive assignments.
slotMap = next;
knownSubjects = nextSubjects;
}
/**
@@ -173,6 +193,9 @@ export function createTimelineStore() {
get data() {
return data;
},
get subjects() {
return subjects;
},
requestRange,
dispose,
};
+6 -6
View File
@@ -1,16 +1,16 @@
/**
* Shared types for the timeline feature.
*
* Subjects are dynamic strings (actual Banner subject codes like "CS",
* "MAT", "BIO") rather than a fixed enum — the set of subjects comes
* from the API response.
*/
import type { ScaleLinear, ScaleTime } from "d3-scale";
import type { Subject } from "./data";
export type { Subject };
/** A single 15-minute time slot with per-subject class counts. */
/** A single 15-minute time slot with per-subject enrollment totals. */
export interface TimeSlot {
time: Date;
subjects: Record<Subject, number>;
subjects: Record<string, number>;
}
/** Lerped animation entry for a single subject within a slot. */
+9 -7
View File
@@ -3,7 +3,6 @@
* hit-testing, and snapping for the timeline canvas.
*/
import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
import { SUBJECTS, type Subject } from "./data";
import type { TimeSlot } from "./types";
/**
@@ -55,8 +54,8 @@ export function snapToSlot(timeMs: number): number {
return Math.floor(timeMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
}
/** Sum of class counts for enabled subjects in a slot. */
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Subject[]): number {
/** Sum of enrollment counts for enabled subjects in a slot. */
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly string[]): number {
let sum = 0;
for (const s of activeSubjects) {
sum += slot.subjects[s] || 0;
@@ -67,15 +66,18 @@ export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Sub
/**
* Determine which subjects to include in the stack: all enabled subjects
* plus any disabled subjects still animating out (current > threshold).
*
* @param allSubjects - the full set of known subject codes
*/
export function getStackSubjects(
visible: TimeSlot[],
enabledSubjects: Set<Subject>,
allSubjects: readonly string[],
enabledSubjects: Set<string>,
animMap: Map<number, Map<string, { current: number }>>,
settleThreshold: number
): Subject[] {
const subjects: Subject[] = [];
for (const subject of SUBJECTS) {
): string[] {
const subjects: string[] = [];
for (const subject of allSubjects) {
if (enabledSubjects.has(subject)) {
subjects.push(subject);
continue;