mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 06:23:38 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c4fcd0c2 | |||
| 215703593b |
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.6.2"
|
||||
".": "0.6.3"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.6.3](https://github.com/Xevion/Banner/compare/v0.6.2...v0.6.3) (2026-02-01)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* Consolidate course data models into structured types ([2157035](https://github.com/Xevion/Banner/commit/215703593b6e6696f2dc478bd29644374fa1e787))
|
||||
|
||||
## [0.6.2](https://github.com/Xevion/Banner/compare/v0.6.1...v0.6.2) (2026-02-01)
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -272,7 +272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "banner"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "banner"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
edition = "2024"
|
||||
default-run = "banner"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
|
||||
use extension_traits::extension;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{cmp::Ordering, str::FromStr};
|
||||
use ts_rs::TS;
|
||||
|
||||
use super::terms::Term;
|
||||
|
||||
@@ -199,7 +200,8 @@ impl TryFrom<MeetingDays> for Weekday {
|
||||
}
|
||||
|
||||
/// Time range for meetings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[ts(export)]
|
||||
pub struct TimeRange {
|
||||
pub start: NaiveTime,
|
||||
pub end: NaiveTime,
|
||||
|
||||
+31
-47
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Used by both the Discord bot commands and the web API endpoints.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime};
|
||||
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||
|
||||
/// Course metadata needed for calendar generation (shared interface between bot and web).
|
||||
pub struct CalendarCourse {
|
||||
@@ -36,42 +36,25 @@ impl CalendarCourse {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date parsing helpers
|
||||
// Day-of-week conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse an HHMM time string into `NaiveTime`.
|
||||
fn parse_hhmm(s: &str) -> Option<NaiveTime> {
|
||||
if s.len() != 4 {
|
||||
return None;
|
||||
/// Convert a `DayOfWeek` to a chrono `Weekday`.
|
||||
fn to_weekday(day: &DayOfWeek) -> Weekday {
|
||||
match day {
|
||||
DayOfWeek::Monday => Weekday::Mon,
|
||||
DayOfWeek::Tuesday => Weekday::Tue,
|
||||
DayOfWeek::Wednesday => Weekday::Wed,
|
||||
DayOfWeek::Thursday => Weekday::Thu,
|
||||
DayOfWeek::Friday => Weekday::Fri,
|
||||
DayOfWeek::Saturday => Weekday::Sat,
|
||||
DayOfWeek::Sunday => Weekday::Sun,
|
||||
}
|
||||
let hours = s[..2].parse::<u32>().ok()?;
|
||||
let minutes = s[2..].parse::<u32>().ok()?;
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0)
|
||||
}
|
||||
|
||||
/// Active weekdays for a meeting time.
|
||||
fn active_weekdays(mt: &DbMeetingTime) -> Vec<Weekday> {
|
||||
let mapping: [(bool, Weekday); 7] = [
|
||||
(mt.monday, Weekday::Mon),
|
||||
(mt.tuesday, Weekday::Tue),
|
||||
(mt.wednesday, Weekday::Wed),
|
||||
(mt.thursday, Weekday::Thu),
|
||||
(mt.friday, Weekday::Fri),
|
||||
(mt.saturday, Weekday::Sat),
|
||||
(mt.sunday, Weekday::Sun),
|
||||
];
|
||||
mapping
|
||||
.iter()
|
||||
.filter(|(active, _)| *active)
|
||||
.map(|(_, day)| *day)
|
||||
.collect()
|
||||
mt.days.iter().map(to_weekday).collect()
|
||||
}
|
||||
|
||||
/// ICS two-letter day code for RRULE BYDAY.
|
||||
@@ -90,11 +73,16 @@ fn ics_day_code(day: Weekday) -> &'static str {
|
||||
/// Location string from a `DbMeetingTime`.
|
||||
fn location_string(mt: &DbMeetingTime) -> String {
|
||||
let building = mt
|
||||
.building_description
|
||||
.as_deref()
|
||||
.or(mt.building.as_deref())
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.building_description.as_deref())
|
||||
.or_else(|| mt.location.as_ref().and_then(|loc| loc.building.as_deref()))
|
||||
.unwrap_or("");
|
||||
let room = mt
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.room.as_deref())
|
||||
.unwrap_or("");
|
||||
let room = mt.room.as_deref().unwrap_or("");
|
||||
let combined = format!("{building} {room}").trim().to_string();
|
||||
if combined.is_empty() {
|
||||
"Online".to_string()
|
||||
@@ -285,13 +273,11 @@ fn generate_ics_event(
|
||||
mt: &DbMeetingTime,
|
||||
index: usize,
|
||||
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
// DTSTART/DTEND: first occurrence with time, or all-day on start_date
|
||||
let (dtstart, dtend) = match (start_time, end_time) {
|
||||
@@ -396,13 +382,11 @@ pub fn generate_gcal_url(
|
||||
course: &CalendarCourse,
|
||||
mt: &DbMeetingTime,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
let dates_text = match (start_time, end_time) {
|
||||
(Some(st), Some(et)) => {
|
||||
|
||||
+108
-17
@@ -1,15 +1,23 @@
|
||||
//! Batch database operations for improved performance.
|
||||
|
||||
use crate::banner::Course;
|
||||
use crate::data::models::{DbMeetingTime, UpsertCounts};
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime, UpsertCounts};
|
||||
use crate::data::names::{decode_html_entities, parse_banner_name};
|
||||
use crate::error::Result;
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::PgConnection;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
/// Parse a date string in MM/DD/YYYY format to `NaiveDate`.
|
||||
fn parse_mm_dd_yyyy(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
}
|
||||
|
||||
/// Convert a Banner API course's meeting times to the DB JSONB shape.
|
||||
fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
let meetings: Vec<DbMeetingTime> = course
|
||||
@@ -17,22 +25,105 @@ fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|mf| {
|
||||
let mt = &mf.meeting_time;
|
||||
|
||||
// Build days BTreeSet from boolean flags
|
||||
let mut days = BTreeSet::new();
|
||||
if mt.monday {
|
||||
days.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if mt.tuesday {
|
||||
days.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if mt.wednesday {
|
||||
days.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if mt.thursday {
|
||||
days.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if mt.friday {
|
||||
days.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if mt.saturday {
|
||||
days.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if mt.sunday {
|
||||
days.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
|
||||
// Parse time range from HHMM strings
|
||||
let time_range = match (mt.begin_time.as_deref(), mt.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
begin, end,
|
||||
"failed to parse meeting time range"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse date range from MM/DD/YYYY strings
|
||||
let date_range = match (
|
||||
parse_mm_dd_yyyy(&mt.start_date),
|
||||
parse_mm_dd_yyyy(&mt.end_date),
|
||||
) {
|
||||
(Some(start), Some(end)) => DateRange::new(start, end).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
%err,
|
||||
"invalid date range, swapping start/end"
|
||||
);
|
||||
// Swap so the invariant holds
|
||||
DateRange {
|
||||
start: end,
|
||||
end: start,
|
||||
}
|
||||
}),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
"failed to parse meeting date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build location if any field is present
|
||||
let location = {
|
||||
let loc = MeetingLocation {
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
};
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
DbMeetingTime {
|
||||
begin_time: mt.begin_time.clone(),
|
||||
end_time: mt.end_time.clone(),
|
||||
start_date: mt.start_date.clone(),
|
||||
end_date: mt.end_date.clone(),
|
||||
monday: mt.monday,
|
||||
tuesday: mt.tuesday,
|
||||
wednesday: mt.wednesday,
|
||||
thursday: mt.thursday,
|
||||
friday: mt.friday,
|
||||
saturday: mt.saturday,
|
||||
sunday: mt.sunday,
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: mt.meeting_type.clone(),
|
||||
meeting_schedule_type: mt.meeting_schedule_type.clone(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Structured types for course API responses.
|
||||
//!
|
||||
//! These types replace scattered Option fields and parallel booleans with
|
||||
//! proper type-safe structures.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// An inclusive date range with the invariant that `start <= end`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DateRange {
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
impl DateRange {
|
||||
/// Creates a new `DateRange`, returning an error if `start` is after `end`.
|
||||
pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, String> {
|
||||
if start > end {
|
||||
return Err(format!(
|
||||
"invalid date range: start ({start}) is after end ({end})"
|
||||
));
|
||||
}
|
||||
Ok(Self { start, end })
|
||||
}
|
||||
|
||||
/// Number of days in the range (inclusive of both endpoints).
|
||||
#[allow(dead_code)]
|
||||
pub fn days(&self) -> i64 {
|
||||
(self.end - self.start).num_days() + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical location where a course section meets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MeetingLocation {
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
}
|
||||
|
||||
/// Credit hours for a course section — either a fixed value or a range.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export)]
|
||||
pub enum CreditHours {
|
||||
/// A single fixed credit hour value.
|
||||
Fixed { hours: i32 },
|
||||
/// A range of credit hours with the invariant that `low <= high`.
|
||||
Range { low: i32, high: i32 },
|
||||
}
|
||||
|
||||
impl CreditHours {
|
||||
/// Creates a `CreditHours::Range`, returning an error if `low > high`.
|
||||
#[allow(dead_code)]
|
||||
pub fn range(low: i32, high: i32) -> Result<Self, String> {
|
||||
if low > high {
|
||||
return Err(format!(
|
||||
"invalid credit hour range: low ({low}) is greater than high ({high})"
|
||||
));
|
||||
}
|
||||
Ok(Self::Range { low, high })
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-listed section information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CrossList {
|
||||
pub identifier: String,
|
||||
pub capacity: i32,
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
/// A linked section reference (e.g. lab linked to a lecture).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SectionLink {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
/// Enrollment counts for a course section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Enrollment {
|
||||
pub current: i32,
|
||||
pub max: i32,
|
||||
pub wait_count: i32,
|
||||
pub wait_capacity: i32,
|
||||
}
|
||||
|
||||
impl Enrollment {
|
||||
/// Number of open seats remaining (never negative).
|
||||
#[allow(dead_code)]
|
||||
pub fn open_seats(&self) -> i32 {
|
||||
(self.max - self.current).max(0)
|
||||
}
|
||||
|
||||
/// Whether the section is at or over capacity.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.current >= self.max
|
||||
}
|
||||
|
||||
/// Whether the section has at least one open seat.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_open(&self) -> bool {
|
||||
!self.is_full()
|
||||
}
|
||||
}
|
||||
|
||||
/// RateMyProfessors rating summary for an instructor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct RmpRating {
|
||||
pub avg_rating: f32,
|
||||
pub num_ratings: i32,
|
||||
pub legacy_id: i32,
|
||||
pub is_confident: bool,
|
||||
}
|
||||
+4
-2
@@ -225,7 +225,8 @@ pub async fn get_course_instructors(
|
||||
) -> Result<Vec<CourseInstructorDetail>> {
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
@@ -261,7 +262,8 @@ pub async fn get_instructors_for_courses(
|
||||
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Database models and schema.
|
||||
|
||||
pub mod batch;
|
||||
pub mod course_types;
|
||||
pub mod courses;
|
||||
pub mod models;
|
||||
pub mod names;
|
||||
|
||||
+225
-17
@@ -1,10 +1,15 @@
|
||||
//! `sqlx` models for the database schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
|
||||
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
|
||||
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&value.to_string())
|
||||
@@ -41,29 +46,230 @@ fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
|
||||
deserializer.deserialize_any(I64OrStringVisitor)
|
||||
}
|
||||
|
||||
/// Day of the week.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum DayOfWeek {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
/// Represents a meeting time stored as JSONB in the courses table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DbMeetingTime {
|
||||
pub begin_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub monday: bool,
|
||||
pub tuesday: bool,
|
||||
pub wednesday: bool,
|
||||
pub thursday: bool,
|
||||
pub friday: bool,
|
||||
pub saturday: bool,
|
||||
pub sunday: bool,
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
/// Time range for the meeting; `None` means TBA.
|
||||
pub time_range: Option<TimeRange>,
|
||||
/// Date range over which the meeting recurs.
|
||||
pub date_range: DateRange,
|
||||
/// Active days of the week. Empty means days are TBA.
|
||||
pub days: BTreeSet<DayOfWeek>,
|
||||
/// Physical location; `None` when all location fields are absent.
|
||||
pub location: Option<MeetingLocation>,
|
||||
pub meeting_type: String,
|
||||
pub meeting_schedule_type: String,
|
||||
}
|
||||
|
||||
impl DbMeetingTime {
|
||||
/// Whether no days of the week are set (i.e. days are TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_days_tba(&self) -> bool {
|
||||
self.days.is_empty()
|
||||
}
|
||||
|
||||
/// Whether no time range is set (i.e. time is TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_time_tba(&self) -> bool {
|
||||
self.time_range.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a date string to ISO-8601 (YYYY-MM-DD).
|
||||
///
|
||||
/// Accepts MM/DD/YYYY (from Banner API) and returns YYYY-MM-DD.
|
||||
/// Already-normalized dates are returned as-is.
|
||||
#[allow(dead_code)]
|
||||
fn normalize_date(s: &str) -> String {
|
||||
if let Some((month_day, year)) = s.rsplit_once('/')
|
||||
&& let Some((month, day)) = month_day.split_once('/')
|
||||
{
|
||||
return format!("{year}-{month:0>2}-{day:0>2}");
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
/// Parse a date string that may be in MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_flexible_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DbMeetingTime {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
/// Intermediate representation that accepts both old and new JSON formats.
|
||||
#[derive(Deserialize)]
|
||||
struct Raw {
|
||||
// New-format fields (camelCase in JSON)
|
||||
#[serde(rename = "timeRange")]
|
||||
time_range: Option<TimeRange>,
|
||||
#[serde(rename = "dateRange")]
|
||||
date_range: Option<DateRange>,
|
||||
days: Option<BTreeSet<DayOfWeek>>,
|
||||
location: Option<MeetingLocation>,
|
||||
|
||||
// Old-format fields (snake_case in JSON)
|
||||
begin_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
#[serde(default)]
|
||||
monday: bool,
|
||||
#[serde(default)]
|
||||
tuesday: bool,
|
||||
#[serde(default)]
|
||||
wednesday: bool,
|
||||
#[serde(default)]
|
||||
thursday: bool,
|
||||
#[serde(default)]
|
||||
friday: bool,
|
||||
#[serde(default)]
|
||||
saturday: bool,
|
||||
#[serde(default)]
|
||||
sunday: bool,
|
||||
building: Option<String>,
|
||||
building_description: Option<String>,
|
||||
room: Option<String>,
|
||||
campus: Option<String>,
|
||||
|
||||
// Always present (camelCase in new format, snake_case in old format)
|
||||
#[serde(rename = "meetingType", alias = "meeting_type")]
|
||||
meeting_type: String,
|
||||
#[serde(rename = "meetingScheduleType", alias = "meeting_schedule_type")]
|
||||
meeting_schedule_type: String,
|
||||
|
||||
// Legacy computed fields (ignored on read)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_days_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_time_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
active_days: Vec<DayOfWeek>,
|
||||
}
|
||||
|
||||
let raw = Raw::deserialize(deserializer)?;
|
||||
|
||||
// Resolve time_range: prefer new field, fall back to old begin_time/end_time
|
||||
let time_range =
|
||||
raw.time_range.or_else(
|
||||
|| match (raw.begin_time.as_deref(), raw.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(begin, end, "failed to parse old-format time range");
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve date_range: prefer new field, fall back to old start_date/end_date
|
||||
let date_range = if let Some(dr) = raw.date_range {
|
||||
dr
|
||||
} else {
|
||||
let start_str = raw.start_date.as_deref().unwrap_or("");
|
||||
let end_str = raw.end_date.as_deref().unwrap_or("");
|
||||
let start = parse_flexible_date(start_str);
|
||||
let end = parse_flexible_date(end_str);
|
||||
match (start, end) {
|
||||
(Some(s), Some(e)) => DateRange { start: s, end: e },
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
start_date = start_str,
|
||||
end_date = end_str,
|
||||
"failed to parse old-format date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve days: prefer new field, fall back to old boolean flags
|
||||
let days = raw.days.unwrap_or_else(|| {
|
||||
let mut set = BTreeSet::new();
|
||||
if raw.monday {
|
||||
set.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if raw.tuesday {
|
||||
set.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if raw.wednesday {
|
||||
set.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if raw.thursday {
|
||||
set.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if raw.friday {
|
||||
set.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if raw.saturday {
|
||||
set.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if raw.sunday {
|
||||
set.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
set
|
||||
});
|
||||
|
||||
// Resolve location: prefer new field, fall back to old building/room/campus fields
|
||||
let location = raw.location.or_else(|| {
|
||||
let loc = MeetingLocation {
|
||||
building: raw.building,
|
||||
building_description: raw.building_description,
|
||||
room: raw.room,
|
||||
campus: raw.campus,
|
||||
};
|
||||
// Only produce Some if at least one field is present
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DbMeetingTime {
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: raw.meeting_type,
|
||||
meeting_schedule_type: raw.meeting_schedule_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct Course {
|
||||
@@ -122,6 +328,8 @@ pub struct CourseInstructorDetail {
|
||||
pub instructor_id: i32,
|
||||
pub banner_id: String,
|
||||
pub display_name: String,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub email: String,
|
||||
pub is_primary: bool,
|
||||
pub avg_rating: Option<f32>,
|
||||
|
||||
+1
-10
@@ -112,16 +112,7 @@ pub async fn course_gcal(
|
||||
// Prefer the first meeting time that has actual days/times scheduled
|
||||
let mt = meeting_times
|
||||
.iter()
|
||||
.find(|mt| {
|
||||
mt.begin_time.is_some()
|
||||
&& (mt.monday
|
||||
|| mt.tuesday
|
||||
|| mt.wednesday
|
||||
|| mt.thursday
|
||||
|| mt.friday
|
||||
|| mt.saturday
|
||||
|| mt.sunday)
|
||||
})
|
||||
.find(|mt| mt.time_range.is_some() && !mt.days.is_empty())
|
||||
.unwrap_or(&meeting_times[0]);
|
||||
|
||||
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Delivery mode classification for course sections.
|
||||
//!
|
||||
//! Moves the delivery concern logic (previously in the TypeScript frontend)
|
||||
//! to the backend so it ships as part of the API response.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Banner instructional method codes for fully-online delivery.
|
||||
const ONLINE_METHODS: &[&str] = &["OA", "OS", "OH"];
|
||||
|
||||
/// Banner instructional method codes for hybrid delivery.
|
||||
const HYBRID_METHODS: &[&str] = &["HB", "H1", "H2"];
|
||||
|
||||
/// Banner campus code for the main (San Antonio) campus.
|
||||
const MAIN_CAMPUS: &str = "11";
|
||||
|
||||
/// Banner campus codes that represent online/virtual campuses.
|
||||
const ONLINE_CAMPUSES: &[&str] = &["9", "ONL"];
|
||||
|
||||
/// Delivery mode classification for visual accents on location cells.
|
||||
///
|
||||
/// `None` means normal in-person on the main campus (no accent needed).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[ts(export)]
|
||||
pub enum DeliveryMode {
|
||||
/// Fully online with no physical location (OA, OS, OH without INT building).
|
||||
Online,
|
||||
/// Internet campus with INT building code.
|
||||
Internet,
|
||||
/// Mix of online and in-person (HB, H1, H2).
|
||||
Hybrid,
|
||||
/// In-person but not on Main Campus.
|
||||
OffCampus,
|
||||
}
|
||||
|
||||
/// Classify the delivery mode for a course section.
|
||||
///
|
||||
/// Returns `None` for normal in-person sections on the main campus.
|
||||
pub fn classify_delivery_mode(
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
meeting_times: &[DbMeetingTime],
|
||||
) -> Option<DeliveryMode> {
|
||||
if let Some(method) = instructional_method {
|
||||
if ONLINE_METHODS.contains(&method) {
|
||||
let has_int_building = meeting_times.iter().any(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
});
|
||||
return Some(if has_int_building {
|
||||
DeliveryMode::Internet
|
||||
} else {
|
||||
DeliveryMode::Online
|
||||
});
|
||||
}
|
||||
if HYBRID_METHODS.contains(&method) {
|
||||
return Some(DeliveryMode::Hybrid);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(campus) = campus
|
||||
&& campus != MAIN_CAMPUS
|
||||
&& !ONLINE_CAMPUSES.contains(&campus)
|
||||
{
|
||||
return Some(DeliveryMode::OffCampus);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn make_mt(building: Option<&str>) -> DbMeetingTime {
|
||||
DbMeetingTime {
|
||||
time_range: None,
|
||||
date_range: DateRange {
|
||||
start: NaiveDate::from_ymd_opt(2024, 8, 26).unwrap(),
|
||||
end: NaiveDate::from_ymd_opt(2024, 12, 12).unwrap(),
|
||||
},
|
||||
days: BTreeSet::new(),
|
||||
location: building.map(|b| MeetingLocation {
|
||||
building: Some(b.to_string()),
|
||||
building_description: None,
|
||||
room: None,
|
||||
campus: None,
|
||||
}),
|
||||
meeting_type: "CLAS".to_string(),
|
||||
meeting_schedule_type: "LEC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_without_int_building() {
|
||||
for method in &["OA", "OS", "OH"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("9"), &[make_mt(None)]),
|
||||
Some(DeliveryMode::Online),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_with_int_building() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some("OA"), Some("9"), &[make_mt(Some("INT"))]),
|
||||
Some(DeliveryMode::Internet),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_methods() {
|
||||
for method in &["HB", "H1", "H2"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("11"), &[]),
|
||||
Some(DeliveryMode::Hybrid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_campus() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(None, Some("22"), &[]),
|
||||
Some(DeliveryMode::OffCampus),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_campus_in_person() {
|
||||
assert_eq!(classify_delivery_mode(None, Some("11"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_campus_no_method_is_normal() {
|
||||
// Campus 9 or ONL without an online method → None (no accent)
|
||||
assert_eq!(classify_delivery_mode(None, Some("9"), &[]), None);
|
||||
assert_eq!(classify_delivery_mode(None, Some("ONL"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_method_no_campus() {
|
||||
assert_eq!(classify_delivery_mode(None, None, &[]), None);
|
||||
}
|
||||
}
|
||||
+37
-19
@@ -6,13 +6,28 @@ use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Machine-readable error code for API responses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[ts(export)]
|
||||
pub enum ApiErrorCode {
|
||||
NotFound,
|
||||
BadRequest,
|
||||
InternalError,
|
||||
InvalidTerm,
|
||||
InvalidRange,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NoTerms,
|
||||
}
|
||||
|
||||
/// Standardized error response for all API endpoints.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ApiError {
|
||||
/// Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM")
|
||||
pub code: String,
|
||||
/// Machine-readable error code
|
||||
pub code: ApiErrorCode,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional additional details (validation errors, field info, etc.)
|
||||
@@ -21,9 +36,9 @@ pub struct ApiError {
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
code,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
@@ -36,28 +51,31 @@ impl ApiError {
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new("NOT_FOUND", message)
|
||||
Self::new(ApiErrorCode::NotFound, message)
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new("BAD_REQUEST", message)
|
||||
Self::new(ApiErrorCode::BadRequest, message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new("INTERNAL_ERROR", message)
|
||||
Self::new(ApiErrorCode::InternalError, message)
|
||||
}
|
||||
|
||||
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
|
||||
Self::new("INVALID_TERM", format!("Invalid term: {}", term))
|
||||
Self::new(ApiErrorCode::InvalidTerm, format!("Invalid term: {}", term))
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.code.as_str() {
|
||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"BAD_REQUEST" | "INVALID_TERM" | "INVALID_RANGE" => StatusCode::BAD_REQUEST,
|
||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
match self.code {
|
||||
ApiErrorCode::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiErrorCode::BadRequest
|
||||
| ApiErrorCode::InvalidTerm
|
||||
| ApiErrorCode::InvalidRange
|
||||
| ApiErrorCode::NoTerms => StatusCode::BAD_REQUEST,
|
||||
ApiErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
ApiErrorCode::Forbidden => StatusCode::FORBIDDEN,
|
||||
ApiErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,11 +91,11 @@ impl IntoResponse for ApiError {
|
||||
impl From<(StatusCode, String)> for ApiError {
|
||||
fn from((status, message): (StatusCode, String)) -> Self {
|
||||
let code = match status {
|
||||
StatusCode::NOT_FOUND => "NOT_FOUND",
|
||||
StatusCode::BAD_REQUEST => "BAD_REQUEST",
|
||||
StatusCode::UNAUTHORIZED => "UNAUTHORIZED",
|
||||
StatusCode::FORBIDDEN => "FORBIDDEN",
|
||||
_ => "INTERNAL_ERROR",
|
||||
StatusCode::NOT_FOUND => ApiErrorCode::NotFound,
|
||||
StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest,
|
||||
StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized,
|
||||
StatusCode::FORBIDDEN => ApiErrorCode::Forbidden,
|
||||
_ => ApiErrorCode::InternalError,
|
||||
};
|
||||
Self::new(code, message)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod admin_scraper;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod delivery;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod encoding;
|
||||
pub mod error;
|
||||
|
||||
+142
-52
@@ -8,10 +8,12 @@ use axum::{
|
||||
routing::{get, post, put},
|
||||
};
|
||||
|
||||
use crate::data::course_types::{CreditHours, CrossList, Enrollment, RmpRating, SectionLink};
|
||||
use crate::web::admin_scraper;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::calendar;
|
||||
use crate::web::error::{ApiError, db_error};
|
||||
use crate::web::delivery::{DeliveryMode, classify_delivery_mode};
|
||||
use crate::web::error::{ApiError, ApiErrorCode, db_error};
|
||||
use crate::web::timeline;
|
||||
use crate::web::ws;
|
||||
use crate::{data, web::admin};
|
||||
@@ -303,7 +305,7 @@ async fn metrics(
|
||||
"30d" => chrono::Duration::days(30),
|
||||
_ => {
|
||||
return Err(ApiError::new(
|
||||
"INVALID_RANGE",
|
||||
ApiErrorCode::InvalidRange,
|
||||
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
|
||||
));
|
||||
}
|
||||
@@ -488,21 +490,20 @@ pub struct CourseResponse {
|
||||
sequence_number: Option<String>,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
enrollment: i32,
|
||||
max_enrollment: i32,
|
||||
wait_count: i32,
|
||||
wait_capacity: i32,
|
||||
credit_hours: Option<i32>,
|
||||
credit_hour_low: Option<i32>,
|
||||
credit_hour_high: Option<i32>,
|
||||
cross_list: Option<String>,
|
||||
cross_list_capacity: Option<i32>,
|
||||
cross_list_count: Option<i32>,
|
||||
link_identifier: Option<String>,
|
||||
is_section_linked: Option<bool>,
|
||||
enrollment: Enrollment,
|
||||
credit_hours: Option<CreditHours>,
|
||||
cross_list: Option<CrossList>,
|
||||
section_link: Option<SectionLink>,
|
||||
part_of_term: Option<String>,
|
||||
meeting_times: Vec<models::DbMeetingTime>,
|
||||
attributes: Vec<String>,
|
||||
is_async_online: bool,
|
||||
delivery_mode: Option<DeliveryMode>,
|
||||
/// Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
primary_location: Option<String>,
|
||||
/// Whether a physical (non-INT) building was found in meeting times.
|
||||
has_physical_location: bool,
|
||||
primary_instructor_id: Option<i32>,
|
||||
instructors: Vec<InstructorResponse>,
|
||||
}
|
||||
|
||||
@@ -513,11 +514,11 @@ pub struct InstructorResponse {
|
||||
instructor_id: i32,
|
||||
banner_id: String,
|
||||
display_name: String,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
email: String,
|
||||
is_primary: bool,
|
||||
rmp_rating: Option<f32>,
|
||||
rmp_num_ratings: Option<i32>,
|
||||
rmp_legacy_id: Option<i32>,
|
||||
rmp: Option<RmpRating>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -571,37 +572,66 @@ pub struct SearchOptionsParams {
|
||||
pub term: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimum number of ratings needed to consider RMP data reliable.
|
||||
const RMP_CONFIDENCE_THRESHOLD: i32 = 7;
|
||||
|
||||
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
|
||||
fn build_course_response(
|
||||
course: &models::Course,
|
||||
instructors: Vec<models::CourseInstructorDetail>,
|
||||
) -> CourseResponse {
|
||||
let instructors = instructors
|
||||
let instructors: Vec<InstructorResponse> = instructors
|
||||
.into_iter()
|
||||
.map(|i| InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
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,
|
||||
rmp_legacy_id: i.rmp_legacy_id,
|
||||
.map(|i| {
|
||||
// Filter out the (0.0, 0) sentinel — treat as unrated
|
||||
let has_rating =
|
||||
i.avg_rating.is_some_and(|r| r != 0.0) || i.num_ratings.is_some_and(|n| n != 0);
|
||||
let rmp = if has_rating {
|
||||
match (i.avg_rating, i.num_ratings, i.rmp_legacy_id) {
|
||||
(Some(avg_rating), Some(num_ratings), Some(legacy_id)) => Some(RmpRating {
|
||||
avg_rating,
|
||||
num_ratings,
|
||||
legacy_id,
|
||||
is_confident: num_ratings >= RMP_CONFIDENCE_THRESHOLD,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
first_name: i.first_name,
|
||||
last_name: i.last_name,
|
||||
email: i.email,
|
||||
is_primary: i.is_primary,
|
||||
rmp,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let meeting_times = serde_json::from_value(course.meeting_times.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize meeting_times JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Primary = first with is_primary flag, or fall back to first instructor
|
||||
let primary_instructor_id = instructors
|
||||
.iter()
|
||||
.find(|i| i.is_primary)
|
||||
.or(instructors.first())
|
||||
.map(|i| i.instructor_id);
|
||||
|
||||
let meeting_times: Vec<models::DbMeetingTime> =
|
||||
serde_json::from_value(course.meeting_times.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize meeting_times JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let attributes = serde_json::from_value(course.attributes.clone())
|
||||
.map_err(|e| {
|
||||
@@ -616,6 +646,69 @@ fn build_course_response(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_async_online = meeting_times.first().is_some_and(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
&& mt.is_time_tba()
|
||||
});
|
||||
|
||||
let delivery_mode = classify_delivery_mode(
|
||||
course.instructional_method.as_deref(),
|
||||
course.campus.as_deref(),
|
||||
&meeting_times,
|
||||
);
|
||||
|
||||
// Compute primary_location: first non-INT building+room, else "Online" or campus fallback
|
||||
let physical_location = meeting_times
|
||||
.iter()
|
||||
.filter(|mt| mt.location.as_ref().and_then(|loc| loc.building.as_deref()) != Some("INT"))
|
||||
.find_map(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| {
|
||||
loc.building.as_ref().map(|b| match &loc.room {
|
||||
Some(r) => format!("{b} {r}"),
|
||||
None => b.clone(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let has_physical_location = physical_location.is_some();
|
||||
|
||||
let primary_location = physical_location.or_else(|| match delivery_mode {
|
||||
Some(DeliveryMode::Online | DeliveryMode::Internet) => Some("Online".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let enrollment = Enrollment {
|
||||
current: course.enrollment,
|
||||
max: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
};
|
||||
|
||||
let credit_hours = match (
|
||||
course.credit_hours,
|
||||
course.credit_hour_low,
|
||||
course.credit_hour_high,
|
||||
) {
|
||||
(Some(fixed), _, _) => Some(CreditHours::Fixed { hours: fixed }),
|
||||
(None, Some(low), Some(high)) if low != high => Some(CreditHours::Range { low, high }),
|
||||
(None, Some(hours), None) | (None, None, Some(hours)) => Some(CreditHours::Fixed { hours }),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let cross_list = course.cross_list.as_ref().and_then(|identifier| {
|
||||
course.cross_list_capacity.and_then(|capacity| {
|
||||
course.cross_list_count.map(|count| CrossList {
|
||||
identifier: identifier.clone(),
|
||||
capacity,
|
||||
count,
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let section_link = course
|
||||
.link_identifier
|
||||
.clone()
|
||||
.map(|identifier| SectionLink { identifier });
|
||||
|
||||
CourseResponse {
|
||||
crn: course.crn.clone(),
|
||||
subject: course.subject.clone(),
|
||||
@@ -625,19 +718,16 @@ fn build_course_response(
|
||||
sequence_number: course.sequence_number.clone(),
|
||||
instructional_method: course.instructional_method.clone(),
|
||||
campus: course.campus.clone(),
|
||||
enrollment: course.enrollment,
|
||||
max_enrollment: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
credit_hours: course.credit_hours,
|
||||
credit_hour_low: course.credit_hour_low,
|
||||
credit_hour_high: course.credit_hour_high,
|
||||
cross_list: course.cross_list.clone(),
|
||||
cross_list_capacity: course.cross_list_capacity,
|
||||
cross_list_count: course.cross_list_count,
|
||||
link_identifier: course.link_identifier.clone(),
|
||||
is_section_linked: course.is_section_linked,
|
||||
enrollment,
|
||||
credit_hours,
|
||||
cross_list,
|
||||
section_link,
|
||||
part_of_term: course.part_of_term.clone(),
|
||||
is_async_online,
|
||||
delivery_mode,
|
||||
primary_location,
|
||||
has_physical_location,
|
||||
primary_instructor_id,
|
||||
meeting_times,
|
||||
attributes,
|
||||
instructors,
|
||||
@@ -800,7 +890,7 @@ async fn get_search_options(
|
||||
let first_term: Term = term_codes
|
||||
.first()
|
||||
.and_then(|code| code.parse().ok())
|
||||
.ok_or_else(|| ApiError::new("NO_TERMS", "No terms available".to_string()))?;
|
||||
.ok_or_else(|| ApiError::new(ApiErrorCode::NoTerms, "No terms available"))?;
|
||||
|
||||
first_term.slug()
|
||||
};
|
||||
|
||||
@@ -238,9 +238,11 @@ fn parse_hhmm(s: &str) -> Option<u16> {
|
||||
Some(hours * 60 + mins)
|
||||
}
|
||||
|
||||
/// Parse "MM/DD/YYYY" → NaiveDate.
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
// ── Slot matching ───────────────────────────────────────────────────
|
||||
|
||||
@@ -3,26 +3,16 @@ import { formatMeetingTimeSummary } from "$lib/course";
|
||||
import type { CourseResponse, DbMeetingTime } from "$lib/api";
|
||||
|
||||
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
|
||||
return {
|
||||
begin_time: null,
|
||||
end_time: null,
|
||||
start_date: "2025-01-13",
|
||||
end_date: "2025-05-08",
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
building: null,
|
||||
building_description: null,
|
||||
room: null,
|
||||
campus: null,
|
||||
meeting_type: "CLAS",
|
||||
meeting_schedule_type: "LEC",
|
||||
const mt: DbMeetingTime = {
|
||||
timeRange: null,
|
||||
dateRange: { start: "2025-01-13", end: "2025-05-08" },
|
||||
days: [],
|
||||
location: null,
|
||||
meetingType: "CLAS",
|
||||
meetingScheduleType: "LEC",
|
||||
...overrides,
|
||||
};
|
||||
return mt;
|
||||
}
|
||||
|
||||
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
|
||||
@@ -35,19 +25,16 @@ function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
|
||||
sequenceNumber: null,
|
||||
instructionalMethod: null,
|
||||
campus: null,
|
||||
enrollment: 10,
|
||||
maxEnrollment: 30,
|
||||
waitCount: 0,
|
||||
waitCapacity: 0,
|
||||
creditHours: 3,
|
||||
creditHourLow: null,
|
||||
creditHourHigh: null,
|
||||
enrollment: { current: 10, max: 30, waitCount: 0, waitCapacity: 0 },
|
||||
creditHours: { type: "fixed", hours: 3 },
|
||||
crossList: null,
|
||||
crossListCapacity: null,
|
||||
crossListCount: null,
|
||||
linkIdentifier: null,
|
||||
isSectionLinked: null,
|
||||
sectionLink: null,
|
||||
partOfTerm: null,
|
||||
isAsyncOnline: false,
|
||||
deliveryMode: null,
|
||||
primaryLocation: null,
|
||||
hasPhysicalLocation: false,
|
||||
primaryInstructorId: null,
|
||||
meetingTimes: [],
|
||||
attributes: [],
|
||||
instructors: [],
|
||||
@@ -58,7 +45,12 @@ function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
|
||||
describe("formatMeetingTimeSummary", () => {
|
||||
it("returns 'Async' for async online courses", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [makeMeetingTime({ building: "INT" })],
|
||||
isAsyncOnline: true,
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
location: { building: "INT", buildingDescription: null, room: null, campus: null },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("Async");
|
||||
});
|
||||
@@ -79,11 +71,8 @@ describe("formatMeetingTimeSummary", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
begin_time: "0900",
|
||||
end_time: "0950",
|
||||
days: ["monday", "wednesday", "friday"],
|
||||
timeRange: { start: "09:00:00", end: "09:50:00" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -94,8 +83,7 @@ describe("formatMeetingTimeSummary", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
tuesday: true,
|
||||
thursday: true,
|
||||
days: ["tuesday", "thursday"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
+9
-3
@@ -2,12 +2,15 @@ import { authStore } from "$lib/auth.svelte";
|
||||
import type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
@@ -57,12 +60,15 @@ const API_BASE_URL = "/api";
|
||||
export type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
@@ -144,7 +150,7 @@ function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
|
||||
* API error class that wraps the structured ApiError response from the backend.
|
||||
*/
|
||||
export class ApiErrorClass extends Error {
|
||||
public readonly code: string;
|
||||
public readonly code: ApiErrorCode;
|
||||
public readonly details: unknown | null;
|
||||
|
||||
constructor(apiError: ApiError) {
|
||||
@@ -213,7 +219,7 @@ export class BannerApiClient {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
@@ -244,7 +250,7 @@ export class BannerApiClient {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ApiErrorCode } from "./ApiErrorCode";
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
/**
|
||||
@@ -6,9 +7,9 @@ import type { JsonValue } from "./serde_json/JsonValue";
|
||||
*/
|
||||
export type ApiError = {
|
||||
/**
|
||||
* Machine-readable error code (e.g., "NOT_FOUND", "INVALID_TERM")
|
||||
* Machine-readable error code
|
||||
*/
|
||||
code: string,
|
||||
code: ApiErrorCode,
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Machine-readable error code for API responses.
|
||||
*/
|
||||
export type ApiErrorCode = "NOT_FOUND" | "BAD_REQUEST" | "INTERNAL_ERROR" | "INVALID_TERM" | "INVALID_RANGE" | "UNAUTHORIZED" | "FORBIDDEN" | "NO_TERMS";
|
||||
@@ -1,5 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CreditHours } from "./CreditHours";
|
||||
import type { CrossList } from "./CrossList";
|
||||
import type { DbMeetingTime } from "./DbMeetingTime";
|
||||
import type { DeliveryMode } from "./DeliveryMode";
|
||||
import type { Enrollment } from "./Enrollment";
|
||||
import type { InstructorResponse } from "./InstructorResponse";
|
||||
import type { SectionLink } from "./SectionLink";
|
||||
|
||||
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: number, maxEnrollment: number, waitCount: number, waitCapacity: number, creditHours: number | null, creditHourLow: number | null, creditHourHigh: number | null, crossList: string | null, crossListCapacity: number | null, crossListCount: number | null, linkIdentifier: string | null, isSectionLinked: boolean | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, instructors: Array<InstructorResponse>, };
|
||||
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: Enrollment, creditHours: CreditHours | null, crossList: CrossList | null, sectionLink: SectionLink | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, isAsyncOnline: boolean, deliveryMode: DeliveryMode | null,
|
||||
/**
|
||||
* Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
*/
|
||||
primaryLocation: string | null,
|
||||
/**
|
||||
* Whether a physical (non-INT) building was found in meeting times.
|
||||
*/
|
||||
hasPhysicalLocation: boolean, primaryInstructorId: number | null, instructors: Array<InstructorResponse>, };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Credit hours for a course section — either a fixed value or a range.
|
||||
*/
|
||||
export type CreditHours = { "type": "fixed", hours: number, } | { "type": "range", low: number, high: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Cross-listed section information.
|
||||
*/
|
||||
export type CrossList = { identifier: string, capacity: number, count: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* An inclusive date range with the invariant that `start <= end`.
|
||||
*/
|
||||
export type DateRange = { start: string, end: string, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Day of the week.
|
||||
*/
|
||||
export type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
|
||||
@@ -1,6 +1,26 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DateRange } from "./DateRange";
|
||||
import type { DayOfWeek } from "./DayOfWeek";
|
||||
import type { MeetingLocation } from "./MeetingLocation";
|
||||
import type { TimeRange } from "./TimeRange";
|
||||
|
||||
/**
|
||||
* Represents a meeting time stored as JSONB in the courses table.
|
||||
*/
|
||||
export type DbMeetingTime = { begin_time: string | null, end_time: string | null, start_date: string, end_date: string, monday: boolean, tuesday: boolean, wednesday: boolean, thursday: boolean, friday: boolean, saturday: boolean, sunday: boolean, building: string | null, building_description: string | null, room: string | null, campus: string | null, meeting_type: string, meeting_schedule_type: string, };
|
||||
export type DbMeetingTime = {
|
||||
/**
|
||||
* Time range for the meeting; `None` means TBA.
|
||||
*/
|
||||
timeRange: TimeRange | null,
|
||||
/**
|
||||
* Date range over which the meeting recurs.
|
||||
*/
|
||||
dateRange: DateRange,
|
||||
/**
|
||||
* Active days of the week. Empty means days are TBA.
|
||||
*/
|
||||
days: Array<DayOfWeek>,
|
||||
/**
|
||||
* Physical location; `None` when all location fields are absent.
|
||||
*/
|
||||
location: MeetingLocation | null, meetingType: string, meetingScheduleType: string, };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Delivery mode classification for visual accents on location cells.
|
||||
*
|
||||
* `None` means normal in-person on the main campus (no accent needed).
|
||||
*/
|
||||
export type DeliveryMode = "online" | "internet" | "hybrid" | "off-campus";
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Enrollment counts for a course section.
|
||||
*/
|
||||
export type Enrollment = { current: number, max: number, waitCount: number, waitCapacity: number, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RmpRating } from "./RmpRating";
|
||||
|
||||
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, email: string, isPrimary: boolean, rmpRating: number | null, rmpNumRatings: number | null, rmpLegacyId: number | null, };
|
||||
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, firstName: string | null, lastName: string | null, email: string, isPrimary: boolean, rmp: RmpRating | null, };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Physical location where a course section meets.
|
||||
*/
|
||||
export type MeetingLocation = { building: string | null, buildingDescription: string | null, room: string | null, campus: string | null, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* RateMyProfessors rating summary for an instructor.
|
||||
*/
|
||||
export type RmpRating = { avgRating: number, numRatings: number, legacyId: number, isConfident: boolean, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A linked section reference (e.g. lab linked to a lecture).
|
||||
*/
|
||||
export type SectionLink = { identifier: string, };
|
||||
@@ -1,11 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TimeRange = {
|
||||
/**
|
||||
* ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z")
|
||||
* Time range for meetings
|
||||
*/
|
||||
start: string,
|
||||
/**
|
||||
* ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z")
|
||||
*/
|
||||
end: string, };
|
||||
export type TimeRange = { start: string, end: string, };
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
export type { AdminServiceInfo } from "./AdminServiceInfo";
|
||||
export type { AdminStatusResponse } from "./AdminStatusResponse";
|
||||
export type { ApiError } from "./ApiError";
|
||||
export type { ApiErrorCode } from "./ApiErrorCode";
|
||||
export type { AuditLogEntry } from "./AuditLogEntry";
|
||||
export type { AuditLogResponse } from "./AuditLogResponse";
|
||||
export type { CandidateResponse } from "./CandidateResponse";
|
||||
export type { CodeDescription } from "./CodeDescription";
|
||||
export type { CourseResponse } from "./CourseResponse";
|
||||
export type { CreditHours } from "./CreditHours";
|
||||
export type { CrossList } from "./CrossList";
|
||||
export type { DateRange } from "./DateRange";
|
||||
export type { DayOfWeek } from "./DayOfWeek";
|
||||
export type { DbMeetingTime } from "./DbMeetingTime";
|
||||
export type { DeliveryMode } from "./DeliveryMode";
|
||||
export type { Enrollment } from "./Enrollment";
|
||||
export type { FilterRanges } from "./FilterRanges";
|
||||
export type { InstructorDetail } from "./InstructorDetail";
|
||||
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
|
||||
@@ -17,12 +24,14 @@ export type { LinkedRmpProfile } from "./LinkedRmpProfile";
|
||||
export type { ListInstructorsParams } from "./ListInstructorsParams";
|
||||
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
||||
export type { MatchBody } from "./MatchBody";
|
||||
export type { MeetingLocation } from "./MeetingLocation";
|
||||
export type { MetricEntry } from "./MetricEntry";
|
||||
export type { MetricsParams } from "./MetricsParams";
|
||||
export type { MetricsResponse } from "./MetricsResponse";
|
||||
export type { OkResponse } from "./OkResponse";
|
||||
export type { RejectCandidateBody } from "./RejectCandidateBody";
|
||||
export type { RescoreResponse } from "./RescoreResponse";
|
||||
export type { RmpRating } from "./RmpRating";
|
||||
export type { ScrapeJobDto } from "./ScrapeJobDto";
|
||||
export type { ScrapeJobEvent } from "./ScrapeJobEvent";
|
||||
export type { ScrapeJobStatus } from "./ScrapeJobStatus";
|
||||
@@ -32,6 +41,7 @@ export type { SearchOptionsReference } from "./SearchOptionsReference";
|
||||
export type { SearchOptionsResponse } from "./SearchOptionsResponse";
|
||||
export type { SearchParams } from "./SearchParams";
|
||||
export type { SearchResponse } from "./SearchResponse";
|
||||
export type { SectionLink } from "./SectionLink";
|
||||
export type { ServiceInfo } from "./ServiceInfo";
|
||||
export type { ServiceStatus } from "./ServiceStatus";
|
||||
export type { SortColumn } from "./SortColumn";
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
abbreviateInstructor,
|
||||
formatMeetingTimeSummary,
|
||||
getPrimaryInstructor,
|
||||
openSeats,
|
||||
seatsColor,
|
||||
seatsDotColor,
|
||||
} from "$lib/course";
|
||||
@@ -34,24 +33,28 @@ let {
|
||||
>
|
||||
<!-- Line 1: Course code + title + seats -->
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
{#snippet seatsDisplay()}
|
||||
{@const openSeats = course.enrollment.max - course.enrollment.current}
|
||||
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(openSeats)} shrink-0"></span>
|
||||
<span class="{seatsColor(openSeats)} font-medium tabular-nums">
|
||||
{#if openSeats === 0}Full{:else}{openSeats}/{formatNumber(course.enrollment.max)}{/if}
|
||||
</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
<div class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span class="font-mono font-semibold text-sm tracking-tight shrink-0">
|
||||
{course.subject} {course.courseNumber}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground truncate">{course.title}</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 shrink-0 text-xs select-none">
|
||||
<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)}/{formatNumber(course.maxEnrollment)}{/if}
|
||||
</span>
|
||||
</span>
|
||||
{@render seatsDisplay()}
|
||||
</div>
|
||||
|
||||
<!-- Line 2: Instructor + time -->
|
||||
<div class="flex items-center justify-between gap-2 mt-1">
|
||||
<span class="text-xs text-muted-foreground truncate">
|
||||
{abbreviateInstructor(getPrimaryInstructor(course.instructors)?.displayName ?? "Staff")}
|
||||
{abbreviateInstructor(getPrimaryInstructor(course.instructors, course.primaryInstructorId)?.displayName ?? "Staff")}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0">
|
||||
{formatMeetingTimeSummary(course)}
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import {
|
||||
RMP_CONFIDENCE_THRESHOLD,
|
||||
formatCreditHours,
|
||||
formatDate,
|
||||
formatMeetingDaysLong,
|
||||
formatTime,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
ratingStyle,
|
||||
rmpUrl,
|
||||
} from "$lib/course";
|
||||
@@ -45,31 +42,29 @@ const clipboard = useClipboard();
|
||||
<span
|
||||
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 instructor.rmpRating != null}
|
||||
{@const rating = instructor.rmpRating}
|
||||
{@const lowConfidence =
|
||||
(instructor.rmpNumRatings ?? 0) <
|
||||
RMP_CONFIDENCE_THRESHOLD}
|
||||
<span
|
||||
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
|
||||
style={ratingStyle(
|
||||
rating,
|
||||
themeStore.isDark,
|
||||
)}
|
||||
>
|
||||
{rating.toFixed(1)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{instructor.displayName}
|
||||
{#if instructor.rmp != null}
|
||||
{@const rating = instructor.rmp.avgRating}
|
||||
{@const lowConfidence = !instructor.rmp.isConfident}
|
||||
<span
|
||||
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
|
||||
style={ratingStyle(
|
||||
rating,
|
||||
themeStore.isDark,
|
||||
)}
|
||||
>
|
||||
{rating.toFixed(1)}
|
||||
{#if lowConfidence}
|
||||
<Triangle
|
||||
class="size-2 fill-current"
|
||||
/>
|
||||
{:else}
|
||||
<Star
|
||||
class="size-2.5 fill-current"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
@@ -77,33 +72,33 @@ const clipboard = useClipboard();
|
||||
<div class="font-medium">
|
||||
{instructor.displayName}
|
||||
</div>
|
||||
{#if instructor.isPrimary}
|
||||
<div class="text-muted-foreground">
|
||||
Primary instructor
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmpRating != null}
|
||||
<div class="text-muted-foreground">
|
||||
{instructor.rmpRating.toFixed(1)}/5
|
||||
· {instructor.rmpNumRatings ?? 0} ratings
|
||||
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
|
||||
(low)
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmpLegacyId != null}
|
||||
<a
|
||||
href={rmpUrl(
|
||||
instructor.rmpLegacyId,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink class="size-3" />
|
||||
<span>View on RMP</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if instructor.isPrimary}
|
||||
<div class="text-muted-foreground">
|
||||
Primary instructor
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmp != null}
|
||||
<div class="text-muted-foreground">
|
||||
{instructor.rmp.avgRating.toFixed(1)}/5
|
||||
· {instructor.rmp.numRatings} ratings
|
||||
{#if !instructor.rmp.isConfident}
|
||||
(low)
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if instructor.rmp?.legacyId != null}
|
||||
<a
|
||||
href={rmpUrl(
|
||||
instructor.rmp.legacyId,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink class="size-3" />
|
||||
<span>View on RMP</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<button
|
||||
onclick={(e) =>
|
||||
@@ -136,56 +131,56 @@ const clipboard = useClipboard();
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
|
||||
{#if course.meetingTimes.length > 0}
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
{#each course.meetingTimes as mt}
|
||||
<li>
|
||||
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
|
||||
<span class="italic text-muted-foreground"
|
||||
>TBA</span
|
||||
>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
<span
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{formatMeetingDaysLong(mt)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
<span class="text-muted-foreground">
|
||||
{formatTime(
|
||||
mt.begin_time,
|
||||
)}–{formatTime(mt.end_time)}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="italic text-muted-foreground"
|
||||
>Time TBA</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mt.building || mt.room}
|
||||
<div
|
||||
class="text-xs text-muted-foreground mt-0.5"
|
||||
>
|
||||
{mt.building_description ??
|
||||
mt.building}{mt.room
|
||||
? ` ${mt.room}`
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{formatDate(mt.start_date)} – {formatDate(
|
||||
mt.end_date,
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
{#each course.meetingTimes as mt}
|
||||
<li>
|
||||
{#if mt.days.length === 0 && mt.timeRange === null}
|
||||
<span class="italic text-muted-foreground"
|
||||
>TBA</span
|
||||
>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if mt.days.length > 0}
|
||||
<span
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{formatMeetingDaysLong(mt)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if mt.timeRange !== null}
|
||||
<span class="text-muted-foreground">
|
||||
{formatTime(
|
||||
mt.timeRange.start,
|
||||
)}–{formatTime(mt.timeRange.end)}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="italic text-muted-foreground"
|
||||
>Time TBA</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mt.location?.building || mt.location?.room}
|
||||
<div
|
||||
class="text-xs text-muted-foreground mt-0.5"
|
||||
>
|
||||
{mt.location.buildingDescription ??
|
||||
mt.location.building}{mt.location.room
|
||||
? ` ${mt.location.room}`
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{formatDate(mt.dateRange.start)} – {formatDate(
|
||||
mt.dateRange.end,
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span class="italic text-muted-foreground">TBA</span>
|
||||
{/if}
|
||||
@@ -256,6 +251,7 @@ const clipboard = useClipboard();
|
||||
|
||||
<!-- Cross-list -->
|
||||
{#if course.crossList}
|
||||
{@const crossList = course.crossList}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
@@ -277,21 +273,21 @@ const clipboard = useClipboard();
|
||||
<span
|
||||
class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{course.crossList}
|
||||
{crossList.identifier}
|
||||
</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
{#if crossList.count != null && crossList.capacity != null}
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{formatNumber(course.crossListCount)}/{formatNumber(course.crossListCapacity)}
|
||||
{formatNumber(crossList.count)}/{formatNumber(crossList.capacity)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
Group <span class="font-mono font-medium"
|
||||
>{course.crossList}</span
|
||||
>{crossList.identifier}</span
|
||||
>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
— {formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
|
||||
{#if crossList.count != null && crossList.capacity != null}
|
||||
— {formatNumber(crossList.count)} enrolled across {formatNumber(crossList.capacity)}
|
||||
shared seats
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -299,15 +295,15 @@ const clipboard = useClipboard();
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist -->
|
||||
{#if course.waitCapacity > 0}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
|
||||
<span class="text-2foreground"
|
||||
>{formatNumber(course.waitCount)} / {formatNumber(course.waitCapacity)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Waitlist -->
|
||||
{#if course.enrollment.waitCapacity > 0}
|
||||
<div>
|
||||
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
|
||||
<span class="text-2foreground"
|
||||
>{formatNumber(course.enrollment.waitCount)} / {formatNumber(course.enrollment.waitCapacity)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Calendar Export -->
|
||||
{#if course.meetingTimes.length > 0}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
RMP_CONFIDENCE_THRESHOLD,
|
||||
abbreviateInstructor,
|
||||
getPrimaryInstructor,
|
||||
ratingStyle,
|
||||
rmpUrl,
|
||||
} from "$lib/course";
|
||||
import { abbreviateInstructor, getPrimaryInstructor, ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
import { ExternalLink, Star, Triangle } from "@lucide/svelte";
|
||||
@@ -14,15 +8,16 @@ import LazyRichTooltip from "$lib/components/LazyRichTooltip.svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let primary = $derived(getPrimaryInstructor(course.instructors));
|
||||
let primary = $derived(getPrimaryInstructor(course.instructors, course.primaryInstructorId));
|
||||
let display = $derived(primary ? abbreviateInstructor(primary.displayName) : "Staff");
|
||||
let commaIdx = $derived(display.indexOf(", "));
|
||||
let ratingData = $derived(
|
||||
primary?.rmpRating != null
|
||||
primary?.rmp != null
|
||||
? {
|
||||
rating: primary.rmpRating,
|
||||
count: primary.rmpNumRatings ?? 0,
|
||||
legacyId: primary.rmpLegacyId ?? null,
|
||||
rating: primary.rmp.avgRating,
|
||||
count: primary.rmp.numRatings,
|
||||
legacyId: primary.rmp.legacyId,
|
||||
isConfident: primary.rmp.isConfident,
|
||||
}
|
||||
: null
|
||||
);
|
||||
@@ -50,7 +45,7 @@ let ratingData = $derived(
|
||||
</span>
|
||||
{/if}
|
||||
{#if ratingData}
|
||||
{@const lowConfidence = ratingData.count < RMP_CONFIDENCE_THRESHOLD}
|
||||
{@const lowConfidence = !ratingData.isConfident}
|
||||
<LazyRichTooltip side="bottom" sideOffset={6} contentClass="px-2.5 py-1.5">
|
||||
{#snippet children()}
|
||||
<span
|
||||
@@ -69,7 +64,7 @@ let ratingData = $derived(
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
{ratingData.rating.toFixed(1)}/5 · {formatNumber(ratingData.count)}
|
||||
ratings
|
||||
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
|
||||
{#if !ratingData.isConfident}
|
||||
(low)
|
||||
{/if}
|
||||
{#if ratingData.legacyId != null}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
concernAccentColor,
|
||||
formatLocationDisplay,
|
||||
formatLocationTooltip,
|
||||
getDeliveryConcern,
|
||||
} from "$lib/course";
|
||||
import { concernAccentColor, formatLocationTooltip } from "$lib/course";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let concern = $derived(getDeliveryConcern(course));
|
||||
let accentColor = $derived(concernAccentColor(concern));
|
||||
let locTooltip = $derived(formatLocationTooltip(course, concern));
|
||||
let locDisplay = $derived(formatLocationDisplay(course, concern));
|
||||
let accentColor = $derived(concernAccentColor(course.deliveryMode));
|
||||
let locTooltip = $derived(formatLocationTooltip(course));
|
||||
let locDisplay = $derived(course.primaryLocation);
|
||||
</script>
|
||||
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import { openSeats, seatsColor, seatsDotColor } from "$lib/course";
|
||||
import { seatsColor, seatsDotColor } from "$lib/course";
|
||||
import { formatNumber } from "$lib/utils";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let open = $derived(openSeats(course));
|
||||
let open = $derived(course.enrollment.max - course.enrollment.current);
|
||||
let seatsTip = $derived(
|
||||
`${formatNumber(open)} of ${formatNumber(course.maxEnrollment)} seats open, ${formatNumber(course.enrollment)} enrolled${course.waitCount > 0 ? `, ${formatNumber(course.waitCount)} waitlisted` : ""}`
|
||||
`${formatNumber(open)} of ${formatNumber(course.enrollment.max)} seats open, ${formatNumber(course.enrollment.current)} enrolled${course.enrollment.waitCount > 0 ? `, ${formatNumber(course.enrollment.waitCount)} waitlisted` : ""}`
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -18,13 +18,13 @@ let seatsTip = $derived(
|
||||
data-tooltip-side="left"
|
||||
data-tooltip-delay="200"
|
||||
>
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
||||
<span class="{seatsColor(course)} font-medium tabular-nums"
|
||||
<span class="size-1.5 rounded-full {seatsDotColor(open)} shrink-0"></span>
|
||||
<span class="{seatsColor(open)} font-medium tabular-nums"
|
||||
>{#if open === 0}Full{:else}{open} open{/if}</span
|
||||
>
|
||||
<span class="text-muted-foreground/60 tabular-nums"
|
||||
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
|
||||
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
|
||||
>{formatNumber(course.enrollment.current)}/{formatNumber(course.enrollment.max)}{#if course.enrollment.waitCount > 0}
|
||||
· WL {formatNumber(course.enrollment.waitCount)}/{formatNumber(course.enrollment.waitCapacity)}{/if}</span
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { CourseResponse } from "$lib/api";
|
||||
import {
|
||||
formatMeetingDays,
|
||||
formatMeetingTimesTooltip,
|
||||
formatTimeRange,
|
||||
isAsyncOnline,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
import { formatMeetingDays, formatMeetingTimesTooltip, formatTimeRange } from "$lib/course";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
function timeIsTBA(c: CourseResponse): boolean {
|
||||
if (c.meetingTimes.length === 0) return true;
|
||||
const mt = c.meetingTimes[0];
|
||||
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||
return mt.days.length === 0 && mt.timeRange === null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,20 +15,20 @@ function timeIsTBA(c: CourseResponse): boolean {
|
||||
class="py-2 px-2 whitespace-nowrap"
|
||||
data-tooltip={formatMeetingTimesTooltip(course.meetingTimes)}
|
||||
>
|
||||
{#if isAsyncOnline(course)}
|
||||
{#if course.isAsyncOnline}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">Async</span>
|
||||
{:else if timeIsTBA(course)}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">TBA</span>
|
||||
{:else}
|
||||
{@const mt = course.meetingTimes[0]}
|
||||
<span>
|
||||
{#if !isMeetingTimeTBA(mt)}
|
||||
{#if mt.days.length > 0}
|
||||
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
||||
{" "}
|
||||
{/if}
|
||||
{#if !isTimeTBA(mt)}
|
||||
{#if mt.timeRange !== null}
|
||||
<span class="text-muted-foreground"
|
||||
>{formatTimeRange(mt.begin_time, mt.end_time)}</span
|
||||
>{formatTimeRange(mt.timeRange.start, mt.timeRange.end)}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/60 select-none">TBA</span>
|
||||
|
||||
@@ -4,11 +4,9 @@ import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { Component } from "svelte";
|
||||
import {
|
||||
abbreviateInstructor,
|
||||
formatLocationDisplay,
|
||||
formatMeetingDays,
|
||||
formatTimeRange,
|
||||
getPrimaryInstructor,
|
||||
openSeats,
|
||||
} from "$lib/course";
|
||||
|
||||
import CrnCell from "./cells/CrnCell.svelte";
|
||||
@@ -41,7 +39,7 @@ export const COLUMN_DEFS: ColumnDef<CourseResponse, unknown>[] = [
|
||||
{
|
||||
id: "instructor",
|
||||
accessorFn: (row) => {
|
||||
const primary = getPrimaryInstructor(row.instructors);
|
||||
const primary = getPrimaryInstructor(row.instructors, row.primaryInstructorId);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
},
|
||||
@@ -53,20 +51,20 @@ export const COLUMN_DEFS: ColumnDef<CourseResponse, unknown>[] = [
|
||||
accessorFn: (row) => {
|
||||
if (row.meetingTimes.length === 0) return "";
|
||||
const mt = row.meetingTimes[0];
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.timeRange?.start ?? null, mt.timeRange?.end ?? null)}`;
|
||||
},
|
||||
header: "Time",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorFn: (row) => formatLocationDisplay(row) ?? "",
|
||||
accessorFn: (row) => row.primaryLocation ?? "",
|
||||
header: "Location",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "seats",
|
||||
accessorFn: (row) => openSeats(row),
|
||||
accessorFn: (row) => row.enrollment.max - row.enrollment.current,
|
||||
header: "Seats",
|
||||
enableSorting: true,
|
||||
},
|
||||
|
||||
+106
-255
@@ -4,96 +4,72 @@ import {
|
||||
formatCreditHours,
|
||||
formatDate,
|
||||
formatDateShort,
|
||||
formatLocationDisplay,
|
||||
formatInstructorName,
|
||||
formatMeetingDays,
|
||||
formatMeetingDaysLong,
|
||||
formatMeetingDaysVerbose,
|
||||
formatMeetingTime,
|
||||
formatMeetingTimeTooltip,
|
||||
formatMeetingTimesTooltip,
|
||||
formatTime,
|
||||
formatTimeRange,
|
||||
getPrimaryInstructor,
|
||||
isAsyncOnline,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
|
||||
return {
|
||||
begin_time: null,
|
||||
end_time: null,
|
||||
start_date: "2024-08-26",
|
||||
end_date: "2024-12-12",
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
building: null,
|
||||
building_description: null,
|
||||
room: null,
|
||||
campus: null,
|
||||
meeting_type: "CLAS",
|
||||
meeting_schedule_type: "LEC",
|
||||
const mt: DbMeetingTime = {
|
||||
timeRange: null,
|
||||
dateRange: { start: "2024-08-26", end: "2024-12-12" },
|
||||
days: [],
|
||||
location: null,
|
||||
meetingType: "CLAS",
|
||||
meetingScheduleType: "LEC",
|
||||
...overrides,
|
||||
};
|
||||
return mt;
|
||||
}
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("converts 0900 to 9:00 AM", () => expect(formatTime("0900")).toBe("9:00 AM"));
|
||||
it("converts 1330 to 1:30 PM", () => expect(formatTime("1330")).toBe("1:30 PM"));
|
||||
it("converts 0000 to 12:00 AM", () => expect(formatTime("0000")).toBe("12:00 AM"));
|
||||
it("converts 1200 to 12:00 PM", () => expect(formatTime("1200")).toBe("12:00 PM"));
|
||||
it("converts 2359 to 11:59 PM", () => expect(formatTime("2359")).toBe("11:59 PM"));
|
||||
it("converts 09:00:00 to 9:00 AM", () => expect(formatTime("09:00:00")).toBe("9:00 AM"));
|
||||
it("converts 13:30:00 to 1:30 PM", () => expect(formatTime("13:30:00")).toBe("1:30 PM"));
|
||||
it("converts 00:00:00 to 12:00 AM", () => expect(formatTime("00:00:00")).toBe("12:00 AM"));
|
||||
it("converts 12:00:00 to 12:00 PM", () => expect(formatTime("12:00:00")).toBe("12:00 PM"));
|
||||
it("converts 23:59:00 to 11:59 PM", () => expect(formatTime("23:59:00")).toBe("11:59 PM"));
|
||||
it("returns TBA for null", () => expect(formatTime(null)).toBe("TBA"));
|
||||
it("returns TBA for empty string", () => expect(formatTime("")).toBe("TBA"));
|
||||
it("returns TBA for short string", () => expect(formatTime("09")).toBe("TBA"));
|
||||
});
|
||||
|
||||
describe("formatMeetingDays", () => {
|
||||
it("returns MWF for mon/wed/fri", () => {
|
||||
expect(
|
||||
formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
|
||||
).toBe("MWF");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["monday", "wednesday", "friday"] }))).toBe(
|
||||
"MWF"
|
||||
);
|
||||
});
|
||||
it("returns TTh for tue/thu", () => {
|
||||
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TTh");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["tuesday", "thursday"] }))).toBe("TTh");
|
||||
});
|
||||
it("returns MW for mon/wed", () => {
|
||||
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true }))).toBe("MW");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["monday", "wednesday"] }))).toBe("MW");
|
||||
});
|
||||
it("returns MTWThF for all weekdays", () => {
|
||||
expect(
|
||||
formatMeetingDays(
|
||||
makeMeetingTime({
|
||||
monday: true,
|
||||
tuesday: true,
|
||||
wednesday: true,
|
||||
thursday: true,
|
||||
friday: true,
|
||||
})
|
||||
makeMeetingTime({ days: ["monday", "tuesday", "wednesday", "thursday", "friday"] })
|
||||
)
|
||||
).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");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["monday"] }))).toBe("Mon");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["thursday"] }))).toBe("Thu");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["saturday"] }))).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({ days: ["monday", "friday"] }))).toBe("MF");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["tuesday", "saturday"] }))).toBe("TSa");
|
||||
expect(formatMeetingDays(makeMeetingTime({ days: ["wednesday", "friday", "sunday"] }))).toBe(
|
||||
"WFSu"
|
||||
);
|
||||
expect(
|
||||
formatMeetingDays(makeMeetingTime({ wednesday: true, friday: true, sunday: true }))
|
||||
).toBe("WFSu");
|
||||
expect(
|
||||
formatMeetingDays(
|
||||
makeMeetingTime({ monday: true, tuesday: true, wednesday: true, thursday: true })
|
||||
)
|
||||
formatMeetingDays(makeMeetingTime({ days: ["monday", "tuesday", "wednesday", "thursday"] }))
|
||||
).toBe("MTWTh");
|
||||
});
|
||||
it("returns empty string when no days", () => {
|
||||
@@ -103,59 +79,23 @@ describe("formatMeetingDays", () => {
|
||||
|
||||
describe("formatTimeRange", () => {
|
||||
it("elides AM when both times are AM", () => {
|
||||
expect(formatTimeRange("0900", "0950")).toBe("9:00–9:50 AM");
|
||||
expect(formatTimeRange("09:00:00", "09:50:00")).toBe("9:00–9:50 AM");
|
||||
});
|
||||
it("elides PM when both times are PM", () => {
|
||||
expect(formatTimeRange("1315", "1430")).toBe("1:15–2:30 PM");
|
||||
expect(formatTimeRange("13:15:00", "14:30:00")).toBe("1:15–2:30 PM");
|
||||
});
|
||||
it("keeps both markers when crossing noon", () => {
|
||||
expect(formatTimeRange("1130", "1220")).toBe("11:30 AM–12:20 PM");
|
||||
expect(formatTimeRange("11:30:00", "12:20:00")).toBe("11:30 AM–12:20 PM");
|
||||
});
|
||||
it("returns TBA for null begin", () => {
|
||||
expect(formatTimeRange(null, "0950")).toBe("TBA");
|
||||
expect(formatTimeRange(null, "09:50:00")).toBe("TBA");
|
||||
});
|
||||
it("returns TBA for null end", () => {
|
||||
expect(formatTimeRange("0900", null)).toBe("TBA");
|
||||
expect(formatTimeRange("09:00:00", null)).toBe("TBA");
|
||||
});
|
||||
it("handles midnight and noon", () => {
|
||||
expect(formatTimeRange("0000", "0050")).toBe("12:00–12:50 AM");
|
||||
expect(formatTimeRange("1200", "1250")).toBe("12:00–12:50 PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMeetingTime", () => {
|
||||
it("formats a standard meeting time with elided AM/PM", () => {
|
||||
expect(
|
||||
formatMeetingTime(
|
||||
makeMeetingTime({
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
begin_time: "0900",
|
||||
end_time: "0950",
|
||||
})
|
||||
)
|
||||
).toBe("MWF 9:00–9: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 AM–12:20 PM");
|
||||
});
|
||||
it("returns TBA when no days", () => {
|
||||
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe(
|
||||
"TBA"
|
||||
);
|
||||
});
|
||||
it("returns days + TBA when no times", () => {
|
||||
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("Mon TBA");
|
||||
expect(formatTimeRange("00:00:00", "00:50:00")).toBe("12:00–12:50 AM");
|
||||
expect(formatTimeRange("12:00:00", "12:50:00")).toBe("12:00–12:50 PM");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,21 +132,21 @@ describe("getPrimaryInstructor", () => {
|
||||
instructorId: 1,
|
||||
bannerId: "1",
|
||||
displayName: "A",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
email: "a@utsa.edu",
|
||||
isPrimary: false,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
rmpLegacyId: null,
|
||||
rmp: null,
|
||||
},
|
||||
{
|
||||
instructorId: 2,
|
||||
bannerId: "2",
|
||||
displayName: "B",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
email: "b@utsa.edu",
|
||||
isPrimary: true,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
rmpLegacyId: null,
|
||||
rmp: null,
|
||||
},
|
||||
];
|
||||
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
|
||||
@@ -217,11 +157,11 @@ describe("getPrimaryInstructor", () => {
|
||||
instructorId: 3,
|
||||
bannerId: "1",
|
||||
displayName: "A",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
email: "a@utsa.edu",
|
||||
isPrimary: false,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
rmpLegacyId: null,
|
||||
rmp: null,
|
||||
},
|
||||
];
|
||||
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
|
||||
@@ -235,18 +175,14 @@ describe("formatCreditHours", () => {
|
||||
it("returns creditHours when set", () => {
|
||||
expect(
|
||||
formatCreditHours({
|
||||
creditHours: 3,
|
||||
creditHourLow: null,
|
||||
creditHourHigh: null,
|
||||
creditHours: { type: "fixed", hours: 3 },
|
||||
} as CourseResponse)
|
||||
).toBe("3");
|
||||
});
|
||||
it("returns range when variable", () => {
|
||||
expect(
|
||||
formatCreditHours({
|
||||
creditHours: null,
|
||||
creditHourLow: 1,
|
||||
creditHourHigh: 3,
|
||||
creditHours: { type: "range", low: 1, high: 3 },
|
||||
} as CourseResponse)
|
||||
).toBe("1–3");
|
||||
});
|
||||
@@ -254,40 +190,11 @@ describe("formatCreditHours", () => {
|
||||
expect(
|
||||
formatCreditHours({
|
||||
creditHours: null,
|
||||
creditHourLow: null,
|
||||
creditHourHigh: null,
|
||||
} as CourseResponse)
|
||||
).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMeetingTimeTBA", () => {
|
||||
it("returns true when no days set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime())).toBe(true);
|
||||
});
|
||||
it("returns false when any day is set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime({ monday: true }))).toBe(false);
|
||||
});
|
||||
it("returns false when multiple days set", () => {
|
||||
expect(isMeetingTimeTBA(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTimeTBA", () => {
|
||||
it("returns true when begin_time is null", () => {
|
||||
expect(isTimeTBA(makeMeetingTime())).toBe(true);
|
||||
});
|
||||
it("returns true when begin_time is empty", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "" }))).toBe(true);
|
||||
});
|
||||
it("returns true when begin_time is short", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "09" }))).toBe(true);
|
||||
});
|
||||
it("returns false when begin_time is valid", () => {
|
||||
expect(isTimeTBA(makeMeetingTime({ begin_time: "0900" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats standard date", () => {
|
||||
expect(formatDate("2024-08-26")).toBe("August 26, 2024");
|
||||
@@ -298,12 +205,6 @@ describe("formatDate", () => {
|
||||
it("formats January 1st", () => {
|
||||
expect(formatDate("2026-01-01")).toBe("January 1, 2026");
|
||||
});
|
||||
it("formats MM/DD/YYYY date", () => {
|
||||
expect(formatDate("01/20/2026")).toBe("January 20, 2026");
|
||||
});
|
||||
it("formats MM/DD/YYYY with May", () => {
|
||||
expect(formatDate("05/13/2026")).toBe("May 13, 2026");
|
||||
});
|
||||
it("returns original string for invalid input", () => {
|
||||
expect(formatDate("bad-date")).toBe("bad-date");
|
||||
});
|
||||
@@ -311,18 +212,18 @@ describe("formatDate", () => {
|
||||
|
||||
describe("formatMeetingDaysLong", () => {
|
||||
it("returns full plural for single day", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ thursday: true }))).toBe("Thursdays");
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ days: ["thursday"] }))).toBe("Thursdays");
|
||||
});
|
||||
it("returns full plural for Monday only", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ monday: true }))).toBe("Mondays");
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ days: ["monday"] }))).toBe("Mondays");
|
||||
});
|
||||
it("returns semi-abbreviated for multiple days", () => {
|
||||
expect(
|
||||
formatMeetingDaysLong(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
|
||||
formatMeetingDaysLong(makeMeetingTime({ days: ["monday", "wednesday", "friday"] }))
|
||||
).toBe("Mon, Wed, Fri");
|
||||
});
|
||||
it("returns semi-abbreviated for TR", () => {
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(
|
||||
expect(formatMeetingDaysLong(makeMeetingTime({ days: ["tuesday", "thursday"] }))).toBe(
|
||||
"Tue, Thur"
|
||||
);
|
||||
});
|
||||
@@ -335,8 +236,8 @@ 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("formats December date to short", () => {
|
||||
expect(formatDateShort("2024-12-12")).toBe("Dec 12, 2024");
|
||||
});
|
||||
it("returns original for invalid", () => {
|
||||
expect(formatDateShort("bad")).toBe("bad");
|
||||
@@ -345,16 +246,16 @@ describe("formatDateShort", () => {
|
||||
|
||||
describe("formatMeetingDaysVerbose", () => {
|
||||
it("returns plural for single day", () => {
|
||||
expect(formatMeetingDaysVerbose(makeMeetingTime({ thursday: true }))).toBe("Thursdays");
|
||||
expect(formatMeetingDaysVerbose(makeMeetingTime({ days: ["thursday"] }))).toBe("Thursdays");
|
||||
});
|
||||
it("joins two days with ampersand", () => {
|
||||
expect(formatMeetingDaysVerbose(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(
|
||||
expect(formatMeetingDaysVerbose(makeMeetingTime({ days: ["tuesday", "thursday"] }))).toBe(
|
||||
"Tuesdays & Thursdays"
|
||||
);
|
||||
});
|
||||
it("uses Oxford-style ampersand for 3+ days", () => {
|
||||
expect(
|
||||
formatMeetingDaysVerbose(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
|
||||
formatMeetingDaysVerbose(makeMeetingTime({ days: ["monday", "wednesday", "friday"] }))
|
||||
).toBe("Mondays, Wednesdays & Fridays");
|
||||
});
|
||||
it("returns empty string when no days", () => {
|
||||
@@ -365,12 +266,9 @@ describe("formatMeetingDaysVerbose", () => {
|
||||
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",
|
||||
days: ["tuesday", "thursday"],
|
||||
timeRange: { start: "16:15:00", end: "17:30:00" },
|
||||
location: { building: null, buildingDescription: "Main Hall", room: "2.206", campus: null },
|
||||
});
|
||||
expect(formatMeetingTimeTooltip(mt)).toBe(
|
||||
"Tuesdays & Thursdays, 4:15–5:30 PM\nMain Hall 2.206, Aug 26, 2024 – Dec 12, 2024"
|
||||
@@ -380,7 +278,7 @@ describe("formatMeetingTimeTooltip", () => {
|
||||
expect(formatMeetingTimeTooltip(makeMeetingTime())).toBe("TBA\nAug 26, 2024 – Dec 12, 2024");
|
||||
});
|
||||
it("handles days with TBA times", () => {
|
||||
expect(formatMeetingTimeTooltip(makeMeetingTime({ monday: true }))).toBe(
|
||||
expect(formatMeetingTimeTooltip(makeMeetingTime({ days: ["monday"] }))).toBe(
|
||||
"Mondays, TBA\nAug 26, 2024 – Dec 12, 2024"
|
||||
);
|
||||
});
|
||||
@@ -393,18 +291,13 @@ describe("formatMeetingTimesTooltip", () => {
|
||||
it("joins multiple meetings with blank line", () => {
|
||||
const mts = [
|
||||
makeMeetingTime({
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
begin_time: "0900",
|
||||
end_time: "0950",
|
||||
days: ["monday", "wednesday", "friday"],
|
||||
timeRange: { start: "09:00:00", end: "09:50:00" },
|
||||
}),
|
||||
makeMeetingTime({
|
||||
thursday: true,
|
||||
begin_time: "1300",
|
||||
end_time: "1400",
|
||||
building_description: "Lab",
|
||||
room: "101",
|
||||
days: ["thursday"],
|
||||
timeRange: { start: "13:00:00", end: "14:00:00" },
|
||||
location: { building: null, buildingDescription: "Lab", room: "101", campus: null },
|
||||
}),
|
||||
];
|
||||
const result = formatMeetingTimesTooltip(mts);
|
||||
@@ -414,91 +307,49 @@ describe("formatMeetingTimesTooltip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAsyncOnline", () => {
|
||||
it("returns true for INT building with no times", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "INT",
|
||||
building_description: "Internet Class",
|
||||
begin_time: null,
|
||||
end_time: null,
|
||||
}),
|
||||
],
|
||||
} as CourseResponse;
|
||||
expect(isAsyncOnline(course)).toBe(true);
|
||||
describe("formatInstructorName", () => {
|
||||
it("formats displayName with comma", () => {
|
||||
expect(formatInstructorName("Ramirez, Maria Elena")).toBe("Maria Elena Ramirez");
|
||||
});
|
||||
it("returns false for INT building with meeting times", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "INT",
|
||||
building_description: "Internet Class",
|
||||
tuesday: true,
|
||||
thursday: true,
|
||||
begin_time: "1000",
|
||||
end_time: "1115",
|
||||
}),
|
||||
],
|
||||
} as CourseResponse;
|
||||
expect(isAsyncOnline(course)).toBe(false);
|
||||
it("returns name as-is without comma", () => {
|
||||
expect(formatInstructorName("Staff")).toBe("Staff");
|
||||
});
|
||||
it("returns false for non-INT building", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "MH",
|
||||
building_description: "Main Hall",
|
||||
begin_time: null,
|
||||
end_time: null,
|
||||
}),
|
||||
],
|
||||
} as CourseResponse;
|
||||
expect(isAsyncOnline(course)).toBe(false);
|
||||
it("trims whitespace", () => {
|
||||
expect(formatInstructorName(" Smith , John ")).toBe("John Smith");
|
||||
});
|
||||
it("returns false for empty meeting times", () => {
|
||||
const course = { meetingTimes: [] } as unknown as CourseResponse;
|
||||
expect(isAsyncOnline(course)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocationDisplay", () => {
|
||||
it("returns 'Online' for INT building", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "INT",
|
||||
building_description: "Internet Class",
|
||||
}),
|
||||
],
|
||||
campus: "9",
|
||||
} as CourseResponse;
|
||||
expect(formatLocationDisplay(course)).toBe("Online");
|
||||
});
|
||||
it("returns building and room for physical location", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "MH",
|
||||
building_description: "Main Hall",
|
||||
room: "2.206",
|
||||
}),
|
||||
],
|
||||
campus: "11",
|
||||
} as CourseResponse;
|
||||
expect(formatLocationDisplay(course)).toBe("MH 2.206");
|
||||
});
|
||||
it("returns building only when no room", () => {
|
||||
const course = {
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
building: "MH",
|
||||
building_description: "Main Hall",
|
||||
room: null,
|
||||
}),
|
||||
],
|
||||
campus: "11",
|
||||
} as CourseResponse;
|
||||
expect(formatLocationDisplay(course)).toBe("MH");
|
||||
it("handles last-name-only after comma", () => {
|
||||
expect(formatInstructorName("Solo,")).toBe("Solo");
|
||||
});
|
||||
it("uses firstName/lastName from InstructorResponse when available", () => {
|
||||
expect(
|
||||
formatInstructorName({
|
||||
displayName: "Ramirez, Maria Elena",
|
||||
firstName: "Maria",
|
||||
lastName: "Ramirez",
|
||||
})
|
||||
).toBe("Maria Ramirez");
|
||||
});
|
||||
it("falls back to displayName when firstName is null", () => {
|
||||
expect(
|
||||
formatInstructorName({
|
||||
displayName: "Ramirez, Maria Elena",
|
||||
firstName: null,
|
||||
lastName: "Ramirez",
|
||||
})
|
||||
).toBe("Maria Elena Ramirez");
|
||||
});
|
||||
it("falls back to displayName when lastName is null", () => {
|
||||
expect(
|
||||
formatInstructorName({
|
||||
displayName: "Ramirez, Maria Elena",
|
||||
firstName: "Maria",
|
||||
lastName: null,
|
||||
})
|
||||
).toBe("Maria Elena Ramirez");
|
||||
});
|
||||
it("falls back to displayName when both are null", () => {
|
||||
expect(formatInstructorName({ displayName: "Staff", firstName: null, lastName: null })).toBe(
|
||||
"Staff"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+118
-211
@@ -1,10 +1,19 @@
|
||||
import type { CourseResponse, DbMeetingTime, InstructorResponse } from "$lib/api";
|
||||
import type {
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
InstructorResponse,
|
||||
} from "$lib/api";
|
||||
|
||||
/** Convert "0900" to "9:00 AM" */
|
||||
/** Convert ISO time string "08:30:00" to "8:30 AM" */
|
||||
export function formatTime(time: string | null): string {
|
||||
if (!time || time.length !== 4) return "TBA";
|
||||
const hours = parseInt(time.slice(0, 2), 10);
|
||||
const minutes = time.slice(2);
|
||||
if (!time) return "TBA";
|
||||
// ISO format: "HH:MM:SS"
|
||||
const parts = time.split(":");
|
||||
if (parts.length < 2) return "TBA";
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parts[1];
|
||||
const period = hours >= 12 ? "PM" : "AM";
|
||||
const display = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
|
||||
return `${display}:${minutes} ${period}`;
|
||||
@@ -19,37 +28,39 @@ export function formatTime(time: string | null): string {
|
||||
* Codes use single letters where unambiguous (M/T/W/F) and
|
||||
* two letters where needed (Th/Sa/Su).
|
||||
*/
|
||||
const DAY_SHORT: Record<DayOfWeek, [string, string]> = {
|
||||
monday: ["M", "Mon"],
|
||||
tuesday: ["T", "Tue"],
|
||||
wednesday: ["W", "Wed"],
|
||||
thursday: ["Th", "Thu"],
|
||||
friday: ["F", "Fri"],
|
||||
saturday: ["Sa", "Sat"],
|
||||
sunday: ["Su", "Sun"],
|
||||
};
|
||||
|
||||
export function formatMeetingDays(mt: DbMeetingTime): string {
|
||||
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"],
|
||||
];
|
||||
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("");
|
||||
const days = mt.days;
|
||||
if (days.length === 0) return "";
|
||||
if (days.length === 1) return DAY_SHORT[days[0]][1];
|
||||
return days.map((d) => DAY_SHORT[d][0]).join("");
|
||||
}
|
||||
|
||||
/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */
|
||||
const DAY_LONG: Record<DayOfWeek, [string, string]> = {
|
||||
monday: ["Mon", "Mondays"],
|
||||
tuesday: ["Tue", "Tuesdays"],
|
||||
wednesday: ["Wed", "Wednesdays"],
|
||||
thursday: ["Thur", "Thursdays"],
|
||||
friday: ["Fri", "Fridays"],
|
||||
saturday: ["Sat", "Saturdays"],
|
||||
sunday: ["Sun", "Sundays"],
|
||||
};
|
||||
|
||||
export function formatMeetingDaysLong(mt: DbMeetingTime): string {
|
||||
const days: [boolean, string, string][] = [
|
||||
[mt.monday, "Mon", "Mondays"],
|
||||
[mt.tuesday, "Tue", "Tuesdays"],
|
||||
[mt.wednesday, "Wed", "Wednesdays"],
|
||||
[mt.thursday, "Thur", "Thursdays"],
|
||||
[mt.friday, "Fri", "Fridays"],
|
||||
[mt.saturday, "Sat", "Saturdays"],
|
||||
[mt.sunday, "Sun", "Sundays"],
|
||||
];
|
||||
const active = days.filter(([a]) => a);
|
||||
if (active.length === 0) return "";
|
||||
if (active.length === 1) return active[0][2];
|
||||
return active.map(([, short]) => short).join(", ");
|
||||
const days = mt.days;
|
||||
if (days.length === 0) return "";
|
||||
if (days.length === 1) return DAY_LONG[days[0]][1];
|
||||
return days.map((d) => DAY_LONG[d][0]).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,30 +71,25 @@ export function formatMeetingDaysLong(mt: DbMeetingTime): string {
|
||||
* Missing: "TBA"
|
||||
*/
|
||||
export function formatTimeRange(begin: string | null, end: string | null): string {
|
||||
if (!begin || begin.length !== 4 || !end || end.length !== 4) return "TBA";
|
||||
if (!begin || !end) return "TBA";
|
||||
|
||||
const bHours = parseInt(begin.slice(0, 2), 10);
|
||||
const eHours = parseInt(end.slice(0, 2), 10);
|
||||
const bParts = begin.split(":");
|
||||
const eParts = end.split(":");
|
||||
if (bParts.length < 2 || eParts.length < 2) return "TBA";
|
||||
|
||||
const bHours = parseInt(bParts[0], 10);
|
||||
const eHours = parseInt(eParts[0], 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}`;
|
||||
const endStr = `${eDisplay}:${eParts[1]} ${ePeriod}`;
|
||||
if (bPeriod === ePeriod) {
|
||||
return `${bDisplay}:${begin.slice(2)}–${endStr}`;
|
||||
return `${bDisplay}:${bParts[1]}–${endStr}`;
|
||||
}
|
||||
return `${bDisplay}:${begin.slice(2)} ${bPeriod}–${endStr}`;
|
||||
}
|
||||
|
||||
/** Condensed meeting time: "MWF 9:00–9:50 AM" */
|
||||
export function formatMeetingTime(mt: DbMeetingTime): string {
|
||||
const days = formatMeetingDays(mt);
|
||||
if (!days) return "TBA";
|
||||
const range = formatTimeRange(mt.begin_time, mt.end_time);
|
||||
if (range === "TBA") return `${days} TBA`;
|
||||
return `${days} ${range}`;
|
||||
return `${bDisplay}:${bParts[1]} ${bPeriod}–${endStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,81 +133,40 @@ export function abbreviateInstructor(name: string, maxLen: number = 18): string
|
||||
return `${last}, ${parts[0][0]}.`;
|
||||
}
|
||||
|
||||
/** Get primary instructor from a course, or first instructor */
|
||||
/**
|
||||
* Get the primary instructor from a course.
|
||||
*
|
||||
* When `primaryInstructorId` is available (from the backend), does a direct
|
||||
* lookup. Falls back to iterating `isPrimary` / first instructor for safety.
|
||||
*/
|
||||
export function getPrimaryInstructor(
|
||||
instructors: InstructorResponse[]
|
||||
instructors: InstructorResponse[],
|
||||
primaryInstructorId?: number | null
|
||||
): InstructorResponse | undefined {
|
||||
if (primaryInstructorId != null) {
|
||||
return instructors.find((i) => i.instructorId === primaryInstructorId) ?? instructors[0];
|
||||
}
|
||||
return instructors.find((i) => i.isPrimary) ?? instructors[0];
|
||||
}
|
||||
|
||||
/** Check if a meeting time has no scheduled days */
|
||||
export function isMeetingTimeTBA(mt: DbMeetingTime): boolean {
|
||||
return (
|
||||
!mt.monday &&
|
||||
!mt.tuesday &&
|
||||
!mt.wednesday &&
|
||||
!mt.thursday &&
|
||||
!mt.friday &&
|
||||
!mt.saturday &&
|
||||
!mt.sunday
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a meeting time has no begin/end times */
|
||||
export function isTimeTBA(mt: DbMeetingTime): boolean {
|
||||
return !mt.begin_time || mt.begin_time.length !== 4;
|
||||
}
|
||||
|
||||
/** Check if course is asynchronous online (INT building with no meeting times) */
|
||||
export function isAsyncOnline(course: CourseResponse): boolean {
|
||||
if (course.meetingTimes.length === 0) return false;
|
||||
const mt = course.meetingTimes[0];
|
||||
return mt.building === "INT" && isTimeTBA(mt);
|
||||
}
|
||||
|
||||
/** Format a date string to "January 20, 2026". Accepts YYYY-MM-DD or MM/DD/YYYY. */
|
||||
/** Format an ISO-8601 date (YYYY-MM-DD) to "January 20, 2026". */
|
||||
export function formatDate(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;
|
||||
}
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
if (!year || !month || !day) return dateStr;
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
/** Short location string from first meeting time: "MH 2.206" or campus fallback */
|
||||
export function formatLocation(course: CourseResponse): string | null {
|
||||
for (const mt of course.meetingTimes) {
|
||||
// Skip INT building - handled by formatLocationDisplay
|
||||
if (mt.building === "INT") continue;
|
||||
if (mt.building && mt.room) return `${mt.building} ${mt.room}`;
|
||||
if (mt.building) return mt.building;
|
||||
}
|
||||
return course.campus ?? null;
|
||||
}
|
||||
|
||||
/** Longer location string using building description: "Main Hall 2.206" */
|
||||
export function formatLocationLong(mt: DbMeetingTime): string | null {
|
||||
const name = mt.building_description ?? mt.building;
|
||||
function formatLocationLong(mt: DbMeetingTime): string | null {
|
||||
const name = mt.location?.buildingDescription ?? mt.location?.building;
|
||||
if (!name) return null;
|
||||
return mt.room ? `${name} ${mt.room}` : name;
|
||||
return mt.location?.room ? `${name} ${mt.location.room}` : name;
|
||||
}
|
||||
|
||||
/** Format a date as "Aug 26, 2024". Accepts YYYY-MM-DD or MM/DD/YYYY. */
|
||||
/** Format an ISO-8601 date (YYYY-MM-DD) as "Aug 26, 2024". */
|
||||
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;
|
||||
}
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
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" });
|
||||
@@ -211,20 +176,21 @@ export function formatDateShort(dateStr: string): string {
|
||||
* Verbose day names for tooltips: "Tuesdays & Thursdays", "Mondays, Wednesdays & Fridays".
|
||||
* Single day → plural: "Thursdays".
|
||||
*/
|
||||
const DAY_VERBOSE: Record<DayOfWeek, string> = {
|
||||
monday: "Mondays",
|
||||
tuesday: "Tuesdays",
|
||||
wednesday: "Wednesdays",
|
||||
thursday: "Thursdays",
|
||||
friday: "Fridays",
|
||||
saturday: "Saturdays",
|
||||
sunday: "Sundays",
|
||||
};
|
||||
|
||||
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];
|
||||
const names = mt.days.map((d) => DAY_VERBOSE[d]);
|
||||
if (names.length === 0) return "";
|
||||
if (names.length === 1) return names[0];
|
||||
return names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +199,7 @@ export function formatMeetingDaysVerbose(mt: DbMeetingTime): string {
|
||||
*/
|
||||
export function formatMeetingTimeTooltip(mt: DbMeetingTime): string {
|
||||
const days = formatMeetingDaysVerbose(mt);
|
||||
const range = formatTimeRange(mt.begin_time, mt.end_time);
|
||||
const range = formatTimeRange(mt.timeRange?.start ?? null, mt.timeRange?.end ?? null);
|
||||
let line1: string;
|
||||
if (!days && range === "TBA") {
|
||||
line1 = "TBA";
|
||||
@@ -248,10 +214,7 @@ export function formatMeetingTimeTooltip(mt: DbMeetingTime): string {
|
||||
const parts = [line1];
|
||||
|
||||
const loc = formatLocationLong(mt);
|
||||
const dateRange =
|
||||
mt.start_date && mt.end_date
|
||||
? `${formatDateShort(mt.start_date)} – ${formatDateShort(mt.end_date)}`
|
||||
: null;
|
||||
const dateRange = `${formatDateShort(mt.dateRange.start)} – ${formatDateShort(mt.dateRange.end)}`;
|
||||
|
||||
if (loc && dateRange) {
|
||||
parts.push(`${loc}, ${dateRange}`);
|
||||
@@ -270,36 +233,8 @@ export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string
|
||||
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 {
|
||||
/** Border accent color for each delivery mode type. */
|
||||
export function concernAccentColor(concern: DeliveryMode | null): string | null {
|
||||
switch (concern) {
|
||||
case "online":
|
||||
return "#3b82f6"; // blue-500
|
||||
@@ -314,32 +249,8 @@ export function concernAccentColor(concern: DeliveryConcern): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Location display text for the table cell.
|
||||
* Shows "Online" for internet class (INT building) or other online courses.
|
||||
*/
|
||||
export function formatLocationDisplay(
|
||||
course: CourseResponse,
|
||||
concern?: DeliveryConcern
|
||||
): string | null {
|
||||
// Check for Internet Class building first
|
||||
const hasIntBuilding = course.meetingTimes.some((mt) => mt.building === "INT");
|
||||
if (hasIntBuilding) return "Online";
|
||||
|
||||
const loc = formatLocation(course);
|
||||
if (loc) return loc;
|
||||
|
||||
const c = concern ?? getDeliveryConcern(course);
|
||||
if (c === "online" || c === "internet") return "Online";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Tooltip text for the location column: long-form location + delivery note */
|
||||
export function formatLocationTooltip(
|
||||
course: CourseResponse,
|
||||
concern?: DeliveryConcern
|
||||
): string | null {
|
||||
export function formatLocationTooltip(course: CourseResponse): string | null {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const mt of course.meetingTimes) {
|
||||
@@ -349,7 +260,7 @@ export function formatLocationTooltip(
|
||||
|
||||
const locationLine = parts.length > 0 ? parts.join(", ") : null;
|
||||
|
||||
const c = concern ?? getDeliveryConcern(course);
|
||||
const c = course.deliveryMode;
|
||||
let deliveryNote: string | null = null;
|
||||
if (c === "online") deliveryNote = "Online";
|
||||
else if (c === "internet") deliveryNote = "Internet";
|
||||
@@ -362,30 +273,20 @@ export function formatLocationTooltip(
|
||||
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";
|
||||
export function seatsColor(openSeats: number): string {
|
||||
if (openSeats === 0) return "text-status-red";
|
||||
if (openSeats <= 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";
|
||||
export function seatsDotColor(openSeats: number): string {
|
||||
if (openSeats === 0) return "bg-red-500";
|
||||
if (openSeats <= 5) return "bg-yellow-500";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
/** Minimum number of ratings needed to consider RMP data reliable */
|
||||
export const RMP_CONFIDENCE_THRESHOLD = 7;
|
||||
|
||||
/** RMP professor page URL from legacy ID */
|
||||
export function rmpUrl(legacyId: number): string {
|
||||
return `https://www.ratemyprofessors.com/professor/${legacyId}`;
|
||||
@@ -430,19 +331,30 @@ export function ratingStyle(rating: number, isDark: boolean): string {
|
||||
|
||||
/** Format credit hours display */
|
||||
export function formatCreditHours(course: CourseResponse): string {
|
||||
if (course.creditHours != null) return String(course.creditHours);
|
||||
if (course.creditHourLow != null && course.creditHourHigh != null) {
|
||||
return `${course.creditHourLow}–${course.creditHourHigh}`;
|
||||
if (course.creditHours == null) return "—";
|
||||
if (course.creditHours.type === "fixed") {
|
||||
return String(course.creditHours.hours);
|
||||
}
|
||||
return "—";
|
||||
return `${course.creditHours.low}–${course.creditHours.high}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Banner "Last, First Middle" → "First Middle Last".
|
||||
* Handles: no comma (returned as-is), trailing/leading spaces,
|
||||
* middle names/initials preserved.
|
||||
* Format an instructor's name for display.
|
||||
*
|
||||
* When an `InstructorResponse` object with `firstName` and `lastName` is
|
||||
* provided, uses them directly: "First Last". Otherwise falls back to parsing
|
||||
* `displayName` from Banner's "Last, First Middle" format.
|
||||
*/
|
||||
export function formatInstructorName(displayName: string): string {
|
||||
export function formatInstructorName(
|
||||
nameOrInstructor: string | Pick<InstructorResponse, "displayName" | "firstName" | "lastName">
|
||||
): string {
|
||||
if (typeof nameOrInstructor !== "string") {
|
||||
const { firstName, lastName, displayName } = nameOrInstructor;
|
||||
if (firstName && lastName) return `${firstName} ${lastName}`;
|
||||
return formatInstructorName(displayName);
|
||||
}
|
||||
|
||||
const displayName = nameOrInstructor;
|
||||
const commaIdx = displayName.indexOf(",");
|
||||
if (commaIdx === -1) return displayName.trim();
|
||||
|
||||
@@ -455,14 +367,9 @@ export function formatInstructorName(displayName: string): string {
|
||||
|
||||
/** Compact meeting time summary for mobile cards: "MWF 9:00–9:50 AM", "Async", or "TBA" */
|
||||
export function formatMeetingTimeSummary(course: CourseResponse): string {
|
||||
if (isAsyncOnline(course)) return "Async";
|
||||
if (course.isAsyncOnline) return "Async";
|
||||
if (course.meetingTimes.length === 0) return "TBA";
|
||||
const mt = course.meetingTimes[0];
|
||||
if (isMeetingTimeTBA(mt) && isTimeTBA(mt)) return "TBA";
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
|
||||
}
|
||||
|
||||
/** Check if a rating value represents real data (not the 0.0 placeholder for unrated professors). */
|
||||
export function isRatingValid(avgRating: number | null, numRatings: number): boolean {
|
||||
return avgRating !== null && !(avgRating === 0 && numRatings === 0);
|
||||
if (mt.days.length === 0 && mt.timeRange === null) return "TBA";
|
||||
return `${formatMeetingDays(mt)} ${formatTimeRange(mt.timeRange?.start ?? null, mt.timeRange?.end ?? null)}`;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ describe("jsonDiff", () => {
|
||||
});
|
||||
|
||||
it("handles object with nested arrays", () => {
|
||||
const oldVal = { meetingTimes: [{ beginTime: "0900" }] };
|
||||
const newVal = { meetingTimes: [{ beginTime: "1000" }] };
|
||||
const oldVal = { meetingTimes: [{ timeRange: { start: "09:00:00" } }] };
|
||||
const newVal = { meetingTimes: [{ timeRange: { start: "10:00:00" } }] };
|
||||
expect(jsonDiff(oldVal, newVal)).toEqual([
|
||||
{ path: ".meetingTimes[0].beginTime", oldVal: "0900", newVal: "1000" },
|
||||
{ path: ".meetingTimes[0].timeRange.start", oldVal: "09:00:00", newVal: "10:00:00" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ describe("formatDiffPath", () => {
|
||||
});
|
||||
|
||||
it("handles mixed paths", () => {
|
||||
expect(formatDiffPath(".meetingTimes[0].beginTime")).toBe("meetingTimes[0].beginTime");
|
||||
expect(formatDiffPath(".meetingTimes[0].timeRange.start")).toBe(
|
||||
"meetingTimes[0].timeRange.start"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
client,
|
||||
} from "$lib/api";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import { formatInstructorName, isRatingValid, ratingStyle } from "$lib/course";
|
||||
import { formatInstructorName, ratingStyle } from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import {
|
||||
Check,
|
||||
@@ -594,7 +594,7 @@ function formatScore(score: number): string {
|
||||
{@const tc = instructor.topCandidate}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-foreground">{tc.firstName} {tc.lastName}</span>
|
||||
{#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)}
|
||||
{#if tc.avgRating != null}
|
||||
<span
|
||||
class="font-semibold tabular-nums"
|
||||
style={ratingStyle(tc.avgRating!, themeStore.isDark)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CandidateResponse } from "$lib/api";
|
||||
import { isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { Check, ExternalLink, LoaderCircle, X, XCircle } from "@lucide/svelte";
|
||||
import ScoreBreakdown from "./ScoreBreakdown.svelte";
|
||||
|
||||
@@ -130,7 +130,7 @@ const isUnmatchLoading = $derived(actionLoading === `unmatch-${candidate.rmpLega
|
||||
|
||||
<!-- Rating stats -->
|
||||
<div class="mt-2 flex items-center gap-3 text-xs flex-wrap">
|
||||
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
|
||||
{#if candidate.avgRating != null}
|
||||
<span
|
||||
class="font-semibold tabular-nums"
|
||||
style={ratingStyle(candidate.avgRating!, isDark)}
|
||||
|
||||
Reference in New Issue
Block a user