mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -06:00
Compare commits
4 Commits
75a99c10ea
...
7b8c11ac13
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b8c11ac13 | |||
| a767a3f8be | |||
| 8ce398c0e0 | |||
| 9fed651641 |
Generated
+35
@@ -280,6 +280,7 @@ dependencies = [
|
|||||||
"axum-extra",
|
"axum-extra",
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"clap",
|
"clap",
|
||||||
"compile-time",
|
"compile-time",
|
||||||
"cookie",
|
"cookie",
|
||||||
@@ -484,6 +485,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.0",
|
"windows-link 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono-tz"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.47"
|
version = "4.5.47"
|
||||||
@@ -2127,6 +2138,24 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -2983,6 +3012,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "skeptic"
|
name = "skeptic"
|
||||||
version = "0.13.7"
|
version = "0.13.7"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ ts-rs = { version = "11.1.0", features = ["chrono-impl", "serde-compat", "serde-
|
|||||||
html-escape = "0.2.13"
|
html-escape = "0.2.13"
|
||||||
axum-extra = { version = "0.12.5", features = ["query"] }
|
axum-extra = { version = "0.12.5", features = ["query"] }
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
chrono-tz = "0.10.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|||||||
+462
@@ -0,0 +1,462 @@
|
|||||||
|
//! Shared calendar generation logic for ICS files and Google Calendar URLs.
|
||||||
|
//!
|
||||||
|
//! Used by both the Discord bot commands and the web API endpoints.
|
||||||
|
|
||||||
|
use crate::data::models::DbMeetingTime;
|
||||||
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
|
||||||
|
|
||||||
|
/// Course metadata needed for calendar generation (shared interface between bot and web).
|
||||||
|
pub struct CalendarCourse {
|
||||||
|
pub crn: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub course_number: String,
|
||||||
|
pub title: String,
|
||||||
|
pub sequence_number: Option<String>,
|
||||||
|
pub primary_instructor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarCourse {
|
||||||
|
/// Display title like "CS 1083 - Introduction to Computer Science"
|
||||||
|
pub fn display_title(&self) -> String {
|
||||||
|
format!("{} {} - {}", self.subject, self.course_number, self.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filename-safe identifier: "CS_1083_001"
|
||||||
|
pub fn filename_stem(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}_{}{}",
|
||||||
|
self.subject.replace(' ', "_"),
|
||||||
|
self.course_number,
|
||||||
|
self.sequence_number
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| format!("_{s}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date parsing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||||
|
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||||
|
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||||
|
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an HHMM time string into `NaiveTime`.
|
||||||
|
fn parse_hhmm(s: &str) -> Option<NaiveTime> {
|
||||||
|
if s.len() != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hours = s[..2].parse::<u32>().ok()?;
|
||||||
|
let minutes = s[2..].parse::<u32>().ok()?;
|
||||||
|
NaiveTime::from_hms_opt(hours, minutes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active weekdays for a meeting time.
|
||||||
|
fn active_weekdays(mt: &DbMeetingTime) -> Vec<Weekday> {
|
||||||
|
let mapping: [(bool, Weekday); 7] = [
|
||||||
|
(mt.monday, Weekday::Mon),
|
||||||
|
(mt.tuesday, Weekday::Tue),
|
||||||
|
(mt.wednesday, Weekday::Wed),
|
||||||
|
(mt.thursday, Weekday::Thu),
|
||||||
|
(mt.friday, Weekday::Fri),
|
||||||
|
(mt.saturday, Weekday::Sat),
|
||||||
|
(mt.sunday, Weekday::Sun),
|
||||||
|
];
|
||||||
|
mapping
|
||||||
|
.iter()
|
||||||
|
.filter(|(active, _)| *active)
|
||||||
|
.map(|(_, day)| *day)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICS two-letter day code for RRULE BYDAY.
|
||||||
|
fn ics_day_code(day: Weekday) -> &'static str {
|
||||||
|
match day {
|
||||||
|
Weekday::Mon => "MO",
|
||||||
|
Weekday::Tue => "TU",
|
||||||
|
Weekday::Wed => "WE",
|
||||||
|
Weekday::Thu => "TH",
|
||||||
|
Weekday::Fri => "FR",
|
||||||
|
Weekday::Sat => "SA",
|
||||||
|
Weekday::Sun => "SU",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Location string from a `DbMeetingTime`.
|
||||||
|
fn location_string(mt: &DbMeetingTime) -> String {
|
||||||
|
let building = mt
|
||||||
|
.building_description
|
||||||
|
.as_deref()
|
||||||
|
.or(mt.building.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
let room = mt.room.as_deref().unwrap_or("");
|
||||||
|
let combined = format!("{building} {room}").trim().to_string();
|
||||||
|
if combined.is_empty() {
|
||||||
|
"Online".to_string()
|
||||||
|
} else {
|
||||||
|
combined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days display string (e.g. "MWF", "TTh").
|
||||||
|
fn days_display(mt: &DbMeetingTime) -> String {
|
||||||
|
let weekdays = active_weekdays(mt);
|
||||||
|
if weekdays.is_empty() {
|
||||||
|
return "TBA".to_string();
|
||||||
|
}
|
||||||
|
weekdays
|
||||||
|
.iter()
|
||||||
|
.map(|d| ics_day_code(*d))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape text for ICS property values.
|
||||||
|
fn escape_ics(text: &str) -> String {
|
||||||
|
text.replace('\\', "\\\\")
|
||||||
|
.replace(';', "\\;")
|
||||||
|
.replace(',', "\\,")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// University holidays (ported from bot/commands/ics.rs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Find the nth occurrence of a weekday in a given month/year (1-based).
|
||||||
|
fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option<NaiveDate> {
|
||||||
|
let first = NaiveDate::from_ymd_opt(year, month, 1)?;
|
||||||
|
let days_ahead = (weekday.num_days_from_monday() as i64
|
||||||
|
- first.weekday().num_days_from_monday() as i64)
|
||||||
|
.rem_euclid(7) as u32;
|
||||||
|
let day = 1 + days_ahead + 7 * (n - 1);
|
||||||
|
NaiveDate::from_ymd_opt(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a consecutive range of dates starting from `start` for `count` days.
|
||||||
|
fn date_range(start: NaiveDate, count: i64) -> Vec<NaiveDate> {
|
||||||
|
(0..count)
|
||||||
|
.filter_map(|i| start.checked_add_signed(Duration::days(i)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute university holidays for a given year.
|
||||||
|
fn compute_holidays_for_year(year: i32) -> Vec<(&'static str, Vec<NaiveDate>)> {
|
||||||
|
let mut holidays = Vec::new();
|
||||||
|
|
||||||
|
// Labor Day: 1st Monday of September
|
||||||
|
if let Some(d) = nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
|
||||||
|
holidays.push(("Labor Day", vec![d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall Break: Mon-Tue of Columbus Day week
|
||||||
|
if let Some(mon) = nth_weekday_of_month(year, 10, Weekday::Mon, 2) {
|
||||||
|
holidays.push(("Fall Break", date_range(mon, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day before Thanksgiving
|
||||||
|
if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4)
|
||||||
|
&& let Some(wed) = thu.checked_sub_signed(Duration::days(1))
|
||||||
|
{
|
||||||
|
holidays.push(("Day Before Thanksgiving", vec![wed]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thanksgiving: 4th Thursday + Friday
|
||||||
|
if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
|
||||||
|
holidays.push(("Thanksgiving", date_range(thu, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Winter Holiday: Dec 23-31
|
||||||
|
if let Some(start) = NaiveDate::from_ymd_opt(year, 12, 23) {
|
||||||
|
holidays.push(("Winter Holiday", date_range(start, 9)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Year's Day
|
||||||
|
if let Some(d) = NaiveDate::from_ymd_opt(year, 1, 1) {
|
||||||
|
holidays.push(("New Year's Day", vec![d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// MLK Day: 3rd Monday of January
|
||||||
|
if let Some(d) = nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
|
||||||
|
holidays.push(("MLK Day", vec![d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring Break: full week starting 2nd Monday of March
|
||||||
|
if let Some(mon) = nth_weekday_of_month(year, 3, Weekday::Mon, 2) {
|
||||||
|
holidays.push(("Spring Break", date_range(mon, 6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
holidays
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get holiday dates within a date range that fall on specific weekdays.
|
||||||
|
fn holiday_exceptions(start: NaiveDate, end: NaiveDate, weekdays: &[Weekday]) -> Vec<NaiveDate> {
|
||||||
|
let start_year = start.year();
|
||||||
|
let end_year = end.year();
|
||||||
|
|
||||||
|
(start_year..=end_year)
|
||||||
|
.flat_map(compute_holidays_for_year)
|
||||||
|
.flat_map(|(_, dates)| dates)
|
||||||
|
.filter(|&date| date >= start && date <= end && weekdays.contains(&date.weekday()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of excluded holidays (for user-facing messages).
|
||||||
|
fn excluded_holiday_names(
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
exceptions: &[NaiveDate],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let start_year = start.year();
|
||||||
|
let end_year = end.year();
|
||||||
|
let all_holidays: Vec<_> = (start_year..=end_year)
|
||||||
|
.flat_map(compute_holidays_for_year)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for (holiday_name, holiday_dates) in &all_holidays {
|
||||||
|
for &exc in exceptions {
|
||||||
|
if holiday_dates.contains(&exc) {
|
||||||
|
names.push(format!("{} ({})", holiday_name, exc.format("%a, %b %d")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ICS generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Result from ICS generation, including the file content and excluded holiday names.
|
||||||
|
pub struct IcsResult {
|
||||||
|
pub content: String,
|
||||||
|
pub filename: String,
|
||||||
|
/// Holiday dates excluded via EXDATE rules, for user-facing messages.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub excluded_holidays: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an ICS calendar file for a course.
|
||||||
|
pub fn generate_ics(
|
||||||
|
course: &CalendarCourse,
|
||||||
|
meeting_times: &[DbMeetingTime],
|
||||||
|
) -> Result<IcsResult, anyhow::Error> {
|
||||||
|
let mut ics = String::new();
|
||||||
|
let mut all_excluded = Vec::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
ics.push_str("BEGIN:VCALENDAR\r\n");
|
||||||
|
ics.push_str("VERSION:2.0\r\n");
|
||||||
|
ics.push_str("PRODID:-//Banner Bot//Course Calendar//EN\r\n");
|
||||||
|
ics.push_str("CALSCALE:GREGORIAN\r\n");
|
||||||
|
ics.push_str("METHOD:PUBLISH\r\n");
|
||||||
|
ics.push_str(&format!(
|
||||||
|
"X-WR-CALNAME:{}\r\n",
|
||||||
|
escape_ics(&course.display_title())
|
||||||
|
));
|
||||||
|
|
||||||
|
for (index, mt) in meeting_times.iter().enumerate() {
|
||||||
|
let (event, holidays) = generate_ics_event(course, mt, index)?;
|
||||||
|
ics.push_str(&event);
|
||||||
|
all_excluded.extend(holidays);
|
||||||
|
}
|
||||||
|
|
||||||
|
ics.push_str("END:VCALENDAR\r\n");
|
||||||
|
|
||||||
|
Ok(IcsResult {
|
||||||
|
content: ics,
|
||||||
|
filename: format!("{}.ics", course.filename_stem()),
|
||||||
|
excluded_holidays: all_excluded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a single VEVENT for one meeting time.
|
||||||
|
fn generate_ics_event(
|
||||||
|
course: &CalendarCourse,
|
||||||
|
mt: &DbMeetingTime,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||||
|
let start_date = parse_date(&mt.start_date)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||||
|
let end_date = parse_date(&mt.end_date)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||||
|
|
||||||
|
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||||
|
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||||
|
|
||||||
|
// DTSTART/DTEND: first occurrence with time, or all-day on start_date
|
||||||
|
let (dtstart, dtend) = match (start_time, end_time) {
|
||||||
|
(Some(st), Some(et)) => {
|
||||||
|
let s = start_date.and_time(st).and_utc();
|
||||||
|
let e = start_date.and_time(et).and_utc();
|
||||||
|
(
|
||||||
|
s.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||||
|
e.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let s = start_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||||
|
let e = start_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||||
|
(
|
||||||
|
s.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||||
|
e.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_title = if index > 0 {
|
||||||
|
format!("{} (Meeting {})", course.display_title(), index + 1)
|
||||||
|
} else {
|
||||||
|
course.display_title()
|
||||||
|
};
|
||||||
|
|
||||||
|
let instructor = course.primary_instructor.as_deref().unwrap_or("Staff");
|
||||||
|
|
||||||
|
let description = format!(
|
||||||
|
"CRN: {}\\nInstructor: {}\\nDays: {}\\nMeeting Type: {}",
|
||||||
|
course.crn,
|
||||||
|
instructor,
|
||||||
|
days_display(mt),
|
||||||
|
mt.meeting_type,
|
||||||
|
);
|
||||||
|
|
||||||
|
let location = location_string(mt);
|
||||||
|
|
||||||
|
let uid = format!(
|
||||||
|
"{}-{}-{}@banner-bot.local",
|
||||||
|
course.crn,
|
||||||
|
index,
|
||||||
|
start_date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.unwrap()
|
||||||
|
.and_utc()
|
||||||
|
.timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut event = String::new();
|
||||||
|
event.push_str("BEGIN:VEVENT\r\n");
|
||||||
|
event.push_str(&format!("UID:{uid}\r\n"));
|
||||||
|
event.push_str(&format!("DTSTART:{dtstart}\r\n"));
|
||||||
|
event.push_str(&format!("DTEND:{dtend}\r\n"));
|
||||||
|
event.push_str(&format!("SUMMARY:{}\r\n", escape_ics(&event_title)));
|
||||||
|
event.push_str(&format!("DESCRIPTION:{}\r\n", escape_ics(&description)));
|
||||||
|
event.push_str(&format!("LOCATION:{}\r\n", escape_ics(&location)));
|
||||||
|
|
||||||
|
let weekdays = active_weekdays(mt);
|
||||||
|
let mut holiday_names = Vec::new();
|
||||||
|
|
||||||
|
if let (false, Some(st)) = (weekdays.is_empty(), start_time) {
|
||||||
|
let by_day: Vec<&str> = weekdays.iter().map(|d| ics_day_code(*d)).collect();
|
||||||
|
let until = end_date.format("%Y%m%dT000000Z").to_string();
|
||||||
|
|
||||||
|
event.push_str(&format!(
|
||||||
|
"RRULE:FREQ=WEEKLY;BYDAY={};UNTIL={}\r\n",
|
||||||
|
by_day.join(","),
|
||||||
|
until,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Holiday exceptions
|
||||||
|
let exceptions = holiday_exceptions(start_date, end_date, &weekdays);
|
||||||
|
if !exceptions.is_empty() {
|
||||||
|
let start_utc = start_date.and_time(st).and_utc();
|
||||||
|
let exdates: Vec<String> = exceptions
|
||||||
|
.iter()
|
||||||
|
.map(|&d| {
|
||||||
|
d.and_time(start_utc.time())
|
||||||
|
.and_utc()
|
||||||
|
.format("%Y%m%dT%H%M%SZ")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
event.push_str(&format!("EXDATE:{}\r\n", exdates.join(",")));
|
||||||
|
}
|
||||||
|
|
||||||
|
holiday_names = excluded_holiday_names(start_date, end_date, &exceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.push_str("END:VEVENT\r\n");
|
||||||
|
Ok((event, holiday_names))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Google Calendar URL generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generate a Google Calendar "add event" URL for a single meeting time.
|
||||||
|
pub fn generate_gcal_url(
|
||||||
|
course: &CalendarCourse,
|
||||||
|
mt: &DbMeetingTime,
|
||||||
|
) -> Result<String, anyhow::Error> {
|
||||||
|
let start_date = parse_date(&mt.start_date)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||||
|
let end_date = parse_date(&mt.end_date)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||||
|
|
||||||
|
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||||
|
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||||
|
|
||||||
|
let dates_text = match (start_time, end_time) {
|
||||||
|
(Some(st), Some(et)) => {
|
||||||
|
let s = start_date.and_time(st);
|
||||||
|
let e = start_date.and_time(et);
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
s.format("%Y%m%dT%H%M%S"),
|
||||||
|
e.format("%Y%m%dT%H%M%S")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let s = start_date.format("%Y%m%d").to_string();
|
||||||
|
format!("{s}/{s}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let instructor = course.primary_instructor.as_deref().unwrap_or("Staff");
|
||||||
|
|
||||||
|
let details = format!(
|
||||||
|
"CRN: {}\nInstructor: {}\nDays: {}",
|
||||||
|
course.crn,
|
||||||
|
instructor,
|
||||||
|
days_display(mt),
|
||||||
|
);
|
||||||
|
|
||||||
|
let location = location_string(mt);
|
||||||
|
|
||||||
|
let weekdays = active_weekdays(mt);
|
||||||
|
let recur = if !weekdays.is_empty() && start_time.is_some() {
|
||||||
|
let by_day: Vec<&str> = weekdays.iter().map(|d| ics_day_code(*d)).collect();
|
||||||
|
let until = end_date.format("%Y%m%dT000000Z").to_string();
|
||||||
|
format!(
|
||||||
|
"RRULE:FREQ=WEEKLY;BYDAY={};UNTIL={}",
|
||||||
|
by_day.join(","),
|
||||||
|
until
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let course_text = course.display_title();
|
||||||
|
|
||||||
|
let params: Vec<(&str, &str)> = vec![
|
||||||
|
("action", "TEMPLATE"),
|
||||||
|
("text", &course_text),
|
||||||
|
("dates", &dates_text),
|
||||||
|
("details", &details),
|
||||||
|
("location", &location),
|
||||||
|
("trp", "true"),
|
||||||
|
("ctz", "America/Chicago"),
|
||||||
|
("recur", &recur),
|
||||||
|
];
|
||||||
|
|
||||||
|
let url = url::Url::parse_with_params("https://calendar.google.com/calendar/render", ¶ms)?;
|
||||||
|
Ok(url.to_string())
|
||||||
|
}
|
||||||
@@ -213,6 +213,70 @@ pub async fn insert_job_result(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-subject aggregated stats from recent scrape results.
|
||||||
|
///
|
||||||
|
/// Populated by [`fetch_subject_stats`] and converted into
|
||||||
|
/// [`crate::scraper::adaptive::SubjectStats`] for interval computation.
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||||
|
pub struct SubjectResultStats {
|
||||||
|
pub subject: String,
|
||||||
|
pub recent_runs: i64,
|
||||||
|
pub avg_change_ratio: f64,
|
||||||
|
pub consecutive_zero_changes: i64,
|
||||||
|
pub consecutive_empty_fetches: i64,
|
||||||
|
pub recent_failure_count: i64,
|
||||||
|
pub recent_success_count: i64,
|
||||||
|
pub last_completed: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch aggregated per-subject statistics from the last 24 hours of results.
|
||||||
|
///
|
||||||
|
/// For each subject, examines the 20 most recent results and computes:
|
||||||
|
/// - Average change ratio (courses_changed / courses_fetched)
|
||||||
|
/// - Consecutive zero-change runs from the most recent result
|
||||||
|
/// - Consecutive empty-fetch runs from the most recent result
|
||||||
|
/// - Failure and success counts
|
||||||
|
/// - Last completion timestamp
|
||||||
|
pub async fn fetch_subject_stats(db_pool: &PgPool) -> Result<Vec<SubjectResultStats>> {
|
||||||
|
let rows = sqlx::query_as::<_, SubjectResultStats>(
|
||||||
|
r#"
|
||||||
|
WITH recent AS (
|
||||||
|
SELECT payload->>'subject' AS subject, success,
|
||||||
|
COALESCE(courses_fetched, 0) AS courses_fetched,
|
||||||
|
COALESCE(courses_changed, 0) AS courses_changed,
|
||||||
|
completed_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY payload->>'subject' ORDER BY completed_at DESC) AS rn
|
||||||
|
FROM scrape_job_results
|
||||||
|
WHERE target_type = 'Subject' AND completed_at > NOW() - INTERVAL '24 hours'
|
||||||
|
),
|
||||||
|
filtered AS (SELECT * FROM recent WHERE rn <= 20),
|
||||||
|
zero_break AS (
|
||||||
|
SELECT subject,
|
||||||
|
MIN(rn) FILTER (WHERE courses_changed > 0 AND success) AS first_nonzero_rn,
|
||||||
|
MIN(rn) FILTER (WHERE courses_fetched > 0 AND success) AS first_nonempty_rn
|
||||||
|
FROM filtered GROUP BY subject
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.subject::TEXT AS subject,
|
||||||
|
COUNT(*)::BIGINT AS recent_runs,
|
||||||
|
COALESCE(AVG(CASE WHEN f.success AND f.courses_fetched > 0
|
||||||
|
THEN f.courses_changed::FLOAT / f.courses_fetched ELSE NULL END), 0.0)::FLOAT8 AS avg_change_ratio,
|
||||||
|
COALESCE(zb.first_nonzero_rn - 1, COUNT(*) FILTER (WHERE f.success AND f.courses_changed = 0))::BIGINT AS consecutive_zero_changes,
|
||||||
|
COALESCE(zb.first_nonempty_rn - 1, COUNT(*) FILTER (WHERE f.success AND f.courses_fetched = 0))::BIGINT AS consecutive_empty_fetches,
|
||||||
|
COUNT(*) FILTER (WHERE NOT f.success)::BIGINT AS recent_failure_count,
|
||||||
|
COUNT(*) FILTER (WHERE f.success)::BIGINT AS recent_success_count,
|
||||||
|
MAX(f.completed_at) AS last_completed
|
||||||
|
FROM filtered f
|
||||||
|
LEFT JOIN zero_break zb ON f.subject = zb.subject
|
||||||
|
GROUP BY f.subject, zb.first_nonzero_rn, zb.first_nonempty_rn
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Batch insert scrape jobs using UNNEST for a single round-trip.
|
/// 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.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod banner;
|
pub mod banner;
|
||||||
pub mod bot;
|
pub mod bot;
|
||||||
|
pub mod calendar;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tracing::info;
|
|||||||
mod app;
|
mod app;
|
||||||
mod banner;
|
mod banner;
|
||||||
mod bot;
|
mod bot;
|
||||||
|
mod calendar;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod data;
|
mod data;
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! Adaptive scraping interval computation.
|
||||||
|
//!
|
||||||
|
//! Assigns per-subject scrape intervals based on recent change rates,
|
||||||
|
//! consecutive zero-change runs, failure patterns, and time of day.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, Timelike, Utc};
|
||||||
|
use chrono_tz::US::Central;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::data::scrape_jobs::SubjectResultStats;
|
||||||
|
|
||||||
|
const FLOOR_INTERVAL: Duration = Duration::from_secs(3 * 60);
|
||||||
|
const MODERATE_HIGH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const MODERATE_LOW_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
const LOW_CHANGE_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||||
|
const ZERO_5_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
const ZERO_10_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
|
||||||
|
const CEILING_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||||
|
const COLD_START_INTERVAL: Duration = FLOOR_INTERVAL;
|
||||||
|
const PAUSE_PROBE_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
const EMPTY_FETCH_PAUSE_THRESHOLD: i64 = 3;
|
||||||
|
const FAILURE_PAUSE_THRESHOLD: i64 = 5;
|
||||||
|
|
||||||
|
/// Aggregated per-subject statistics derived from recent scrape results.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SubjectStats {
|
||||||
|
pub subject: String,
|
||||||
|
pub recent_runs: i64,
|
||||||
|
pub avg_change_ratio: f64,
|
||||||
|
pub consecutive_zero_changes: i64,
|
||||||
|
pub consecutive_empty_fetches: i64,
|
||||||
|
pub recent_failure_count: i64,
|
||||||
|
pub recent_success_count: i64,
|
||||||
|
pub last_completed: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheduling decision for a subject.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SubjectSchedule {
|
||||||
|
/// Subject is due for scraping, with the computed interval.
|
||||||
|
Eligible(Duration),
|
||||||
|
/// Subject was scraped recently; wait for the remaining cooldown.
|
||||||
|
Cooldown(Duration),
|
||||||
|
/// Subject is paused due to repeated empty fetches or failures.
|
||||||
|
Paused,
|
||||||
|
/// Subject belongs to a past term and should not be scraped.
|
||||||
|
ReadOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SubjectResultStats> for SubjectStats {
|
||||||
|
fn from(row: SubjectResultStats) -> Self {
|
||||||
|
Self {
|
||||||
|
subject: row.subject,
|
||||||
|
recent_runs: row.recent_runs,
|
||||||
|
avg_change_ratio: row.avg_change_ratio,
|
||||||
|
consecutive_zero_changes: row.consecutive_zero_changes,
|
||||||
|
consecutive_empty_fetches: row.consecutive_empty_fetches,
|
||||||
|
recent_failure_count: row.recent_failure_count,
|
||||||
|
recent_success_count: row.recent_success_count,
|
||||||
|
last_completed: row.last_completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the base interval tier from change-rate statistics.
|
||||||
|
pub fn compute_base_interval(stats: &SubjectStats) -> Duration {
|
||||||
|
if stats.recent_runs == 0 {
|
||||||
|
return COLD_START_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consecutive-zero tiers take precedence when change ratio is near zero
|
||||||
|
if stats.avg_change_ratio < 0.001 {
|
||||||
|
return match stats.consecutive_zero_changes {
|
||||||
|
0..5 => LOW_CHANGE_INTERVAL,
|
||||||
|
5..10 => ZERO_5_INTERVAL,
|
||||||
|
10..20 => ZERO_10_INTERVAL,
|
||||||
|
_ => CEILING_INTERVAL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match stats.avg_change_ratio {
|
||||||
|
r if r >= 0.10 => FLOOR_INTERVAL,
|
||||||
|
r if r >= 0.05 => MODERATE_HIGH_INTERVAL,
|
||||||
|
r if r >= 0.01 => MODERATE_LOW_INTERVAL,
|
||||||
|
_ => LOW_CHANGE_INTERVAL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a time-of-day multiplier for the given UTC timestamp.
|
||||||
|
///
|
||||||
|
/// Peak hours (weekdays 8am-6pm CT) return 1; off-peak (weekdays 6pm-midnight CT)
|
||||||
|
/// return 2; night (midnight-8am CT) and weekends return 4.
|
||||||
|
pub fn time_of_day_multiplier(now: DateTime<Utc>) -> u32 {
|
||||||
|
let ct = now.with_timezone(&Central);
|
||||||
|
let weekday = ct.weekday();
|
||||||
|
let hour = ct.hour();
|
||||||
|
|
||||||
|
// Weekends get the slowest multiplier
|
||||||
|
if matches!(weekday, chrono::Weekday::Sat | chrono::Weekday::Sun) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
match hour {
|
||||||
|
8..18 => 1, // peak
|
||||||
|
18..24 => 2, // off-peak
|
||||||
|
_ => 4, // night (0..8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate whether a subject should be scraped now.
|
||||||
|
///
|
||||||
|
/// Combines base interval, time-of-day multiplier, pause detection (empty
|
||||||
|
/// fetches / consecutive failures), and past-term read-only status.
|
||||||
|
pub fn evaluate_subject(
|
||||||
|
stats: &SubjectStats,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
is_past_term: bool,
|
||||||
|
) -> SubjectSchedule {
|
||||||
|
if is_past_term {
|
||||||
|
return SubjectSchedule::ReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = (now - stats.last_completed)
|
||||||
|
.to_std()
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
let probe_due = elapsed >= PAUSE_PROBE_INTERVAL;
|
||||||
|
|
||||||
|
// Pause on repeated empty fetches
|
||||||
|
if stats.consecutive_empty_fetches >= EMPTY_FETCH_PAUSE_THRESHOLD {
|
||||||
|
return if probe_due {
|
||||||
|
SubjectSchedule::Eligible(PAUSE_PROBE_INTERVAL)
|
||||||
|
} else {
|
||||||
|
SubjectSchedule::Paused
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause on all-failures
|
||||||
|
if stats.recent_success_count == 0 && stats.recent_failure_count >= FAILURE_PAUSE_THRESHOLD {
|
||||||
|
return if probe_due {
|
||||||
|
SubjectSchedule::Eligible(PAUSE_PROBE_INTERVAL)
|
||||||
|
} else {
|
||||||
|
SubjectSchedule::Paused
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = compute_base_interval(stats);
|
||||||
|
let multiplier = time_of_day_multiplier(now);
|
||||||
|
let effective = base * multiplier;
|
||||||
|
|
||||||
|
if elapsed >= effective {
|
||||||
|
SubjectSchedule::Eligible(effective)
|
||||||
|
} else {
|
||||||
|
let remaining = effective - elapsed;
|
||||||
|
SubjectSchedule::Cooldown(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
/// Create a default `SubjectStats` for testing. Callers mutate fields as needed.
|
||||||
|
fn make_stats(subject: &str) -> SubjectStats {
|
||||||
|
SubjectStats {
|
||||||
|
subject: subject.to_string(),
|
||||||
|
recent_runs: 10,
|
||||||
|
avg_change_ratio: 0.0,
|
||||||
|
consecutive_zero_changes: 0,
|
||||||
|
consecutive_empty_fetches: 0,
|
||||||
|
recent_failure_count: 0,
|
||||||
|
recent_success_count: 10,
|
||||||
|
last_completed: Utc::now() - chrono::Duration::hours(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- compute_base_interval tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cold_start_returns_floor() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.recent_runs = 0;
|
||||||
|
assert_eq!(compute_base_interval(&stats), COLD_START_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_high_change_rate() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.15;
|
||||||
|
assert_eq!(compute_base_interval(&stats), FLOOR_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_moderate_high_change() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.07;
|
||||||
|
assert_eq!(compute_base_interval(&stats), MODERATE_HIGH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_moderate_low_change() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.03;
|
||||||
|
assert_eq!(compute_base_interval(&stats), MODERATE_LOW_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_low_change() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.005;
|
||||||
|
assert_eq!(compute_base_interval(&stats), LOW_CHANGE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_5_consecutive() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.0;
|
||||||
|
stats.consecutive_zero_changes = 5;
|
||||||
|
assert_eq!(compute_base_interval(&stats), ZERO_5_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_10_consecutive() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.0;
|
||||||
|
stats.consecutive_zero_changes = 10;
|
||||||
|
assert_eq!(compute_base_interval(&stats), ZERO_10_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_20_consecutive() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.0;
|
||||||
|
stats.consecutive_zero_changes = 20;
|
||||||
|
assert_eq!(compute_base_interval(&stats), CEILING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- evaluate_subject tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pause_empty_fetches() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.consecutive_empty_fetches = 3;
|
||||||
|
stats.last_completed = Utc::now() - chrono::Duration::minutes(10);
|
||||||
|
let result = evaluate_subject(&stats, Utc::now(), false);
|
||||||
|
assert_eq!(result, SubjectSchedule::Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pause_all_failures() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.recent_success_count = 0;
|
||||||
|
stats.recent_failure_count = 5;
|
||||||
|
stats.last_completed = Utc::now() - chrono::Duration::minutes(10);
|
||||||
|
let result = evaluate_subject(&stats, Utc::now(), false);
|
||||||
|
assert_eq!(result, SubjectSchedule::Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_probe_after_pause() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.consecutive_empty_fetches = 5;
|
||||||
|
stats.last_completed = Utc::now() - chrono::Duration::hours(7);
|
||||||
|
let result = evaluate_subject(&stats, Utc::now(), false);
|
||||||
|
assert_eq!(result, SubjectSchedule::Eligible(PAUSE_PROBE_INTERVAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_only_past_term() {
|
||||||
|
let stats = make_stats("CS");
|
||||||
|
let result = evaluate_subject(&stats, Utc::now(), true);
|
||||||
|
assert_eq!(result, SubjectSchedule::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cooldown_not_elapsed() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.15; // floor = 3 min
|
||||||
|
stats.last_completed = Utc::now() - chrono::Duration::seconds(30);
|
||||||
|
// Use a peak-hours timestamp so multiplier = 1
|
||||||
|
let peak = Utc.with_ymd_and_hms(2025, 7, 14, 15, 0, 0).unwrap(); // Mon 10am CT
|
||||||
|
stats.last_completed = peak - chrono::Duration::seconds(30);
|
||||||
|
let result = evaluate_subject(&stats, peak, false);
|
||||||
|
assert!(matches!(result, SubjectSchedule::Cooldown(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_eligible_elapsed() {
|
||||||
|
let mut stats = make_stats("CS");
|
||||||
|
stats.avg_change_ratio = 0.15; // floor = 3 min
|
||||||
|
let peak = Utc.with_ymd_and_hms(2025, 7, 14, 15, 0, 0).unwrap(); // Mon 10am CT
|
||||||
|
stats.last_completed = peak - chrono::Duration::minutes(5);
|
||||||
|
let result = evaluate_subject(&stats, peak, false);
|
||||||
|
assert!(matches!(result, SubjectSchedule::Eligible(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- time_of_day_multiplier tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_multiplier_peak() {
|
||||||
|
// Monday 10am CT = 15:00 UTC
|
||||||
|
let dt = Utc.with_ymd_and_hms(2025, 7, 14, 15, 0, 0).unwrap();
|
||||||
|
assert_eq!(time_of_day_multiplier(dt), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_multiplier_offpeak() {
|
||||||
|
// Monday 8pm CT = 01:00 UTC next day, but let's use Tuesday 01:00 UTC = Mon 8pm CT
|
||||||
|
let dt = Utc.with_ymd_and_hms(2025, 7, 15, 1, 0, 0).unwrap();
|
||||||
|
assert_eq!(time_of_day_multiplier(dt), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_multiplier_night() {
|
||||||
|
// 3am CT = 08:00 UTC
|
||||||
|
let dt = Utc.with_ymd_and_hms(2025, 7, 14, 8, 0, 0).unwrap();
|
||||||
|
assert_eq!(time_of_day_multiplier(dt), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_multiplier_weekend() {
|
||||||
|
// Saturday noon CT = 17:00 UTC
|
||||||
|
let dt = Utc.with_ymd_and_hms(2025, 7, 12, 17, 0, 0).unwrap();
|
||||||
|
assert_eq!(time_of_day_multiplier(dt), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod adaptive;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|||||||
+73
-18
@@ -3,11 +3,14 @@ use crate::data::models::{ReferenceData, ScrapePriority, TargetType};
|
|||||||
use crate::data::scrape_jobs;
|
use crate::data::scrape_jobs;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::rmp::RmpClient;
|
use crate::rmp::RmpClient;
|
||||||
|
use crate::scraper::adaptive::{SubjectSchedule, SubjectStats, evaluate_subject};
|
||||||
use crate::scraper::jobs::subject::SubjectJob;
|
use crate::scraper::jobs::subject::SubjectJob;
|
||||||
use crate::state::ReferenceCache;
|
use crate::state::ReferenceCache;
|
||||||
use crate::web::ws::{ScrapeJobDto, ScrapeJobEvent};
|
use crate::web::ws::{ScrapeJobDto, ScrapeJobEvent};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
@@ -148,10 +151,9 @@ impl Scheduler {
|
|||||||
|
|
||||||
/// Core scheduling logic that analyzes data and creates scrape jobs.
|
/// Core scheduling logic that analyzes data and creates scrape jobs.
|
||||||
///
|
///
|
||||||
/// Strategy:
|
/// Uses adaptive scheduling to determine per-subject scrape intervals based
|
||||||
/// 1. Fetch all subjects for the current term from Banner API
|
/// on recent change rates, failure patterns, and time of day. Only subjects
|
||||||
/// 2. Query existing jobs in a single batch query
|
/// that are eligible (i.e. their cooldown has elapsed) are enqueued.
|
||||||
/// 3. Create jobs only for subjects that don't have pending jobs
|
|
||||||
///
|
///
|
||||||
/// This is a static method (not &self) to allow it to be called from spawned tasks.
|
/// This is a static method (not &self) to allow it to be called from spawned tasks.
|
||||||
#[tracing::instrument(skip_all, fields(term))]
|
#[tracing::instrument(skip_all, fields(term))]
|
||||||
@@ -160,10 +162,6 @@ impl Scheduler {
|
|||||||
banner_api: &BannerApi,
|
banner_api: &BannerApi,
|
||||||
job_events_tx: Option<&broadcast::Sender<ScrapeJobEvent>>,
|
job_events_tx: Option<&broadcast::Sender<ScrapeJobEvent>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// For now, we will implement a simple baseline scheduling strategy:
|
|
||||||
// 1. Get a list of all subjects from the Banner API.
|
|
||||||
// 2. Query existing jobs for all subjects in a single query.
|
|
||||||
// 3. Create new jobs only for subjects that don't have existing jobs.
|
|
||||||
let term = Term::get_current().inner().to_string();
|
let term = Term::get_current().inner().to_string();
|
||||||
|
|
||||||
tracing::Span::current().record("term", term.as_str());
|
tracing::Span::current().record("term", term.as_str());
|
||||||
@@ -175,13 +173,70 @@ impl Scheduler {
|
|||||||
"Retrieved subjects from API"
|
"Retrieved subjects from API"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create payloads for all subjects
|
// Fetch per-subject stats and build a lookup map
|
||||||
let subject_payloads: Vec<_> = subjects
|
let stats_rows = scrape_jobs::fetch_subject_stats(db_pool).await?;
|
||||||
.iter()
|
let stats_map: HashMap<String, SubjectStats> = stats_rows
|
||||||
.map(|subject| json!({ "subject": subject.code }))
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let subject = row.subject.clone();
|
||||||
|
(subject, SubjectStats::from(row))
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Query existing jobs for all subjects in a single query
|
// Evaluate each subject using adaptive scheduling
|
||||||
|
let now = Utc::now();
|
||||||
|
let is_past_term = false; // Scheduler currently only fetches current term subjects
|
||||||
|
let mut eligible_subjects: Vec<String> = Vec::new();
|
||||||
|
let mut cooldown_count: usize = 0;
|
||||||
|
let mut paused_count: usize = 0;
|
||||||
|
let mut read_only_count: usize = 0;
|
||||||
|
|
||||||
|
for subject in &subjects {
|
||||||
|
let stats = stats_map.get(&subject.code).cloned().unwrap_or_else(|| {
|
||||||
|
// Cold start: no history for this subject
|
||||||
|
SubjectStats {
|
||||||
|
subject: subject.code.clone(),
|
||||||
|
recent_runs: 0,
|
||||||
|
avg_change_ratio: 0.0,
|
||||||
|
consecutive_zero_changes: 0,
|
||||||
|
consecutive_empty_fetches: 0,
|
||||||
|
recent_failure_count: 0,
|
||||||
|
recent_success_count: 0,
|
||||||
|
last_completed: DateTime::<Utc>::MIN_UTC,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match evaluate_subject(&stats, now, is_past_term) {
|
||||||
|
SubjectSchedule::Eligible(_) => {
|
||||||
|
eligible_subjects.push(subject.code.clone());
|
||||||
|
}
|
||||||
|
SubjectSchedule::Cooldown(_) => cooldown_count += 1,
|
||||||
|
SubjectSchedule::Paused => paused_count += 1,
|
||||||
|
SubjectSchedule::ReadOnly => read_only_count += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
total = subjects.len(),
|
||||||
|
eligible = eligible_subjects.len(),
|
||||||
|
cooldown = cooldown_count,
|
||||||
|
paused = paused_count,
|
||||||
|
read_only = read_only_count,
|
||||||
|
"Adaptive scheduling decisions"
|
||||||
|
);
|
||||||
|
|
||||||
|
if eligible_subjects.is_empty() {
|
||||||
|
debug!("No eligible subjects to schedule");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payloads only for eligible subjects
|
||||||
|
let subject_payloads: Vec<_> = eligible_subjects
|
||||||
|
.iter()
|
||||||
|
.map(|code| json!({ "subject": code }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Query existing jobs for eligible subjects only
|
||||||
let existing_payloads = scrape_jobs::find_existing_job_payloads(
|
let existing_payloads = scrape_jobs::find_existing_job_payloads(
|
||||||
TargetType::Subject,
|
TargetType::Subject,
|
||||||
&subject_payloads,
|
&subject_payloads,
|
||||||
@@ -189,12 +244,12 @@ impl Scheduler {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Filter out subjects that already have jobs and prepare new jobs
|
// Filter out subjects that already have pending jobs
|
||||||
let mut skipped_count = 0;
|
let mut skipped_count = 0;
|
||||||
let new_jobs: Vec<_> = subjects
|
let new_jobs: Vec<_> = eligible_subjects
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|subject| {
|
.filter_map(|subject_code| {
|
||||||
let job = SubjectJob::new(subject.code.clone());
|
let job = SubjectJob::new(subject_code.clone());
|
||||||
let payload = serde_json::to_value(&job).unwrap();
|
let payload = serde_json::to_value(&job).unwrap();
|
||||||
let payload_str = payload.to_string();
|
let payload_str = payload.to_string();
|
||||||
|
|
||||||
@@ -202,7 +257,7 @@ impl Scheduler {
|
|||||||
skipped_count += 1;
|
skipped_count += 1;
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((payload, subject.code))
|
Some((payload, subject_code))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
//! Admin API handlers for scraper observability.
|
||||||
|
//!
|
||||||
|
//! All endpoints require the `AdminUser` extractor, returning 401/403 as needed.
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Json;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::Row;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::banner::models::terms::Term;
|
||||||
|
use crate::data::scrape_jobs;
|
||||||
|
use crate::scraper::adaptive::{self, SubjectSchedule, SubjectStats};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::web::extractors::AdminUser;
|
||||||
|
|
||||||
|
type ApiError = (StatusCode, Json<serde_json::Value>);
|
||||||
|
|
||||||
|
fn parse_period(period: &str) -> Result<chrono::Duration, ApiError> {
|
||||||
|
match period {
|
||||||
|
"1h" => Ok(chrono::Duration::hours(1)),
|
||||||
|
"6h" => Ok(chrono::Duration::hours(6)),
|
||||||
|
"24h" => Ok(chrono::Duration::hours(24)),
|
||||||
|
"7d" => Ok(chrono::Duration::days(7)),
|
||||||
|
"30d" => Ok(chrono::Duration::days(30)),
|
||||||
|
_ => Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(
|
||||||
|
json!({"error": format!("Invalid period '{period}'. Valid: 1h, 6h, 24h, 7d, 30d")}),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn period_to_interval_str(period: &str) -> &'static str {
|
||||||
|
match period {
|
||||||
|
"1h" => "1 hour",
|
||||||
|
"6h" => "6 hours",
|
||||||
|
"24h" => "24 hours",
|
||||||
|
"7d" => "7 days",
|
||||||
|
"30d" => "30 days",
|
||||||
|
_ => "24 hours",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bucket(bucket: &str) -> Result<&'static str, ApiError> {
|
||||||
|
match bucket {
|
||||||
|
"1m" => Ok("1 minute"),
|
||||||
|
"5m" => Ok("5 minutes"),
|
||||||
|
"15m" => Ok("15 minutes"),
|
||||||
|
"1h" => Ok("1 hour"),
|
||||||
|
"6h" => Ok("6 hours"),
|
||||||
|
_ => Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(
|
||||||
|
json!({"error": format!("Invalid bucket '{bucket}'. Valid: 1m, 5m, 15m, 1h, 6h")}),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bucket_for_period(period: &str) -> &'static str {
|
||||||
|
match period {
|
||||||
|
"1h" => "1m",
|
||||||
|
"6h" => "5m",
|
||||||
|
"24h" => "15m",
|
||||||
|
"7d" => "1h",
|
||||||
|
"30d" => "6h",
|
||||||
|
_ => "15m",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint 1: GET /api/admin/scraper/stats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StatsParams {
|
||||||
|
#[serde(default = "default_period")]
|
||||||
|
period: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_period() -> String {
|
||||||
|
"24h".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScraperStatsResponse {
|
||||||
|
period: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_scrapes: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
successful_scrapes: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
failed_scrapes: i64,
|
||||||
|
success_rate: Option<f64>,
|
||||||
|
avg_duration_ms: Option<f64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_courses_changed: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_courses_fetched: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_audits_generated: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pending_jobs: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
locked_jobs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scraper_stats(
|
||||||
|
_admin: AdminUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<StatsParams>,
|
||||||
|
) -> Result<Json<ScraperStatsResponse>, ApiError> {
|
||||||
|
let _duration = parse_period(¶ms.period)?;
|
||||||
|
let interval_str = period_to_interval_str(¶ms.period);
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
COUNT(*) AS total_scrapes, \
|
||||||
|
COUNT(*) FILTER (WHERE success) AS successful_scrapes, \
|
||||||
|
COUNT(*) FILTER (WHERE NOT success) AS failed_scrapes, \
|
||||||
|
(AVG(duration_ms) FILTER (WHERE success))::FLOAT8 AS avg_duration_ms, \
|
||||||
|
COALESCE(SUM(courses_changed) FILTER (WHERE success), 0) AS total_courses_changed, \
|
||||||
|
COALESCE(SUM(courses_fetched) FILTER (WHERE success), 0) AS total_courses_fetched, \
|
||||||
|
COALESCE(SUM(audits_generated) FILTER (WHERE success), 0) AS total_audits_generated \
|
||||||
|
FROM scrape_job_results \
|
||||||
|
WHERE completed_at > NOW() - $1::interval",
|
||||||
|
)
|
||||||
|
.bind(interval_str)
|
||||||
|
.fetch_one(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to fetch scraper stats");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch scraper stats"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let total_scrapes: i64 = row.get("total_scrapes");
|
||||||
|
let successful_scrapes: i64 = row.get("successful_scrapes");
|
||||||
|
let failed_scrapes: i64 = row.get("failed_scrapes");
|
||||||
|
let avg_duration_ms: Option<f64> = row.get("avg_duration_ms");
|
||||||
|
let total_courses_changed: i64 = row.get("total_courses_changed");
|
||||||
|
let total_courses_fetched: i64 = row.get("total_courses_fetched");
|
||||||
|
let total_audits_generated: i64 = row.get("total_audits_generated");
|
||||||
|
|
||||||
|
let queue_row = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
COUNT(*) FILTER (WHERE locked_at IS NULL) AS pending_jobs, \
|
||||||
|
COUNT(*) FILTER (WHERE locked_at IS NOT NULL) AS locked_jobs \
|
||||||
|
FROM scrape_jobs",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to fetch queue stats");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch queue stats"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pending_jobs: i64 = queue_row.get("pending_jobs");
|
||||||
|
let locked_jobs: i64 = queue_row.get("locked_jobs");
|
||||||
|
|
||||||
|
let success_rate = if total_scrapes > 0 {
|
||||||
|
Some(successful_scrapes as f64 / total_scrapes as f64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ScraperStatsResponse {
|
||||||
|
period: params.period,
|
||||||
|
total_scrapes,
|
||||||
|
successful_scrapes,
|
||||||
|
failed_scrapes,
|
||||||
|
success_rate,
|
||||||
|
avg_duration_ms,
|
||||||
|
total_courses_changed,
|
||||||
|
total_courses_fetched,
|
||||||
|
total_audits_generated,
|
||||||
|
pending_jobs,
|
||||||
|
locked_jobs,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint 2: GET /api/admin/scraper/timeseries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TimeseriesParams {
|
||||||
|
#[serde(default = "default_period")]
|
||||||
|
period: String,
|
||||||
|
bucket: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TimeseriesResponse {
|
||||||
|
period: String,
|
||||||
|
bucket: String,
|
||||||
|
points: Vec<TimeseriesPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TimeseriesPoint {
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
scrape_count: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
success_count: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
error_count: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
courses_changed: i64,
|
||||||
|
avg_duration_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scraper_timeseries(
|
||||||
|
_admin: AdminUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<TimeseriesParams>,
|
||||||
|
) -> Result<Json<TimeseriesResponse>, ApiError> {
|
||||||
|
let _duration = parse_period(¶ms.period)?;
|
||||||
|
let period_interval = period_to_interval_str(¶ms.period);
|
||||||
|
|
||||||
|
let bucket_code = match ¶ms.bucket {
|
||||||
|
Some(b) => {
|
||||||
|
// Validate the bucket
|
||||||
|
parse_bucket(b)?;
|
||||||
|
b.as_str()
|
||||||
|
}
|
||||||
|
None => default_bucket_for_period(¶ms.period),
|
||||||
|
};
|
||||||
|
let bucket_interval = parse_bucket(bucket_code)?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"WITH buckets AS ( \
|
||||||
|
SELECT generate_series( \
|
||||||
|
date_bin($1::interval, NOW() - $2::interval, '2020-01-01'::timestamptz), \
|
||||||
|
date_bin($1::interval, NOW(), '2020-01-01'::timestamptz), \
|
||||||
|
$1::interval \
|
||||||
|
) AS bucket_start \
|
||||||
|
), \
|
||||||
|
raw AS ( \
|
||||||
|
SELECT date_bin($1::interval, completed_at, '2020-01-01'::timestamptz) AS bucket_start, \
|
||||||
|
COUNT(*)::BIGINT AS scrape_count, \
|
||||||
|
COUNT(*) FILTER (WHERE success)::BIGINT AS success_count, \
|
||||||
|
COUNT(*) FILTER (WHERE NOT success)::BIGINT AS error_count, \
|
||||||
|
COALESCE(SUM(courses_changed) FILTER (WHERE success), 0)::BIGINT AS courses_changed, \
|
||||||
|
COALESCE(AVG(duration_ms) FILTER (WHERE success), 0)::FLOAT8 AS avg_duration_ms \
|
||||||
|
FROM scrape_job_results \
|
||||||
|
WHERE completed_at > NOW() - $2::interval \
|
||||||
|
GROUP BY 1 \
|
||||||
|
) \
|
||||||
|
SELECT b.bucket_start, \
|
||||||
|
COALESCE(r.scrape_count, 0) AS scrape_count, \
|
||||||
|
COALESCE(r.success_count, 0) AS success_count, \
|
||||||
|
COALESCE(r.error_count, 0) AS error_count, \
|
||||||
|
COALESCE(r.courses_changed, 0) AS courses_changed, \
|
||||||
|
COALESCE(r.avg_duration_ms, 0) AS avg_duration_ms \
|
||||||
|
FROM buckets b \
|
||||||
|
LEFT JOIN raw r ON b.bucket_start = r.bucket_start \
|
||||||
|
ORDER BY b.bucket_start",
|
||||||
|
)
|
||||||
|
.bind(bucket_interval)
|
||||||
|
.bind(period_interval)
|
||||||
|
.fetch_all(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to fetch scraper timeseries");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch scraper timeseries"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let points = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| TimeseriesPoint {
|
||||||
|
timestamp: row.get("bucket_start"),
|
||||||
|
scrape_count: row.get("scrape_count"),
|
||||||
|
success_count: row.get("success_count"),
|
||||||
|
error_count: row.get("error_count"),
|
||||||
|
courses_changed: row.get("courses_changed"),
|
||||||
|
avg_duration_ms: row.get("avg_duration_ms"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TimeseriesResponse {
|
||||||
|
period: params.period,
|
||||||
|
bucket: bucket_code.to_string(),
|
||||||
|
points,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint 3: GET /api/admin/scraper/subjects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubjectsResponse {
|
||||||
|
subjects: Vec<SubjectSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubjectSummary {
|
||||||
|
subject: String,
|
||||||
|
subject_description: Option<String>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
tracked_course_count: i64,
|
||||||
|
schedule_state: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
current_interval_secs: u64,
|
||||||
|
time_multiplier: u32,
|
||||||
|
last_scraped: DateTime<Utc>,
|
||||||
|
next_eligible_at: Option<DateTime<Utc>>,
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
cooldown_remaining_secs: Option<u64>,
|
||||||
|
avg_change_ratio: f64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
consecutive_zero_changes: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
recent_runs: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
recent_failures: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scraper_subjects(
|
||||||
|
_admin: AdminUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<SubjectsResponse>, ApiError> {
|
||||||
|
let raw_stats = scrape_jobs::fetch_subject_stats(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to fetch subject stats");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch subject stats"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let multiplier = adaptive::time_of_day_multiplier(now);
|
||||||
|
|
||||||
|
// Look up subject descriptions from the reference cache
|
||||||
|
let ref_cache = state.reference_cache.read().await;
|
||||||
|
|
||||||
|
// Count tracked courses per subject for the current term
|
||||||
|
let term = Term::get_current().inner().to_string();
|
||||||
|
let course_counts: std::collections::HashMap<String, i64> = sqlx::query_as(
|
||||||
|
"SELECT subject, COUNT(*)::BIGINT AS cnt FROM courses WHERE term_code = $1 GROUP BY subject",
|
||||||
|
)
|
||||||
|
.bind(&term)
|
||||||
|
.fetch_all(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to fetch course counts");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch course counts"})),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(subject, cnt): (String, i64)| (subject, cnt))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let subjects = raw_stats
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let stats: SubjectStats = row.into();
|
||||||
|
let schedule = adaptive::evaluate_subject(&stats, now, false);
|
||||||
|
let base_interval = adaptive::compute_base_interval(&stats);
|
||||||
|
|
||||||
|
let schedule_state = match &schedule {
|
||||||
|
SubjectSchedule::Eligible(_) => "eligible",
|
||||||
|
SubjectSchedule::Cooldown(_) => "cooldown",
|
||||||
|
SubjectSchedule::Paused => "paused",
|
||||||
|
SubjectSchedule::ReadOnly => "read_only",
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_interval_secs = base_interval.as_secs() * multiplier as u64;
|
||||||
|
|
||||||
|
let (next_eligible_at, cooldown_remaining_secs) = match &schedule {
|
||||||
|
SubjectSchedule::Eligible(_) => (Some(now), Some(0)),
|
||||||
|
SubjectSchedule::Cooldown(remaining) => {
|
||||||
|
let remaining_secs = remaining.as_secs();
|
||||||
|
(
|
||||||
|
Some(now + chrono::Duration::seconds(remaining_secs as i64)),
|
||||||
|
Some(remaining_secs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SubjectSchedule::Paused | SubjectSchedule::ReadOnly => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject_description = ref_cache
|
||||||
|
.lookup("subject", &stats.subject)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let tracked_course_count = course_counts.get(&stats.subject).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
SubjectSummary {
|
||||||
|
subject: stats.subject,
|
||||||
|
subject_description,
|
||||||
|
tracked_course_count,
|
||||||
|
schedule_state: schedule_state.to_string(),
|
||||||
|
current_interval_secs,
|
||||||
|
time_multiplier: multiplier,
|
||||||
|
last_scraped: stats.last_completed,
|
||||||
|
next_eligible_at,
|
||||||
|
cooldown_remaining_secs,
|
||||||
|
avg_change_ratio: stats.avg_change_ratio,
|
||||||
|
consecutive_zero_changes: stats.consecutive_zero_changes,
|
||||||
|
recent_runs: stats.recent_runs,
|
||||||
|
recent_failures: stats.recent_failure_count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(SubjectsResponse { subjects }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint 4: GET /api/admin/scraper/subjects/{subject}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SubjectDetailParams {
|
||||||
|
#[serde(default = "default_detail_limit")]
|
||||||
|
limit: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_detail_limit() -> i32 {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubjectDetailResponse {
|
||||||
|
subject: String,
|
||||||
|
results: Vec<SubjectResultEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubjectResultEntry {
|
||||||
|
#[ts(type = "number")]
|
||||||
|
id: i64,
|
||||||
|
completed_at: DateTime<Utc>,
|
||||||
|
duration_ms: i32,
|
||||||
|
success: bool,
|
||||||
|
error_message: Option<String>,
|
||||||
|
courses_fetched: Option<i32>,
|
||||||
|
courses_changed: Option<i32>,
|
||||||
|
courses_unchanged: Option<i32>,
|
||||||
|
audits_generated: Option<i32>,
|
||||||
|
metrics_generated: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scraper_subject_detail(
|
||||||
|
_admin: AdminUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(subject): Path<String>,
|
||||||
|
Query(params): Query<SubjectDetailParams>,
|
||||||
|
) -> Result<Json<SubjectDetailResponse>, ApiError> {
|
||||||
|
let limit = params.limit.clamp(1, 200);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, completed_at, duration_ms, success, error_message, \
|
||||||
|
courses_fetched, courses_changed, courses_unchanged, \
|
||||||
|
audits_generated, metrics_generated \
|
||||||
|
FROM scrape_job_results \
|
||||||
|
WHERE target_type = 'Subject' AND payload->>'subject' = $1 \
|
||||||
|
ORDER BY completed_at DESC \
|
||||||
|
LIMIT $2",
|
||||||
|
)
|
||||||
|
.bind(&subject)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, subject = %subject, "Failed to fetch subject detail");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to fetch subject detail"})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let results = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| SubjectResultEntry {
|
||||||
|
id: row.get("id"),
|
||||||
|
completed_at: row.get("completed_at"),
|
||||||
|
duration_ms: row.get("duration_ms"),
|
||||||
|
success: row.get("success"),
|
||||||
|
error_message: row.get("error_message"),
|
||||||
|
courses_fetched: row.get("courses_fetched"),
|
||||||
|
courses_changed: row.get("courses_changed"),
|
||||||
|
courses_unchanged: row.get("courses_unchanged"),
|
||||||
|
audits_generated: row.get("audits_generated"),
|
||||||
|
metrics_generated: row.get("metrics_generated"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(SubjectDetailResponse { subject, results }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! Web API endpoints for calendar export (ICS download + Google Calendar redirect).
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::calendar::{CalendarCourse, generate_gcal_url, generate_ics};
|
||||||
|
use crate::data::models::DbMeetingTime;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Fetch course + meeting times, build a `CalendarCourse`.
|
||||||
|
async fn load_calendar_course(
|
||||||
|
state: &AppState,
|
||||||
|
term: &str,
|
||||||
|
crn: &str,
|
||||||
|
) -> Result<(CalendarCourse, Vec<DbMeetingTime>), (StatusCode, String)> {
|
||||||
|
let course = crate::data::courses::get_course_by_crn(&state.db_pool, crn, term)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Calendar: course lookup failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Lookup failed".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Course not found".to_string()))?;
|
||||||
|
|
||||||
|
let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let primary_instructor = instructors
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.is_primary)
|
||||||
|
.or(instructors.first())
|
||||||
|
.map(|i| i.display_name.clone());
|
||||||
|
|
||||||
|
let meeting_times: Vec<DbMeetingTime> =
|
||||||
|
serde_json::from_value(course.meeting_times.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let cal_course = CalendarCourse {
|
||||||
|
crn: course.crn.clone(),
|
||||||
|
subject: course.subject.clone(),
|
||||||
|
course_number: course.course_number.clone(),
|
||||||
|
title: course.title.clone(),
|
||||||
|
sequence_number: course.sequence_number.clone(),
|
||||||
|
primary_instructor,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((cal_course, meeting_times))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/courses/{term}/{crn}/calendar.ics`
|
||||||
|
///
|
||||||
|
/// Returns an ICS file download for the course.
|
||||||
|
pub async fn course_ics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((term, crn)): Path<(String, String)>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?;
|
||||||
|
|
||||||
|
if meeting_times.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"No meeting times found for this course".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = generate_ics(&cal_course, &meeting_times).map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "ICS generation failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate ICS file".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = (
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/calendar; charset=utf-8"),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
&format!("attachment; filename=\"{}\"", result.filename),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, "no-cache"),
|
||||||
|
],
|
||||||
|
result.content,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/courses/{term}/{crn}/gcal`
|
||||||
|
///
|
||||||
|
/// Redirects to Google Calendar with a pre-filled event for the first meeting time.
|
||||||
|
/// If multiple meeting times exist, uses the first one with scheduled days/times.
|
||||||
|
pub async fn course_gcal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((term, crn)): Path<(String, String)>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?;
|
||||||
|
|
||||||
|
if meeting_times.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"No meeting times found for this course".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the first meeting time that has actual days/times scheduled
|
||||||
|
let mt = meeting_times
|
||||||
|
.iter()
|
||||||
|
.find(|mt| {
|
||||||
|
mt.begin_time.is_some()
|
||||||
|
&& (mt.monday
|
||||||
|
|| mt.tuesday
|
||||||
|
|| mt.wednesday
|
||||||
|
|| mt.thursday
|
||||||
|
|| mt.friday
|
||||||
|
|| mt.saturday
|
||||||
|
|| mt.sunday)
|
||||||
|
})
|
||||||
|
.unwrap_or(&meeting_times[0]);
|
||||||
|
|
||||||
|
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Google Calendar URL generation failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate Google Calendar URL".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(&url).into_response())
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod admin_rmp;
|
pub mod admin_rmp;
|
||||||
|
pub mod admin_scraper;
|
||||||
#[cfg(feature = "embed-assets")]
|
#[cfg(feature = "embed-assets")]
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod calendar;
|
||||||
#[cfg(feature = "embed-assets")]
|
#[cfg(feature = "embed-assets")]
|
||||||
pub mod encoding;
|
pub mod encoding;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use axum::{
|
|||||||
|
|
||||||
use crate::web::admin;
|
use crate::web::admin;
|
||||||
use crate::web::admin_rmp;
|
use crate::web::admin_rmp;
|
||||||
|
use crate::web::admin_scraper;
|
||||||
use crate::web::auth::{self, AuthConfig};
|
use crate::web::auth::{self, AuthConfig};
|
||||||
|
use crate::web::calendar;
|
||||||
use crate::web::ws;
|
use crate::web::ws;
|
||||||
#[cfg(feature = "embed-assets")]
|
#[cfg(feature = "embed-assets")]
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -44,6 +46,11 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
|||||||
.route("/metrics", get(metrics))
|
.route("/metrics", get(metrics))
|
||||||
.route("/courses/search", get(search_courses))
|
.route("/courses/search", get(search_courses))
|
||||||
.route("/courses/{term}/{crn}", get(get_course))
|
.route("/courses/{term}/{crn}", get(get_course))
|
||||||
|
.route(
|
||||||
|
"/courses/{term}/{crn}/calendar.ics",
|
||||||
|
get(calendar::course_ics),
|
||||||
|
)
|
||||||
|
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
|
||||||
.route("/terms", get(get_terms))
|
.route("/terms", get(get_terms))
|
||||||
.route("/subjects", get(get_subjects))
|
.route("/subjects", get(get_subjects))
|
||||||
.route("/reference/{category}", get(get_reference))
|
.route("/reference/{category}", get(get_reference))
|
||||||
@@ -86,6 +93,19 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
|||||||
post(admin_rmp::unmatch_instructor),
|
post(admin_rmp::unmatch_instructor),
|
||||||
)
|
)
|
||||||
.route("/admin/rmp/rescore", post(admin_rmp::rescore))
|
.route("/admin/rmp/rescore", post(admin_rmp::rescore))
|
||||||
|
.route("/admin/scraper/stats", get(admin_scraper::scraper_stats))
|
||||||
|
.route(
|
||||||
|
"/admin/scraper/timeseries",
|
||||||
|
get(admin_scraper::scraper_timeseries),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/admin/scraper/subjects",
|
||||||
|
get(admin_scraper::scraper_subjects),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/admin/scraper/subjects/{subject}",
|
||||||
|
get(admin_scraper::scraper_subject_detail),
|
||||||
|
)
|
||||||
.with_state(app_state);
|
.with_state(app_state);
|
||||||
|
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
|
|||||||
+199
@@ -10,6 +10,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^4.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"layerchart": "^1.0.13",
|
||||||
"overlayscrollbars": "^2.14.0",
|
"overlayscrollbars": "^2.14.0",
|
||||||
"overlayscrollbars-svelte": "^0.5.5",
|
"overlayscrollbars-svelte": "^0.5.5",
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
||||||
|
|
||||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||||
@@ -70,6 +73,10 @@
|
|||||||
|
|
||||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||||
|
|
||||||
|
"@dagrejs/dagre": ["@dagrejs/dagre@1.1.8", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw=="],
|
||||||
|
|
||||||
|
"@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
@@ -144,8 +151,22 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "date-fns": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ=="],
|
||||||
|
|
||||||
|
"@layerstack/svelte-stores": ["@layerstack/svelte-stores@1.0.2", "", { "dependencies": { "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "immer": "^10.1.1", "lodash-es": "^4.17.21", "zod": "^3.24.2" } }, "sha512-IxK0UKD0PVxg1VsyaR+n7NyJ+NlvyqvYYAp+J10lkjDQxm0yx58CaF2LBV08T22C3aY1iTlqJaatn/VHV4SoQg=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind": ["@layerstack/tailwind@1.0.1", "", { "dependencies": { "@layerstack/utils": "^1.0.1", "clsx": "^2.1.1", "culori": "^4.0.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "lodash-es": "^4.17.21", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.15" } }, "sha512-nlshEkUCfaV0zYzrFXVVYRnS8bnBjs4M7iui6l/tu6NeBBlxDivIyRraJkdYGCSL1lZHi6FqacLQ3eerHtz90A=="],
|
||||||
|
|
||||||
|
"@layerstack/utils": ["@layerstack/utils@1.0.1", "", { "dependencies": { "d3-array": "^3.2.4", "date-fns": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-sWP9b+SFMkJYMZyYFI01aLxbg2ZUrix6Tv+BCDmeOrcLNxtWFsMYAomMhALzTMHbb+Vis/ua5vXhpdNXEw8a2Q=="],
|
||||||
|
|
||||||
"@lucide/svelte": ["@lucide/svelte@0.563.1", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-Kt+MbnE5D9RsuI/csmf7M+HWxALe57x3A0DhQ8pPnnUpneh7zuldrYjlT+veWtk+tVnp5doQtaAAxLujzIlhBw=="],
|
"@lucide/svelte": ["@lucide/svelte@0.563.1", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-Kt+MbnE5D9RsuI/csmf7M+HWxALe57x3A0DhQ8pPnnUpneh7zuldrYjlT+veWtk+tVnp5doQtaAAxLujzIlhBw=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
|
|
||||||
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
||||||
@@ -282,16 +303,28 @@
|
|||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||||
|
|
||||||
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
|
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.6.4", "@floating-ui/dom": "^1.6.7", "@internationalized/date": "^3.5.6", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.23.2", "svelte-toolbelt": "^0.7.1", "tabbable": "^6.2.0" }, "peerDependencies": { "svelte": "^5.11.0" } }, "sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg=="],
|
"bits-ui": ["bits-ui@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.6.4", "@floating-ui/dom": "^1.6.7", "@internationalized/date": "^3.5.6", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.23.2", "svelte-toolbelt": "^0.7.1", "tabbable": "^6.2.0" }, "peerDependencies": { "svelte": "^5.11.0" } }, "sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
|
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
||||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||||
@@ -300,30 +333,66 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||||
|
|
||||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||||
|
|
||||||
|
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
|
||||||
|
|
||||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
|
||||||
|
|
||||||
|
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||||
|
|
||||||
|
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
|
||||||
|
|
||||||
|
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||||
|
|
||||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||||
|
|
||||||
|
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
||||||
|
|
||||||
|
"d3-geo-voronoi": ["d3-geo-voronoi@2.1.0", "", { "dependencies": { "d3-array": "3", "d3-delaunay": "6", "d3-geo": "3", "d3-tricontour": "1" } }, "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q=="],
|
||||||
|
|
||||||
|
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
|
||||||
|
|
||||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-interpolate-path": ["d3-interpolate-path@2.3.0", "", {}, "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ=="],
|
||||||
|
|
||||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||||
|
|
||||||
|
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
|
||||||
|
|
||||||
|
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
|
||||||
|
|
||||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
|
||||||
|
|
||||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-tile": ["d3-tile@1.0.0", "", {}, "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q=="],
|
||||||
|
|
||||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"d3-tricontour": ["d3-tricontour@1.1.0", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ=="],
|
||||||
|
|
||||||
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
|
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
|
||||||
|
|
||||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
@@ -336,10 +405,16 @@
|
|||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||||
|
|
||||||
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||||
|
|
||||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
@@ -356,12 +431,24 @@
|
|||||||
|
|
||||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
|
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
|
|
||||||
|
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
@@ -370,10 +457,22 @@
|
|||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
|
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||||
|
|
||||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
@@ -386,6 +485,10 @@
|
|||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"layercake": ["layercake@8.4.3", "", { "dependencies": { "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0" }, "peerDependencies": { "svelte": "3 - 5 || >=5.0.0-next.120", "typescript": "^5.0.2" } }, "sha512-PZDduaPFxgHHkxlmsz5MVBECf6ZCT39DI3LgMVvuMwrmlrtlXwXUM/elJp46zHYzCE1j+cGyDuBDxnANv94tOQ=="],
|
||||||
|
|
||||||
|
"layerchart": ["layerchart@1.0.13", "", { "dependencies": { "@dagrejs/dagre": "^1.1.4", "@layerstack/svelte-actions": "^1.0.1", "@layerstack/svelte-stores": "^1.0.2", "@layerstack/tailwind": "^1.0.1", "@layerstack/utils": "^1.0.1", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", "d3-dsv": "^3.0.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "d3-geo-voronoi": "^2.1.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", "date-fns": "^4.1.0", "layercake": "8.4.3", "lodash-es": "^4.17.21" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0" } }, "sha512-bjcrfyTdHtfYZn7yj26dvA1qUjM+R6+akp2VeBJ4JWKmDGhb5WvT9nMCs52Rb+gSd/omFq5SjZLz49MqlVljZw=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
@@ -410,30 +513,50 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||||
|
|
||||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
|
|
||||||
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
|
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
|
||||||
|
|
||||||
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
"overlayscrollbars": ["overlayscrollbars@2.14.0", "", {}, "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig=="],
|
"overlayscrollbars": ["overlayscrollbars@2.14.0", "", {}, "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig=="],
|
||||||
|
|
||||||
"overlayscrollbars-svelte": ["overlayscrollbars-svelte@0.5.5", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "svelte": "^5.0.0" } }, "sha512-+dRW3YZSvFbKi5vDCpnUOHuoPLLSdu0BUVVMYZdmfVghu7XkafDRebG2y91/ImPqj6YDAUsz1rcWVYhCJSS/pQ=="],
|
"overlayscrollbars-svelte": ["overlayscrollbars-svelte@0.5.5", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "svelte": "^5.0.0" } }, "sha512-+dRW3YZSvFbKi5vDCpnUOHuoPLLSdu0BUVVMYZdmfVghu7XkafDRebG2y91/ImPqj6YDAUsz1rcWVYhCJSS/pQ=="],
|
||||||
|
|
||||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
@@ -442,18 +565,48 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||||
|
|
||||||
|
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
|
|
||||||
|
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||||
|
|
||||||
|
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|
||||||
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||||
|
|
||||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||||
|
|
||||||
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
"runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
||||||
|
|
||||||
|
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
@@ -476,6 +629,10 @@
|
|||||||
|
|
||||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||||
|
|
||||||
|
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.49.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Fn2mCc3XX0gnnbBYzWOTrZHi5WnF9KvqmB1+KGlUWoJkdioPmFYtg2ALBr6xl2dcnFTz3Vi7/mHpbKSVg/imVg=="],
|
"svelte": ["svelte@5.49.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Fn2mCc3XX0gnnbBYzWOTrZHi5WnF9KvqmB1+KGlUWoJkdioPmFYtg2ALBr6xl2dcnFTz3Vi7/mHpbKSVg/imVg=="],
|
||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||||
@@ -492,6 +649,10 @@
|
|||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
@@ -508,18 +669,24 @@
|
|||||||
|
|
||||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
||||||
|
|
||||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||||
|
|
||||||
|
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||||
|
|
||||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||||
@@ -548,6 +715,12 @@
|
|||||||
|
|
||||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -559,5 +732,31 @@
|
|||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
||||||
|
|
||||||
|
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||||
|
|
||||||
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
|
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||||
|
|
||||||
|
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"@layerstack/tailwind/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^4.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"layerchart": "^1.0.13",
|
||||||
"overlayscrollbars": "^2.14.0",
|
"overlayscrollbars": "^2.14.0",
|
||||||
"overlayscrollbars-svelte": "^0.5.5"
|
"overlayscrollbars-svelte": "^0.5.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ import type {
|
|||||||
LinkedRmpProfile,
|
LinkedRmpProfile,
|
||||||
ListInstructorsResponse,
|
ListInstructorsResponse,
|
||||||
RescoreResponse,
|
RescoreResponse,
|
||||||
|
ScraperStatsResponse,
|
||||||
SearchResponse as SearchResponseGenerated,
|
SearchResponse as SearchResponseGenerated,
|
||||||
ServiceInfo,
|
ServiceInfo,
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
|
SubjectDetailResponse,
|
||||||
|
SubjectResultEntry,
|
||||||
|
SubjectSummary,
|
||||||
|
SubjectsResponse,
|
||||||
|
TimeseriesPoint,
|
||||||
|
TimeseriesResponse,
|
||||||
TopCandidateResponse,
|
TopCandidateResponse,
|
||||||
User,
|
User,
|
||||||
} from "$lib/bindings";
|
} from "$lib/bindings";
|
||||||
@@ -35,9 +42,16 @@ export type {
|
|||||||
LinkedRmpProfile,
|
LinkedRmpProfile,
|
||||||
ListInstructorsResponse,
|
ListInstructorsResponse,
|
||||||
RescoreResponse,
|
RescoreResponse,
|
||||||
|
ScraperStatsResponse,
|
||||||
ServiceInfo,
|
ServiceInfo,
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
|
SubjectDetailResponse,
|
||||||
|
SubjectResultEntry,
|
||||||
|
SubjectSummary,
|
||||||
|
SubjectsResponse,
|
||||||
|
TimeseriesPoint,
|
||||||
|
TimeseriesResponse,
|
||||||
TopCandidateResponse,
|
TopCandidateResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +63,8 @@ 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;
|
||||||
|
|
||||||
|
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
// 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";
|
||||||
@@ -341,6 +357,32 @@ export class BannerApiClient {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scraper analytics endpoints
|
||||||
|
|
||||||
|
async getScraperStats(period?: ScraperPeriod): Promise<ScraperStatsResponse> {
|
||||||
|
const qs = period ? `?period=${period}` : "";
|
||||||
|
return this.request<ScraperStatsResponse>(`/admin/scraper/stats${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScraperTimeseries(period?: ScraperPeriod, bucket?: string): Promise<TimeseriesResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (period) query.set("period", period);
|
||||||
|
if (bucket) query.set("bucket", bucket);
|
||||||
|
const qs = query.toString();
|
||||||
|
return this.request<TimeseriesResponse>(`/admin/scraper/timeseries${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScraperSubjects(): Promise<SubjectsResponse> {
|
||||||
|
return this.request<SubjectsResponse>("/admin/scraper/subjects");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScraperSubjectDetail(subject: string, limit?: number): Promise<SubjectDetailResponse> {
|
||||||
|
const qs = limit !== undefined ? `?limit=${limit}` : "";
|
||||||
|
return this.request<SubjectDetailResponse>(
|
||||||
|
`/admin/scraper/subjects/${encodeURIComponent(subject)}${qs}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = new BannerApiClient();
|
export const client = new BannerApiClient();
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ export type { LinkedRmpProfile } from "./LinkedRmpProfile";
|
|||||||
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
||||||
export type { OkResponse } from "./OkResponse";
|
export type { OkResponse } from "./OkResponse";
|
||||||
export type { RescoreResponse } from "./RescoreResponse";
|
export type { RescoreResponse } from "./RescoreResponse";
|
||||||
|
export type { ScraperStatsResponse } from "./ScraperStatsResponse";
|
||||||
export type { SearchResponse } from "./SearchResponse";
|
export type { SearchResponse } from "./SearchResponse";
|
||||||
export type { ServiceInfo } from "./ServiceInfo";
|
export type { ServiceInfo } from "./ServiceInfo";
|
||||||
export type { ServiceStatus } from "./ServiceStatus";
|
export type { ServiceStatus } from "./ServiceStatus";
|
||||||
export type { StatusResponse } from "./StatusResponse";
|
export type { StatusResponse } from "./StatusResponse";
|
||||||
|
export type { SubjectDetailResponse } from "./SubjectDetailResponse";
|
||||||
|
export type { SubjectResultEntry } from "./SubjectResultEntry";
|
||||||
|
export type { SubjectSummary } from "./SubjectSummary";
|
||||||
|
export type { SubjectsResponse } from "./SubjectsResponse";
|
||||||
|
export type { TimeseriesPoint } from "./TimeseriesPoint";
|
||||||
|
export type { TimeseriesResponse } from "./TimeseriesResponse";
|
||||||
export type { TopCandidateResponse } from "./TopCandidateResponse";
|
export type { TopCandidateResponse } from "./TopCandidateResponse";
|
||||||
export type { User } from "./User";
|
export type { User } from "./User";
|
||||||
|
|||||||
@@ -16,7 +16,16 @@ import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
|||||||
import { cn, tooltipContentClass, formatNumber } from "$lib/utils";
|
import { cn, tooltipContentClass, formatNumber } 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, Star, Triangle, ExternalLink } from "@lucide/svelte";
|
import {
|
||||||
|
Info,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Star,
|
||||||
|
Triangle,
|
||||||
|
ExternalLink,
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
let { course }: { course: CourseResponse } = $props();
|
let { course }: { course: CourseResponse } = $props();
|
||||||
|
|
||||||
@@ -302,5 +311,42 @@ const clipboard = useClipboard();
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Calendar Export -->
|
||||||
|
{#if course.meetingTimes.length > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
Calendar
|
||||||
|
<SimpleTooltip
|
||||||
|
text="Export this course schedule to your calendar app"
|
||||||
|
delay={150}
|
||||||
|
passthrough
|
||||||
|
>
|
||||||
|
<Info class="size-3 text-muted-foreground/50" />
|
||||||
|
</SimpleTooltip>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<a
|
||||||
|
href="/api/courses/{course.termCode}/{course.crn}/calendar.ics"
|
||||||
|
download
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Download class="size-3.5" />
|
||||||
|
ICS File
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/api/courses/{course.termCode}/{course.crn}/gcal"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Calendar class="size-3.5" />
|
||||||
|
Google Calendar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ export function formatDuration(ms: number): string {
|
|||||||
* Uses {@link formatDuration} for the text, plus computes the optimal refresh
|
* Uses {@link formatDuration} for the text, plus computes the optimal refresh
|
||||||
* interval so callers can schedule the next update efficiently.
|
* interval so callers can schedule the next update efficiently.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Format a millisecond duration with a dynamic unit, optimised for
|
||||||
|
* scrape-style timings that are typically under 60 seconds.
|
||||||
|
*
|
||||||
|
* - < 1 000 ms → "423ms"
|
||||||
|
* - < 10 000 ms → "4.52s" (two decimals)
|
||||||
|
* - < 60 000 ms → "16.9s" (one decimal)
|
||||||
|
* - ≥ 60 000 ms → delegates to {@link formatDuration} ("1m 5s")
|
||||||
|
*/
|
||||||
|
export function formatDurationMs(ms: number): string {
|
||||||
|
const abs = Math.abs(ms);
|
||||||
|
if (abs < 1_000) return `${Math.round(abs)}ms`;
|
||||||
|
if (abs < 10_000) return `${(abs / 1_000).toFixed(2)}s`;
|
||||||
|
if (abs < 60_000) return `${(abs / 1_000).toFixed(1)}s`;
|
||||||
|
return formatDuration(ms);
|
||||||
|
}
|
||||||
|
|
||||||
export function relativeTime(date: Date, ref: Date): RelativeTimeResult {
|
export function relativeTime(date: Date, ref: Date): RelativeTimeResult {
|
||||||
const diffMs = ref.getTime() - date.getTime();
|
const diffMs = ref.getTime() - date.getTime();
|
||||||
const totalSeconds = Math.floor(diffMs / 1000);
|
const totalSeconds = Math.floor(diffMs / 1000);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authStore } from "$lib/auth.svelte";
|
|||||||
import PageTransition from "$lib/components/PageTransition.svelte";
|
import PageTransition from "$lib/components/PageTransition.svelte";
|
||||||
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
|
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
@@ -59,6 +60,7 @@ const userItems = [
|
|||||||
|
|
||||||
const adminItems = [
|
const adminItems = [
|
||||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/admin/scraper", label: "Scraper", icon: Activity },
|
||||||
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
|
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
|
||||||
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
|
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
|
||||||
{ href: "/admin/users", label: "Users", icon: Users },
|
{ href: "/admin/users", label: "Users", icon: Users },
|
||||||
|
|||||||
@@ -0,0 +1,756 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
import type {
|
||||||
|
ScraperStatsResponse,
|
||||||
|
SubjectDetailResponse,
|
||||||
|
SubjectSummary,
|
||||||
|
TimeseriesResponse,
|
||||||
|
} from "$lib/bindings";
|
||||||
|
|
||||||
|
// Persisted across navigation so returning to the page shows cached data.
|
||||||
|
let stats = $state<ScraperStatsResponse | null>(null);
|
||||||
|
let timeseries = $state<TimeseriesResponse | null>(null);
|
||||||
|
let subjects = $state<SubjectSummary[]>([]);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let refreshError = $state(false);
|
||||||
|
let refreshInterval = 5_000;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { client, type ScraperPeriod } from "$lib/api";
|
||||||
|
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||||
|
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
|
||||||
|
import { formatAbsoluteDate } from "$lib/date";
|
||||||
|
import { formatDuration, formatDurationMs, relativeTime } from "$lib/time";
|
||||||
|
import { formatNumber } from "$lib/utils";
|
||||||
|
import { Chart, Svg, Area, Axis, Highlight, Tooltip } from "layerchart";
|
||||||
|
import { curveMonotoneX } from "d3-shape";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { Tween } from "svelte/motion";
|
||||||
|
import { scaleTime, scaleLinear } from "d3-scale";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
LoaderCircle,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUpDown,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
type Updater,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
} from "@tanstack/table-core";
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
import { fade, slide } from "svelte/transition";
|
||||||
|
|
||||||
|
const PERIODS: ScraperPeriod[] = ["1h", "6h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
let selectedPeriod = $state<ScraperPeriod>("24h");
|
||||||
|
|
||||||
|
// Expanded subject detail
|
||||||
|
let expandedSubject = $state<string | null>(null);
|
||||||
|
let subjectDetail = $state<SubjectDetailResponse | null>(null);
|
||||||
|
let detailLoading = $state(false);
|
||||||
|
|
||||||
|
// Live-updating clock for relative timestamps
|
||||||
|
let now = $state(new Date());
|
||||||
|
let tickTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function scheduleTick() {
|
||||||
|
tickTimer = setTimeout(() => {
|
||||||
|
now = new Date();
|
||||||
|
scheduleTick();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-refresh with backoff (ported from audit log) ---
|
||||||
|
const MIN_INTERVAL = 5_000;
|
||||||
|
const MAX_INTERVAL = 60_000;
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const MIN_SPIN_MS = 700;
|
||||||
|
let spinnerVisible = $state(false);
|
||||||
|
let spinHoldTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
refreshError = false;
|
||||||
|
spinnerVisible = true;
|
||||||
|
clearTimeout(spinHoldTimer);
|
||||||
|
const startedAt = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsRes, timeseriesRes, subjectsRes] = await Promise.all([
|
||||||
|
client.getScraperStats(selectedPeriod),
|
||||||
|
client.getScraperTimeseries(selectedPeriod),
|
||||||
|
client.getScraperSubjects(),
|
||||||
|
]);
|
||||||
|
stats = statsRes;
|
||||||
|
timeseries = timeseriesRes;
|
||||||
|
subjects = subjectsRes.subjects;
|
||||||
|
error = null;
|
||||||
|
refreshInterval = MIN_INTERVAL;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Failed to load scraper data";
|
||||||
|
refreshError = true;
|
||||||
|
refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL);
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const remaining = MIN_SPIN_MS - elapsed;
|
||||||
|
if (remaining > 0) {
|
||||||
|
spinHoldTimer = setTimeout(() => {
|
||||||
|
spinnerVisible = false;
|
||||||
|
}, remaining);
|
||||||
|
} else {
|
||||||
|
spinnerVisible = false;
|
||||||
|
}
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh() {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = setTimeout(fetchAll, refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSubjectDetail(subject: string) {
|
||||||
|
if (expandedSubject === subject) {
|
||||||
|
expandedSubject = null;
|
||||||
|
subjectDetail = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandedSubject = subject;
|
||||||
|
detailLoading = true;
|
||||||
|
try {
|
||||||
|
subjectDetail = await client.getScraperSubjectDetail(subject);
|
||||||
|
} catch {
|
||||||
|
subjectDetail = null;
|
||||||
|
} finally {
|
||||||
|
detailLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chart data ---
|
||||||
|
|
||||||
|
type ChartPoint = { date: Date; success: number; errors: number; coursesChanged: number };
|
||||||
|
|
||||||
|
let chartData = $derived(
|
||||||
|
(timeseries?.points ?? []).map((p) => ({
|
||||||
|
date: new Date(p.timestamp),
|
||||||
|
success: p.successCount,
|
||||||
|
errors: p.errorCount,
|
||||||
|
coursesChanged: p.coursesChanged,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tween the data array so stacked areas stay aligned (both read the same interpolated values each frame)
|
||||||
|
const tweenedChart = new Tween<ChartPoint[]>([], {
|
||||||
|
duration: 600,
|
||||||
|
easing: cubicOut,
|
||||||
|
interpolate(from, to) {
|
||||||
|
// Different lengths: snap immediately (period change reshapes the array)
|
||||||
|
if (from.length !== to.length) return () => to;
|
||||||
|
return (t) =>
|
||||||
|
to.map((dest, i) => ({
|
||||||
|
date: dest.date,
|
||||||
|
success: from[i].success + (dest.success - from[i].success) * t,
|
||||||
|
errors: from[i].errors + (dest.errors - from[i].errors) * t,
|
||||||
|
coursesChanged: from[i].coursesChanged + (dest.coursesChanged - from[i].coursesChanged) * t,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedChart.set(chartData);
|
||||||
|
});
|
||||||
|
|
||||||
|
let scrapeYMax = $derived(Math.max(1, ...chartData.map((d) => d.success + d.errors)));
|
||||||
|
let changesYMax = $derived(Math.max(1, ...chartData.map((d) => d.coursesChanged)));
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function formatInterval(secs: number): string {
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
if (secs < 3600) return `${Math.round(secs / 60)}m`;
|
||||||
|
return `${(secs / 3600).toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function successRateColor(rate: number): string {
|
||||||
|
if (rate >= 0.95) return "text-green-600 dark:text-green-400";
|
||||||
|
if (rate >= 0.8) return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
return "text-red-600 dark:text-red-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted class for zero/default values, foreground for interesting ones. */
|
||||||
|
function emphasisClass(value: number, zeroIsDefault = true): string {
|
||||||
|
if (zeroIsDefault) {
|
||||||
|
return value === 0 ? "text-muted-foreground" : "text-foreground";
|
||||||
|
}
|
||||||
|
return value === 1 ? "text-muted-foreground" : "text-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function xAxisFormat(period: ScraperPeriod) {
|
||||||
|
return (v: Date) => {
|
||||||
|
if (period === "1h" || period === "6h") {
|
||||||
|
return v.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
if (period === "24h") {
|
||||||
|
return v.toLocaleTimeString("en-US", { hour: "numeric" });
|
||||||
|
}
|
||||||
|
return v.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TanStack Table ---
|
||||||
|
|
||||||
|
let sorting: SortingState = $state([{ id: "subject", desc: false }]);
|
||||||
|
|
||||||
|
function handleSortingChange(updater: Updater<SortingState>) {
|
||||||
|
sorting = typeof updater === "function" ? updater(sorting) : updater;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<SubjectSummary, unknown>[] = [
|
||||||
|
{
|
||||||
|
id: "subject",
|
||||||
|
accessorKey: "subject",
|
||||||
|
header: "Subject",
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (a, b) => a.original.subject.localeCompare(b.original.subject),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorFn: (row) => row.scheduleState,
|
||||||
|
header: "Status",
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (a, b) => {
|
||||||
|
const order: Record<string, number> = { eligible: 0, cooldown: 1, paused: 2, read_only: 3 };
|
||||||
|
const sa = order[a.original.scheduleState] ?? 4;
|
||||||
|
const sb = order[b.original.scheduleState] ?? 4;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return (a.original.cooldownRemainingSecs ?? Infinity) - (b.original.cooldownRemainingSecs ?? Infinity);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "interval",
|
||||||
|
accessorFn: (row) => row.currentIntervalSecs * row.timeMultiplier,
|
||||||
|
header: "Interval",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastScraped",
|
||||||
|
accessorKey: "lastScraped",
|
||||||
|
header: "Last Scraped",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changeRate",
|
||||||
|
accessorKey: "avgChangeRatio",
|
||||||
|
header: "Change %",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zeros",
|
||||||
|
accessorKey: "consecutiveZeroChanges",
|
||||||
|
header: "Zeros",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "runs",
|
||||||
|
accessorKey: "recentRuns",
|
||||||
|
header: "Runs",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fails",
|
||||||
|
accessorKey: "recentFailures",
|
||||||
|
header: "Fails",
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = createSvelteTable({
|
||||||
|
get data() {
|
||||||
|
return subjects;
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.subject,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSortingChange: handleSortingChange,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel<SubjectSummary>(),
|
||||||
|
enableSortingRemoval: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const skeletonWidths: Record<string, string> = {
|
||||||
|
subject: "w-24",
|
||||||
|
status: "w-20",
|
||||||
|
interval: "w-14",
|
||||||
|
lastScraped: "w-20",
|
||||||
|
changeRate: "w-12",
|
||||||
|
zeros: "w-8",
|
||||||
|
runs: "w-8",
|
||||||
|
fails: "w-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnCount = columns.length;
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchAll();
|
||||||
|
scheduleTick();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearTimeout(tickTimer);
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
clearTimeout(spinHoldTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch when period changes
|
||||||
|
$effect(() => {
|
||||||
|
void selectedPeriod;
|
||||||
|
fetchAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-base font-semibold text-foreground">Scraper</h1>
|
||||||
|
{#if spinnerVisible}
|
||||||
|
<span in:fade={{ duration: 150 }} out:fade={{ duration: 200 }}>
|
||||||
|
<LoaderCircle class="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
{:else if refreshError}
|
||||||
|
<span in:fade={{ duration: 150 }} out:fade={{ duration: 200 }}>
|
||||||
|
<SimpleTooltip text={error ?? "Refresh failed"} side="right" passthrough>
|
||||||
|
<AlertCircle class="size-4 text-destructive" />
|
||||||
|
</SimpleTooltip>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted flex rounded-md p-0.5">
|
||||||
|
{#each PERIODS as period}
|
||||||
|
<button
|
||||||
|
class="rounded px-2.5 py-1 text-xs font-medium transition-colors
|
||||||
|
{selectedPeriod === period
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => (selectedPeriod = period)}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error && !stats}
|
||||||
|
<p class="text-destructive">{error}</p>
|
||||||
|
{:else if stats}
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Total Scrapes</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.totalScrapes)}</p>
|
||||||
|
<p class="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
{formatNumber(stats.successfulScrapes)} ok / {formatNumber(stats.failedScrapes)} failed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Success Rate</p>
|
||||||
|
{#if stats.successRate != null}
|
||||||
|
<p class="text-2xl font-bold {successRateColor(stats.successRate)}">
|
||||||
|
{(stats.successRate * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-2xl font-bold text-muted-foreground">N/A</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Avg Duration</p>
|
||||||
|
{#if stats.avgDurationMs != null}
|
||||||
|
<p class="text-2xl font-bold">{formatDurationMs(stats.avgDurationMs)}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-2xl font-bold text-muted-foreground">N/A</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Courses Changed</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.totalCoursesChanged)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Pending Jobs</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.pendingJobs)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Locked Jobs</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.lockedJobs)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Courses Fetched</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.totalCoursesFetched)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-card border-border rounded-lg border p-3">
|
||||||
|
<p class="text-muted-foreground text-xs">Audits Generated</p>
|
||||||
|
<p class="text-2xl font-bold">{formatNumber(stats.totalAuditsGenerated)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time-Series Charts -->
|
||||||
|
{#if chartData.length > 0}
|
||||||
|
<div class="bg-card border-border rounded-lg border p-4">
|
||||||
|
<h2 class="mb-3 text-xs font-semibold text-foreground">Scrape Activity</h2>
|
||||||
|
<div class="h-[250px]">
|
||||||
|
<Chart
|
||||||
|
data={tweenedChart.current}
|
||||||
|
x="date"
|
||||||
|
xScale={scaleTime()}
|
||||||
|
y={(d: any) => d.success + d.errors}
|
||||||
|
yScale={scaleLinear()}
|
||||||
|
yDomain={[0, scrapeYMax]}
|
||||||
|
yNice
|
||||||
|
padding={{ top: 10, bottom: 30, left: 45, right: 10 }}
|
||||||
|
tooltip={{ mode: "bisect-x" }}
|
||||||
|
>
|
||||||
|
<Svg>
|
||||||
|
<Axis
|
||||||
|
placement="left"
|
||||||
|
grid={{ class: "stroke-muted-foreground/15" }}
|
||||||
|
rule={false}
|
||||||
|
classes={{ tickLabel: "fill-muted-foreground" }}
|
||||||
|
/>
|
||||||
|
<Axis
|
||||||
|
placement="bottom"
|
||||||
|
format={xAxisFormat(selectedPeriod)}
|
||||||
|
grid={{ class: "stroke-muted-foreground/10" }}
|
||||||
|
rule={false}
|
||||||
|
classes={{ tickLabel: "fill-muted-foreground" }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
y1="success"
|
||||||
|
fill="var(--status-green)"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
curve={curveMonotoneX}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
y0="success"
|
||||||
|
y1={(d: any) => d.success + d.errors}
|
||||||
|
fill="var(--status-red)"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
curve={curveMonotoneX}
|
||||||
|
/>
|
||||||
|
<Highlight lines />
|
||||||
|
</Svg>
|
||||||
|
<Tooltip.Root
|
||||||
|
let:data
|
||||||
|
classes={{ root: "text-xs" }}
|
||||||
|
variant="none"
|
||||||
|
>
|
||||||
|
<div class="bg-card text-card-foreground shadow-md rounded-md px-2.5 py-1.5 space-y-1">
|
||||||
|
<p class="text-muted-foreground font-medium">{data.date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center gap-1.5"><span class="inline-block size-2 rounded-full bg-status-green"></span>Successful</span>
|
||||||
|
<span class="tabular-nums font-medium">{data.success}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center gap-1.5"><span class="inline-block size-2 rounded-full bg-status-red"></span>Errors</span>
|
||||||
|
<span class="tabular-nums font-medium">{data.errors}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Chart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-4 mb-3 text-xs font-semibold text-foreground">Courses Changed</h2>
|
||||||
|
<div class="h-[150px]">
|
||||||
|
<Chart
|
||||||
|
data={tweenedChart.current}
|
||||||
|
x="date"
|
||||||
|
xScale={scaleTime()}
|
||||||
|
y="coursesChanged"
|
||||||
|
yScale={scaleLinear()}
|
||||||
|
yDomain={[0, changesYMax]}
|
||||||
|
yNice
|
||||||
|
padding={{ top: 10, bottom: 30, left: 45, right: 10 }}
|
||||||
|
tooltip={{ mode: "bisect-x" }}
|
||||||
|
>
|
||||||
|
<Svg>
|
||||||
|
<Axis
|
||||||
|
placement="left"
|
||||||
|
grid={{ class: "stroke-muted-foreground/15" }}
|
||||||
|
rule={false}
|
||||||
|
classes={{ tickLabel: "fill-muted-foreground" }}
|
||||||
|
/>
|
||||||
|
<Axis
|
||||||
|
placement="bottom"
|
||||||
|
format={xAxisFormat(selectedPeriod)}
|
||||||
|
grid={{ class: "stroke-muted-foreground/10" }}
|
||||||
|
rule={false}
|
||||||
|
classes={{ tickLabel: "fill-muted-foreground" }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
fill="var(--status-blue)"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
curve={curveMonotoneX}
|
||||||
|
/>
|
||||||
|
<Highlight lines />
|
||||||
|
</Svg>
|
||||||
|
<Tooltip.Root
|
||||||
|
let:data
|
||||||
|
classes={{ root: "text-xs" }}
|
||||||
|
variant="none"
|
||||||
|
>
|
||||||
|
<div class="bg-card text-card-foreground shadow-md rounded-md px-2.5 py-1.5 space-y-1">
|
||||||
|
<p class="text-muted-foreground font-medium">{data.date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center gap-1.5"><span class="inline-block size-2 rounded-full bg-status-blue"></span>Changed</span>
|
||||||
|
<span class="tabular-nums font-medium">{data.coursesChanged}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Subjects Table -->
|
||||||
|
<div class="bg-card border-border rounded-lg border">
|
||||||
|
<h2 class="border-border border-b px-3 py-2.5 text-xs font-semibold text-foreground">
|
||||||
|
Subjects ({subjects.length})
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
{#each table.getHeaderGroups() as headerGroup}
|
||||||
|
<tr class="border-border border-b text-left text-muted-foreground">
|
||||||
|
{#each headerGroup.headers as header}
|
||||||
|
<th
|
||||||
|
class="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider"
|
||||||
|
class:cursor-pointer={header.column.getCanSort()}
|
||||||
|
class:select-none={header.column.getCanSort()}
|
||||||
|
onclick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{#if header.column.getCanSort()}
|
||||||
|
<span class="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{#if typeof header.column.columnDef.header === "string"}
|
||||||
|
{header.column.columnDef.header}
|
||||||
|
{:else}
|
||||||
|
<FlexRender
|
||||||
|
content={header.column.columnDef.header}
|
||||||
|
context={header.getContext()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if header.column.getIsSorted() === "asc"}
|
||||||
|
<ArrowUp class="size-3.5" />
|
||||||
|
{:else if header.column.getIsSorted() === "desc"}
|
||||||
|
<ArrowDown class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else if typeof header.column.columnDef.header === "string"}
|
||||||
|
{header.column.columnDef.header}
|
||||||
|
{:else}
|
||||||
|
<FlexRender
|
||||||
|
content={header.column.columnDef.header}
|
||||||
|
context={header.getContext()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if !subjects.length && !error}
|
||||||
|
<!-- Skeleton loading -->
|
||||||
|
{#each Array(12) as _}
|
||||||
|
<tr class="border-border border-b">
|
||||||
|
{#each columns as col}
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div
|
||||||
|
class="h-3.5 rounded bg-muted animate-pulse {skeletonWidths[col.id ?? ''] ?? 'w-16'}"
|
||||||
|
></div>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each table.getRowModel().rows as row (row.id)}
|
||||||
|
{@const subject = row.original}
|
||||||
|
{@const isExpanded = expandedSubject === subject.subject}
|
||||||
|
{@const rel = relativeTime(new Date(subject.lastScraped), now)}
|
||||||
|
<tr
|
||||||
|
class="border-border cursor-pointer border-b transition-colors hover:bg-muted/50
|
||||||
|
{isExpanded ? 'bg-muted/30' : ''}"
|
||||||
|
onclick={() => toggleSubjectDetail(subject.subject)}
|
||||||
|
>
|
||||||
|
{#each row.getVisibleCells() as cell (cell.id)}
|
||||||
|
{@const colId = cell.column.id}
|
||||||
|
{#if colId === "subject"}
|
||||||
|
<td class="px-3 py-1.5 font-medium">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if isExpanded}
|
||||||
|
<ChevronDown size={12} class="shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={12} class="shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span>{subject.subject}</span>
|
||||||
|
{#if subject.subjectDescription}
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground font-normal text-[10px] max-w-[140px] truncate inline-block align-middle"
|
||||||
|
title={subject.subjectDescription}
|
||||||
|
>{subject.subjectDescription}</span>
|
||||||
|
{/if}
|
||||||
|
{#if subject.trackedCourseCount > 0}
|
||||||
|
<span class="text-muted-foreground/60 font-normal text-[10px]">({subject.trackedCourseCount})</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{:else if colId === "status"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#if subject.scheduleState === "paused"}
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">paused</span>
|
||||||
|
{:else if subject.scheduleState === "read_only"}
|
||||||
|
<span class="text-muted-foreground">read only</span>
|
||||||
|
{:else if subject.nextEligibleAt}
|
||||||
|
{@const remainingMs = new Date(subject.nextEligibleAt).getTime() - now.getTime()}
|
||||||
|
{#if remainingMs > 0}
|
||||||
|
<span class="text-muted-foreground">{formatDuration(remainingMs)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-green-600 dark:text-green-400 font-medium">ready</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="text-green-600 dark:text-green-400 font-medium">ready</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "interval"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span>{formatInterval(subject.currentIntervalSecs)}</span>
|
||||||
|
{#if subject.timeMultiplier !== 1}
|
||||||
|
<span class="text-muted-foreground ml-0.5">×{subject.timeMultiplier}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{:else if colId === "lastScraped"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<SimpleTooltip text={formatAbsoluteDate(subject.lastScraped)} side="top" passthrough>
|
||||||
|
<span class="text-muted-foreground">{rel.text === "now" ? "just now" : rel.text}</span>
|
||||||
|
</SimpleTooltip>
|
||||||
|
</td>
|
||||||
|
{:else if colId === "changeRate"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(subject.avgChangeRatio)}>{(subject.avgChangeRatio * 100).toFixed(2)}%</span>
|
||||||
|
</td>
|
||||||
|
{:else if colId === "zeros"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(subject.consecutiveZeroChanges)}>{subject.consecutiveZeroChanges}</span>
|
||||||
|
</td>
|
||||||
|
{:else if colId === "runs"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(subject.recentRuns)}>{subject.recentRuns}</span>
|
||||||
|
</td>
|
||||||
|
{:else if colId === "fails"}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#if subject.recentFailures > 0}
|
||||||
|
<span class="text-red-600 dark:text-red-400">{subject.recentFailures}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">{subject.recentFailures}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
<!-- Expanded Detail -->
|
||||||
|
{#if isExpanded}
|
||||||
|
<tr class="border-border border-b last:border-b-0">
|
||||||
|
<td colspan={columnCount} class="p-0">
|
||||||
|
<div transition:slide={{ duration: 200 }}>
|
||||||
|
<div class="bg-muted/20 px-4 py-3">
|
||||||
|
{#if detailLoading}
|
||||||
|
<p class="text-muted-foreground text-sm">Loading results...</p>
|
||||||
|
{:else if subjectDetail && subjectDetail.results.length > 0}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-muted-foreground text-left">
|
||||||
|
<th class="px-3 py-1.5 font-medium">Time</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Duration</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Status</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Fetched</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Changed</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Unchanged</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Audits</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each subjectDetail.results as result (result.id)}
|
||||||
|
{@const detailRel = relativeTime(new Date(result.completedAt), now)}
|
||||||
|
<tr class="border-border/50 border-t">
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<SimpleTooltip text={formatAbsoluteDate(result.completedAt)} side="top" passthrough>
|
||||||
|
<span class="text-muted-foreground">{detailRel.text === "now" ? "just now" : detailRel.text}</span>
|
||||||
|
</SimpleTooltip>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5">{formatDurationMs(result.durationMs)}</td>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#if result.success}
|
||||||
|
<span class="text-green-600 dark:text-green-400">ok</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-red-600 dark:text-red-400">fail</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(result.coursesFetched ?? 0)}>{result.coursesFetched ?? "\u2014"}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(result.coursesChanged ?? 0)}>{result.coursesChanged ?? "\u2014"}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class="text-muted-foreground">{result.coursesUnchanged ?? "\u2014"}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class={emphasisClass(result.auditsGenerated ?? 0)}>{result.auditsGenerated ?? "\u2014"}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted-foreground max-w-[200px] truncate px-3 py-1.5">
|
||||||
|
{result.errorMessage ?? ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground text-sm">No recent results.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Initial loading skeleton -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{#each Array(8) as _}
|
||||||
|
<div class="bg-card border-border rounded-lg border p-4">
|
||||||
|
<div class="h-4 w-24 rounded bg-muted animate-pulse"></div>
|
||||||
|
<div class="mt-2 h-8 w-16 rounded bg-muted animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { House } from "@lucide/svelte";
|
||||||
|
|
||||||
|
const status = $derived(page.status);
|
||||||
|
|
||||||
|
const messages: Record<number, string> = {
|
||||||
|
400: "Bad request",
|
||||||
|
401: "Unauthorized",
|
||||||
|
403: "Forbidden",
|
||||||
|
404: "Page not found",
|
||||||
|
405: "Method not allowed",
|
||||||
|
408: "Request timeout",
|
||||||
|
429: "Too many requests",
|
||||||
|
500: "Something went wrong",
|
||||||
|
502: "Service temporarily unavailable",
|
||||||
|
503: "Service temporarily unavailable",
|
||||||
|
504: "Gateway timeout",
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = $derived(messages[status] ?? "An error occurred");
|
||||||
|
const isServerError = $derived(status >= 500);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{status} - {message}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center px-4 pb-14">
|
||||||
|
<div class="max-w-md text-center">
|
||||||
|
<h1 class="text-8xl font-bold tracking-tight text-muted-foreground/50">{status}</h1>
|
||||||
|
<p class="mt-4 text-xl text-muted-foreground">{message}</p>
|
||||||
|
|
||||||
|
{#if isServerError}
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground/60">This may be temporary. Try again in a moment.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-8 inline-flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-2.5 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<House size={16} strokeWidth={2} />
|
||||||
|
Return home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
--status-red: oklch(0.63 0.2 25);
|
--status-red: oklch(0.63 0.2 25);
|
||||||
--status-orange: oklch(0.75 0.18 70);
|
--status-orange: oklch(0.75 0.18 70);
|
||||||
--status-gray: oklch(0.556 0 0);
|
--status-gray: oklch(0.556 0 0);
|
||||||
|
--status-blue: oklch(0.55 0.15 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
--status-red: oklch(0.7 0.19 25);
|
--status-red: oklch(0.7 0.19 25);
|
||||||
--status-orange: oklch(0.8 0.16 70);
|
--status-orange: oklch(0.8 0.16 70);
|
||||||
--status-gray: oklch(0.708 0 0);
|
--status-gray: oklch(0.708 0 0);
|
||||||
|
--status-blue: oklch(0.7 0.15 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -54,6 +56,9 @@
|
|||||||
--color-status-red: var(--status-red);
|
--color-status-red: var(--status-red);
|
||||||
--color-status-orange: var(--status-orange);
|
--color-status-orange: var(--status-orange);
|
||||||
--color-status-gray: var(--status-gray);
|
--color-status-gray: var(--status-gray);
|
||||||
|
--color-status-blue: var(--status-blue);
|
||||||
|
--color-surface-100: var(--card);
|
||||||
|
--color-surface-content: var(--foreground);
|
||||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
--animate-accordion-down: accordion-down 200ms ease-out;
|
--animate-accordion-down: accordion-down 200ms ease-out;
|
||||||
--animate-accordion-up: accordion-up 200ms ease-out;
|
--animate-accordion-up: accordion-up 200ms ease-out;
|
||||||
|
|||||||
Reference in New Issue
Block a user