8 Commits

Author SHA1 Message Date
ebb7a97c11 fix(ci): add postgres container service for rust tests
Also updated deprecated codeql action to v4.
2026-01-30 21:36:32 -06:00
2df0ba0ec5 chore: add ts-rs generated bindings 2026-01-30 21:29:32 -06:00
dd148e08a0 fix(ci): fix rust/frontend/security job failures and expand local checks 2026-01-30 21:22:01 -06:00
3494341e3f ci: split quality checks into parallel jobs with security scanning
Reorganize CI pipeline into separate jobs for Rust quality, frontend
quality, tests, Docker build, and security audits. Add cargo-audit,
bun audit, and Trivy filesystem scanning. Allow formatting checks to
pass with warnings on push events while failing on PRs.
2026-01-30 21:08:16 -06:00
acccaa54d4 chore: update frontend packages 2026-01-30 21:07:08 -06:00
6863ee58d0 ci: add Release Please automation for changelog and version management 2026-01-30 21:05:15 -06:00
550401b85c refactor: use friendly term codes in URL query parameters 2026-01-30 20:49:08 -06:00
b02a0738e2 chore: bump to v0.6.0, update roadmap & changelog 2026-01-30 20:37:31 -06:00
78 changed files with 903 additions and 541 deletions
+11
View File
@@ -0,0 +1,11 @@
# cargo-audit configuration
# https://github.com/rustsec/rustsec/tree/main/cargo-audit
[advisories]
# Transitive dependencies we can't control
ignore = [
# rsa: Marvin Attack timing sidechannel (via sqlx-mysql, no fix available)
"RUSTSEC-2023-0071",
# derivative: unmaintained (via poise)
"RUSTSEC-2024-0388",
]
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "ci", "section": "Continuous Integration" },
{ "type": "build", "section": "Build System" },
{ "type": "chore", "section": "Miscellaneous" },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true }
],
"bump-minor-pre-major": true,
"always-update": true,
"bump-patch-for-minor-pre-major": true,
"include-v-in-tag": true,
"include-component-in-tag": false,
"plugins": ["sentence-case"],
"packages": {
".": {
"release-type": "rust",
"exclude-paths": [".vscode", "docs"],
"extra-files": [
{
"type": "toml",
"path": "Cargo.lock",
"jsonpath": "$.package[?(@.name=='banner')].version"
}
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.6.0"
}
+147 -27
View File
@@ -11,9 +11,9 @@ env:
RUST_BACKTRACE: 1
jobs:
check:
rust-quality:
name: Rust Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -22,44 +22,164 @@ jobs:
with:
components: rustfmt, clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Check formatting
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
cargo fmt --all -- --check
else
cargo fmt --all -- --check || echo "::warning::Rust formatting issues found (not failing on push)"
fi
- name: Clippy
run: cargo clippy --no-default-features -- -D warnings
frontend-quality:
name: Frontend Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
- name: Check formatting
working-directory: web
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
bun run format:check
else
bun run format:check || echo "::warning::Frontend formatting issues found (not failing on push)"
fi
- name: Lint
working-directory: web
run: bun run lint
- name: Type check
working-directory: web
run: bun run typecheck
rust-tests:
name: Rust Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: banner
POSTGRES_PASSWORD: banner
POSTGRES_DB: banner
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://banner:banner@localhost:5432/banner
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Run tests
run: cargo test --no-default-features
frontend-tests:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
- name: Run tests
working-directory: web
run: bun run test
docker-build:
name: Docker Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
security:
name: Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: Rust security audit
run: cargo audit
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install frontend dependencies
working-directory: web
run: bun install --frozen-lockfile
- name: Check Rust formatting
run: cargo fmt --all -- --check
- name: Check TypeScript formatting
- name: Frontend security audit
working-directory: web
run: bun run format:check
run: bun audit --audit-level=moderate
continue-on-error: true
- name: TypeScript type check
working-directory: web
run: bun run typecheck
- name: Trivy filesystem scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 0
- name: ESLint
working-directory: web
run: bun run lint
- name: Clippy
run: cargo clippy --all-features -- --deny warnings
- name: Run tests
run: cargo test --all-features
- name: Build frontend
working-directory: web
run: bun run build
- name: Build backend
run: cargo build --release --bin banner
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('trivy-results.sarif') != ''
with:
sarif_file: trivy-results.sarif
+27
View File
@@ -0,0 +1,27 @@
name: Release Please
on:
workflow_dispatch:
workflow_run:
workflows: ["CI"]
types:
- completed
branches:
- master
permissions:
contents: write
pull-requests: write
jobs:
release-please:
name: Create Release PR
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/release-please-config.json
manifest-file: .github/release-please-manifest.json
Vendored
+1 -3
View File
@@ -1,6 +1,4 @@
.env
/target
# ts-rs bindings
web/src/lib/bindings/**/*.ts
!web/src/lib/bindings/index.ts
+43
View File
@@ -6,6 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.6.0] - 2026-01-30
### Added
- User authentication system with Discord OAuth, sessions, admin roles, and login page with FAQ.
- Interactive timeline visualization with D3 canvas, pan/zoom, touch gestures, and enrollment aggregation API.
- Scraper analytics dashboard with timeseries charts, subject monitoring, and per-subject detail views.
- Adaptive scraper scheduling with admin endpoints for monitoring and configuration.
- Scrape job result persistence for effectiveness tracking.
- WebSocket support for real-time scrape job monitoring with connection status indicators.
- Course change auditing with field-level tracking and time-series metrics endpoint.
- Audit log UI with smart JSON diffing, conditional request caching, and auto-refresh.
- Calendar export web endpoints for ICS download and Google Calendar redirect.
- Confidence-based RMP matching with manual review workflow and admin instructor UI.
- RMP profile links and confidence-aware rating display.
- Name parsing and normalization for improved instructor-RMP matching.
- Mobile touch controls with gesture detection for timeline.
- Worker timeout protection and crash recovery for job queue.
- Build-time asset compression with encoding negotiation (gzip, brotli, zstd).
- Smart page transitions with theme-aware element transitions.
- Search duration and result count feedback.
- Root error page handling.
- Login page with FAQ section and improved styling.
### Changed
- Consolidated navigation with top nav bar and route groups.
- Centralized number formatting with locale-aware utility.
- Modernized Justfile commands and simplified service management.
- Persisted audit log state in module scope for cross-navigation caching.
- Relative time feedback and improved tooltip customization.
### Fixed
- Instructor/course mismatching via build-order-independent map for association.
- Page content clipping.
- Backend startup delays with retry logic in auth.
- Banner API timeouts increased to handle slow responses.
- i64 serialization for JavaScript compatibility, fixing avatar URL display.
- Frontend build ordering with `-e` embed flag in Justfile.
- Login page centering and unnecessary scrollbar.
- ts-rs serde warnings.
## [0.5.0] - 2026-01-29
### Added
Generated
+154 -317
View File
@@ -182,14 +182,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"base64 0.22.1",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"hyper 1.7.0",
"hyper",
"hyper-util",
"itoa",
"matchit",
@@ -202,7 +202,7 @@ dependencies = [
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper 1.0.2",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.28.0",
"tower",
@@ -219,12 +219,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.3.1",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper 1.0.2",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
@@ -242,8 +242,8 @@ dependencies = [
"form_urlencoded",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
@@ -272,13 +272,13 @@ dependencies = [
[[package]]
name = "banner"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-extra",
"bitflags 2.9.4",
"bitflags",
"chrono",
"chrono-tz",
"clap",
@@ -293,14 +293,14 @@ dependencies = [
"governor",
"html-escape",
"htmlize",
"http 1.3.1",
"http",
"mime_guess",
"num-format",
"poise",
"rand 0.9.2",
"rapidhash",
"regex",
"reqwest 0.12.23",
"reqwest",
"reqwest-middleware",
"rust-embed",
"serde",
@@ -322,12 +322,6 @@ dependencies = [
"yansi",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -340,12 +334,6 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.4"
@@ -473,6 +461,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
@@ -545,9 +539,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "command_attr"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fcc89439e1bb4e19050a9586a767781a3060000d2f3296fd2a40597ad9421c5"
checksum = "8208103c5e25a091226dfa8d61d08d0561cc14f31b25691811ba37d4ec9b157b"
dependencies = [
"proc-macro2",
"quote",
@@ -1146,15 +1140,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1172,8 +1157,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1238,25 +1225,6 @@ dependencies = [
"web-time",
]
[[package]]
name = "h2"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.12"
@@ -1268,7 +1236,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http 1.3.1",
"http",
"indexmap",
"slab",
"tokio",
@@ -1363,17 +1331,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "http"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http"
version = "1.3.1"
@@ -1385,17 +1342,6 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.0.1"
@@ -1403,7 +1349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.3.1",
"http",
]
[[package]]
@@ -1414,8 +1360,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http 1.3.1",
"http-body 1.0.1",
"http",
"http-body",
"pin-project-lite",
]
@@ -1431,30 +1377,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.7.0"
@@ -1465,9 +1387,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
@@ -1478,34 +1400,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.3.1",
"hyper 1.7.0",
"http",
"hyper",
"hyper-util",
"rustls 0.23.31",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.2",
"tower-service",
"webpki-roots 1.0.2",
]
[[package]]
@@ -1516,7 +1425,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.7.0",
"hyper",
"hyper-util",
"native-tls",
"tokio",
@@ -1530,20 +1439,20 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
dependencies = [
"base64 0.22.1",
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"hyper 1.7.0",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.0",
"system-configuration 0.6.1",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
@@ -1709,7 +1618,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"cfg-if",
"libc",
]
@@ -1795,7 +1704,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"libc",
"redox_syscall",
]
@@ -1844,6 +1753,12 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2048,7 +1963,7 @@ version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
@@ -2357,7 +2272,7 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"memchr",
"unicase",
]
@@ -2377,6 +2292,61 @@ dependencies = [
"winapi",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.31",
"socket2",
"thiserror 2.0.16",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls 0.23.31",
"rustls-pki-types",
"slab",
"thiserror 2.0.16",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.40"
@@ -2466,7 +2436,7 @@ version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.9.4",
"bitflags",
]
[[package]]
@@ -2475,7 +2445,7 @@ version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
dependencies = [
"bitflags 2.9.4",
"bitflags",
]
[[package]]
@@ -2507,90 +2477,54 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "reqwest"
version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64 0.21.7",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper-rustls 0.24.2",
"ipnet",
"js-sys",
"log",
"mime",
"mime_guess",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls 0.21.12",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
"system-configuration 0.5.1",
"tokio",
"tokio-rustls 0.24.1",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.25.4",
"winreg",
]
[[package]]
name = "reqwest"
version = "0.12.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"base64",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper 1.7.0",
"hyper-rustls 0.27.7",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.31",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.2",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.2",
]
[[package]]
@@ -2601,8 +2535,8 @@ checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
dependencies = [
"anyhow",
"async-trait",
"http 1.3.1",
"reqwest 0.12.23",
"http",
"reqwest",
"serde",
"thiserror 1.0.69",
"tower-service",
@@ -2683,6 +2617,12 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2698,25 +2638,13 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.22.4"
@@ -2745,34 +2673,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
@@ -2831,16 +2741,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "secrecy"
version = "0.8.0"
@@ -2857,7 +2757,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -2980,26 +2880,26 @@ dependencies = [
[[package]]
name = "serenity"
version = "0.12.4"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76"
checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541"
dependencies = [
"arrayvec",
"async-trait",
"base64 0.22.1",
"bitflags 2.9.4",
"base64",
"bitflags",
"bytes",
"chrono",
"command_attr",
"dashmap 5.5.3",
"flate2",
"futures",
"fxhash",
"levenshtein",
"mime_guess",
"parking_lot",
"percent-encoding",
"reqwest 0.11.27",
"reqwest",
"rustc-hash",
"secrecy",
"serde",
"serde_cow",
@@ -3107,16 +3007,6 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
@@ -3174,7 +3064,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"base64",
"bytes",
"chrono",
"crc",
@@ -3250,8 +3140,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.9.4",
"base64",
"bitflags",
"byteorder",
"bytes",
"chrono",
@@ -3293,8 +3183,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.9.4",
"base64",
"bitflags",
"byteorder",
"chrono",
"crc",
@@ -3406,12 +3296,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3432,36 +3316,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys 0.5.0",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.4",
"bitflags",
"core-foundation",
"system-configuration-sys 0.6.0",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
"system-configuration-sys",
]
[[package]]
@@ -3621,7 +3484,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2 0.6.0",
"socket2",
"tokio-macros",
"windows-sys 0.59.0",
]
@@ -3647,16 +3510,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
@@ -3780,7 +3633,7 @@ dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper 1.0.2",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
@@ -3794,12 +3647,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"async-compression",
"bitflags 2.9.4",
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tokio",
@@ -3942,7 +3795,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.3.1",
"http",
"httparse",
"log",
"rand 0.8.5",
@@ -3962,7 +3815,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"http",
"httparse",
"log",
"rand 0.9.2",
@@ -4270,12 +4123,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -4642,16 +4489,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "wit-bindgen"
version = "0.45.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "banner"
version = "0.5.0"
version = "0.6.0"
edition = "2024"
default-run = "banner"
+3 -1
View File
@@ -38,7 +38,7 @@ check *flags:
],
},
biome: {
peers: ["svelte-check", "web-test"],
peers: ["svelte-check", "biome-lint", "web-test"],
format: () => run(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
@@ -53,7 +53,9 @@ check *flags:
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "biome-lint", cmd: ["bun", "run", "--cwd", "web", "lint"] },
{ name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
{ name: "actionlint", cmd: ["actionlint"] },
// { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] },
];
+23 -14
View File
@@ -2,34 +2,43 @@
## Now
- **Notification and subscription system** - Subscribe to courses and get alerts on seat availability, waitlist movement, and detail changes (time, location, professor, seats). DB schema exists.
- **Discord bot revival** - Audit and fix all existing commands (search, terms, ics, gcal) against the current data model. Add test coverage. Bot has been untouched since ~0.3.4 and commands may be broken.
- **Notification and subscription system** - Subscribe to courses and get alerts on seat availability, waitlist movement, and detail changes (time, location, professor, seats). Deliver via Discord bot and web dashboard.
- **Mobile/responsive redesign** - Hamburger nav for sidebar, responsive table column hiding, mobile-friendly admin pages. Timeline is the only area with solid mobile support; most pages need work.
- **Professor name search filter** - Filter search results by instructor. Backend code exists but is commented out.
- **Autocomplete for search fields** - Typeahead for course titles, course numbers, professors, and terms.
- **Test coverage expansion** - Broaden coverage with session/rate-limiter tests and more DB integration tests.
- **Search field autocomplete** - Typeahead for course titles, course numbers, professors, and terms.
- **Large component extraction** - Break down CourseTable, Instructors page, and TimelineCanvas into smaller, testable subcomponents.
## Soon
- **Smart time-of-day search parsing** - Support natural queries like "2 PM", "2-3 PM", "ends by 2 PM", "after 2 PM", "before 2 PM" mapped to time ranges.
- **Section-based lookup** - Search by full section identifier, e.g. "CS 4393 001".
- **Search result pagination** - Paginated embeds for large result sets in Discord.
- **Bot slash command parity** - Keep Discord bot commands in sync with web features: timeline summaries, RMP lookups, audit log highlights, notification management via bot.
- **E2E test suite** - Playwright tests for critical user flows: search, login, admin pages, timeline interaction.
- **Settings page** - Replace placeholder with theme preferences, notification settings, default term/subject selection.
- **Profile enhancements** - Expand from read-only stub to subscription management, saved searches, and course watchlists.
- **Smart time-of-day search parsing** - Support natural queries like "2 PM", "ends by 2 PM", "after 2 PM" mapped to time ranges.
- **Multi-term querying** - Query across multiple terms in a single search instead of one at a time.
- **Historical analytics** - Track seat availability over time and visualize fill-rate trends per course or professor.
- **Schedule builder** - Visual weekly schedule tool for assembling a conflict-free course lineup.
- **Professor stats** - Aggregate data views: average class size, typical waitlist length, schedule patterns across semesters.
- **Historical analytics visualization** - Build trend UI on top of existing course metrics and timeline API. Fill-rate charts per course or professor.
- **Schedule builder** - Visual weekly schedule tool for assembling a conflict-free course lineup. Timeline visualization serves as a foundation.
## Eventually
- **API rate limiting** - Rate limiter on public API endpoints. Needed before any public or external exposure.
- **Bulk admin operations** - Batch RMP match/reject, bulk user management, data export from admin pages.
- **Degree audit helper** - Map available courses to degree requirements and suggest what to take next.
- **Dynamic scraper scheduling** - Adjust scrape intervals based on change frequency and course count (e.g. 2 hours per 500 courses, shorter intervals when changes are detected).
- **DM support** - Allow the Discord bot to respond in direct messages, not just guild channels.
- **"Classes Now" command** - Find classes currently in session based on the current day and time.
- **CRN direct lookup** - Look up a course by its CRN without going through search.
- **Metrics dashboard** - Surface scraper and service metrics visually on the web dashboard.
- **Privileged error feedback** - Detailed error information surfaced to bot admins when commands fail.
## Done
- **Interactive timeline visualization** - D3 canvas with pan/zoom, touch gestures, and enrollment aggregation API. *(0.6.0)*
- **Scraper analytics dashboard** - Timeseries charts, subject monitoring, adaptive scheduling, and admin endpoints. *(0.6.0)*
- **WebSocket job monitoring** - Real-time scrape job queue with live connection status indicators. *(0.6.0)*
- **Course change audit log** - Field-level change tracking with smart diffing, conditional caching, and auto-refresh. *(0.6.0)*
- **User authentication system** - Discord OAuth, sessions, admin roles, and login page. *(0.6.0)*
- **Dynamic scraper scheduling** - Adaptive scrape intervals based on change frequency and course volume. *(0.6.0)*
- **Metrics dashboard** - Scraper and service metrics surfaced on the web dashboard. *(0.6.0)*
- **Subject/major search filter** - Multi-select subject filtering with searchable comboboxes. *(0.5.0)*
- **Web course search UI** - Browser-based course search with interactive data table, sorting, pagination, and column controls. *(0.4.0)*
- **RateMyProfessor integration** - Bulk professor sync via GraphQL with inline ratings in search results. *(0.4.0)*
- **Subject/major search filter** - Multi-select subject filtering with searchable comboboxes. *(0.5.0)*
- **Test coverage expansion** - Unit tests for course formatting, API client, query builder, CLI args, and config parsing. *(0.3.40.4.0)*
- **Test coverage expansion** - Unit tests for course formatting, API client, query builder, CLI args, and config parsing. *(0.3.4--0.4.0)*
+1 -1
View File
@@ -633,7 +633,7 @@
"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.1", "", { "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.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ=="],
"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=="],
+17 -15
View File
@@ -7,33 +7,35 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "biome check .",
"test": "vitest run",
"format": "biome format --write .",
"format:check": "biome format ."
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@fontsource-variable/inter": "^5.2.5",
"@lucide/svelte": "^0.563.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@lucide/svelte": "^0.563.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/table-core": "^8.21.3",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/d3-time-format": "^4.0.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"bits-ui": "^1.8.0",
"clsx": "^2.1.1",
"jsdom": "^26.0.0",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vitest": "^3.0.5"
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@icons-pack/svelte-simple-icons": "^6.5.0",
+4 -4
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bun
import { extname, join } from "path";
import { constants, brotliCompressSync, gzipSync } from "zlib";
import { $ } from "bun";
/**
* Pre-compress static assets with maximum compression levels.
* Run after `bun run build`.
@@ -7,10 +10,7 @@
* These are embedded alongside originals by rust-embed and served via
* content negotiation in src/web/assets.rs.
*/
import { readdir, stat, readFile, writeFile } from "fs/promises";
import { join, extname } from "path";
import { gzipSync, brotliCompressSync, constants } from "zlib";
import { $ } from "bun";
import { readFile, readdir, stat, writeFile } from "fs/promises";
// Must match COMPRESSION_MIN_SIZE in src/web/encoding.rs
const MIN_SIZE = 512;
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A match candidate in the detail view.
*/
export type CandidateResponse = { id: number, rmpLegacyId: number, firstName: string | null, lastName: string | null, department: string | null, avgRating: number | null, avgDifficulty: number | null, numRatings: number | null, wouldTakeAgainPct: number | null, score: number | null, scoreBreakdown: { [key in string]?: number } | null, status: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CodeDescription = { code: string, description: string, };
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DbMeetingTime } from "./DbMeetingTime";
import type { InstructorResponse } from "./InstructorResponse";
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: number, maxEnrollment: number, waitCount: number, waitCapacity: number, creditHours: number | null, creditHourLow: number | null, creditHourHigh: number | null, crossList: string | null, crossListCapacity: number | null, crossListCount: number | null, linkIdentifier: string | null, isSectionLinked: boolean | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, instructors: Array<InstructorResponse>, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Represents a meeting time stored as JSONB in the courses table.
*/
export type DbMeetingTime = { begin_time: string | null, end_time: string | null, start_date: string, end_date: string, monday: boolean, tuesday: boolean, wednesday: boolean, thursday: boolean, friday: boolean, saturday: boolean, sunday: boolean, building: string | null, building_description: string | null, room: string | null, campus: string | null, meeting_type: string, meeting_schedule_type: string, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Instructor summary in the detail view.
*/
export type InstructorDetail = { id: number, displayName: string, email: string, rmpMatchStatus: string, subjectsTaught: Array<string>, courseCount: number, };
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CandidateResponse } from "./CandidateResponse";
import type { InstructorDetail } from "./InstructorDetail";
import type { LinkedRmpProfile } from "./LinkedRmpProfile";
/**
* Response for `GET /api/admin/instructors/{id}` and `POST .../match`.
*/
export type InstructorDetailResponse = { instructor: InstructorDetail, currentMatches: Array<LinkedRmpProfile>, candidates: Array<CandidateResponse>, };
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TopCandidateResponse } from "./TopCandidateResponse";
/**
* An instructor row in the paginated list.
*/
export type InstructorListItem = { id: number, displayName: string, email: string, rmpMatchStatus: string, rmpLinkCount: number, candidateCount: number, courseSubjectCount: number, topCandidate: TopCandidateResponse | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, email: string, isPrimary: boolean, rmpRating: number | null, rmpNumRatings: number | null, rmpLegacyId: number | null, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Aggregate status counts for the instructor list.
*/
export type InstructorStats = { total: number, unmatched: number, auto: number, confirmed: number, rejected: number, withCandidates: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A linked RMP profile in the detail view.
*/
export type LinkedRmpProfile = { linkId: number, legacyId: number, firstName: string | null, lastName: string | null, department: string | null, avgRating: number | null, avgDifficulty: number | null, numRatings: number | null, wouldTakeAgainPct: number | null, };
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstructorListItem } from "./InstructorListItem";
import type { InstructorStats } from "./InstructorStats";
/**
* Response for `GET /api/admin/instructors`.
*/
export type ListInstructorsResponse = { instructors: Array<InstructorListItem>, total: number, page: number, perPage: number, stats: InstructorStats, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Simple acknowledgement response for mutating operations.
*/
export type OkResponse = { ok: boolean, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Response for `POST /api/admin/rmp/rescore`.
*/
export type RescoreResponse = { totalUnmatched: number, candidatesCreated: number, candidatesRescored: number, autoMatched: number, skippedUnparseable: number, skippedNoCandidates: number, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ScraperStatsResponse = { period: string, totalScrapes: number, successfulScrapes: number, failedScrapes: number, successRate: number | null, avgDurationMs: number | null, totalCoursesChanged: number, totalCoursesFetched: number, totalAuditsGenerated: number, pendingJobs: number, lockedJobs: number, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CourseResponse } from "./CourseResponse";
export type SearchResponse = { courses: Array<CourseResponse>, totalCount: number, offset: number, limit: number, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceStatus } from "./ServiceStatus";
export type ServiceInfo = { name: string, status: ServiceStatus, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Health status of a service.
*/
export type ServiceStatus = "starting" | "active" | "connected" | "disabled" | "error";
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceInfo } from "./ServiceInfo";
import type { ServiceStatus } from "./ServiceStatus";
export type StatusResponse = { status: ServiceStatus, version: string, commit: string, services: { [key in string]?: ServiceInfo }, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SubjectResultEntry } from "./SubjectResultEntry";
export type SubjectDetailResponse = { subject: string, results: Array<SubjectResultEntry>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubjectResultEntry = { id: number, completedAt: string, durationMs: number, success: boolean, errorMessage: string | null, coursesFetched: number | null, coursesChanged: number | null, coursesUnchanged: number | null, auditsGenerated: number | null, metricsGenerated: number | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubjectSummary = { subject: string, subjectDescription: string | null, trackedCourseCount: number, scheduleState: string, currentIntervalSecs: number, timeMultiplier: number, lastScraped: string, nextEligibleAt: string | null, cooldownRemainingSecs: number | null, avgChangeRatio: number, consecutiveZeroChanges: number, recentRuns: number, recentFailures: number, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SubjectSummary } from "./SubjectSummary";
export type SubjectsResponse = { subjects: Array<SubjectSummary>, };
+12
View File
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TimelineSlot } from "./TimelineSlot";
export type TimelineResponse = {
/**
* 15-minute slots with per-subject enrollment totals, sorted by time.
*/
slots: Array<TimelineSlot>,
/**
* All subject codes present in the returned data.
*/
subjects: Array<string>, };
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TimelineSlot = {
/**
* ISO-8601 timestamp at the start of this 15-minute bucket.
*/
time: string,
/**
* Subject code → total enrollment in this slot.
*/
subjects: { [key in string]?: bigint }, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TimeseriesPoint = { timestamp: string, scrapeCount: number, successCount: number, errorCount: number, coursesChanged: number, avgDurationMs: number, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TimeseriesPoint } from "./TimeseriesPoint";
export type TimeseriesResponse = { period: string, bucket: string, points: Array<TimeseriesPoint>, };
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A top-candidate summary shown in the instructor list view.
*/
export type TopCandidateResponse = { rmpLegacyId: number, score: number | null, scoreBreakdown: { [key in string]?: number } | null, firstName: string | null, lastName: string | null, department: string | null, avgRating: number | null, numRatings: number | null, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A user authenticated via Discord OAuth.
*/
export type User = { discordId: string, discordUsername: string, discordAvatarHash: string | null, isAdmin: boolean, createdAt: string, updatedAt: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
+11 -11
View File
@@ -1,31 +1,31 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import {
formatTime,
RMP_CONFIDENCE_THRESHOLD,
formatCreditHours,
formatDate,
formatMeetingDaysLong,
formatTime,
isMeetingTimeTBA,
isTimeTBA,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass, formatNumber } from "$lib/utils";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import {
Info,
Copy,
Calendar,
Check,
Copy,
Download,
ExternalLink,
Info,
Star,
Triangle,
ExternalLink,
Calendar,
Download,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
let { course }: { course: CourseResponse } = $props();
+22 -22
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import type { CourseResponse } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import {
RMP_CONFIDENCE_THRESHOLD,
abbreviateInstructor,
concernAccentColor,
formatLocationDisplay,
@@ -13,40 +17,36 @@ import {
isMeetingTimeTBA,
isTimeTBA,
openSeats,
seatsColor,
seatsDotColor,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
seatsColor,
seatsDotColor,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition";
import { flip } from "svelte/animate";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import {
getCoreRowModel,
getSortedRowModel,
type ColumnDef,
type SortingState,
type VisibilityState,
type Updater,
} from "@tanstack/table-core";
import {
ArrowUp,
ArrowDown,
ArrowUp,
ArrowUpDown,
Columns3,
Check,
Columns3,
ExternalLink,
RotateCcw,
Star,
Triangle,
ExternalLink,
} from "@lucide/svelte";
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
import { cn, tooltipContentClass, formatNumber } from "$lib/utils";
import {
type ColumnDef,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
import { flip } from "svelte/animate";
import { fade, fly, slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from "$app/state";
import { TriangleAlert, RotateCcw } from "@lucide/svelte";
import { RotateCcw, TriangleAlert } from "@lucide/svelte";
interface Props {
/** Heading shown in the error card */
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { Search, User, Clock } from "@lucide/svelte";
import { authStore } from "$lib/auth.svelte";
import { Clock, Search, User } from "@lucide/svelte";
import ThemeToggle from "./ThemeToggle.svelte";
const staticTabs = [
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { Select } from "bits-ui";
import { ChevronUp, ChevronDown } from "@lucide/svelte";
import type { Action } from "svelte/action";
import { formatNumber } from "$lib/utils";
import { ChevronDown, ChevronUp } from "@lucide/svelte";
import { Select } from "bits-ui";
import type { Action } from "svelte/action";
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { Term, Subject } from "$lib/api";
import type { Subject, Term } from "$lib/api";
import SimpleTooltip from "./SimpleTooltip.svelte";
import TermCombobox from "./TermCombobox.svelte";
import SubjectCombobox from "./SubjectCombobox.svelte";
import TermCombobox from "./TermCombobox.svelte";
let {
terms,
+1 -1
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
import { onMount } from "svelte";
export interface SearchMeta {
totalCount: number;
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
text,
@@ -1,9 +1,9 @@
<script lang="ts">
import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Subject } from "$lib/api";
import { formatNumber } from "$lib/utils";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { Combobox } from "bits-ui";
import { fly } from "svelte/transition";
let {
subjects,
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Term } from "$lib/api";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { Combobox } from "bits-ui";
import { fly } from "svelte/transition";
let {
terms,
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from "svelte";
import { Moon, Sun } from "@lucide/svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { Moon, Sun } from "@lucide/svelte";
import { tick } from "svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
/**
+42 -42
View File
@@ -1,60 +1,60 @@
<script lang="ts">
import { scaleLinear, scaleTime } from "d3-scale";
import { onMount } from "svelte";
import { scaleTime, scaleLinear } from "d3-scale";
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
import {
PADDING,
DEFAULT_AXIS_RATIO,
CHART_HEIGHT_RATIO,
MIN_SPAN_MS,
MAX_SPAN_MS,
DEFAULT_SPAN_MS,
ZOOM_FACTOR,
ZOOM_KEY_FACTOR,
ZOOM_EASE,
ZOOM_SETTLE_THRESHOLD,
PAN_FRICTION,
PAN_STOP_THRESHOLD,
PAN_STOP_THRESHOLD_Y,
VELOCITY_SAMPLE_WINDOW,
VELOCITY_MIN_DT,
PAN_STEP_RATIO,
PAN_STEP_CTRL_RATIO,
PAN_EASE,
PAN_SETTLE_THRESHOLD_PX,
YRATIO_STEP,
YRATIO_MIN,
YRATIO_MAX,
YRATIO_SETTLE_THRESHOLD,
FOLLOW_EASE,
MIN_MAXY,
MAX_DT,
DEFAULT_DT,
TAP_MAX_DURATION_MS,
TAP_MAX_DISTANCE_PX,
} from "$lib/timeline/constants";
import { createTimelineStore } from "$lib/timeline/store.svelte";
import {
createAnimMap,
syncAnimTargets,
stepAnimations,
pruneAnimMap,
stepAnimations,
syncAnimTargets,
} from "$lib/timeline/animation";
import {
getVisibleSlots,
findSlotByTime,
snapToSlot,
enabledTotalClasses,
} from "$lib/timeline/viewport";
CHART_HEIGHT_RATIO,
DEFAULT_AXIS_RATIO,
DEFAULT_DT,
DEFAULT_SPAN_MS,
FOLLOW_EASE,
MAX_DT,
MAX_SPAN_MS,
MIN_MAXY,
MIN_SPAN_MS,
PADDING,
PAN_EASE,
PAN_FRICTION,
PAN_SETTLE_THRESHOLD_PX,
PAN_STEP_CTRL_RATIO,
PAN_STEP_RATIO,
PAN_STOP_THRESHOLD,
PAN_STOP_THRESHOLD_Y,
TAP_MAX_DISTANCE_PX,
TAP_MAX_DURATION_MS,
VELOCITY_MIN_DT,
VELOCITY_SAMPLE_WINDOW,
YRATIO_MAX,
YRATIO_MIN,
YRATIO_SETTLE_THRESHOLD,
YRATIO_STEP,
ZOOM_EASE,
ZOOM_FACTOR,
ZOOM_KEY_FACTOR,
ZOOM_SETTLE_THRESHOLD,
} from "$lib/timeline/constants";
import {
drawGrid,
drawHoverColumn,
drawStackedArea,
drawNowLine,
drawStackedArea,
drawTimeAxis,
stackVisibleSlots,
} from "$lib/timeline/renderer";
import { createTimelineStore } from "$lib/timeline/store.svelte";
import type { ChartContext, TimeSlot } from "$lib/timeline/types";
import {
enabledTotalClasses,
findSlotByTime,
getVisibleSlots,
snapToSlot,
} from "$lib/timeline/viewport";
import TimelineDrawer from "./TimelineDrawer.svelte";
import TimelineTooltip from "./TimelineTooltip.svelte";
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Filter, X } from "@lucide/svelte";
import { getSubjectColor } from "$lib/timeline/data";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
import { getSubjectColor } from "$lib/timeline/data";
import { Filter, X } from "@lucide/svelte";
interface Props {
open: boolean;
@@ -1,8 +1,8 @@
<script lang="ts">
import { timeFormat } from "d3-time-format";
import { getSubjectColor } from "$lib/timeline/data";
import type { TimeSlot } from "$lib/timeline/types";
import { enabledTotalClasses } from "$lib/timeline/viewport";
import { timeFormat } from "d3-time-format";
interface Props {
visible: boolean;
@@ -1,6 +1,6 @@
import { onMount } from "svelte";
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte";
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
import { onMount } from "svelte";
/**
* Set up OverlayScrollbars on an element with automatic theme reactivity.
+9 -9
View File
@@ -1,22 +1,22 @@
import { describe, it, expect } from "vitest";
import type { CourseResponse, DbMeetingTime, InstructorResponse } from "$lib/api";
import {
formatTime,
formatTimeRange,
abbreviateInstructor,
formatCreditHours,
formatDate,
formatDateShort,
formatMeetingDays,
formatMeetingDaysLong,
formatMeetingDaysVerbose,
formatMeetingTime,
formatMeetingTimeTooltip,
formatMeetingTimesTooltip,
abbreviateInstructor,
formatCreditHours,
formatTime,
formatTimeRange,
getPrimaryInstructor,
isMeetingTimeTBA,
isTimeTBA,
formatDate,
formatDateShort,
formatMeetingDaysLong,
} from "$lib/course";
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
import { describe, expect, it } from "vitest";
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
return {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
import type { CourseResponse, DbMeetingTime, InstructorResponse } from "$lib/api";
/** Convert "0900" to "9:00 AM" */
export function formatTime(time: string | null): string {
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { termToBanner, termToFriendly } from "./term-format";
describe("termToFriendly", () => {
it("converts spring term correctly", () => {
expect(termToFriendly("202610")).toBe("spring-26");
expect(termToFriendly("202510")).toBe("spring-25");
});
it("converts summer term correctly", () => {
expect(termToFriendly("202620")).toBe("summer-26");
expect(termToFriendly("202520")).toBe("summer-25");
});
it("converts fall term correctly", () => {
expect(termToFriendly("202630")).toBe("fall-26");
expect(termToFriendly("202530")).toBe("fall-25");
});
it("returns null for invalid codes", () => {
expect(termToFriendly("20261")).toBe(null);
expect(termToFriendly("2026100")).toBe(null);
expect(termToFriendly("202640")).toBe(null); // Invalid semester code
expect(termToFriendly("")).toBe(null);
});
});
describe("termToBanner", () => {
it("converts spring term correctly", () => {
expect(termToBanner("spring-26")).toBe("202610");
expect(termToBanner("spring-25")).toBe("202510");
});
it("converts summer term correctly", () => {
expect(termToBanner("summer-26")).toBe("202620");
expect(termToBanner("summer-25")).toBe("202520");
});
it("converts fall term correctly", () => {
expect(termToBanner("fall-26")).toBe("202630");
expect(termToBanner("fall-25")).toBe("202530");
});
it("returns null for invalid formats", () => {
expect(termToBanner("winter-26")).toBe(null);
expect(termToBanner("spring26")).toBe(null);
expect(termToBanner("spring-2026")).toBe(null);
expect(termToBanner("26-spring")).toBe(null);
expect(termToBanner("")).toBe(null);
});
});
describe("round-trip conversion", () => {
it("converts back and forth correctly", () => {
const bannerCodes = ["202610", "202620", "202630", "202510"];
for (const code of bannerCodes) {
const friendly = termToFriendly(code);
expect(friendly).not.toBeNull();
const backToBanner = termToBanner(friendly!);
expect(backToBanner).toBe(code);
}
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Convert between Banner's internal term codes (e.g., "202620") and human-friendly format (e.g., "summer-26")
*/
export type SemesterName = "spring" | "summer" | "fall";
const SEMESTER_CODES: Record<string, SemesterName> = {
"10": "spring",
"20": "summer",
"30": "fall",
};
const SEMESTER_TO_CODE: Record<SemesterName, string> = {
spring: "10",
summer: "20",
fall: "30",
};
/**
* Convert Banner term code (e.g., "202620") to friendly format (e.g., "summer-26")
*/
export function termToFriendly(bannerCode: string): string | null {
if (bannerCode.length !== 6) return null;
const year = bannerCode.substring(0, 4);
const semesterCode = bannerCode.substring(4, 6);
const semester = SEMESTER_CODES[semesterCode];
if (!semester) return null;
const shortYear = year.substring(2, 4);
return `${semester}-${shortYear}`;
}
/**
* Convert friendly format (e.g., "summer-26") to Banner term code (e.g., "202620")
*/
export function termToBanner(friendly: string): string | null {
const match = friendly.match(/^(spring|summer|fall)-(\d{2})$/);
if (!match) return null;
const [, semester, shortYear] = match;
const semesterCode = SEMESTER_TO_CODE[semester as SemesterName];
if (!semesterCode) return null;
const fullYear = `20${shortYear}`;
return `${fullYear}${semesterCode}`;
}
+1 -1
View File
@@ -5,7 +5,7 @@
* targets. This module owns the AnimMap lifecycle: syncing targets,
* stepping current values, and pruning offscreen entries.
*/
import { VALUE_EASE, MAXY_EASE, SETTLE_THRESHOLD, MIN_MAXY } from "./constants";
import { MAXY_EASE, MIN_MAXY, SETTLE_THRESHOLD, VALUE_EASE } from "./constants";
import type { AnimEntry, TimeSlot } from "./types";
export type AnimMap = Map<number, Map<string, AnimEntry>>;
+14 -14
View File
@@ -4,28 +4,28 @@
* Every function takes a {@link ChartContext} plus any data it needs.
* No Svelte reactivity, no side-effects beyond drawing on the context.
*/
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
import { type Series, area, curveMonotoneX, stack } from "d3-shape";
import { timeFormat } from "d3-time-format";
import { getSubjectColor } from "./data";
import type { AnimMap } from "./animation";
import { getStackSubjects } from "./viewport";
import type { ChartContext, TimeSlot } from "./types";
import {
GRID_ALPHA,
HOUR_GRID_ALPHA,
NOW_LINE_WIDTH,
NOW_LINE_COLOR,
NOW_TRIANGLE_HEIGHT,
NOW_TRIANGLE_HALF_WIDTH,
NOW_LABEL_FONT,
HOVER_HIGHLIGHT_ALPHA,
AREA_FILL_ALPHA,
AREA_STROKE_ALPHA,
SLOT_INTERVAL_MS,
SETTLE_THRESHOLD,
AXIS_FONT,
GRID_ALPHA,
HOUR_GRID_ALPHA,
HOVER_HIGHLIGHT_ALPHA,
NOW_LABEL_FONT,
NOW_LINE_COLOR,
NOW_LINE_WIDTH,
NOW_TRIANGLE_HALF_WIDTH,
NOW_TRIANGLE_HEIGHT,
SETTLE_THRESHOLD,
SLOT_INTERVAL_MS,
} from "./constants";
import { getSubjectColor } from "./data";
import type { ChartContext, TimeSlot } from "./types";
import { getStackSubjects } from "./viewport";
// ── Formatters (allocated once) ─────────────────────────────────────
const fmtHour = timeFormat("%-I %p");
+1 -1
View File
@@ -5,7 +5,7 @@
* the missing segments when the view expands into unloaded territory.
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
*/
import { client, type TimelineRange } from "$lib/api";
import { type TimelineRange, client } from "$lib/api";
import { SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
+1 -1
View File
@@ -2,7 +2,7 @@
* Pure viewport utility functions: binary search, visible-slot slicing,
* hit-testing, and snapping for the timeline canvas.
*/
import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
import { RENDER_MARGIN_SLOTS, SLOT_INTERVAL_MS } from "./constants";
import type { TimeSlot } from "./types";
/**
+1 -1
View File
@@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
+1 -1
View File
@@ -2,8 +2,8 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import {
Activity,
ClipboardList,
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { client, type AdminStatus } from "$lib/api";
import { type AdminStatus, client } from "$lib/api";
import { formatNumber } from "$lib/utils";
import { onMount } from "svelte";
let status = $state<AdminStatus | null>(null);
let error = $state<string | null>(null);
@@ -1,13 +1,12 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { slide, fade } from "svelte/transition";
import {
client,
type CandidateResponse,
type InstructorDetailResponse,
type InstructorListItem,
type InstructorStats,
type InstructorDetailResponse,
type CandidateResponse,
client,
} from "$lib/api";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { formatInstructorName, isRatingValid, ratingStyle } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import {
@@ -19,7 +18,8 @@ import {
Search,
X,
} from "@lucide/svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { onDestroy, onMount } from "svelte";
import { fade, slide } from "svelte/transition";
import CandidateCard from "./CandidateCard.svelte";
// --- State ---
+1 -1
View File
@@ -3,6 +3,7 @@ import { type ScrapeJob, client } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { formatAbsoluteDate } from "$lib/date";
import { formatDuration } from "$lib/time";
import { type ConnectionState, ScrapeJobsStore } from "$lib/ws";
import { ArrowDown, ArrowUp, ArrowUpDown, TriangleAlert } from "@lucide/svelte";
import {
type ColumnDef,
@@ -12,7 +13,6 @@ import {
getSortedRowModel,
} from "@tanstack/table-core";
import { onMount } from "svelte";
import { type ConnectionState, ScrapeJobsStore } from "$lib/ws";
let jobs = $state<ScrapeJob[]>([]);
let connectionState = $state<ConnectionState>("disconnected");
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { client } from "$lib/api";
import type { User } from "$lib/bindings";
import { Shield, ShieldOff } from "@lucide/svelte";
import { onMount } from "svelte";
let users = $state<User[]>([]);
let error = $state<string | null>(null);
+3 -3
View File
@@ -2,14 +2,14 @@
import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { page } from "$app/state";
import PageTransition from "$lib/components/PageTransition.svelte";
import { authStore } from "$lib/auth.svelte";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import NavBar from "$lib/components/NavBar.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { authStore } from "$lib/auth.svelte";
import { Tooltip } from "bits-ui";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import { onMount } from "svelte";
let { children } = $props();
+15 -9
View File
@@ -1,27 +1,30 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import {
type Subject,
type SearchResponse,
type SortColumn,
type SortDirection,
type Subject,
client,
} from "$lib/api";
import type { SortingState } from "@tanstack/table-core";
import CourseTable from "$lib/components/CourseTable.svelte";
import Footer from "$lib/components/Footer.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte";
import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import Footer from "$lib/components/Footer.svelte";
import { termToBanner, termToFriendly } from "$lib/term-format";
import type { SortingState } from "@tanstack/table-core";
import { untrack } from "svelte";
let { data } = $props();
// Read initial state from URL params (intentionally captured once)
const initialParams = untrack(() => new URLSearchParams(data.url.search));
// Filter state
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
// Filter state - only set term from URL if present (no auto-default)
const urlTerm = initialParams.get("term");
const bannerTerm = urlTerm ? (termToBanner(urlTerm) ?? "") : "";
let selectedTerm = $state(bannerTerm);
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
let query = $state(initialParams.get("q") ?? "");
let openOnly = $state(initialParams.get("open") === "true");
@@ -160,7 +163,10 @@ async function performSearch(
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
const params = new URLSearchParams();
params.set("term", term);
const friendlyTerm = termToFriendly(term);
if (friendlyTerm) {
params.set("term", friendlyTerm);
}
for (const s of subjects) {
params.append("subject", s);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { PageLoad } from "./$types";
import { BannerApiClient } from "$lib/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ url, fetch }) => {
const client = new BannerApiClient(undefined, fetch);
+6 -6
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { type ServiceInfo, type ServiceStatus, type StatusResponse, client } from "$lib/api";
import Footer from "$lib/components/Footer.svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
import {
Activity,
Bot,
@@ -12,11 +16,7 @@ import {
WifiOff,
XCircle,
} from "@lucide/svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import Footer from "$lib/components/Footer.svelte";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
import { onMount } from "svelte";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
const REQUEST_TIMEOUT = 10000;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import TimelineCanvas from "$lib/components/TimelineCanvas.svelte";
import { onMount } from "svelte";
// Prevent body scroll while this page is mounted via a CSS class
// (avoids conflicting with other components that may manage overflow).
+2 -2
View File
@@ -1,8 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { resolve } from "node:path";
import { readFileSync, existsSync } from "node:fs";
function getVersion() {
const filename = "Cargo.toml";