refactor: consolidate query logic and eliminate N+1 instructor loads

This commit is contained in:
2026-01-29 12:03:06 -06:00
parent 61f8bd9de7
commit c90bd740de
22 changed files with 414 additions and 398 deletions
+10 -7
View File
@@ -1,4 +1,4 @@
use bitflags::{Flags, bitflags}; use bitflags::{bitflags, Flags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension; use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
@@ -320,10 +320,11 @@ pub enum MeetingType {
Unknown(String), Unknown(String),
} }
impl MeetingType { impl std::str::FromStr for MeetingType {
/// Parse from the meeting type string type Err = std::convert::Infallible;
pub fn from_string(s: &str) -> Self {
match s { fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"HB" | "H2" | "H1" => MeetingType::HybridBlended, "HB" | "H2" | "H1" => MeetingType::HybridBlended,
"OS" => MeetingType::OnlineSynchronous, "OS" => MeetingType::OnlineSynchronous,
"OA" => MeetingType::OnlineAsynchronous, "OA" => MeetingType::OnlineAsynchronous,
@@ -331,9 +332,11 @@ impl MeetingType {
"ID" => MeetingType::IndependentStudy, "ID" => MeetingType::IndependentStudy,
"FF" => MeetingType::FaceToFace, "FF" => MeetingType::FaceToFace,
other => MeetingType::Unknown(other.to_string()), other => MeetingType::Unknown(other.to_string()),
} })
} }
}
impl MeetingType {
/// Get description for the meeting type /// Get description for the meeting type
pub fn description(&self) -> &'static str { pub fn description(&self) -> &'static str {
match self { match self {
@@ -424,7 +427,7 @@ impl MeetingScheduleInfo {
end: now, end: now,
} }
}); });
let meeting_type = MeetingType::from_string(&meeting_time.meeting_type); let meeting_type: MeetingType = meeting_time.meeting_type.parse().unwrap();
let location = MeetingLocation::from_meeting_time(meeting_time); let location = MeetingLocation::from_meeting_time(meeting_time);
let duration_weeks = date_range.weeks_duration(); let duration_weeks = date_range.weeks_duration();
+3 -14
View File
@@ -10,8 +10,9 @@ pub struct Range {
pub high: i32, pub high: i32,
} }
/// Builder for constructing Banner API search queries /// Builder for constructing Banner API search queries.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct SearchQuery { pub struct SearchQuery {
subject: Option<String>, subject: Option<String>,
title: Option<String>, title: Option<String>,
@@ -32,6 +33,7 @@ pub struct SearchQuery {
course_number_range: Option<Range>, course_number_range: Option<Range>,
} }
#[allow(dead_code)]
impl SearchQuery { impl SearchQuery {
/// Creates a new SearchQuery with default values /// Creates a new SearchQuery with default values
pub fn new() -> Self { pub fn new() -> Self {
@@ -67,7 +69,6 @@ impl SearchQuery {
} }
/// Adds a keyword to the query /// Adds a keyword to the query
#[allow(dead_code)]
pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self { pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self {
match &mut self.keywords { match &mut self.keywords {
Some(keywords) => keywords.push(keyword.into()), Some(keywords) => keywords.push(keyword.into()),
@@ -77,63 +78,54 @@ impl SearchQuery {
} }
/// Sets whether to search for open courses only /// Sets whether to search for open courses only
#[allow(dead_code)]
pub fn open_only(mut self, open_only: bool) -> Self { pub fn open_only(mut self, open_only: bool) -> Self {
self.open_only = Some(open_only); self.open_only = Some(open_only);
self self
} }
/// Sets the term part for the query /// Sets the term part for the query
#[allow(dead_code)]
pub fn term_part(mut self, term_part: Vec<String>) -> Self { pub fn term_part(mut self, term_part: Vec<String>) -> Self {
self.term_part = Some(term_part); self.term_part = Some(term_part);
self self
} }
/// Sets the campuses for the query /// Sets the campuses for the query
#[allow(dead_code)]
pub fn campus(mut self, campus: Vec<String>) -> Self { pub fn campus(mut self, campus: Vec<String>) -> Self {
self.campus = Some(campus); self.campus = Some(campus);
self self
} }
/// Sets the instructional methods for the query /// Sets the instructional methods for the query
#[allow(dead_code)]
pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self { pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self {
self.instructional_method = Some(instructional_method); self.instructional_method = Some(instructional_method);
self self
} }
/// Sets the attributes for the query /// Sets the attributes for the query
#[allow(dead_code)]
pub fn attributes(mut self, attributes: Vec<String>) -> Self { pub fn attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = Some(attributes); self.attributes = Some(attributes);
self self
} }
/// Sets the instructors for the query /// Sets the instructors for the query
#[allow(dead_code)]
pub fn instructor(mut self, instructor: Vec<u64>) -> Self { pub fn instructor(mut self, instructor: Vec<u64>) -> Self {
self.instructor = Some(instructor); self.instructor = Some(instructor);
self self
} }
/// Sets the start time for the query /// Sets the start time for the query
#[allow(dead_code)]
pub fn start_time(mut self, start_time: Duration) -> Self { pub fn start_time(mut self, start_time: Duration) -> Self {
self.start_time = Some(start_time); self.start_time = Some(start_time);
self self
} }
/// Sets the end time for the query /// Sets the end time for the query
#[allow(dead_code)]
pub fn end_time(mut self, end_time: Duration) -> Self { pub fn end_time(mut self, end_time: Duration) -> Self {
self.end_time = Some(end_time); self.end_time = Some(end_time);
self self
} }
/// Sets the credit range for the query /// Sets the credit range for the query
#[allow(dead_code)]
pub fn credits(mut self, low: i32, high: i32) -> Self { pub fn credits(mut self, low: i32, high: i32) -> Self {
self.min_credits = Some(low); self.min_credits = Some(low);
self.max_credits = Some(high); self.max_credits = Some(high);
@@ -141,14 +133,12 @@ impl SearchQuery {
} }
/// Sets the minimum credits for the query /// Sets the minimum credits for the query
#[allow(dead_code)]
pub fn min_credits(mut self, value: i32) -> Self { pub fn min_credits(mut self, value: i32) -> Self {
self.min_credits = Some(value); self.min_credits = Some(value);
self self
} }
/// Sets the maximum credits for the query /// Sets the maximum credits for the query
#[allow(dead_code)]
pub fn max_credits(mut self, value: i32) -> Self { pub fn max_credits(mut self, value: i32) -> Self {
self.max_credits = Some(value); self.max_credits = Some(value);
self self
@@ -161,7 +151,6 @@ impl SearchQuery {
} }
/// Sets the offset for pagination /// Sets the offset for pagination
#[allow(dead_code)]
pub fn offset(mut self, offset: i32) -> Self { pub fn offset(mut self, offset: i32) -> Self {
self.offset = offset; self.offset = offset;
self self
+128 -71
View File
@@ -1,8 +1,74 @@
//! Database query functions for courses, used by the web API. //! Database query functions for courses, used by the web API.
use crate::data::models::Course; use crate::data::models::{Course, CourseInstructorDetail};
use crate::error::Result; use crate::error::Result;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap;
/// Column to sort search results by.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortColumn {
CourseCode,
Title,
Instructor,
Time,
Seats,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
/// Shared WHERE clause for course search filters.
///
/// Parameters $1-$8 match the bind order in `search_courses`.
const SEARCH_WHERE: &str = r#"
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
"#;
/// Build a safe ORDER BY clause from typed sort parameters.
///
/// All column names are hardcoded string literals — no caller input is interpolated.
fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) -> String {
let dir = match direction.unwrap_or(SortDirection::Asc) {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match column {
Some(SortColumn::CourseCode) => {
format!("subject {dir}, course_number {dir}, sequence_number {dir}")
}
Some(SortColumn::Title) => format!("title {dir}"),
Some(SortColumn::Instructor) => {
format!(
"(SELECT i.display_name FROM course_instructors ci \
JOIN instructors i ON i.banner_id = ci.instructor_id \
WHERE ci.course_id = courses.id AND ci.is_primary = true \
LIMIT 1) {dir} NULLS LAST"
)
}
Some(SortColumn::Time) => {
format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST")
}
Some(SortColumn::Seats) => {
format!("(max_enrollment - enrollment) {dir}")
}
None => "subject ASC, course_number ASC, sequence_number ASC".to_string(),
}
}
/// Search courses by term with optional filters. /// Search courses by term with optional filters.
/// ///
@@ -21,32 +87,17 @@ pub async fn search_courses(
campus: Option<&str>, campus: Option<&str>,
limit: i32, limit: i32,
offset: i32, offset: i32,
order_by: &str, sort_by: Option<SortColumn>,
sort_dir: Option<SortDirection>,
) -> Result<(Vec<Course>, i64)> { ) -> Result<(Vec<Course>, i64)> {
// Build WHERE clauses dynamically via parameter binding + COALESCE trick: let order_by = sort_clause(sort_by, sort_dir);
// each optional filter uses ($N IS NULL OR column = $N) so NULL means "no filter".
//
// ORDER BY is interpolated as a string since column names can't be bound as
// parameters. The caller must provide a safe, pre-validated clause (see
// `sort_clause` in routes.rs).
let query = format!(
r#"
SELECT *
FROM courses
WHERE term_code = $1
AND ($2::text[] IS NULL OR subject = ANY($2))
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
AND ($4::int IS NULL OR course_number::int >= $4)
AND ($5::int IS NULL OR course_number::int <= $5)
AND ($6::bool = false OR max_enrollment > enrollment)
AND ($7::text IS NULL OR instructional_method = $7)
AND ($8::text IS NULL OR campus = $8)
ORDER BY {order_by}
LIMIT $9 OFFSET $10
"#
);
let courses = sqlx::query_as::<_, Course>(&query) let data_query = format!(
"SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10"
);
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
let courses = sqlx::query_as::<_, Course>(&data_query)
.bind(term_code) .bind(term_code)
.bind(subject) .bind(subject)
.bind(title_query) .bind(title_query)
@@ -60,30 +111,17 @@ pub async fn search_courses(
.fetch_all(db_pool) .fetch_all(db_pool)
.await?; .await?;
let total: (i64,) = sqlx::query_as( let total: (i64,) = sqlx::query_as(&count_query)
r#" .bind(term_code)
SELECT COUNT(*) .bind(subject)
FROM courses .bind(title_query)
WHERE term_code = $1 .bind(course_number_low)
AND ($2::text[] IS NULL OR subject = ANY($2)) .bind(course_number_high)
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%') .bind(open_only)
AND ($4::int IS NULL OR course_number::int >= $4) .bind(instructional_method)
AND ($5::int IS NULL OR course_number::int <= $5) .bind(campus)
AND ($6::bool = false OR max_enrollment > enrollment) .fetch_one(db_pool)
AND ($7::text IS NULL OR instructional_method = $7) .await?;
AND ($8::text IS NULL OR campus = $8)
"#,
)
.bind(term_code)
.bind(subject)
.bind(title_query)
.bind(course_number_low)
.bind(course_number_high)
.bind(open_only)
.bind(instructional_method)
.bind(campus)
.fetch_one(db_pool)
.await?;
Ok((courses, total.0)) Ok((courses, total.0))
} }
@@ -103,33 +141,16 @@ pub async fn get_course_by_crn(
Ok(course) Ok(course)
} }
/// Get instructors for a course by course ID. /// Get instructors for a single course by course ID.
///
/// Returns `(banner_id, display_name, email, is_primary, rmp_avg_rating, rmp_num_ratings)` tuples.
pub async fn get_course_instructors( pub async fn get_course_instructors(
db_pool: &PgPool, db_pool: &PgPool,
course_id: i32, course_id: i32,
) -> Result< ) -> Result<Vec<CourseInstructorDetail>> {
Vec<( let rows = sqlx::query_as::<_, CourseInstructorDetail>(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)>,
> {
let rows: Vec<(
String,
String,
Option<String>,
bool,
Option<f32>,
Option<i32>,
)> = sqlx::query_as(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary, SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings rp.avg_rating, rp.num_ratings,
ci.course_id
FROM course_instructors ci FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id JOIN instructors i ON i.banner_id = ci.instructor_id
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
@@ -143,6 +164,42 @@ pub async fn get_course_instructors(
Ok(rows) Ok(rows)
} }
/// Batch-fetch instructors for multiple courses in a single query.
///
/// Returns a map of `course_id → Vec<CourseInstructorDetail>`.
pub async fn get_instructors_for_courses(
db_pool: &PgPool,
course_ids: &[i32],
) -> Result<HashMap<i32, Vec<CourseInstructorDetail>>> {
if course_ids.is_empty() {
return Ok(HashMap::new());
}
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
WHERE ci.course_id = ANY($1)
ORDER BY ci.course_id, ci.is_primary DESC, i.display_name
"#,
)
.bind(course_ids)
.fetch_all(db_pool)
.await?;
let mut map: HashMap<i32, Vec<CourseInstructorDetail>> = HashMap::new();
for row in rows {
// course_id is always present in the batch query
let cid = row.course_id.unwrap_or_default();
map.entry(cid).or_default().push(row);
}
Ok(map)
}
/// Get subjects for a term, sorted by total enrollment (descending). /// Get subjects for a term, sorted by total enrollment (descending).
/// ///
/// Returns only subjects that have courses in the given term, with their /// Returns only subjects that have courses in the given term, with their
+13
View File
@@ -76,6 +76,19 @@ pub struct CourseInstructor {
pub is_primary: bool, pub is_primary: bool,
} }
/// Joined instructor data for a course (from course_instructors + instructors + rmp_professors).
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct CourseInstructorDetail {
pub banner_id: String,
pub display_name: String,
pub email: Option<String>,
pub is_primary: bool,
pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>,
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(sqlx::FromRow, Debug, Clone)] #[derive(sqlx::FromRow, Debug, Clone)]
pub struct ReferenceData { pub struct ReferenceData {
+20 -13
View File
@@ -134,7 +134,7 @@ pub async fn find_existing_job_payloads(
Ok(existing_payloads) Ok(existing_payloads)
} }
/// Batch insert scrape jobs in a single transaction. /// Batch insert scrape jobs using UNNEST for a single round-trip.
/// ///
/// All jobs are inserted with `execute_at` set to the current time. /// All jobs are inserted with `execute_at` set to the current time.
/// ///
@@ -149,22 +149,29 @@ pub async fn batch_insert_jobs(
return Ok(()); return Ok(());
} }
let now = chrono::Utc::now(); let mut target_types: Vec<String> = Vec::with_capacity(jobs.len());
let mut tx = db_pool.begin().await?; let mut payloads: Vec<serde_json::Value> = Vec::with_capacity(jobs.len());
let mut priorities: Vec<String> = Vec::with_capacity(jobs.len());
for (payload, target_type, priority) in jobs { for (payload, target_type, priority) in jobs {
sqlx::query( target_types.push(format!("{target_type:?}"));
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)" payloads.push(payload.clone());
) priorities.push(format!("{priority:?}"));
.bind(target_type)
.bind(payload)
.bind(priority)
.bind(now)
.execute(&mut *tx)
.await?;
} }
tx.commit().await?; sqlx::query(
r#"
INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
SELECT v.target_type::target_type, v.payload, v.priority::scrape_priority, NOW()
FROM UNNEST($1::text[], $2::jsonb[], $3::text[])
AS v(target_type, payload, priority)
"#,
)
.bind(&target_types)
.bind(&payloads)
.bind(&priorities)
.execute(db_pool)
.await?;
Ok(()) Ok(())
} }
-1
View File
@@ -19,7 +19,6 @@ mod scraper;
mod services; mod services;
mod signals; mod signals;
mod state; mod state;
#[allow(dead_code)]
mod status; mod status;
mod web; mod web;
+6
View File
@@ -35,6 +35,12 @@ pub struct RmpClient {
http: reqwest::Client, http: reqwest::Client,
} }
impl Default for RmpClient {
fn default() -> Self {
Self::new()
}
}
impl RmpClient { impl RmpClient {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
+17 -12
View File
@@ -13,9 +13,10 @@ use tokio::sync::RwLock;
/// In-memory cache for reference data (code→description lookups). /// In-memory cache for reference data (code→description lookups).
/// ///
/// Loaded from the `reference_data` table on startup and refreshed periodically. /// Loaded from the `reference_data` table on startup and refreshed periodically.
/// Uses a two-level HashMap so lookups take `&str` without allocating.
pub struct ReferenceCache { pub struct ReferenceCache {
/// `(category, code)``description` /// category → (code → description)
data: HashMap<(String, String), String>, data: HashMap<String, HashMap<String, String>>,
} }
impl Default for ReferenceCache { impl Default for ReferenceCache {
@@ -34,27 +35,31 @@ impl ReferenceCache {
/// Build cache from a list of reference data entries. /// Build cache from a list of reference data entries.
pub fn from_entries(entries: Vec<ReferenceData>) -> Self { pub fn from_entries(entries: Vec<ReferenceData>) -> Self {
let data = entries let mut data: HashMap<String, HashMap<String, String>> = HashMap::new();
.into_iter() for e in entries {
.map(|e| ((e.category, e.code), e.description)) data.entry(e.category)
.collect(); .or_default()
.insert(e.code, e.description);
}
Self { data } Self { data }
} }
/// Look up a description by category and code. /// Look up a description by category and code. Zero allocations.
pub fn lookup(&self, category: &str, code: &str) -> Option<&str> { pub fn lookup(&self, category: &str, code: &str) -> Option<&str> {
self.data self.data
.get(&(category.to_string(), code.to_string())) .get(category)
.and_then(|codes| codes.get(code))
.map(|s| s.as_str()) .map(|s| s.as_str())
} }
/// Get all `(code, description)` pairs for a category, sorted by description. /// Get all `(code, description)` pairs for a category, sorted by description.
pub fn entries_for_category(&self, category: &str) -> Vec<(&str, &str)> { pub fn entries_for_category(&self, category: &str) -> Vec<(&str, &str)> {
let mut entries: Vec<(&str, &str)> = self let Some(codes) = self.data.get(category) else {
.data return Vec::new();
};
let mut entries: Vec<(&str, &str)> = codes
.iter() .iter()
.filter(|((cat, _), _)| cat == category) .map(|(code, desc)| (code.as_str(), desc.as_str()))
.map(|((_, code), desc)| (code.as_str(), desc.as_str()))
.collect(); .collect();
entries.sort_by(|a, b| a.1.cmp(b.1)); entries.sort_by(|a, b| a.1.cmp(b.1));
entries entries
+3
View File
@@ -10,6 +10,7 @@ use ts_rs::TS;
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export)] #[ts(export)]
pub enum ServiceStatus { pub enum ServiceStatus {
#[allow(dead_code)]
Starting, Starting,
Active, Active,
Connected, Connected,
@@ -21,6 +22,7 @@ pub enum ServiceStatus {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StatusEntry { pub struct StatusEntry {
pub status: ServiceStatus, pub status: ServiceStatus,
#[allow(dead_code)]
pub updated_at: Instant, pub updated_at: Instant,
} }
@@ -48,6 +50,7 @@ impl ServiceStatusRegistry {
} }
/// Returns the current status of a named service, if present. /// Returns the current status of a named service, if present.
#[allow(dead_code)]
pub fn get(&self, name: &str) -> Option<ServiceStatus> { pub fn get(&self, name: &str) -> Option<ServiceStatus> {
self.inner.get(name).map(|entry| entry.status.clone()) self.inner.get(name).map(|entry| entry.status.clone())
} }
+33 -74
View File
@@ -323,59 +323,12 @@ struct SearchParams {
sort_dir: Option<SortDirection>, sort_dir: Option<SortDirection>,
} }
#[derive(Debug, Clone, Copy, Deserialize)] use crate::data::courses::{SortColumn, SortDirection};
#[serde(rename_all = "snake_case")]
enum SortColumn {
CourseCode,
Title,
Instructor,
Time,
Seats,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortDirection {
Asc,
Desc,
}
fn default_limit() -> i32 { fn default_limit() -> i32 {
25 25
} }
/// Build a safe ORDER BY clause from the validated sort column and direction.
fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) -> String {
let dir = match direction.unwrap_or(SortDirection::Asc) {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
match column {
Some(SortColumn::CourseCode) => {
format!("subject {dir}, course_number {dir}, sequence_number {dir}")
}
Some(SortColumn::Title) => format!("title {dir}"),
Some(SortColumn::Instructor) => {
// Sort by primary instructor display name via a subquery
format!(
"(SELECT i.display_name FROM course_instructors ci \
JOIN instructors i ON i.banner_id = ci.instructor_id \
WHERE ci.course_id = courses.id AND ci.is_primary = true \
LIMIT 1) {dir} NULLS LAST"
)
}
Some(SortColumn::Time) => {
// Sort by first meeting time's begin_time via JSONB
format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST")
}
Some(SortColumn::Seats) => {
format!("(max_enrollment - enrollment) {dir}")
}
None => "subject ASC, course_number ASC, sequence_number ASC".to_string(),
}
}
#[derive(Serialize, TS)] #[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
@@ -436,27 +389,21 @@ pub struct CodeDescription {
description: String, description: String,
} }
/// Build a `CourseResponse` from a DB course, fetching its instructors. /// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
async fn build_course_response( fn build_course_response(
course: &crate::data::models::Course, course: &crate::data::models::Course,
db_pool: &sqlx::PgPool, instructors: Vec<crate::data::models::CourseInstructorDetail>,
) -> CourseResponse { ) -> CourseResponse {
let instructors = crate::data::courses::get_course_instructors(db_pool, course.id) let instructors = instructors
.await
.unwrap_or_default()
.into_iter() .into_iter()
.map( .map(|i| InstructorResponse {
|(banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings)| { banner_id: i.banner_id,
InstructorResponse { display_name: i.display_name,
banner_id, email: i.email,
display_name, is_primary: i.is_primary,
email, rmp_rating: i.avg_rating,
is_primary, rmp_num_ratings: i.num_ratings,
rmp_rating, })
rmp_num_ratings,
}
},
)
.collect(); .collect();
CourseResponse { CourseResponse {
@@ -495,8 +442,6 @@ async fn search_courses(
let limit = params.limit.clamp(1, 100); let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0); let offset = params.offset.max(0);
let order_by = sort_clause(params.sort_by, params.sort_dir);
let (courses, total_count) = crate::data::courses::search_courses( let (courses, total_count) = crate::data::courses::search_courses(
&state.db_pool, &state.db_pool,
&params.term, &params.term,
@@ -513,7 +458,8 @@ async fn search_courses(
params.campus.as_deref(), params.campus.as_deref(),
limit, limit,
offset, offset,
&order_by, params.sort_by,
params.sort_dir,
) )
.await .await
.map_err(|e| { .map_err(|e| {
@@ -524,10 +470,20 @@ async fn search_courses(
) )
})?; })?;
let mut course_responses = Vec::with_capacity(courses.len()); // Batch-fetch all instructors in a single query instead of N+1
for course in &courses { let course_ids: Vec<i32> = courses.iter().map(|c| c.id).collect();
course_responses.push(build_course_response(course, &state.db_pool).await); let mut instructor_map =
} crate::data::courses::get_instructors_for_courses(&state.db_pool, &course_ids)
.await
.unwrap_or_default();
let course_responses: Vec<CourseResponse> = courses
.iter()
.map(|course| {
let instructors = instructor_map.remove(&course.id).unwrap_or_default();
build_course_response(course, instructors)
})
.collect();
Ok(Json(SearchResponse { Ok(Json(SearchResponse {
courses: course_responses, courses: course_responses,
@@ -553,7 +509,10 @@ async fn get_course(
})? })?
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?; .ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
Ok(Json(build_course_response(&course, &state.db_pool).await)) let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id)
.await
.unwrap_or_default();
Ok(Json(build_course_response(&course, instructors)))
} }
/// `GET /api/terms` /// `GET /api/terms`
+1 -18
View File
@@ -11,23 +11,6 @@ describe("BannerApiClient", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("should fetch health data", async () => {
const mockHealth = {
status: "healthy",
timestamp: "2024-01-01T00:00:00Z",
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHealth),
} as Response);
const result = await apiClient.getHealth();
expect(fetch).toHaveBeenCalledWith("/api/health");
expect(result).toEqual(mockHealth);
});
it("should fetch status data", async () => { it("should fetch status data", async () => {
const mockStatus = { const mockStatus = {
status: "active" as const, status: "active" as const,
@@ -57,7 +40,7 @@ describe("BannerApiClient", () => {
statusText: "Internal Server Error", statusText: "Internal Server Error",
} as Response); } as Response);
await expect(apiClient.getHealth()).rejects.toThrow( await expect(apiClient.getStatus()).rejects.toThrow(
"API request failed: 500 Internal Server Error" "API request failed: 500 Internal Server Error"
); );
}); });
-21
View File
@@ -30,19 +30,6 @@ export type ReferenceEntry = CodeDescription;
// SearchResponse re-exported (aliased to strip the "Generated" suffix) // SearchResponse re-exported (aliased to strip the "Generated" suffix)
export type SearchResponse = SearchResponseGenerated; export type SearchResponse = SearchResponseGenerated;
// Health/metrics endpoints return ad-hoc JSON — keep manual types
export interface HealthResponse {
status: string;
timestamp: string;
}
export interface MetricsResponse {
banner_api: {
status: string;
};
timestamp: string;
}
// Client-side only — not generated from Rust // Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
@@ -77,18 +64,10 @@ export class BannerApiClient {
return (await response.json()) as T; return (await response.json()) as T;
} }
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>("/health");
}
async getStatus(): Promise<StatusResponse> { async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/status"); return this.request<StatusResponse>("/status");
} }
async getMetrics(): Promise<MetricsResponse> {
return this.request<MetricsResponse>("/metrics");
}
async searchCourses(params: SearchParams): Promise<SearchResponse> { async searchCourses(params: SearchParams): Promise<SearchResponse> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set("term", params.term); query.set("term", params.term);
+9 -19
View File
@@ -7,27 +7,17 @@ import {
formatMeetingDaysLong, formatMeetingDaysLong,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
ratingColor,
} from "$lib/course"; } from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte"; import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte"; import { Info, Copy, Check } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props(); let { course }: { course: CourseResponse } = $props();
let copiedEmail: string | null = $state(null); const clipboard = useClipboard();
async function copyEmail(email: string, event: MouseEvent) {
event.stopPropagation();
try {
await navigator.clipboard.writeText(email);
copiedEmail = email;
setTimeout(() => {
copiedEmail = null;
}, 2000);
} catch (err) {
console.error("Failed to copy email:", err);
}
}
</script> </script>
<div class="bg-muted/60 p-5 text-sm border-b border-border"> <div class="bg-muted/60 p-5 text-sm border-b border-border">
@@ -49,14 +39,14 @@ async function copyEmail(email: string, event: MouseEvent) {
{#if instructor.rmpRating != null} {#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating} {@const rating = instructor.rmpRating}
<span <span
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}" class="text-[10px] font-semibold {ratingColor(rating)}"
>{rating.toFixed(1)}</span> >{rating.toFixed(1)}</span>
{/if} {/if}
</span> </span>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content <Tooltip.Content
sideOffset={6} sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72" class={cn(tooltipContentClass, "px-3 py-2")}
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div> <div class="font-medium">{instructor.displayName}</div>
@@ -70,10 +60,10 @@ async function copyEmail(email: string, event: MouseEvent) {
{/if} {/if}
{#if instructor.email} {#if instructor.email}
<button <button
onclick={(e) => copyEmail(instructor.email!, e)} onclick={(e) => clipboard.copy(instructor.email!, e)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
> >
{#if copiedEmail === instructor.email} {#if clipboard.copiedValue === instructor.email}
<Check class="size-3" /> <Check class="size-3" />
<span>Copied!</span> <span>Copied!</span>
{:else} {:else}
@@ -212,7 +202,7 @@ async function copyEmail(email: string, event: MouseEvent) {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content <Tooltip.Content
sideOffset={6} sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72" class={tooltipContentClass}
> >
Group <span class="font-mono font-medium">{course.crossList}</span> Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null} {#if course.crossListCount != null && course.crossListCapacity != null}
+18 -87
View File
@@ -12,13 +12,16 @@ import {
getPrimaryInstructor, getPrimaryInstructor,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
openSeats,
seatsColor,
seatsDotColor,
ratingColor,
} from "$lib/course"; } from "$lib/course";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte"; import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition"; import { fade, fly, slide } from "svelte/transition";
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js"; import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import { import {
getCoreRowModel, getCoreRowModel,
@@ -50,8 +53,7 @@ let {
let expandedCrn: string | null = $state(null); let expandedCrn: string | null = $state(null);
let tableWrapper: HTMLDivElement = undefined!; let tableWrapper: HTMLDivElement = undefined!;
let copiedCrn: string | null = $state(null); const clipboard = useClipboard(1000);
let copyTimeoutId: number | undefined;
// Collapse expanded row when the dataset changes to avoid stale detail rows // Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content // and FLIP position calculation glitches from lingering expanded content
@@ -60,30 +62,9 @@ $effect(() => {
expandedCrn = null; expandedCrn = null;
}); });
onMount(() => { useOverlayScrollbars(() => tableWrapper, {
const osInstance = OverlayScrollbars(tableWrapper, { overflow: { x: "scroll", y: "hidden" },
overflow: { x: "scroll", y: "hidden" }, scrollbars: { autoHide: "never" },
scrollbars: {
autoHide: "never",
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
// React to theme changes
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
}); });
// Column visibility state // Column visibility state
@@ -104,63 +85,12 @@ function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn; expandedCrn = expandedCrn === crn ? null : crn;
} }
async function handleCopyCrn(event: MouseEvent | KeyboardEvent, crn: string) {
event.stopPropagation();
try {
await navigator.clipboard.writeText(crn);
if (copyTimeoutId !== undefined) {
clearTimeout(copyTimeoutId);
}
copiedCrn = crn;
copyTimeoutId = window.setTimeout(() => {
copiedCrn = null;
copyTimeoutId = undefined;
}, 1000);
} catch (err) {
console.error("Failed to copy CRN:", err);
}
}
function handleCrnKeydown(event: KeyboardEvent, crn: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleCopyCrn(event, crn);
}
}
function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
function primaryInstructorDisplay(course: CourseResponse): string { function primaryInstructorDisplay(course: CourseResponse): string {
const primary = getPrimaryInstructor(course.instructors); const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff"; if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName); return abbreviateInstructor(primary.displayName);
} }
function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
function primaryRating(course: CourseResponse): { rating: number; count: number } | null { function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
const primary = getPrimaryInstructor(course.instructors); const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null; if (!primary?.rmpRating) return null;
@@ -470,19 +400,20 @@ const table = createSvelteTable({
<button <button
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70" class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
onclick={(e) => onclick={(e) =>
handleCopyCrn( clipboard.copy(
e,
course.crn, course.crn,
)}
onkeydown={(e) =>
handleCrnKeydown(
e, e,
course.crn,
)} )}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
clipboard.copy(course.crn, e);
}
}}
aria-label="Copy CRN {course.crn} to clipboard" aria-label="Copy CRN {course.crn} to clipboard"
> >
{course.crn} {course.crn}
{#if copiedCrn === course.crn} {#if clipboard.copiedValue === course.crn}
<span <span
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10" class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
in:fade={{ in:fade={{
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from "$lib/utils";
let {
commitHash,
showStatusLink = true,
class: className,
}: {
commitHash?: string | null;
showStatusLink?: boolean;
class?: string;
} = $props();
</script>
<div class={cn("flex justify-center items-center gap-2 mt-auto pt-6 pb-4", className)}>
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href={commitHash
? `https://github.com/Xevion/banner/commit/${commitHash}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
{#if showStatusLink}
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
{/if}
</div>
@@ -0,0 +1,32 @@
/**
* Reactive clipboard copy with automatic "copied" state reset.
*
* Returns a `copiedValue` that is non-null while the copied feedback
* should be displayed, and a `copy()` function to trigger a copy.
*/
export function useClipboard(resetMs = 2000) {
let copiedValue = $state<string | null>(null);
let timeoutId: number | undefined;
async function copy(text: string, event?: MouseEvent | KeyboardEvent) {
event?.stopPropagation();
try {
await navigator.clipboard.writeText(text);
clearTimeout(timeoutId);
copiedValue = text;
timeoutId = window.setTimeout(() => {
copiedValue = null;
timeoutId = undefined;
}, resetMs);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
}
return {
get copiedValue() {
return copiedValue;
},
copy,
};
}
@@ -0,0 +1,37 @@
import { onMount } from "svelte";
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
/**
* Set up OverlayScrollbars on an element with automatic theme reactivity.
*
* Must be called during component initialization (uses `onMount` internally).
* The scrollbar theme automatically syncs with `themeStore.isDark`.
*/
export function useOverlayScrollbars(getElement: () => HTMLElement, options: PartialOptions = {}) {
onMount(() => {
const element = getElement();
const osInstance = OverlayScrollbars(element, {
...options,
scrollbars: {
...options.scrollbars,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
});
}
+28
View File
@@ -341,6 +341,34 @@ export function formatLocationTooltip(course: CourseResponse): string | null {
return null; return null;
} }
/** Number of open seats in a course section */
export function openSeats(course: CourseResponse): number {
return Math.max(0, course.maxEnrollment - course.enrollment);
}
/** Text color class for seat availability: red (full), yellow (low), green (open) */
export function seatsColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
/** Background dot color class for seat availability */
export function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
if (open === 0) return "bg-red-500";
if (open <= 5) return "bg-yellow-500";
return "bg-green-500";
}
/** Text color class for a RateMyProfessors rating */
export function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
}
/** Format credit hours display */ /** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string { export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours); if (course.creditHours != null) return String(course.creditHours);
+4
View File
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/** Shared tooltip content styling for bits-ui Tooltip.Content */
export const tooltipContentClass =
"z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72";
+8 -26
View File
@@ -2,44 +2,26 @@
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css"; import "./layout.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
let { children } = $props(); let { children } = $props();
useOverlayScrollbars(() => document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
},
});
onMount(() => { onMount(() => {
themeStore.init(); themeStore.init();
// Enable theme transitions now that the page has rendered with the correct theme.
// Without this delay, the initial paint would animate from light to dark colors.
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition"); document.documentElement.classList.remove("no-transition");
}); });
const osInstance = OverlayScrollbars(document.body, {
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
const unwatch = $effect.root(() => {
$effect(() => {
osInstance.options({
scrollbars: {
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
});
});
return () => {
unwatch();
osInstance.destroy();
};
}); });
</script> </script>
+2 -18
View File
@@ -12,6 +12,7 @@ import type { SortingState } from "@tanstack/table-core";
import SearchFilters from "$lib/components/SearchFilters.svelte"; import SearchFilters from "$lib/components/SearchFilters.svelte";
import CourseTable from "$lib/components/CourseTable.svelte"; import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte"; import Pagination from "$lib/components/Pagination.svelte";
import Footer from "$lib/components/Footer.svelte";
let { data } = $props(); let { data } = $props();
@@ -240,23 +241,6 @@ function handlePageChange(newOffset: number) {
{/if} {/if}
<!-- Footer --> <!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4"> <Footer />
{#if __APP_VERSION__}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
{/if}
<a
href="https://github.com/Xevion/banner"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
Status
</a>
</div>
</div> </div>
</div> </div>
+6 -17
View File
@@ -13,6 +13,7 @@ import {
XCircle, XCircle,
} from "@lucide/svelte"; } from "@lucide/svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte"; import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import Footer from "$lib/components/Footer.svelte";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api"; import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time"; import { relativeTime } from "$lib/time";
@@ -61,7 +62,6 @@ let statusState = $state({ mode: "loading" } as StatusState);
let now = $state(new Date()); let now = $state(new Date());
const isLoading = $derived(statusState.mode === "loading"); const isLoading = $derived(statusState.mode === "loading");
const hasResponse = $derived(statusState.mode === "response");
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error"); const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
const overallHealth: ServiceStatus | "Unreachable" = $derived( const overallHealth: ServiceStatus | "Unreachable" = $derived(
@@ -304,20 +304,9 @@ onMount(() => {
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-center items-center gap-2 mt-3"> <Footer
{#if __APP_VERSION__} commitHash={statusState.mode === "response" ? statusState.status.commit : undefined}
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span> showStatusLink={false}
<div class="w-px h-3 bg-muted-foreground opacity-30"></div> class="mt-3 pt-0 pb-0"
{/if} />
<a
href={hasResponse && statusState.mode === "response" && statusState.status.commit
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
: "https://github.com/Xevion/banner"}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground no-underline hover:underline"
>
GitHub
</a>
</div>
</div> </div>