mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 08:23:35 -06:00
refactor: consolidate query logic and eliminate N+1 instructor loads
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
¶ms.term,
|
¶ms.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
@@ -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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user