10 Commits

Author SHA1 Message Date
4207783cdd docs: add changelog entries and roadmap completion tracking 2026-01-29 12:27:46 -06:00
c90bd740de refactor: consolidate query logic and eliminate N+1 instructor loads 2026-01-29 12:03:06 -06:00
61f8bd9de7 refactor: consolidate menu snippets and strengthen type safety
Replaces duplicated dropdown/context menu code with parameterized snippet,
eliminates unsafe type casts, adds error handling for clipboard and API
calls, and improves accessibility annotations.
2026-01-29 11:40:55 -06:00
b5eaedc9bc feat: add delivery mode indicators and tooltips to location column 2026-01-29 11:32:35 -06:00
58475c8673 feat: add page selector dropdown with animated pagination controls
Replace Previous/Next buttons with 5-slot page navigation centered in
pagination bar. Current page becomes a dropdown trigger allowing direct
page jumps. Side slots animate on page transitions.
2026-01-29 11:31:55 -06:00
78159707e2 feat: table FLIP animations, improved time tooltip details & day abbreviations 2026-01-29 03:40:40 -06:00
779144a4d5 feat: implement smart name abbreviation for instructor display 2026-01-29 03:14:55 -06:00
0da2e810fe feat: add multi-select subject filtering with searchable comboboxes 2026-01-29 03:03:21 -06:00
ed72ac6bff refactor: extract reusable SimpleTooltip component and enhance UI hints 2026-01-29 01:37:04 -06:00
57b5cafb27 feat: enhance table scrolling and eliminate initial theme flash 2026-01-29 01:18:02 -06:00
40 changed files with 2282 additions and 813 deletions
Generated
+58 -11
View File
@@ -149,9 +149,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.4"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
@@ -168,8 +168,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
@@ -183,9 +182,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.2"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
@@ -194,13 +193,37 @@ dependencies = [
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper 1.0.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.75"
@@ -223,6 +246,7 @@ dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-extra",
"bitflags 2.9.4",
"chrono",
"clap",
@@ -2688,9 +2712,19 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
@@ -2706,15 +2740,28 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.143"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "banner"
version = "0.3.4"
version = "0.5.0"
edition = "2024"
default-run = "banner"
@@ -57,6 +57,7 @@ yansi = "1.0.1"
extension-traits = "2"
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
html-escape = "0.2.13"
axum-extra = { version = "0.12.5", features = ["query"] }
[dev-dependencies]
+27 -4
View File
@@ -4,10 +4,33 @@
The Banner project is built as a multi-service application with the following components:
- **Discord Bot Service**: Handles Discord interactions and commands
- **Web Service**: Serves the React frontend and provides API endpoints
- **Scraper Service**: Background data collection and synchronization
- **Database Layer**: PostgreSQL for persistent storage
- **Discord Bot Service**: Handles Discord interactions and commands (Serenity/Poise)
- **Web Service**: Axum HTTP server serving the SvelteKit frontend and REST API endpoints
- **Scraper Service**: Background data collection and synchronization with job queue
- **Database Layer**: PostgreSQL 17 for persistent storage (SQLx with compile-time verification)
- **RateMyProfessors Client**: GraphQL-based bulk sync of professor ratings
### Frontend Stack
- **SvelteKit** with Svelte 5 runes (`$state`, `$derived`, `$effect`)
- **Tailwind CSS v4** via `@tailwindcss/vite`
- **bits-ui** for headless UI primitives (comboboxes, tooltips, dropdowns)
- **TanStack Table** for interactive data tables with sorting and column control
- **OverlayScrollbars** for styled, theme-aware scrollable areas
- **ts-rs** generates TypeScript type bindings from Rust structs
### API Endpoints
| Endpoint | Description |
|---|---|
| `GET /api/health` | Health check |
| `GET /api/status` | Service status, version, and commit hash |
| `GET /api/metrics` | Basic metrics |
| `GET /api/courses/search` | Paginated course search with filters (term, subject, query, open-only, sort) |
| `GET /api/courses/:term/:crn` | Single course detail with instructors and RMP ratings |
| `GET /api/terms` | Available terms from reference cache |
| `GET /api/subjects?term=` | Subjects for a term, ordered by enrollment |
| `GET /api/reference/:category` | Reference data lookups (campuses, instructional methods, etc.) |
## Technical Analysis
+35 -1
View File
@@ -6,7 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.1.0] - 2026-01
## [0.5.0] - 2026-01-29
### Added
- Multi-select subject filtering with searchable comboboxes.
- Smart instructor name abbreviation for compact table display.
- Delivery mode indicators and tooltips in location column.
- Page selector dropdown with animated pagination controls.
- FLIP animations for smooth table row transitions during pagination.
- Time tooltip with detailed meeting schedule and day abbreviations.
- Reusable SimpleTooltip component for consistent UI hints.
### Changed
- Consolidated query logic and eliminated N+1 instructor loads via batch fetching.
- Consolidated menu snippets and strengthened component type safety.
- Enhanced table scrolling with OverlayScrollbars and theme-aware styling.
- Eliminated initial theme flash on page load.
## [0.4.0] - 2026-01-28
### Added
- Web-based course search UI with interactive data table, multi-column sorting, and column visibility controls.
- TypeScript type bindings generated from Rust types via ts-rs.
- RateMyProfessors integration: bulk professor sync via GraphQL and inline rating display in search results.
- Course detail expansion panel with enrollment, meeting times, and instructor info.
- OverlayScrollbars integration for styled, theme-aware scrollable areas.
- Pagination component for navigating large search result sets.
- Footer component with version display.
- API endpoints: `/api/courses/search`, `/api/courses/:term/:crn`, `/api/terms`, `/api/subjects`, `/api/reference/:category`.
- Frontend API client with typed request/response handling and test coverage.
- Course formatting utilities with comprehensive unit tests.
## [0.3.4] - 2026-01
### Added
+2 -1
View File
@@ -4,7 +4,8 @@ This folder contains detailed documentation for the Banner project. This file ac
## Files
- [`FEATURES.md`](FEATURES.md) - Current features, implemented functionality, and future roadmap
- [`CHANGELOG.md`](CHANGELOG.md) - Notable changes by version
- [`ROADMAP.md`](ROADMAP.md) - Planned features and priorities
- [`BANNER.md`](BANNER.md) - General API documentation on the Banner system
- [`ARCHITECTURE.md`](ARCHITECTURE.md) - Technical implementation details, system design, and analysis
+8 -4
View File
@@ -3,12 +3,9 @@
## Now
- **Notification and subscription system** - Subscribe to courses and get alerts on seat availability, waitlist movement, and detail changes (time, location, professor, seats). DB schema exists.
- **RateMyProfessor integration** - Show professor ratings inline with search results and course details.
- **Professor name search filter** - Filter search results by instructor. Backend code exists but is commented out.
- **Subject/major search filter** - Search by department code (e.g. CS, MAT). Also partially implemented.
- **Autocomplete for search fields** - Typeahead for course titles, course numbers, professors, and terms.
- **Test coverage expansion** - Broaden coverage with pure function tests (term parsing, search parsing, job types), session/rate-limiter tests, and more DB integration tests.
- **Web course search UI** - Add a browser-based course search interface to the dashboard, supplementing the Discord bot.
- **Test coverage expansion** - Broaden coverage with session/rate-limiter tests and more DB integration tests.
## Soon
@@ -29,3 +26,10 @@
- **CRN direct lookup** - Look up a course by its CRN without going through search.
- **Metrics dashboard** - Surface scraper and service metrics visually on the web dashboard.
- **Privileged error feedback** - Detailed error information surfaced to bot admins when commands fail.
## Done
- **Web course search UI** - Browser-based course search with interactive data table, sorting, pagination, and column controls. *(0.4.0)*
- **RateMyProfessor integration** - Bulk professor sync via GraphQL with inline ratings in search results. *(0.4.0)*
- **Subject/major search filter** - Multi-select subject filtering with searchable comboboxes. *(0.5.0)*
- **Test coverage expansion** - Unit tests for course formatting, API client, query builder, CLI args, and config parsing. *(0.3.40.4.0)*
+10 -7
View File
@@ -1,4 +1,4 @@
use bitflags::{Flags, bitflags};
use bitflags::{bitflags, Flags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize};
@@ -320,10 +320,11 @@ pub enum MeetingType {
Unknown(String),
}
impl MeetingType {
/// Parse from the meeting type string
pub fn from_string(s: &str) -> Self {
match s {
impl std::str::FromStr for MeetingType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"HB" | "H2" | "H1" => MeetingType::HybridBlended,
"OS" => MeetingType::OnlineSynchronous,
"OA" => MeetingType::OnlineAsynchronous,
@@ -331,9 +332,11 @@ impl MeetingType {
"ID" => MeetingType::IndependentStudy,
"FF" => MeetingType::FaceToFace,
other => MeetingType::Unknown(other.to_string()),
}
})
}
}
impl MeetingType {
/// Get description for the meeting type
pub fn description(&self) -> &'static str {
match self {
@@ -424,7 +427,7 @@ impl MeetingScheduleInfo {
end: now,
}
});
let meeting_type = MeetingType::from_string(&meeting_time.meeting_type);
let meeting_type: MeetingType = meeting_time.meeting_type.parse().unwrap();
let location = MeetingLocation::from_meeting_time(meeting_time);
let duration_weeks = date_range.weeks_duration();
+3 -14
View File
@@ -10,8 +10,9 @@ pub struct Range {
pub high: i32,
}
/// Builder for constructing Banner API search queries
/// Builder for constructing Banner API search queries.
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct SearchQuery {
subject: Option<String>,
title: Option<String>,
@@ -32,6 +33,7 @@ pub struct SearchQuery {
course_number_range: Option<Range>,
}
#[allow(dead_code)]
impl SearchQuery {
/// Creates a new SearchQuery with default values
pub fn new() -> Self {
@@ -67,7 +69,6 @@ impl SearchQuery {
}
/// Adds a keyword to the query
#[allow(dead_code)]
pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self {
match &mut self.keywords {
Some(keywords) => keywords.push(keyword.into()),
@@ -77,63 +78,54 @@ impl SearchQuery {
}
/// Sets whether to search for open courses only
#[allow(dead_code)]
pub fn open_only(mut self, open_only: bool) -> Self {
self.open_only = Some(open_only);
self
}
/// Sets the term part for the query
#[allow(dead_code)]
pub fn term_part(mut self, term_part: Vec<String>) -> Self {
self.term_part = Some(term_part);
self
}
/// Sets the campuses for the query
#[allow(dead_code)]
pub fn campus(mut self, campus: Vec<String>) -> Self {
self.campus = Some(campus);
self
}
/// Sets the instructional methods for the query
#[allow(dead_code)]
pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self {
self.instructional_method = Some(instructional_method);
self
}
/// Sets the attributes for the query
#[allow(dead_code)]
pub fn attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = Some(attributes);
self
}
/// Sets the instructors for the query
#[allow(dead_code)]
pub fn instructor(mut self, instructor: Vec<u64>) -> Self {
self.instructor = Some(instructor);
self
}
/// Sets the start time for the query
#[allow(dead_code)]
pub fn start_time(mut self, start_time: Duration) -> Self {
self.start_time = Some(start_time);
self
}
/// Sets the end time for the query
#[allow(dead_code)]
pub fn end_time(mut self, end_time: Duration) -> Self {
self.end_time = Some(end_time);
self
}
/// Sets the credit range for the query
#[allow(dead_code)]
pub fn credits(mut self, low: i32, high: i32) -> Self {
self.min_credits = Some(low);
self.max_credits = Some(high);
@@ -141,14 +133,12 @@ impl SearchQuery {
}
/// Sets the minimum credits for the query
#[allow(dead_code)]
pub fn min_credits(mut self, value: i32) -> Self {
self.min_credits = Some(value);
self
}
/// Sets the maximum credits for the query
#[allow(dead_code)]
pub fn max_credits(mut self, value: i32) -> Self {
self.max_credits = Some(value);
self
@@ -161,7 +151,6 @@ impl SearchQuery {
}
/// Sets the offset for pagination
#[allow(dead_code)]
pub fn offset(mut self, offset: i32) -> Self {
self.offset = offset;
self
+155 -72
View File
@@ -1,8 +1,74 @@
//! Database query functions for courses, used by the web API.
use crate::data::models::Course;
use crate::data::models::{Course, CourseInstructorDetail};
use crate::error::Result;
use sqlx::PgPool;
use std::collections::HashMap;
/// Column to sort search results by.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortColumn {
CourseCode,
Title,
Instructor,
Time,
Seats,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
/// Shared WHERE clause for course search filters.
///
/// Parameters $1-$8 match the bind order in `search_courses`.
const SEARCH_WHERE: &str = r#"
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
"#;
/// Build a safe ORDER BY clause from typed sort parameters.
///
/// All column names are hardcoded string literals — no caller input is interpolated.
fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) -> String {
let dir = match direction.unwrap_or(SortDirection::Asc) {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match column {
Some(SortColumn::CourseCode) => {
format!("subject {dir}, course_number {dir}, sequence_number {dir}")
}
Some(SortColumn::Title) => format!("title {dir}"),
Some(SortColumn::Instructor) => {
format!(
"(SELECT i.display_name FROM course_instructors ci \
JOIN instructors i ON i.banner_id = ci.instructor_id \
WHERE ci.course_id = courses.id AND ci.is_primary = true \
LIMIT 1) {dir} NULLS LAST"
)
}
Some(SortColumn::Time) => {
format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST")
}
Some(SortColumn::Seats) => {
format!("(max_enrollment - enrollment) {dir}")
}
None => "subject ASC, course_number ASC, sequence_number ASC".to_string(),
}
}
/// Search courses by term with optional filters.
///
@@ -12,7 +78,7 @@ use sqlx::PgPool;
pub async fn search_courses(
db_pool: &PgPool,
term_code: &str,
subject: Option<&str>,
subject: Option<&[String]>,
title_query: Option<&str>,
course_number_low: Option<i32>,
course_number_high: Option<i32>,
@@ -21,32 +87,17 @@ pub async fn search_courses(
campus: Option<&str>,
limit: i32,
offset: i32,
order_by: &str,
sort_by: Option<SortColumn>,
sort_dir: Option<SortDirection>,
) -> Result<(Vec<Course>, i64)> {
// Build WHERE clauses dynamically via parameter binding + COALESCE trick:
// each optional filter uses ($N IS NULL OR column = $N) so NULL means "no filter".
//
// ORDER BY is interpolated as a string since column names can't be bound as
// parameters. The caller must provide a safe, pre-validated clause (see
// `sort_clause` in routes.rs).
let query = format!(
r#"
SELECT *
FROM courses
WHERE term_code = $1
AND ($2::text IS NULL OR subject = $2)
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
ORDER BY {order_by}
LIMIT $9 OFFSET $10
"#
);
let order_by = sort_clause(sort_by, sort_dir);
let courses = sqlx::query_as::<_, Course>(&query)
let data_query = format!(
"SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10"
);
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
let courses = sqlx::query_as::<_, Course>(&data_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
@@ -60,30 +111,17 @@ pub async fn search_courses(
.fetch_all(db_pool)
.await?;
let total: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM courses
WHERE term_code = $1
AND ($2::text IS NULL OR subject = $2)
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
"#,
)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.fetch_one(db_pool)
.await?;
let total: (i64,) = sqlx::query_as(&count_query)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.fetch_one(db_pool)
.await?;
Ok((courses, total.0))
}
@@ -103,33 +141,16 @@ pub async fn get_course_by_crn(
Ok(course)
}
/// Get instructors for a course by course ID.
///
/// Returns `(banner_id, display_name, email, is_primary, rmp_avg_rating, rmp_num_ratings)` tuples.
/// Get instructors for a single course by course ID.
pub async fn get_course_instructors(
db_pool: &PgPool,
course_id: i32,
) -> Result<
Vec<(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)>,
> {
let rows: Vec<(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)> = sqlx::query_as(
) -> Result<Vec<CourseInstructorDetail>> {
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings
rp.avg_rating, rp.num_ratings,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
@@ -143,6 +164,68 @@ pub async fn get_course_instructors(
Ok(rows)
}
/// Batch-fetch instructors for multiple courses in a single query.
///
/// Returns a map of `course_id → Vec<CourseInstructorDetail>`.
pub async fn get_instructors_for_courses(
db_pool: &PgPool,
course_ids: &[i32],
) -> Result<HashMap<i32, Vec<CourseInstructorDetail>>> {
if course_ids.is_empty() {
return Ok(HashMap::new());
}
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
WHERE ci.course_id = ANY($1)
ORDER BY ci.course_id, ci.is_primary DESC, i.display_name
"#,
)
.bind(course_ids)
.fetch_all(db_pool)
.await?;
let mut map: HashMap<i32, Vec<CourseInstructorDetail>> = HashMap::new();
for row in rows {
// course_id is always present in the batch query
let cid = row.course_id.unwrap_or_default();
map.entry(cid).or_default().push(row);
}
Ok(map)
}
/// Get subjects for a term, sorted by total enrollment (descending).
///
/// Returns only subjects that have courses in the given term, with their
/// descriptions from reference_data and enrollment totals for ranking.
pub async fn get_subjects_by_enrollment(
db_pool: &PgPool,
term_code: &str,
) -> Result<Vec<(String, String, i64)>> {
let rows: Vec<(String, String, i64)> = sqlx::query_as(
r#"
SELECT c.subject,
COALESCE(rd.description, c.subject),
COALESCE(SUM(c.enrollment), 0) as total_enrollment
FROM courses c
LEFT JOIN reference_data rd ON rd.category = 'subject' AND rd.code = c.subject
WHERE c.term_code = $1
GROUP BY c.subject, rd.description
ORDER BY total_enrollment DESC
"#,
)
.bind(term_code)
.fetch_all(db_pool)
.await?;
Ok(rows)
}
/// Get all distinct term codes that have courses in the DB.
pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
let rows: Vec<(String,)> =
+13
View File
@@ -76,6 +76,19 @@ pub struct CourseInstructor {
pub is_primary: bool,
}
/// Joined instructor data for a course (from course_instructors + instructors + rmp_professors).
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct CourseInstructorDetail {
pub banner_id: String,
pub display_name: String,
pub email: Option<String>,
pub is_primary: bool,
pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>,
}
#[allow(dead_code)]
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct ReferenceData {
+20 -13
View File
@@ -134,7 +134,7 @@ pub async fn find_existing_job_payloads(
Ok(existing_payloads)
}
/// Batch insert scrape jobs in a single transaction.
/// Batch insert scrape jobs using UNNEST for a single round-trip.
///
/// All jobs are inserted with `execute_at` set to the current time.
///
@@ -149,22 +149,29 @@ pub async fn batch_insert_jobs(
return Ok(());
}
let now = chrono::Utc::now();
let mut tx = db_pool.begin().await?;
let mut target_types: Vec<String> = Vec::with_capacity(jobs.len());
let mut payloads: Vec<serde_json::Value> = Vec::with_capacity(jobs.len());
let mut priorities: Vec<String> = Vec::with_capacity(jobs.len());
for (payload, target_type, priority) in jobs {
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)"
)
.bind(target_type)
.bind(payload)
.bind(priority)
.bind(now)
.execute(&mut *tx)
.await?;
target_types.push(format!("{target_type:?}"));
payloads.push(payload.clone());
priorities.push(format!("{priority:?}"));
}
tx.commit().await?;
sqlx::query(
r#"
INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
SELECT v.target_type::target_type, v.payload, v.priority::scrape_priority, NOW()
FROM UNNEST($1::text[], $2::jsonb[], $3::text[])
AS v(target_type, payload, priority)
"#,
)
.bind(&target_types)
.bind(&payloads)
.bind(&priorities)
.execute(db_pool)
.await?;
Ok(())
}
-1
View File
@@ -19,7 +19,6 @@ mod scraper;
mod services;
mod signals;
mod state;
#[allow(dead_code)]
mod status;
mod web;
+6
View File
@@ -35,6 +35,12 @@ pub struct RmpClient {
http: reqwest::Client,
}
impl Default for RmpClient {
fn default() -> Self {
Self::new()
}
}
impl RmpClient {
pub fn new() -> Self {
Self {
+17 -12
View File
@@ -13,9 +13,10 @@ use tokio::sync::RwLock;
/// In-memory cache for reference data (code→description lookups).
///
/// Loaded from the `reference_data` table on startup and refreshed periodically.
/// Uses a two-level HashMap so lookups take `&str` without allocating.
pub struct ReferenceCache {
/// `(category, code)``description`
data: HashMap<(String, String), String>,
/// category → (code → description)
data: HashMap<String, HashMap<String, String>>,
}
impl Default for ReferenceCache {
@@ -34,27 +35,31 @@ impl ReferenceCache {
/// Build cache from a list of reference data entries.
pub fn from_entries(entries: Vec<ReferenceData>) -> Self {
let data = entries
.into_iter()
.map(|e| ((e.category, e.code), e.description))
.collect();
let mut data: HashMap<String, HashMap<String, String>> = HashMap::new();
for e in entries {
data.entry(e.category)
.or_default()
.insert(e.code, e.description);
}
Self { data }
}
/// Look up a description by category and code.
/// Look up a description by category and code. Zero allocations.
pub fn lookup(&self, category: &str, code: &str) -> Option<&str> {
self.data
.get(&(category.to_string(), code.to_string()))
.get(category)
.and_then(|codes| codes.get(code))
.map(|s| s.as_str())
}
/// Get all `(code, description)` pairs for a category, sorted by description.
pub fn entries_for_category(&self, category: &str) -> Vec<(&str, &str)> {
let mut entries: Vec<(&str, &str)> = self
.data
let Some(codes) = self.data.get(category) else {
return Vec::new();
};
let mut entries: Vec<(&str, &str)> = codes
.iter()
.filter(|((cat, _), _)| cat == category)
.map(|((_, code), desc)| (code.as_str(), desc.as_str()))
.map(|(code, desc)| (code.as_str(), desc.as_str()))
.collect();
entries.sort_by(|a, b| a.1.cmp(b.1));
entries
+3
View File
@@ -10,6 +10,7 @@ use ts_rs::TS;
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum ServiceStatus {
#[allow(dead_code)]
Starting,
Active,
Connected,
@@ -21,6 +22,7 @@ pub enum ServiceStatus {
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub status: ServiceStatus,
#[allow(dead_code)]
pub updated_at: Instant,
}
@@ -48,6 +50,7 @@ impl ServiceStatusRegistry {
}
/// Returns the current status of a named service, if present.
#[allow(dead_code)]
pub fn get(&self, name: &str) -> Option<ServiceStatus> {
self.inner.get(name).map(|entry| entry.status.clone())
}
+59 -85
View File
@@ -298,10 +298,16 @@ async fn metrics() -> Json<Value> {
// Course search & detail API
// ============================================================
#[derive(Deserialize)]
struct SubjectsParams {
term: String,
}
#[derive(Deserialize)]
struct SearchParams {
term: String,
subject: Option<String>,
#[serde(default)]
subject: Vec<String>,
q: Option<String>,
course_number_low: Option<i32>,
course_number_high: Option<i32>,
@@ -317,59 +323,12 @@ struct SearchParams {
sort_dir: Option<SortDirection>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortColumn {
CourseCode,
Title,
Instructor,
Time,
Seats,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortDirection {
Asc,
Desc,
}
use crate::data::courses::{SortColumn, SortDirection};
fn default_limit() -> i32 {
25
}
/// Build a safe ORDER BY clause from the validated sort column and direction.
fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) -> String {
let dir = match direction.unwrap_or(SortDirection::Asc) {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match column {
Some(SortColumn::CourseCode) => {
format!("subject {dir}, course_number {dir}, sequence_number {dir}")
}
Some(SortColumn::Title) => format!("title {dir}"),
Some(SortColumn::Instructor) => {
// Sort by primary instructor display name via a subquery
format!(
"(SELECT i.display_name FROM course_instructors ci \
JOIN instructors i ON i.banner_id = ci.instructor_id \
WHERE ci.course_id = courses.id AND ci.is_primary = true \
LIMIT 1) {dir} NULLS LAST"
)
}
Some(SortColumn::Time) => {
// Sort by first meeting time's begin_time via JSONB
format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST")
}
Some(SortColumn::Seats) => {
format!("(max_enrollment - enrollment) {dir}")
}
None => "subject ASC, course_number ASC, sequence_number ASC".to_string(),
}
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
@@ -430,27 +389,21 @@ pub struct CodeDescription {
description: String,
}
/// Build a `CourseResponse` from a DB course, fetching its instructors.
async fn build_course_response(
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
fn build_course_response(
course: &crate::data::models::Course,
db_pool: &sqlx::PgPool,
instructors: Vec<crate::data::models::CourseInstructorDetail>,
) -> CourseResponse {
let instructors = crate::data::courses::get_course_instructors(db_pool, course.id)
.await
.unwrap_or_default()
let instructors = instructors
.into_iter()
.map(
|(banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings)| {
InstructorResponse {
banner_id,
display_name,
email,
is_primary,
rmp_rating,
rmp_num_ratings,
}
},
)
.map(|i| InstructorResponse {
banner_id: i.banner_id,
display_name: i.display_name,
email: i.email,
is_primary: i.is_primary,
rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings,
})
.collect();
CourseResponse {
@@ -484,17 +437,19 @@ async fn build_course_response(
/// `GET /api/courses/search`
async fn search_courses(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let order_by = sort_clause(params.sort_by, params.sort_dir);
let (courses, total_count) = crate::data::courses::search_courses(
&state.db_pool,
&params.term,
params.subject.as_deref(),
if params.subject.is_empty() {
None
} else {
Some(&params.subject)
},
params.q.as_deref(),
params.course_number_low,
params.course_number_high,
@@ -503,7 +458,8 @@ async fn search_courses(
params.campus.as_deref(),
limit,
offset,
&order_by,
params.sort_by,
params.sort_dir,
)
.await
.map_err(|e| {
@@ -514,10 +470,20 @@ async fn search_courses(
)
})?;
let mut course_responses = Vec::with_capacity(courses.len());
for course in &courses {
course_responses.push(build_course_response(course, &state.db_pool).await);
}
// Batch-fetch all instructors in a single query instead of N+1
let course_ids: Vec<i32> = courses.iter().map(|c| c.id).collect();
let mut instructor_map =
crate::data::courses::get_instructors_for_courses(&state.db_pool, &course_ids)
.await
.unwrap_or_default();
let course_responses: Vec<CourseResponse> = courses
.iter()
.map(|course| {
let instructors = instructor_map.remove(&course.id).unwrap_or_default();
build_course_response(course, instructors)
})
.collect();
Ok(Json(SearchResponse {
courses: course_responses,
@@ -543,7 +509,10 @@ async fn get_course(
})?
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
Ok(Json(build_course_response(&course, &state.db_pool).await))
let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id)
.await
.unwrap_or_default();
Ok(Json(build_course_response(&course, instructors)))
}
/// `GET /api/terms`
@@ -575,19 +544,24 @@ async fn get_terms(
Ok(Json(terms))
}
/// `GET /api/subjects?term=202420`
/// `GET /api/subjects?term=202620`
async fn get_subjects(
State(state): State<AppState>,
Query(params): Query<SubjectsParams>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
let cache = state.reference_cache.read().await;
let entries = cache.entries_for_category("subject");
let rows = crate::data::courses::get_subjects_by_enrollment(&state.db_pool, &params.term)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to get subjects");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Failed to get subjects".to_string(),
)
})?;
let subjects: Vec<CodeDescription> = entries
let subjects: Vec<CodeDescription> = rows
.into_iter()
.map(|(code, description)| CodeDescription {
code: code.to_string(),
description: description.to_string(),
})
.map(|(code, description, _enrollment)| CodeDescription { code, description })
.collect();
Ok(Json(subjects))
+1 -1
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["dist/", "node_modules/", ".svelte-kit/"]
"ignore": ["dist/", "node_modules/", ".svelte-kit/", "src/lib/bindings/"]
},
"formatter": {
"enabled": true,
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="no-transition">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+2 -19
View File
@@ -11,23 +11,6 @@ describe("BannerApiClient", () => {
vi.clearAllMocks();
});
it("should fetch health data", async () => {
const mockHealth = {
status: "healthy",
timestamp: "2024-01-01T00:00:00Z",
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHealth),
} as Response);
const result = await apiClient.getHealth();
expect(fetch).toHaveBeenCalledWith("/api/health");
expect(result).toEqual(mockHealth);
});
it("should fetch status data", async () => {
const mockStatus = {
status: "active" as const,
@@ -57,7 +40,7 @@ describe("BannerApiClient", () => {
statusText: "Internal Server Error",
} as Response);
await expect(apiClient.getHealth()).rejects.toThrow(
await expect(apiClient.getStatus()).rejects.toThrow(
"API request failed: 500 Internal Server Error"
);
});
@@ -77,7 +60,7 @@ describe("BannerApiClient", () => {
const result = await apiClient.searchCourses({
term: "202420",
subject: "CS",
subjects: ["CS"],
q: "data",
open_only: true,
limit: 25,
+6 -23
View File
@@ -30,26 +30,13 @@ export type ReferenceEntry = CodeDescription;
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
export type SearchResponse = SearchResponseGenerated;
// Health/metrics endpoints return ad-hoc JSON — keep manual types
export interface HealthResponse {
status: string;
timestamp: string;
}
export interface MetricsResponse {
banner_api: {
status: string;
};
timestamp: string;
}
// Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
export interface SearchParams {
term: string;
subject?: string;
subjects?: string[];
q?: string;
open_only?: boolean;
limit?: number;
@@ -77,22 +64,18 @@ export class BannerApiClient {
return (await response.json()) as T;
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>("/health");
}
async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/status");
}
async getMetrics(): Promise<MetricsResponse> {
return this.request<MetricsResponse>("/metrics");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams();
query.set("term", params.term);
if (params.subject) query.set("subject", params.subject);
if (params.subjects) {
for (const s of params.subjects) {
query.append("subject", s);
}
}
if (params.q) query.set("q", params.q);
if (params.open_only) query.set("open_only", "true");
if (params.limit !== undefined) query.set("limit", String(params.limit));
+8
View File
@@ -0,0 +1,8 @@
export type { CodeDescription } from "./CodeDescription";
export type { CourseResponse } from "./CourseResponse";
export type { DbMeetingTime } from "./DbMeetingTime";
export type { InstructorResponse } from "./InstructorResponse";
export type { SearchResponse } from "./SearchResponse";
export type { ServiceInfo } from "./ServiceInfo";
export type { ServiceStatus } from "./ServiceStatus";
export type { StatusResponse } from "./StatusResponse";
+32 -63
View File
@@ -7,22 +7,17 @@ import {
formatMeetingDaysLong,
isMeetingTimeTBA,
isTimeTBA,
ratingColor,
} from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props();
let copiedEmail: string | null = $state(null);
async function copyEmail(email: string, event: MouseEvent) {
event.stopPropagation();
await navigator.clipboard.writeText(email);
copiedEmail = email;
setTimeout(() => {
copiedEmail = null;
}, 2000);
}
const clipboard = useClipboard();
</script>
<div class="bg-muted/60 p-5 text-sm border-b border-border">
@@ -41,33 +36,34 @@ async function copyEmail(email: string, event: MouseEvent) {
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
{instructor.displayName}
{#if 'rmpRating' in instructor && instructor.rmpRating}
{@const rating = instructor.rmpRating as number}
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
<span
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}"
class="text-[10px] font-semibold {ratingColor(rating)}"
>{rating.toFixed(1)}</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div>
{#if instructor.isPrimary}
<div class="text-muted-foreground">Primary instructor</div>
{/if}
{#if 'rmpRating' in instructor && instructor.rmpRating}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{(instructor.rmpRating as number).toFixed(1)}/5 ({(instructor as any).rmpNumRatings ?? 0} ratings)
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
</div>
{/if}
{#if instructor.email}
<button
onclick={(e) => copyEmail(instructor.email!, e)}
onclick={(e) => clipboard.copy(instructor.email!, e)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if copiedEmail === instructor.email}
{#if clipboard.copiedValue === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else}
@@ -134,16 +130,9 @@ async function copyEmail(email: string, event: MouseEvent) {
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<Info class="size-3 text-muted-foreground/50" />
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
>
How the course is taught: in-person, online, hybrid, etc.
</Tooltip.Content>
</Tooltip.Root>
<SimpleTooltip text="How the course is taught: in-person, online, hybrid, etc." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<span class="text-foreground">
@@ -168,34 +157,20 @@ async function copyEmail(email: string, event: MouseEvent) {
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<Info class="size-3 text-muted-foreground/50" />
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
>
Course flags for degree requirements, core curriculum, or special designations
</Tooltip.Content>
</Tooltip.Root>
<SimpleTooltip text="Course flags for degree requirements, core curriculum, or special designations" delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
{attr}
</span>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-64"
<SimpleTooltip text="Course attribute code" delay={150} passthrough>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
Course attribute code
</Tooltip.Content>
</Tooltip.Root>
{attr}
</span>
</SimpleTooltip>
{/each}
</div>
</div>
@@ -207,19 +182,12 @@ async function copyEmail(email: string, event: MouseEvent) {
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<Info class="size-3 text-muted-foreground/50" />
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
>
Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class.
</Tooltip.Content>
</Tooltip.Root>
<SimpleTooltip text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={100}>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
@@ -233,7 +201,8 @@ async function copyEmail(email: string, event: MouseEvent) {
</span>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
sideOffset={6}
class={tooltipContentClass}
>
Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
+512 -280
View File
@@ -2,14 +2,26 @@
import type { CourseResponse } from "$lib/api";
import {
abbreviateInstructor,
formatTime,
concernAccentColor,
formatLocationDisplay,
formatLocationTooltip,
formatMeetingDays,
formatLocation,
formatMeetingTimesTooltip,
formatTimeRange,
getDeliveryConcern,
getPrimaryInstructor,
isMeetingTimeTBA,
isTimeTBA,
openSeats,
seatsColor,
seatsDotColor,
ratingColor,
} from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition";
import { flip } from "svelte/animate";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import {
getCoreRowModel,
@@ -21,7 +33,7 @@ import {
} from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
import { DropdownMenu, ContextMenu } from "bits-ui";
import { fade, fly } from "svelte/transition";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
courses,
@@ -29,23 +41,37 @@ let {
sorting = [],
onSortingChange,
manualSorting = false,
subjectMap = {},
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
subjectMap?: Record<string, string>;
} = $props();
let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = undefined!;
const clipboard = useClipboard(1000);
// Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content
$effect(() => {
courses; // track dependency
expandedCrn = null;
});
useOverlayScrollbars(() => tableWrapper, {
overflow: { x: "scroll", y: "hidden" },
scrollbars: { autoHide: "never" },
});
// Column visibility state
let columnVisibility: VisibilityState = $state({});
const DEFAULT_VISIBILITY: VisibilityState = {};
function resetColumnVisibility() {
columnVisibility = { ...DEFAULT_VISIBILITY };
columnVisibility = {};
}
function handleVisibilityChange(updater: Updater<VisibilityState>) {
@@ -59,36 +85,12 @@ function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName);
}
function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
@@ -132,14 +134,14 @@ const columns: ColumnDef<CourseResponse, unknown>[] = [
accessorFn: (row) => {
if (row.meetingTimes.length === 0) return "";
const mt = row.meetingTimes[0];
return `${formatMeetingDays(mt)} ${formatTime(mt.begin_time)}`;
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
},
header: "Time",
enableSorting: true,
},
{
id: "location",
accessorFn: (row) => formatLocation(row) ?? "",
accessorFn: (row) => formatLocationDisplay(row) ?? "",
header: "Location",
enableSorting: false,
},
@@ -167,6 +169,7 @@ const table = createSvelteTable({
get data() {
return courses;
},
getRowId: (row) => String(row.crn),
columns,
state: {
get sorting() {
@@ -189,264 +192,493 @@ const table = createSvelteTable({
});
</script>
{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
{#if variant === "dropdown"}
<DropdownMenu.Group>
<DropdownMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Toggle columns
</DropdownMenu.GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<DropdownMenu.CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = { ...columnVisibility, [id]: checked };
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
{#snippet columnVisibilityGroup(
Group: typeof DropdownMenu.Group,
GroupHeading: typeof DropdownMenu.GroupHeading,
CheckboxItem: typeof DropdownMenu.CheckboxItem,
Separator: typeof DropdownMenu.Separator,
Item: typeof DropdownMenu.Item,
)}
<Group>
<GroupHeading
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{#snippet children({ checked })}
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
Toggle columns
</GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label =
typeof col.header === "string" ? col.header : id}
<CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = {
...columnVisibility,
[id]: checked,
};
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{#snippet children({ checked })}
<span
class="flex size-4 items-center justify-center rounded-sm border border-border"
>
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</CheckboxItem>
{/each}
</Group>
{#if hasCustomVisibility}
<DropdownMenu.Separator class="mx-1 my-1 h-px bg-border" />
<DropdownMenu.Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</DropdownMenu.Item>
{/if}
{:else}
<ContextMenu.Group>
<ContextMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Toggle columns
</ContextMenu.GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<ContextMenu.CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = { ...columnVisibility, [id]: checked };
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
<Separator class="mx-1 my-1 h-px bg-border" />
<Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
onSelect={resetColumnVisibility}
>
{#snippet children({ checked })}
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</ContextMenu.CheckboxItem>
{/each}
</ContextMenu.Group>
{#if hasCustomVisibility}
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
<ContextMenu.Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</ContextMenu.Item>
<RotateCcw class="size-3.5" />
Reset to default
</Item>
{/if}
{/if}
{/snippet}
<!-- Toolbar: View columns button -->
<div class="flex items-center justify-end pb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
<Columns3 class="size-3.5" />
View
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
align="end"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -10 }}>
{@render columnVisibilityItems("dropdown")}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
<Columns3 class="size-3.5" />
View
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
align="end"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
transition:fly={{ duration: 150, y: -10 }}
>
{@render columnVisibilityGroup(
DropdownMenu.Group,
DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem,
DropdownMenu.Separator,
DropdownMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<!-- Table with context menu on header -->
<div class="overflow-x-auto">
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr class="border-b border-border text-left text-muted-foreground">
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium {header.id === 'seats' ? 'text-right' : ''}"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span class="inline-flex items-center gap-1">
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown class="size-3.5" />
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</th>
<div bind:this={tableWrapper} class="overflow-x-auto">
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr
class="border-b border-border text-left text-muted-foreground"
>
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium {header.id ===
'seats'
? 'text-right'
: ''}"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span
class="inline-flex items-center gap-1"
>
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef
.header}
{:else}
<FlexRender
content={header.column
.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown
class="size-3.5"
/>
{:else}
<ArrowUpDown
class="size-3.5 text-muted-foreground/40"
/>
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef
.header}
context={header.getContext()}
/>
{/if}
</th>
{/if}
{/each}
</tr>
{/each}
</thead>
{#if loading && courses.length === 0}
<tbody>
{#each Array(5) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
<div
class="h-4 bg-muted rounded animate-pulse {col.id ===
'seats'
? 'w-14 ml-auto'
: col.id === 'title'
? 'w-40'
: col.id === 'crn'
? 'w-10'
: 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
</tbody>
{:else if courses.length === 0}
<tbody>
<tr>
<td
colspan={visibleColumnIds.length}
class="py-12 text-center text-muted-foreground"
>
No courses found. Try adjusting your filters.
</td>
</tr>
</tbody>
{:else}
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{ duration: 200, delay: Math.min(i * 20, 400) }}
out:fade={{ duration: 150 }}
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
course.crn
? 'bg-muted/30'
: ''}"
onclick={() => toggleRow(course.crn)}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "crn"}
<td class="py-2 px-2 relative">
<button
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
onclick={(e) =>
clipboard.copy(
course.crn,
e,
)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
clipboard.copy(course.crn, e);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
>
{course.crn}
{#if clipboard.copiedValue === course.crn}
<span
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
in:fade={{
duration: 100,
}}
out:fade={{
duration: 200,
}}
>
Copied!
</span>
{/if}
</button>
</td>
{:else if colId === "course_code"}
{@const subjectDesc =
subjectMap[course.subject]}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={subjectDesc
? `${subjectDesc} ${course.courseNumber}`
: `${course.subject} ${course.courseNumber}`}
delay={200}
side="bottom"
passthrough
>
<span class="font-semibold"
>{course.subject}
{course.courseNumber}</span
>{#if course.sequenceNumber}<span
class="text-muted-foreground"
>-{course.sequenceNumber}</span
>{/if}
</SimpleTooltip>
</td>
{:else if colId === "title"}
<td
class="py-2 px-2 font-medium max-w-50 truncate"
>
<SimpleTooltip
text={course.title}
delay={200}
side="bottom"
passthrough
>
<span class="block truncate"
>{course.title}</span
>
</SimpleTooltip>
</td>
{:else if colId === "instructor"}
{@const primary = getPrimaryInstructor(
course.instructors,
)}
{@const display = primaryInstructorDisplay(course)}
{@const commaIdx = display.indexOf(", ")}
{@const ratingData = primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span
class="text-xs text-muted-foreground/60 uppercase"
>Staff</span
>
{:else}
<SimpleTooltip
text={primary?.displayName ??
"Staff"}
delay={200}
side="bottom"
passthrough
>
{#if commaIdx !== -1}
<span>{display.slice(0, commaIdx)},
<span class="text-muted-foreground">{display.slice(commaIdx + 1)}</span
></span>
{:else}
<span>{display}</span>
{/if}
</SimpleTooltip>
{/if}
{#if ratingData}
<SimpleTooltip
text="{ratingData.rating.toFixed(
1,
)}/5 ({ratingData.count} ratings on RateMyProfessors)"
delay={150}
side="bottom"
passthrough
>
<span
class="ml-1 text-xs font-medium {ratingColor(
ratingData.rating,
)}"
>{ratingData.rating.toFixed(
1,
)}★</span
>
</SimpleTooltip>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={formatMeetingTimesTooltip(course.meetingTimes)}
passthrough
>
{#if timeIsTBA(course)}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{:else}
{@const mt =
course.meetingTimes[0]}
<span>
{#if !isMeetingTimeTBA(mt)}
<span
class="font-mono font-medium"
>{formatMeetingDays(
mt,
)}</span
>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span
class="text-muted-foreground"
>{formatTimeRange(
mt.begin_time,
mt.end_time,
)}</span
>
{:else}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{/if}
{#if course.meetingTimes.length > 1}
<span
class="ml-1 text-xs text-muted-foreground/70 font-medium"
>+{course
.meetingTimes
.length -
1}</span
>
{/if}
</span>
{/if}
</SimpleTooltip>
</td>
{:else if colId === "location"}
{@const concern = getDeliveryConcern(course)}
{@const accentColor = concernAccentColor(concern)}
{@const locTooltip = formatLocationTooltip(course)}
{@const locDisplay = formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip}
<SimpleTooltip
text={locTooltip}
delay={200}
passthrough
>
<span
class="text-muted-foreground"
class:pl-2={accentColor !== null}
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span class="text-muted-foreground">
{locDisplay}
</span>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
</td>
{:else if colId === "seats"}
<td
class="py-2 px-2 text-right whitespace-nowrap"
>
<SimpleTooltip
text="{openSeats(
course,
)} of {course.maxEnrollment} seats open, {course.enrollment} enrolled{course.waitCount >
0
? `, ${course.waitCount} waitlisted`
: ''}"
delay={200}
side="left"
passthrough
>
<span
class="inline-flex items-center gap-1.5"
>
<span
class="size-1.5 rounded-full {seatsDotColor(
course,
)} shrink-0"
></span>
<span
class="{seatsColor(
course,
)} font-medium tabular-nums"
>{#if openSeats(course) === 0}Full{:else}{openSeats(
course,
)} open{/if}</span
>
<span
class="text-muted-foreground/60 tabular-nums"
>{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0}
· WL {course.waitCount}/{course.waitCapacity}{/if}</span
>
</span>
</SimpleTooltip>
</td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td
colspan={visibleColumnIds.length}
class="p-0"
>
<div
transition:slide={{ duration: 200 }}
>
<CourseDetail {course} />
</div>
</td>
</tr>
{/if}
</tbody>
{/each}
{/if}
{/each}
</tr>
{/each}
</thead>
<tbody>
{#if loading && courses.length === 0}
{#each Array(5) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
<div class="h-4 bg-muted rounded animate-pulse {col.id === 'seats' ? 'w-14 ml-auto' : col.id === 'title' ? 'w-40' : col.id === 'crn' ? 'w-10' : 'w-20'}"></div>
</td>
{/each}
</tr>
{/each}
{:else if courses.length === 0}
<tr>
<td colspan={visibleColumnIds.length} class="py-12 text-center text-muted-foreground">
No courses found. Try adjusting your filters.
</td>
</tr>
{:else}
{#each table.getRowModel().rows as row (row.id)}
{@const course = row.original}
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
onclick={() => toggleRow(course.crn)}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "crn"}
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
{:else if colId === "course_code"}
<td class="py-2 px-2 whitespace-nowrap">
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
</td>
{:else if colId === "title"}
<td class="py-2 px-2 font-medium">{course.title}</td>
{:else if colId === "instructor"}
<td class="py-2 px-2 whitespace-nowrap">
{primaryInstructorDisplay(course)}
{#if primaryRating(course)}
{@const r = primaryRating(course)!}
<span
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
>{r.rating.toFixed(1)}</span>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
{#if timeIsTBA(course)}
<span class="text-xs text-muted-foreground/60">TBA</span>
{:else}
{@const mt = course.meetingTimes[0]}
{#if !isMeetingTimeTBA(mt)}
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}</span>
{:else}
<span class="text-xs text-muted-foreground/60">TBA</span>
{/if}
{/if}
</td>
{:else if colId === "location"}
<td class="py-2 px-2 whitespace-nowrap">
{#if formatLocation(course)}
<span class="text-muted-foreground">{formatLocation(course)}</span>
{:else}
<span class="text-xs text-muted-foreground/50"></span>
{/if}
</td>
{:else if colId === "seats"}
<td class="py-2 px-2 text-right whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
</span>
</td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td colspan={visibleColumnIds.length} class="p-0">
<CourseDetail {course} />
</td>
</tr>
{/if}
{/each}
{/if}
</tbody>
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
{@render columnVisibilityItems("context")}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-40 rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
>
{@render columnVisibilityGroup(
ContextMenu.Group,
ContextMenu.GroupHeading,
ContextMenu.CheckboxItem,
ContextMenu.Separator,
ContextMenu.Item,
)}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from "$lib/utils";
let {
commitHash,
showStatusLink = true,
class: className,
}: {
commitHash?: string | null;
showStatusLink?: boolean;
class?: string;
} = $props();
</script>
<div class={cn("flex justify-center items-center gap-2 mt-auto pt-6 pb-4", className)}>
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href={commitHash
? `https://github.com/Xevion/banner/commit/${commitHash}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
{#if showStatusLink}
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
{/if}
</div>
+142 -22
View File
@@ -1,4 +1,8 @@
<script lang="ts">
import { Select } from "bits-ui";
import { ChevronUp, ChevronDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
let {
totalCount,
offset,
@@ -11,32 +15,148 @@ let {
onPageChange: (newOffset: number) => void;
} = $props();
const currentPage = $derived(Math.floor(offset / limit) + 1);
const totalPages = $derived(Math.ceil(totalCount / limit));
const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount));
const hasPrev = $derived(offset > 0);
const hasNext = $derived(offset + limit < totalCount);
// Track direction for slide animation
let prevPage = $state(1);
let direction = $state(0);
$effect(() => {
const page = currentPage;
if (page !== prevPage) {
direction = page > prevPage ? 1 : -1;
prevPage = page;
}
});
// 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
function isSlotVisible(page: number): boolean {
return page >= 1 && page <= totalPages;
}
function goToPage(page: number) {
onPageChange((page - 1) * limit);
}
// Build items array for the Select dropdown
const pageItems = $derived(
Array.from({ length: totalPages }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))
);
const selectValue = $derived(String(currentPage));
</script>
{#if totalCount > 0}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
Showing {start}{end} of {totalCount} courses
</span>
<div class="flex gap-2">
<button
disabled={!hasPrev}
onclick={() => onPageChange(offset - limit)}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
>
Previous
</button>
<button
disabled={!hasNext}
onclick={() => onPageChange(offset + limit)}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
>
Next
</button>
{#if totalCount > 0 && totalPages > 1}
<div class="flex items-center text-sm">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {start}&ndash;{end} of {totalCount} courses
</span>
</div>
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
rounded-md text-sm font-medium tabular-nums
border border-border bg-card text-foreground
hover:bg-muted/50 active:bg-muted transition-colors
cursor-pointer select-none outline-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
max-h-72 min-w-16 w-auto
select-none rounded-md p-1
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=top]:slide-in-from-bottom-2
data-[side=bottom]:slide-in-from-top-2"
side="top"
sideOffset={6}
>
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
<ChevronUp class="size-3.5 text-muted-foreground" />
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
justify-center px-3 text-sm tabular-nums
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
data-[selected]:font-semibold"
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
<ChevronDown class="size-3.5 text-muted-foreground" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
rounded-md text-sm tabular-nums
text-muted-foreground
hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors
cursor-pointer select-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
{isSlotVisible(page) ? '' : 'invisible pointer-events-none'}"
onclick={() => goToPage(page)}
aria-label="Go to page {page}"
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)}
in:fly={{ x: direction * 20, duration: 200 }}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-center text-sm">
<span class="text-muted-foreground">
Showing {start}&ndash;{end} of {totalCount} courses
</span>
</div>
{/if}
+18 -25
View File
@@ -1,52 +1,45 @@
<script lang="ts">
import type { Term, Subject } from "$lib/api";
import SimpleTooltip from "./SimpleTooltip.svelte";
import TermCombobox from "./TermCombobox.svelte";
import SubjectCombobox from "./SubjectCombobox.svelte";
let {
terms,
subjects,
selectedTerm = $bindable(),
selectedSubject = $bindable(),
selectedSubjects = $bindable(),
query = $bindable(),
openOnly = $bindable(),
}: {
terms: Term[];
subjects: Subject[];
selectedTerm: string;
selectedSubject: string;
selectedSubjects: string[];
query: string;
openOnly: boolean;
} = $props();
</script>
<div class="flex flex-wrap gap-3 items-center">
<select
bind:value={selectedTerm}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
>
{#each terms as term (term.code)}
<option value={term.code}>{term.description}</option>
{/each}
</select>
<div class="flex flex-wrap gap-3 items-start">
<TermCombobox {terms} bind:value={selectedTerm} />
<select
bind:value={selectedSubject}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm"
>
<option value="">All Subjects</option>
{#each subjects as subject (subject.code)}
<option value={subject.code}>{subject.description}</option>
{/each}
</select>
<SubjectCombobox {subjects} bind:value={selectedSubjects} />
<input
type="text"
placeholder="Search courses..."
aria-label="Search courses"
bind:value={query}
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm flex-1 min-w-[200px]"
class="h-9 border border-border bg-card text-foreground rounded-md px-3 text-sm flex-1 min-w-[200px]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
transition-colors"
/>
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
<input type="checkbox" bind:checked={openOnly} />
Open only
</label>
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
<label class="flex items-center gap-1.5 h-9 text-sm text-muted-foreground cursor-pointer">
<input type="checkbox" bind:checked={openOnly} />
Open only
</label>
</SimpleTooltip>
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
let {
text,
delay = 150,
side = "top",
passthrough = false,
children,
}: {
text: string;
delay?: number;
side?: "top" | "bottom" | "left" | "right";
passthrough?: boolean;
children: Snippet;
} = $props();
</script>
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
<Tooltip.Trigger>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content
{side}
sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md whitespace-pre-line max-w-max"
>
{text}
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,161 @@
<script lang="ts">
import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Subject } from "$lib/api";
let {
subjects,
value = $bindable(),
}: {
subjects: Subject[];
value: string[];
} = $props();
let open = $state(false);
let searchValue = $state("");
let containerEl = $state<HTMLDivElement>(null!);
const filteredSubjects = $derived.by(() => {
const query = searchValue.toLowerCase().trim();
if (query === "") return subjects;
const exactCode: Subject[] = [];
const codeStartsWith: Subject[] = [];
const descriptionMatch: Subject[] = [];
for (const s of subjects) {
const codeLower = s.code.toLowerCase();
const descLower = s.description.toLowerCase();
if (codeLower === query) {
exactCode.push(s);
} else if (codeLower.startsWith(query)) {
codeStartsWith.push(s);
} else if (descLower.includes(query) || codeLower.includes(query)) {
descriptionMatch.push(s);
}
}
return [...exactCode, ...codeStartsWith, ...descriptionMatch];
});
const MAX_VISIBLE_CHIPS = 3;
const visibleChips = $derived(value.slice(0, MAX_VISIBLE_CHIPS));
const overflowCount = $derived(Math.max(0, value.length - MAX_VISIBLE_CHIPS));
function removeSubject(code: string) {
value = value.filter((v) => v !== code);
}
// bits-ui sets the input text to the last selected item's label — clear it
$effect(() => {
value;
const input = containerEl?.querySelector("input");
if (input) {
input.value = "";
searchValue = "";
}
});
</script>
<Combobox.Root
type="multiple"
bind:value
bind:open
onOpenChange={(o: boolean) => {
if (!o) searchValue = "";
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-9 rounded-md border border-border bg-card
flex flex-nowrap items-center gap-1 w-56 pr-9 overflow-hidden cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
bind:this={containerEl}
onclick={() => { containerEl?.querySelector('input')?.focus(); }}
>
{#if value.length > 0}
{#each (open ? value : visibleChips) as code (code)}
<span
role="button"
tabindex="-1"
onmousedown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onclick={(e) => { e.stopPropagation(); removeSubject(code); }}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation(); removeSubject(code); } }}
class="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs font-mono shrink-0
text-muted-foreground hover:outline hover:outline-1 hover:outline-ring
cursor-pointer transition-[outline] duration-100 first:ml-2"
>
{code}
</span>
{/each}
{#if !open && overflowCount > 0}
<span class="text-xs text-muted-foreground shrink-0">+{overflowCount}</span>
{/if}
{/if}
<Combobox.Input
oninput={(e) => (searchValue = e.currentTarget.value)}
onfocus={() => { open = true; }}
class="h-full min-w-0 flex-1 bg-transparent text-muted-foreground text-sm
placeholder:text-muted-foreground outline-none border-none
{value.length > 0 ? 'pl-1' : 'pl-3'}"
placeholder={value.length > 0 ? "Filter..." : "All Subjects"}
aria-label="Search subjects"
autocomplete="off"
autocorrect="off"
spellcheck={false}
/>
<span class="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
<ChevronsUpDown class="size-4" />
</span>
</div>
<Combobox.Portal>
<Combobox.Content
customAnchor={containerEl}
class="border border-border bg-card shadow-md
outline-hidden z-50
max-h-72 min-w-[var(--bits-combobox-anchor-width)] w-max max-w-96
select-none rounded-md p-1
data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open: isOpen })}
{#if isOpen}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredSubjects as subject (subject.code)}
<Combobox.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center gap-2 px-2 text-sm whitespace-nowrap
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
value={subject.code}
label={subject.description}
>
{#snippet children({ selected })}
<span class="inline-flex items-center justify-center rounded bg-muted px-1 py-0.5
text-xs font-mono text-muted-foreground w-10 shrink-0 text-center">
{subject.code}
</span>
<span class="flex-1">{subject.description}</span>
{#if selected}
<Check class="ml-auto size-4 shrink-0" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<span class="block px-2 py-2 text-sm text-muted-foreground">
No subjects found.
</span>
{/each}
</Combobox.Viewport>
</div>
</div>
{/if}
{/snippet}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Term } from "$lib/api";
let {
terms,
value = $bindable(),
}: {
terms: Term[];
value: string;
} = $props();
let open = $state(false);
let searchValue = $state("");
let containerEl = $state<HTMLDivElement>(null!);
const currentTermCode = $derived(
terms.find((t) => !t.description.includes("(View Only)"))?.code ?? ""
);
const selectedLabel = $derived(
terms.find((t) => t.code === value)?.description ?? "Select term..."
);
const filteredTerms = $derived.by(() => {
const query = searchValue.toLowerCase();
const matched =
query === "" ? terms : terms.filter((t) => t.description.toLowerCase().includes(query));
const current = matched.find((t) => t.code === currentTermCode);
const rest = matched.filter((t) => t.code !== currentTermCode);
return current ? [current, ...rest] : rest;
});
// Manage DOM input text: clear when open for searching, restore label when closed
$effect(() => {
const _open = open;
void value; // track selection changes
const _label = selectedLabel;
const input = containerEl?.querySelector("input");
if (!input) return;
if (_open) {
input.value = "";
searchValue = "";
} else {
input.value = _label;
}
});
</script>
<Combobox.Root
type="single"
bind:value={() => value, (v) => { if (v) value = v; }}
bind:open
onOpenChange={(o: boolean) => {
if (!o) searchValue = "";
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-9 rounded-md border border-border bg-card
flex items-center w-40 cursor-pointer
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background"
role="presentation"
bind:this={containerEl}
onclick={() => { containerEl?.querySelector('input')?.focus(); }}
onkeydown={() => { containerEl?.querySelector('input')?.focus(); }}
>
<Combobox.Input
oninput={(e) => (searchValue = e.currentTarget.value)}
onfocus={() => { open = true; }}
class="h-full w-full bg-transparent text-muted-foreground text-sm
placeholder:text-muted-foreground outline-none border-none
pl-3 pr-9 truncate"
placeholder="Select term..."
aria-label="Select term"
autocomplete="off"
autocorrect="off"
spellcheck={false}
/>
<span class="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
<ChevronsUpDown class="size-4" />
</span>
</div>
<Combobox.Portal>
<Combobox.Content
customAnchor={containerEl}
class="border border-border bg-card shadow-md
outline-hidden z-50
max-h-72 min-w-[var(--bits-combobox-anchor-width)]
select-none rounded-md p-1
data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open: isOpen })}
{#if isOpen}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredTerms as term, i (term.code)}
{#if i === 1 && term.code !== currentTermCode && filteredTerms[0]?.code === currentTermCode}
<div class="mx-2 my-1 h-px bg-border"></div>
{/if}
<Combobox.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center px-2 text-sm
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
{term.code === value ? 'cursor-default' : 'cursor-pointer'}
{term.code === currentTermCode ? 'font-medium text-foreground' : 'text-foreground'}"
value={term.code}
label={term.description}
>
{#snippet children({ selected })}
<span class="flex-1 truncate">
{term.description}
{#if term.code === currentTermCode}
<span class="ml-1.5 text-xs text-muted-foreground font-normal">current</span>
{/if}
</span>
{#if selected}
<Check class="ml-2 size-4 shrink-0" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<span class="block px-2 py-2 text-sm text-muted-foreground">
No terms found.
</span>
{/each}
</Combobox.Viewport>
</div>
</div>
{/if}
{/snippet}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
+25 -22
View File
@@ -2,6 +2,7 @@
import { tick } from "svelte";
import { Moon, Sun } from "@lucide/svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
/**
* Theme toggle with View Transitions API circular reveal animation.
@@ -42,25 +43,27 @@ async function handleToggle(event: MouseEvent) {
}
</script>
<button
type="button"
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
>
<div class="relative size-[18px]">
<Sun
size={18}
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
? 'rotate-90 scale-0 opacity-0'
: 'rotate-0 scale-100 opacity-100'}"
/>
<Moon
size={18}
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
? 'rotate-0 scale-100 opacity-100'
: '-rotate-90 scale-0 opacity-0'}"
/>
</div>
</button>
<SimpleTooltip text={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"} delay={200} side="bottom" passthrough>
<button
type="button"
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
>
<div class="relative size-[18px]">
<Sun
size={18}
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
? 'rotate-90 scale-0 opacity-0'
: 'rotate-0 scale-100 opacity-100'}"
/>
<Moon
size={18}
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
? 'rotate-0 scale-100 opacity-100'
: '-rotate-90 scale-0 opacity-0'}"
/>
</div>
</button>
</SimpleTooltip>
@@ -0,0 +1,32 @@
/**
* Reactive clipboard copy with automatic "copied" state reset.
*
* Returns a `copiedValue` that is non-null while the copied feedback
* should be displayed, and a `copy()` function to trigger a copy.
*/
export function useClipboard(resetMs = 2000) {
let copiedValue = $state<string | null>(null);
let timeoutId: number | undefined;
async function copy(text: string, event?: MouseEvent | KeyboardEvent) {
event?.stopPropagation();
try {
await navigator.clipboard.writeText(text);
clearTimeout(timeoutId);
copiedValue = text;
timeoutId = window.setTimeout(() => {
copiedValue = null;
timeoutId = undefined;
}, resetMs);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
}
return {
get copiedValue() {
return copiedValue;
},
copy,
};
}
@@ -0,0 +1,37 @@
import { onMount } from "svelte";
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
/**
* Set up OverlayScrollbars on an element with automatic theme reactivity.
*
* Must be called during component initialization (uses `onMount` internally).
* The scrollbar theme automatically syncs with `themeStore.isDark`.
*/
export function useOverlayScrollbars(getElement: () => HTMLElement, options: PartialOptions = {}) {
onMount(() => {
const element = getElement();
const osInstance = OverlayScrollbars(element, {
...options,
scrollbars: {
...options.scrollbars,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
});
}
+197 -18
View File
@@ -1,14 +1,19 @@
import { describe, it, expect } from "vitest";
import {
formatTime,
formatTimeRange,
formatMeetingDays,
formatMeetingDaysVerbose,
formatMeetingTime,
formatMeetingTimeTooltip,
formatMeetingTimesTooltip,
abbreviateInstructor,
formatCreditHours,
getPrimaryInstructor,
isMeetingTimeTBA,
isTimeTBA,
formatDate,
formatDateShort,
formatMeetingDaysLong,
} from "$lib/course";
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
@@ -53,13 +58,13 @@ describe("formatMeetingDays", () => {
formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
).toBe("MWF");
});
it("returns TR for tue/thu", () => {
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR");
it("returns TTh for tue/thu", () => {
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TTh");
});
it("returns empty string when no days", () => {
expect(formatMeetingDays(makeMeetingTime())).toBe("");
it("returns MW for mon/wed", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true }))).toBe("MW");
});
it("returns all days", () => {
it("returns MTWThF for all weekdays", () => {
expect(
formatMeetingDays(
makeMeetingTime({
@@ -68,16 +73,56 @@ describe("formatMeetingDays", () => {
wednesday: true,
thursday: true,
friday: true,
saturday: true,
sunday: true,
})
)
).toBe("MTWRFSU");
).toBe("MTWThF");
});
it("returns partial abbreviation for single day", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true }))).toBe("Mon");
expect(formatMeetingDays(makeMeetingTime({ thursday: true }))).toBe("Thu");
expect(formatMeetingDays(makeMeetingTime({ saturday: true }))).toBe("Sat");
});
it("concatenates codes for other multi-day combos", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true, friday: true }))).toBe("MF");
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, saturday: true }))).toBe("TSa");
expect(
formatMeetingDays(makeMeetingTime({ wednesday: true, friday: true, sunday: true }))
).toBe("WFSu");
expect(
formatMeetingDays(
makeMeetingTime({ monday: true, tuesday: true, wednesday: true, thursday: true })
)
).toBe("MTWTh");
});
it("returns empty string when no days", () => {
expect(formatMeetingDays(makeMeetingTime())).toBe("");
});
});
describe("formatTimeRange", () => {
it("elides AM when both times are AM", () => {
expect(formatTimeRange("0900", "0950")).toBe("9:009:50 AM");
});
it("elides PM when both times are PM", () => {
expect(formatTimeRange("1315", "1430")).toBe("1:152:30 PM");
});
it("keeps both markers when crossing noon", () => {
expect(formatTimeRange("1130", "1220")).toBe("11:30 AM12:20 PM");
});
it("returns TBA for null begin", () => {
expect(formatTimeRange(null, "0950")).toBe("TBA");
});
it("returns TBA for null end", () => {
expect(formatTimeRange("0900", null)).toBe("TBA");
});
it("handles midnight and noon", () => {
expect(formatTimeRange("0000", "0050")).toBe("12:0012:50 AM");
expect(formatTimeRange("1200", "1250")).toBe("12:0012:50 PM");
});
});
describe("formatMeetingTime", () => {
it("formats a standard meeting time", () => {
it("formats a standard meeting time with elided AM/PM", () => {
expect(
formatMeetingTime(
makeMeetingTime({
@@ -88,7 +133,19 @@ describe("formatMeetingTime", () => {
end_time: "0950",
})
)
).toBe("MWF 9:00 AM9:50 AM");
).toBe("MWF 9:009:50 AM");
});
it("keeps both markers when crossing noon", () => {
expect(
formatMeetingTime(
makeMeetingTime({
tuesday: true,
thursday: true,
begin_time: "1130",
end_time: "1220",
})
)
).toBe("TTh 11:30 AM12:20 PM");
});
it("returns TBA when no days", () => {
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe(
@@ -96,29 +153,68 @@ describe("formatMeetingTime", () => {
);
});
it("returns days + TBA when no times", () => {
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("Mon TBA");
});
});
describe("abbreviateInstructor", () => {
it("abbreviates standard name", () =>
expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J."));
it("returns short names unabbreviated", () =>
expect(abbreviateInstructor("Li, Bo")).toBe("Li, Bo"));
it("returns names within budget unabbreviated", () =>
expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, John"));
it("handles no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff"));
it("handles multiple first names", () =>
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
// Progressive abbreviation with multiple given names
it("abbreviates trailing given names first", () =>
expect(abbreviateInstructor("Ramirez, Maria Elena")).toBe("Ramirez, Maria E."));
it("abbreviates all given names when needed", () =>
expect(abbreviateInstructor("Ramirez, Maria Elena", 16)).toBe("Ramirez, M. E."));
it("falls back to first initial only", () =>
expect(abbreviateInstructor("Ramirez, Maria Elena", 12)).toBe("Ramirez, M."));
// Single given name that exceeds budget
it("abbreviates single given name when over budget", () =>
expect(abbreviateInstructor("Bartholomew, Christopher", 18)).toBe("Bartholomew, C."));
// Respects custom maxLen
it("keeps full name when within custom budget", () =>
expect(abbreviateInstructor("Ramirez, Maria Elena", 30)).toBe("Ramirez, Maria Elena"));
it("always abbreviates when budget is tiny", () =>
expect(abbreviateInstructor("Heaps, John", 5)).toBe("Heaps, J."));
});
describe("getPrimaryInstructor", () => {
it("returns primary instructor", () => {
const instructors: InstructorResponse[] = [
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
{ bannerId: "2", displayName: "B", email: null, isPrimary: true },
{
bannerId: "1",
displayName: "A",
email: null,
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
},
{
bannerId: "2",
displayName: "B",
email: null,
isPrimary: true,
rmpRating: null,
rmpNumRatings: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
});
it("returns first instructor when no primary", () => {
const instructors: InstructorResponse[] = [
{ bannerId: "1", displayName: "A", email: null, isPrimary: false },
{
bannerId: "1",
displayName: "A",
email: null,
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
});
@@ -226,3 +322,86 @@ describe("formatMeetingDaysLong", () => {
expect(formatMeetingDaysLong(makeMeetingTime())).toBe("");
});
});
describe("formatDateShort", () => {
it("formats YYYY-MM-DD to short", () => {
expect(formatDateShort("2024-08-26")).toBe("Aug 26, 2024");
});
it("formats MM/DD/YYYY to short", () => {
expect(formatDateShort("12/12/2024")).toBe("Dec 12, 2024");
});
it("returns original for invalid", () => {
expect(formatDateShort("bad")).toBe("bad");
});
});
describe("formatMeetingDaysVerbose", () => {
it("returns plural for single day", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime({ thursday: true }))).toBe("Thursdays");
});
it("joins two days with ampersand", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(
"Tuesdays & Thursdays"
);
});
it("uses Oxford-style ampersand for 3+ days", () => {
expect(
formatMeetingDaysVerbose(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
).toBe("Mondays, Wednesdays & Fridays");
});
it("returns empty string when no days", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime())).toBe("");
});
});
describe("formatMeetingTimeTooltip", () => {
it("formats full tooltip with location and dates", () => {
const mt = makeMeetingTime({
tuesday: true,
thursday: true,
begin_time: "1615",
end_time: "1730",
building_description: "Main Hall",
room: "2.206",
});
expect(formatMeetingTimeTooltip(mt)).toBe(
"Tuesdays & Thursdays, 4:155:30 PM\nMain Hall 2.206, Aug 26, 2024 Dec 12, 2024"
);
});
it("handles TBA days and times", () => {
expect(formatMeetingTimeTooltip(makeMeetingTime())).toBe("TBA\nAug 26, 2024 Dec 12, 2024");
});
it("handles days with TBA times", () => {
expect(formatMeetingTimeTooltip(makeMeetingTime({ monday: true }))).toBe(
"Mondays, TBA\nAug 26, 2024 Dec 12, 2024"
);
});
});
describe("formatMeetingTimesTooltip", () => {
it("returns TBA for empty array", () => {
expect(formatMeetingTimesTooltip([])).toBe("TBA");
});
it("joins multiple meetings with blank line", () => {
const mts = [
makeMeetingTime({
monday: true,
wednesday: true,
friday: true,
begin_time: "0900",
end_time: "0950",
}),
makeMeetingTime({
thursday: true,
begin_time: "1300",
end_time: "1400",
building_description: "Lab",
room: "101",
}),
];
const result = formatMeetingTimesTooltip(mts);
expect(result).toContain("Mondays, Wednesdays & Fridays, 9:009:50 AM");
expect(result).toContain("Thursdays, 1:002:00 PM\nLab 101");
expect(result).toContain("\n\n");
});
});
+272 -22
View File
@@ -10,21 +10,29 @@ export function formatTime(time: string | null): string {
return `${display}:${minutes} ${period}`;
}
/** Get day abbreviation string like "MWF" from a meeting time */
/**
* Compact day abbreviation for table cells.
*
* Single day → 3-letter: "Mon", "Thu"
* Multi-day → concatenated codes: "MWF", "TTh", "MTWTh", "TSa"
*
* Codes use single letters where unambiguous (M/T/W/F) and
* two letters where needed (Th/Sa/Su).
*/
export function formatMeetingDays(mt: DbMeetingTime): string {
const days: [boolean, string][] = [
[mt.monday, "M"],
[mt.tuesday, "T"],
[mt.wednesday, "W"],
[mt.thursday, "R"],
[mt.friday, "F"],
[mt.saturday, "S"],
[mt.sunday, "U"],
const dayDefs: [boolean, string, string][] = [
[mt.monday, "M", "Mon"],
[mt.tuesday, "T", "Tue"],
[mt.wednesday, "W", "Wed"],
[mt.thursday, "Th", "Thu"],
[mt.friday, "F", "Fri"],
[mt.saturday, "Sa", "Sat"],
[mt.sunday, "Su", "Sun"],
];
return days
.filter(([active]) => active)
.map(([, abbr]) => abbr)
.join("");
const active = dayDefs.filter(([a]) => a);
if (active.length === 0) return "";
if (active.length === 1) return active[0][2];
return active.map(([, code]) => code).join("");
}
/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */
@@ -44,23 +52,79 @@ export function formatMeetingDaysLong(mt: DbMeetingTime): string {
return active.map(([, short]) => short).join(", ");
}
/** Condensed meeting time: "MWF 9:00 AM9:50 AM" */
/**
* Format a time range with smart AM/PM elision.
*
* Same period: "9:009:50 AM"
* Cross-period: "11:30 AM12:20 PM"
* Missing: "TBA"
*/
export function formatTimeRange(begin: string | null, end: string | null): string {
if (!begin || begin.length !== 4 || !end || end.length !== 4) return "TBA";
const bHours = parseInt(begin.slice(0, 2), 10);
const eHours = parseInt(end.slice(0, 2), 10);
const bPeriod = bHours >= 12 ? "PM" : "AM";
const ePeriod = eHours >= 12 ? "PM" : "AM";
const bDisplay = bHours > 12 ? bHours - 12 : bHours === 0 ? 12 : bHours;
const eDisplay = eHours > 12 ? eHours - 12 : eHours === 0 ? 12 : eHours;
const endStr = `${eDisplay}:${end.slice(2)} ${ePeriod}`;
if (bPeriod === ePeriod) {
return `${bDisplay}:${begin.slice(2)}${endStr}`;
}
return `${bDisplay}:${begin.slice(2)} ${bPeriod}${endStr}`;
}
/** Condensed meeting time: "MWF 9:009:50 AM" */
export function formatMeetingTime(mt: DbMeetingTime): string {
const days = formatMeetingDays(mt);
if (!days) return "TBA";
const begin = formatTime(mt.begin_time);
const end = formatTime(mt.end_time);
if (begin === "TBA") return `${days} TBA`;
return `${days} ${begin}${end}`;
const range = formatTimeRange(mt.begin_time, mt.end_time);
if (range === "TBA") return `${days} TBA`;
return `${days} ${range}`;
}
/** Abbreviate instructor name: "Heaps, John" → "Heaps, J." */
export function abbreviateInstructor(name: string): string {
/**
* Progressively abbreviate an instructor name to fit within a character budget.
*
* Tries each level until the result fits `maxLen`:
* 1. Full name: "Ramirez, Maria Elena"
* 2. Abbreviate trailing given names: "Ramirez, Maria E."
* 3. Abbreviate all given names: "Ramirez, M. E."
* 4. First initial only: "Ramirez, M."
*
* Names without a comma (e.g. "Staff") are returned as-is.
*/
export function abbreviateInstructor(name: string, maxLen: number = 18): string {
if (name.length <= maxLen) return name;
const commaIdx = name.indexOf(", ");
if (commaIdx === -1) return name;
const last = name.slice(0, commaIdx);
const first = name.slice(commaIdx + 2);
return `${last}, ${first.charAt(0)}.`;
const parts = name.slice(commaIdx + 2).split(" ");
// Level 2: abbreviate trailing given names, keep first given name intact
// "Maria Elena" → "Maria E."
if (parts.length > 1) {
const abbreviated = [parts[0], ...parts.slice(1).map((p) => `${p[0]}.`)].join(" ");
const result = `${last}, ${abbreviated}`;
if (result.length <= maxLen) return result;
}
// Level 3: abbreviate all given names
// "Maria Elena" → "M. E."
if (parts.length > 1) {
const allInitials = parts.map((p) => `${p[0]}.`).join(" ");
const result = `${last}, ${allInitials}`;
if (result.length <= maxLen) return result;
}
// Level 4: first initial only
// "Maria Elena" → "M." or "John" → "J."
return `${last}, ${parts[0][0]}.`;
}
/** Get primary instructor from a course, or first instructor */
@@ -119,6 +183,192 @@ export function formatLocationLong(mt: DbMeetingTime): string | null {
return mt.room ? `${name} ${mt.room}` : name;
}
/** Format a date as "Aug 26, 2024". Accepts YYYY-MM-DD or MM/DD/YYYY. */
export function formatDateShort(dateStr: string): string {
let year: number, month: number, day: number;
if (dateStr.includes("-")) {
[year, month, day] = dateStr.split("-").map(Number);
} else if (dateStr.includes("/")) {
[month, day, year] = dateStr.split("/").map(Number);
} else {
return dateStr;
}
if (!year || !month || !day) return dateStr;
const date = new Date(year, month - 1, day);
return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
/**
* Verbose day names for tooltips: "Tuesdays & Thursdays", "Mondays, Wednesdays & Fridays".
* Single day → plural: "Thursdays".
*/
export function formatMeetingDaysVerbose(mt: DbMeetingTime): string {
const dayDefs: [boolean, string][] = [
[mt.monday, "Mondays"],
[mt.tuesday, "Tuesdays"],
[mt.wednesday, "Wednesdays"],
[mt.thursday, "Thursdays"],
[mt.friday, "Fridays"],
[mt.saturday, "Saturdays"],
[mt.sunday, "Sundays"],
];
const active = dayDefs.filter(([a]) => a).map(([, name]) => name);
if (active.length === 0) return "";
if (active.length === 1) return active[0];
return active.slice(0, -1).join(", ") + " & " + active[active.length - 1];
}
/**
* Full verbose tooltip for a single meeting time:
* "Tuesdays & Thursdays, 4:155:30 PM\nMain Hall 2.206 · Aug 26 Dec 12, 2024"
*/
export function formatMeetingTimeTooltip(mt: DbMeetingTime): string {
const days = formatMeetingDaysVerbose(mt);
const range = formatTimeRange(mt.begin_time, mt.end_time);
let line1: string;
if (!days && range === "TBA") {
line1 = "TBA";
} else if (!days) {
line1 = range;
} else if (range === "TBA") {
line1 = `${days}, TBA`;
} else {
line1 = `${days}, ${range}`;
}
const parts = [line1];
const loc = formatLocationLong(mt);
const dateRange =
mt.start_date && mt.end_date
? `${formatDateShort(mt.start_date)} ${formatDateShort(mt.end_date)}`
: null;
if (loc && dateRange) {
parts.push(`${loc}, ${dateRange}`);
} else if (loc) {
parts.push(loc);
} else if (dateRange) {
parts.push(dateRange);
}
return parts.join("\n");
}
/** Full verbose tooltip for all meeting times on a course, newline-separated. */
export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string {
if (meetingTimes.length === 0) return "TBA";
return meetingTimes.map(formatMeetingTimeTooltip).join("\n\n");
}
/**
* Delivery concern category for visual accent on location cells.
* - "online": fully online with no physical location (OA, OS, OH without INT building)
* - "internet": internet campus with INT building code
* - "hybrid": mix of online and in-person (HB, H1, H2)
* - "off-campus": in-person but not on Main Campus
* - null: normal in-person on main campus (no accent)
*/
export type DeliveryConcern = "online" | "internet" | "hybrid" | "off-campus" | null;
const ONLINE_METHODS = new Set(["OA", "OS", "OH"]);
const HYBRID_METHODS = new Set(["HB", "H1", "H2"]);
const MAIN_CAMPUS = "11";
const ONLINE_CAMPUSES = new Set(["9", "ONL"]);
export function getDeliveryConcern(course: CourseResponse): DeliveryConcern {
const method = course.instructionalMethod;
if (method && ONLINE_METHODS.has(method)) {
const hasIntBuilding = course.meetingTimes.some((mt: DbMeetingTime) => mt.building === "INT");
return hasIntBuilding ? "internet" : "online";
}
if (method && HYBRID_METHODS.has(method)) return "hybrid";
if (course.campus && course.campus !== MAIN_CAMPUS && !ONLINE_CAMPUSES.has(course.campus)) {
return "off-campus";
}
return null;
}
/** Border accent color for each delivery concern type. */
export function concernAccentColor(concern: DeliveryConcern): string | null {
switch (concern) {
case "online":
return "#3b82f6"; // blue-500
case "internet":
return "#06b6d4"; // cyan-500
case "hybrid":
return "#a855f7"; // purple-500
case "off-campus":
return "#f59e0b"; // amber-500
default:
return null;
}
}
/**
* Location display text for the table cell.
* Falls back to "Online" for online courses instead of showing a dash.
*/
export function formatLocationDisplay(course: CourseResponse): string | null {
const loc = formatLocation(course);
if (loc) return loc;
const concern = getDeliveryConcern(course);
if (concern === "online") return "Online";
return null;
}
/** Tooltip text for the location column: long-form location + delivery note */
export function formatLocationTooltip(course: CourseResponse): string | null {
const parts: string[] = [];
for (const mt of course.meetingTimes) {
const loc = formatLocationLong(mt);
if (loc && !parts.includes(loc)) parts.push(loc);
}
const locationLine = parts.length > 0 ? parts.join(", ") : null;
const concern = getDeliveryConcern(course);
let deliveryNote: string | null = null;
if (concern === "online") deliveryNote = "Online";
else if (concern === "internet") deliveryNote = "Internet";
else if (concern === "hybrid") deliveryNote = "Hybrid";
else if (concern === "off-campus") deliveryNote = "Off-campus";
if (locationLine && deliveryNote) return `${locationLine}\n${deliveryNote}`;
if (locationLine) return locationLine;
if (deliveryNote) return deliveryNote;
return null;
}
/** Number of open seats in a course section */
export function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
/** Text color class for seat availability: red (full), yellow (low), green (open) */
export function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
/** Background dot color class for seat availability */
export function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
/** Text color class for a RateMyProfessors rating */
export function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
/** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours);
+4
View File
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared tooltip content styling for bits-ui Tooltip.Content */
export const tooltipContentClass =
"z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72";
+10 -11
View File
@@ -2,27 +2,26 @@
import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars";
import { Tooltip } from "bits-ui";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
let { children } = $props();
useOverlayScrollbars(() => document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
},
});
onMount(() => {
themeStore.init();
const osInstance = OverlayScrollbars(document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition");
});
return () => {
osInstance?.destroy();
};
});
</script>
+75 -45
View File
@@ -12,6 +12,7 @@ import type { SortingState } from "@tanstack/table-core";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import Footer from "$lib/components/Footer.svelte";
let { data } = $props();
@@ -20,7 +21,7 @@ const initialParams = untrack(() => new URLSearchParams(data.url.search));
// Filter state
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
let selectedSubject = $state(initialParams.get("subject") ?? "");
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
let query = $state(initialParams.get("q") ?? "");
let openOnly = $state(initialParams.get("open") === "true");
let offset = $state(Number(initialParams.get("offset")) || 0);
@@ -51,6 +52,9 @@ function handleSortingChange(newSorting: SortingState) {
// Data state
let subjects: Subject[] = $state([]);
let subjectMap: Record<string, string> = $derived(
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
);
let searchResult: SearchResponse | null = $state(null);
let loading = $state(false);
let error = $state<string | null>(null);
@@ -59,36 +63,78 @@ let error = $state<string | null>(null);
$effect(() => {
const term = selectedTerm;
if (!term) return;
client.getSubjects(term).then((s) => {
subjects = s;
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
selectedSubject = "";
}
});
client
.getSubjects(term)
.then((s) => {
subjects = s;
const validCodes = new Set(s.map((sub) => sub.code));
selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code));
})
.catch((e) => {
console.error("Failed to fetch subjects:", e);
});
});
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const term = selectedTerm;
const subject = selectedSubject;
const q = query;
const open = openOnly;
const off = offset;
const sort = sorting;
// Centralized throttle configuration - maps trigger source to throttle delay (ms)
const THROTTLE_MS = {
term: 0, // Immediate
subjects: 100, // Short delay for combobox selection
query: 300, // Standard input debounce
openOnly: 0, // Immediate
offset: 0, // Immediate (pagination)
sorting: 0, // Immediate (column sort)
} as const;
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function scheduleSearch(source: keyof typeof THROTTLE_MS) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(term, subject, q, open, off, sort);
}, 300);
performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting);
}, THROTTLE_MS[source]);
}
// Separate effects for each trigger source with appropriate throttling
$effect(() => {
selectedTerm;
scheduleSearch("term");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
selectedSubjects;
scheduleSearch("subjects");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
query;
scheduleSearch("query");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
openOnly;
scheduleSearch("openOnly");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
offset;
scheduleSearch("offset");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
sorting;
scheduleSearch("sorting");
return () => clearTimeout(searchTimeout);
});
// Reset offset when filters change (not offset itself)
let prevFilters = $state("");
$effect(() => {
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
const key = `${selectedTerm}|${selectedSubjects.join(",")}|${query}|${openOnly}`;
if (prevFilters && key !== prevFilters) {
offset = 0;
}
@@ -97,7 +143,7 @@ $effect(() => {
async function performSearch(
term: string,
subject: string,
subjects: string[],
q: string,
open: boolean,
off: number,
@@ -107,15 +153,15 @@ async function performSearch(
loading = true;
error = null;
// Derive server sort params from TanStack sorting state
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
const sortDir: SortDirection | undefined =
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
// Sync URL
const params = new URLSearchParams();
params.set("term", term);
if (subject) params.set("subject", subject);
for (const s of subjects) {
params.append("subject", s);
}
if (q) params.set("q", q);
if (open) params.set("open", "true");
if (off > 0) params.set("offset", String(off));
@@ -126,7 +172,7 @@ async function performSearch(
try {
searchResult = await client.searchCourses({
term,
subject: subject || undefined,
subjects: subjects.length > 0 ? subjects : undefined,
q: q || undefined,
open_only: open || undefined,
limit,
@@ -147,7 +193,7 @@ function handlePageChange(newOffset: number) {
</script>
<div class="min-h-screen flex flex-col items-center p-5">
<div class="w-full max-w-4xl flex flex-col gap-6">
<div class="w-full max-w-6xl flex flex-col gap-6">
<!-- Title -->
<div class="text-center pt-8 pb-2">
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
@@ -158,7 +204,7 @@ function handlePageChange(newOffset: number) {
terms={data.terms}
{subjects}
bind:selectedTerm
bind:selectedSubject
bind:selectedSubjects
bind:query
bind:openOnly
/>
@@ -168,7 +214,7 @@ function handlePageChange(newOffset: number) {
<div class="text-center py-8">
<p class="text-status-red">{error}</p>
<button
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset, sorting)}
onclick={() => performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting)}
class="mt-2 text-sm text-muted-foreground hover:underline"
>
Retry
@@ -181,6 +227,7 @@ function handlePageChange(newOffset: number) {
{sorting}
onSortingChange={handleSortingChange}
manualSorting={true}
{subjectMap}
/>
{#if searchResult}
@@ -194,23 +241,6 @@ function handlePageChange(newOffset: number) {
{/if}
<!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href="https://github.com/Xevion/banner"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
</div>
<Footer />
</div>
</div>
+7 -2
View File
@@ -3,6 +3,11 @@ import { BannerApiClient } from "$lib/api";
export const load: PageLoad = async ({ url, fetch }) => {
const client = new BannerApiClient(undefined, fetch);
const terms = await client.getTerms();
return { terms, url };
try {
const terms = await client.getTerms();
return { terms, url };
} catch (e) {
console.error("Failed to load terms:", e);
return { terms: [], url };
}
};
+13 -31
View File
@@ -12,7 +12,8 @@ import {
WifiOff,
XCircle,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import Footer from "$lib/components/Footer.svelte";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
@@ -61,7 +62,6 @@ let statusState = $state({ mode: "loading" } as StatusState);
let now = $state(new Date());
const isLoading = $derived(statusState.mode === "loading");
const hasResponse = $derived(statusState.mode === "response");
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
const overallHealth: ServiceStatus | "Unreachable" = $derived(
@@ -290,20 +290,13 @@ onMount(() => {
<Clock size={13} />
<span class="text-sm text-muted-foreground">Last Updated</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<abbr
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
>
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
</abbr>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
<SimpleTooltip text="as of {lastFetch.toLocaleTimeString()}" delay={150} passthrough>
<abbr
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
>
as of {lastFetch.toLocaleTimeString()}
</Tooltip.Content>
</Tooltip.Root>
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
</abbr>
</SimpleTooltip>
</div>
{/if}
</div>
@@ -311,20 +304,9 @@ onMount(() => {
</div>
<!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-3">
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href={hasResponse && statusState.mode === "response" && statusState.status.commit
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
</div>
<Footer
commitHash={statusState.mode === "response" ? statusState.status.commit : undefined}
showStatusLink={false}
class="mt-3 pt-0 pb-0"
/>
</div>
+103 -2
View File
@@ -12,6 +12,8 @@
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--accent: oklch(0.96 0 0);
--accent-foreground: oklch(0.145 0 0);
--status-green: oklch(0.65 0.2 145);
--status-red: oklch(0.63 0.2 25);
@@ -28,6 +30,8 @@
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
--ring: oklch(0.556 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--status-green: oklch(0.72 0.19 145);
--status-red: oklch(0.7 0.19 25);
@@ -44,6 +48,8 @@
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-status-green: var(--status-green);
--color-status-red: var(--status-red);
--color-status-orange: var(--status-orange);
@@ -64,8 +70,67 @@ body {
margin: 0;
}
body,
body * {
/* Focus styling - only visible on keyboard navigation */
*:focus {
outline: none;
}
/* Form inputs get outline-based focus directly on border */
input[type="text"]:focus-visible,
input[type="search"]:focus-visible,
input[type="email"]:focus-visible,
input[type="password"]:focus-visible,
input[type="number"]:focus-visible,
input[type="url"]:focus-visible,
input[type="tel"]:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 0;
}
/* Buttons get rounded box-shadow focus (outline doesn't support border-radius) */
button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
}
/* Checkboxes get direct outline focus */
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Checkbox styling - theme-aware appearance */
input[type="checkbox"] {
appearance: none;
width: 1rem;
height: 1rem;
border: 1.5px solid var(--border);
border-radius: 0.25rem;
background-color: var(--card);
cursor: pointer;
display: inline-grid;
place-content: center;
flex-shrink: 0;
}
input[type="checkbox"]:checked {
background-color: var(--foreground);
border-color: var(--foreground);
}
input[type="checkbox"]:checked::before {
content: "";
width: 0.5rem;
height: 0.5rem;
background-color: var(--background);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
html:not(.no-transition) body,
html:not(.no-transition) body * {
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
}
@@ -105,6 +170,42 @@ body::-webkit-scrollbar {
display: none;
}
/* Native scrollbars — theme-aware styling for inner scrollable elements */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
}
.dark * {
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.35);
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
@keyframes pulse {
0%,
100% {