17 Commits

Author SHA1 Message Date
47132e71d7 chore(master): release 0.6.1 (#1) 2026-01-31 00:37:13 -06:00
87db1a4ccb refactor: extract Justfile inline scripts into scripts/ directory
Move all [script("bun")] blocks into standalone TypeScript files under
scripts/ with shared utilities in scripts/lib/. The Justfile is now ~40
lines of thin `bun scripts/*.ts` wrappers.

Shared code consolidated into two lib files:
- lib/proc.ts: process spawning (run, spawnCollect, raceInOrder, ProcessGroup)
- lib/fmt.ts: color output, elapsed timers, reusable flag parser
2026-01-31 00:34:27 -06:00
e203e8e182 feat(build): auto-regenerate TypeScript bindings on source changes 2026-01-31 00:27:27 -06:00
cbb0a51bca refactor(terms): move term formatting from frontend to backend 2026-01-31 00:26:41 -06:00
c533768362 feat(scraper): improve results visibility and loading states 2026-01-30 23:36:23 -06:00
16039e02a9 fix(metrics): always emit baseline metrics on initial course insertion 2026-01-30 23:32:04 -06:00
7d2255a988 fix(data): decode HTML entities in course titles and instructor names 2026-01-30 23:31:05 -06:00
8bfc14e55c feat(course): distinguish async from synchronous online courses
Add logic to detect and label asynchronous online sections (INT building
with TBA times) separately from synchronous online courses. Update table
rendering to show "Async" instead of "TBA" for these sections.
2026-01-30 23:27:54 -06:00
2689587dd5 fix: avoid status flickering on subjects table 2026-01-30 22:04:48 -06:00
1ad614dad0 feat(scraper): improve dashboard clarity with stat tooltips 2026-01-30 22:00:59 -06:00
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
94 changed files with 2070 additions and 1119 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.1"
}
+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
+2 -3
View File
@@ -1,6 +1,5 @@
.env
/target
/scripts/node_modules
# ts-rs bindings
web/src/lib/bindings/**/*.ts
!web/src/lib/bindings/index.ts
+37
View File
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.1](https://github.com/Xevion/Banner/compare/v0.6.0...v0.6.1) (2026-01-31)
### Features
* **build:** Auto-regenerate TypeScript bindings on source changes ([e203e8e](https://github.com/Xevion/Banner/commit/e203e8e182f7a0b0224a8f9e6bf79d15259215a2))
* **course:** Distinguish async from synchronous online courses ([8bfc14e](https://github.com/Xevion/Banner/commit/8bfc14e55c1bdf5acc2006096476e0b1eb1b7cc6))
* **scraper:** Improve dashboard clarity with stat tooltips ([1ad614d](https://github.com/Xevion/Banner/commit/1ad614dad03d3631a8d119203786718c814e72c7))
* **scraper:** Improve results visibility and loading states ([c533768](https://github.com/Xevion/Banner/commit/c53376836238f3aca92ac82cd5fd59a077bcceff))
### Bug Fixes
* Avoid status flickering on subjects table ([2689587](https://github.com/Xevion/Banner/commit/2689587dd53c572a65eeb91f74c737662e1f148b))
* **ci:** Add postgres container service for rust tests ([ebb7a97](https://github.com/Xevion/Banner/commit/ebb7a97c113fa1d4b61b8637dfe97cae5260075c))
* **ci:** Fix rust/frontend/security job failures and expand local checks ([dd148e0](https://github.com/Xevion/Banner/commit/dd148e08a0b6d5b7afe4ff614d7d6e4e4d0dfce6))
* **data:** Decode HTML entities in course titles and instructor names ([7d2255a](https://github.com/Xevion/Banner/commit/7d2255a988a23f6e1b1c8e7cb5a8ead833ad34da))
* **metrics:** Always emit baseline metrics on initial course insertion ([16039e0](https://github.com/Xevion/Banner/commit/16039e02a999c668d4969a43eb9ed1d4e8d370e1))
### Code Refactoring
* **terms:** Move term formatting from frontend to backend ([cbb0a51](https://github.com/Xevion/Banner/commit/cbb0a51bca9e4e0d6a8fcee90465c93943f2a30e))
* Use friendly term codes in URL query parameters ([550401b](https://github.com/Xevion/Banner/commit/550401b85ceb8a447e316209b479c69062c5b658))
### Continuous Integration
* Add Release Please automation for changelog and version management ([6863ee5](https://github.com/Xevion/Banner/commit/6863ee58d0a5778303af1b7626b2a9eda3043ca0))
* Split quality checks into parallel jobs with security scanning ([3494341](https://github.com/Xevion/Banner/commit/3494341e3fbe9ffd96b6fcd8abbe7f95ecec6f45))
### Miscellaneous
* Add ts-rs generated bindings ([2df0ba0](https://github.com/Xevion/Banner/commit/2df0ba0ec58155d73830a66132cb635dc819e8a9))
* Update frontend packages ([acccaa5](https://github.com/Xevion/Banner/commit/acccaa54d4455500db60d1b6437cad1c592445f1))
## [Unreleased]
## [0.6.0] - 2026-01-30
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.6.0"
version = "0.6.1"
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.6.0"
version = "0.6.1"
edition = "2024"
default-run = "banner"
+7 -406
View File
@@ -4,161 +4,8 @@ default:
just --list
# Run all checks in parallel. Pass -f/--fix to auto-format and fix first.
[script("bun")]
check *flags:
const args = "{{flags}}".split(/\s+/).filter(Boolean);
let fix = false;
for (const arg of args) {
if (arg === "-f" || arg === "--fix") fix = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
}
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
if (fix) {
console.log("\x1b[1;36m→ Fixing...\x1b[0m");
run(["cargo", "fmt", "--all"]);
run(["bun", "run", "--cwd", "web", "format"]);
run(["cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged",
"--", "--deny", "warnings"]);
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
}
// Domain groups: format check name → { peers (other checks), formatter, sanity re-check }
const domains = {
rustfmt: {
peers: ["clippy", "rust-test"],
format: () => run(["cargo", "fmt", "--all"]),
recheck: [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
],
},
biome: {
peers: ["svelte-check", "web-test"],
format: () => run(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
],
},
};
const checks = [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"], terse: true },
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ 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: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
// { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] },
];
const isTTY = process.stderr.isTTY;
const start = Date.now();
const remaining = new Set(checks.map(c => c.name));
const promises = checks.map(async (check) => {
const proc = Bun.spawn(check.cmd, {
env: { ...process.env, FORCE_COLOR: "1" },
stdout: "pipe", stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return { ...check, stdout, stderr, exitCode: proc.exitCode,
elapsed: ((Date.now() - start) / 1000).toFixed(1) };
});
const interval = isTTY ? setInterval(() => {
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
}, 100) : null;
// Phase 1: collect all results, eagerly displaying whichever finishes first
const results = {};
let anyFailed = false;
const tagged = promises.map((p, i) => p.then(r => ({ i, r })));
for (let n = 0; n < checks.length; n++) {
const { i, r } = await Promise.race(tagged);
tagged[i] = new Promise(() => {}); // sentinel: never resolves
results[r.name] = r;
remaining.delete(r.name);
if (isTTY) process.stderr.write(`\r\x1b[K`);
if (r.exitCode !== 0) {
anyFailed = true;
process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
if (!r.terse) {
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
}
} else {
process.stdout.write(`\x1b[32m✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
}
}
if (interval) clearInterval(interval);
if (isTTY) process.stderr.write(`\r\x1b[K`);
// Phase 2: auto-fix formatting if it's the only failure in a domain
let autoFixed = false;
for (const [fmtName, domain] of Object.entries(domains)) {
const fmtResult = results[fmtName];
if (!fmtResult || fmtResult.exitCode === 0) continue;
const peersAllPassed = domain.peers.every(p => results[p]?.exitCode === 0);
if (!peersAllPassed) continue;
process.stdout.write(`\n\x1b[1;36m→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...\x1b[0m\n`);
domain.format();
// Re-verify format + sanity check in parallel
const recheckStart = Date.now();
const recheckPromises = domain.recheck.map(async (check) => {
const proc = Bun.spawn(check.cmd, {
env: { ...process.env, FORCE_COLOR: "1" },
stdout: "pipe", stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return { ...check, stdout, stderr, exitCode: proc.exitCode,
elapsed: ((Date.now() - recheckStart) / 1000).toFixed(1) };
});
let recheckFailed = false;
for (const p of recheckPromises) {
const r = await p;
if (r.exitCode !== 0) {
recheckFailed = true;
process.stdout.write(`\x1b[31m ✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
} else {
process.stdout.write(`\x1b[32m ✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
}
}
if (!recheckFailed) {
process.stdout.write(`\x1b[32m ✓ ${fmtName} auto-fix succeeded\x1b[0m\n`);
results[fmtName].exitCode = 0;
autoFixed = true;
} else {
process.stdout.write(`\x1b[31m ✗ ${fmtName} auto-fix failed sanity check\x1b[0m\n`);
}
}
const finalFailed = Object.values(results).some(r => r.exitCode !== 0);
if (autoFixed && !finalFailed) {
process.stdout.write(`\n\x1b[1;32m✓ All checks passed (formatting was auto-fixed)\x1b[0m\n`);
}
process.exit(finalFailed ? 1 : 0);
bun scripts/check.ts {{flags}}
# Format all Rust and TypeScript code
format:
@@ -166,276 +13,30 @@ format:
bun run --cwd web format
# Run tests. Usage: just test [rust|web|<nextest filter args>]
[script("bun")]
test *args:
const input = "{{args}}".trim();
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
if (input === "web") {
run(["bun", "run", "--cwd", "web", "test"]);
} else if (input === "rust") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
} else if (input === "") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
run(["bun", "run", "--cwd", "web", "test"]);
} else {
run(["cargo", "nextest", "run", ...input.split(/\s+/)]);
}
bun scripts/test.ts {{args}}
# Generate TypeScript bindings from Rust types (ts-rs)
bindings:
cargo test export_bindings
bun scripts/bindings.ts
# Run the Banner API search demo (hits live UTSA API, ~20s)
search *ARGS:
cargo run -q --bin search -- {{ARGS}}
# Pass args to binary after --: just dev -n -- --some-flag
# Dev server. Flags: -f(rontend) -b(ackend) -W(no-watch) -n(o-build) -r(elease) -e(mbed) --tracing <fmt>
[script("bun")]
# Pass args to binary after --: just dev -n -- --some-flag
dev *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let frontendOnly = false, backendOnly = false;
let noWatch = false, noBuild = false, release = false, embed = false;
let tracing = "pretty";
const passthrough = [];
let i = 0;
let seenDashDash = false;
while (i < argv.length) {
const arg = argv[i];
if (seenDashDash) { passthrough.push(arg); i++; continue; }
if (arg === "--") { seenDashDash = true; i++; continue; }
if (arg.startsWith("--")) {
if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else if (arg === "--no-watch") noWatch = true;
else if (arg === "--no-build") noBuild = true;
else if (arg === "--release") release = true;
else if (arg === "--embed") embed = true;
else if (arg === "--tracing") { tracing = argv[++i] || "pretty"; }
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else if (c === "W") noWatch = true;
else if (c === "n") noBuild = true;
else if (c === "r") release = true;
else if (c === "e") embed = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
i++;
}
// -e implies -b (no point running Vite if assets are embedded)
if (embed) backendOnly = true;
// -n implies -W (no build means no watch)
if (noBuild) noWatch = true;
// Validate conflicting flags
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together (or -e implies -b)");
process.exit(1);
}
const runFrontend = !backendOnly;
const runBackend = !frontendOnly;
const profile = release ? "release" : "dev";
const profileDir = release ? "release" : "debug";
const procs = [];
const cleanup = async () => {
for (const p of procs) p.kill();
await Promise.all(procs.map(p => p.exited));
};
process.on("SIGINT", async () => { await cleanup(); process.exit(0); });
process.on("SIGTERM", async () => { await cleanup(); process.exit(0); });
// Build frontend first when embedding assets (backend will bake them in)
if (embed && !noBuild) {
console.log(`\x1b[1;36m→ Building frontend (for embedding)...\x1b[0m`);
const fb = Bun.spawnSync(["bun", "run", "--cwd", "web", "build"], {
stdio: ["inherit", "inherit", "inherit"],
});
if (fb.exitCode !== 0) process.exit(fb.exitCode);
}
// Frontend: Vite dev server
if (runFrontend) {
const proc = Bun.spawn(["bun", "run", "--cwd", "web", "dev"], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
// Backend
if (runBackend) {
const backendArgs = [`--tracing`, tracing, ...passthrough];
const bin = `target/${profileDir}/banner`;
if (noWatch) {
// Build first unless -n (skip build)
if (!noBuild) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cargoArgs = ["cargo", "build", "--bin", "banner"];
if (!embed) cargoArgs.push("--no-default-features");
if (release) cargoArgs.push("--release");
const build = Bun.spawnSync(cargoArgs, { stdio: ["inherit", "inherit", "inherit"] });
if (build.exitCode !== 0) { cleanup(); process.exit(build.exitCode); }
}
// Run the binary directly (no watch)
const { existsSync } = await import("fs");
if (!existsSync(bin)) {
console.error(`Binary not found: ${bin}`);
console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`);
cleanup();
process.exit(1);
}
console.log(`\x1b[1;36m→ Running ${bin} (no watch)\x1b[0m`);
const proc = Bun.spawn([bin, ...backendArgs], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
} else {
// Bacon watch mode
const baconArgs = ["bacon", "--headless", "run", "--"];
if (!embed) baconArgs.push("--no-default-features");
if (release) baconArgs.push("--profile", "release");
baconArgs.push("--", ...backendArgs);
const proc = Bun.spawn(baconArgs, {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
}
// Wait for any process to exit, then kill the rest
const results = procs.map((p, i) => p.exited.then(code => ({ i, code })));
const first = await Promise.race(results);
cleanup();
process.exit(first.code);
bun scripts/dev.ts {{flags}}
# Production build. Flags: -d(ebug) -f(rontend-only) -b(ackend-only)
[script("bun")]
build *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let debug = false, frontendOnly = false, backendOnly = false;
for (const arg of argv) {
if (arg.startsWith("--")) {
if (arg === "--debug") debug = true;
else if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "d") debug = true;
else if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
}
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together");
process.exit(1);
}
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
const buildFrontend = !backendOnly;
const buildBackend = !frontendOnly;
const profile = debug ? "debug" : "release";
if (buildFrontend) {
console.log("\x1b[1;36m→ Building frontend...\x1b[0m");
run(["bun", "run", "--cwd", "web", "build"]);
}
if (buildBackend) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cmd = ["cargo", "build", "--bin", "banner"];
if (!debug) cmd.push("--release");
run(cmd);
}
bun scripts/build.ts {{flags}}
# Start PostgreSQL in Docker and update .env with connection string
# Commands: start (default), reset, rm
[script("bun")]
db cmd="start":
const fs = await import("fs/promises");
const { spawnSync } = await import("child_process");
const NAME = "banner-postgres";
const USER = "banner";
const PASS = "banner";
const DB = "banner";
const PORT = "59489";
const ENV_FILE = ".env";
const CMD = "{{cmd}}";
const run = (args) => spawnSync("docker", args, { encoding: "utf8" });
const getContainer = () => {
const res = run(["ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"]);
return res.stdout.trim() ? JSON.parse(res.stdout) : null;
};
const updateEnv = async () => {
const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`;
try {
let content = await fs.readFile(ENV_FILE, "utf8");
content = content.includes("DATABASE_URL=")
? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`)
: content.trim() + `\nDATABASE_URL=${url}\n`;
await fs.writeFile(ENV_FILE, content);
} catch {
await fs.writeFile(ENV_FILE, `DATABASE_URL=${url}\n`);
}
};
const create = () => {
run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`,
"-e", `POSTGRES_PASSWORD=${PASS}`, "-e", `POSTGRES_DB=${DB}`,
"-p", `${PORT}:5432`, "postgres:17-alpine"]);
console.log("created");
};
const container = getContainer();
if (CMD === "rm") {
if (!container) process.exit(0);
run(["stop", NAME]);
run(["rm", NAME]);
console.log("removed");
} else if (CMD === "reset") {
if (!container) create();
else {
run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`]);
run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`]);
console.log("reset");
}
await updateEnv();
} else {
if (!container) {
create();
} else if (container.State !== "running") {
run(["start", NAME]);
console.log("started");
} else {
console.log("running");
}
await updateEnv();
}
bun scripts/db.ts {{cmd}}
alias b := bun
bun *ARGS:
+32
View File
@@ -0,0 +1,32 @@
/**
* Generate TypeScript bindings from Rust types (ts-rs).
*
* Usage: bun scripts/bindings.ts
*/
import { readdirSync, writeFileSync, rmSync } from "fs";
import { run } from "./lib/proc";
const BINDINGS_DIR = "web/src/lib/bindings";
// Build test binary first (slow part) — fail before deleting anything
run(["cargo", "test", "--no-run"]);
// Clean slate
rmSync(BINDINGS_DIR, { recursive: true, force: true });
// Run the export (fast, already compiled)
run(["cargo", "test", "export_bindings"]);
// Auto-generate index.ts from emitted .ts files
const types = readdirSync(BINDINGS_DIR)
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
.map((f) => f.replace(/\.ts$/, ""))
.sort();
writeFileSync(
`${BINDINGS_DIR}/index.ts`,
types.map((t) => `export type { ${t} } from "./${t}";`).join("\n") + "\n",
);
console.log(`Generated ${BINDINGS_DIR}/index.ts (${types.length} types)`);
+45
View File
@@ -0,0 +1,45 @@
/**
* Production build.
*
* Usage: bun scripts/build.ts [flags]
*
* Flags:
* -d, --debug Debug build instead of release
* -f, --frontend-only Frontend only
* -b, --backend-only Backend only
*/
import { parseFlags, c } from "./lib/fmt";
import { run } from "./lib/proc";
const { flags } = parseFlags(
process.argv.slice(2),
{
debug: "bool",
"frontend-only": "bool",
"backend-only": "bool",
} as const,
{ d: "debug", f: "frontend-only", b: "backend-only" },
{ debug: false, "frontend-only": false, "backend-only": false },
);
if (flags["frontend-only"] && flags["backend-only"]) {
console.error("Cannot use -f and -b together");
process.exit(1);
}
const buildFrontend = !flags["backend-only"];
const buildBackend = !flags["frontend-only"];
const profile = flags.debug ? "debug" : "release";
if (buildFrontend) {
console.log(c("1;36", "→ Building frontend..."));
run(["bun", "run", "--cwd", "web", "build"]);
}
if (buildBackend) {
console.log(c("1;36", `→ Building backend (${profile})...`));
const cmd = ["cargo", "build", "--bin", "banner"];
if (!flags.debug) cmd.push("--release");
run(cmd);
}
+21
View File
@@ -0,0 +1,21 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "banner-scripts",
"devDependencies": {
"@types/bun": "^1.3.8",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Run all project checks in parallel. Auto-fixes formatting when safe.
*
* Usage: bun scripts/check.ts [--fix|-f]
*/
import { c, elapsed, isStderrTTY } from "./lib/fmt";
import { run, runPiped, spawnCollect, raceInOrder, type CollectResult } from "./lib/proc";
import { existsSync, statSync, readdirSync, writeFileSync, rmSync } from "fs";
const fix = process.argv.includes("--fix") || process.argv.includes("-f");
// ---------------------------------------------------------------------------
// Fix path: format + clippy fix, then fall through to verification
// ---------------------------------------------------------------------------
if (fix) {
console.log(c("1;36", "→ Fixing..."));
run(["cargo", "fmt", "--all"]);
run(["bun", "run", "--cwd", "web", "format"]);
run([
"cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged",
"--", "--deny", "warnings",
]);
console.log(c("1;36", "→ Verifying..."));
}
// ---------------------------------------------------------------------------
// Ensure TypeScript bindings are up-to-date before frontend checks
// ---------------------------------------------------------------------------
{
const BINDINGS_DIR = "web/src/lib/bindings";
let newestSrcMtime = 0;
for (const file of new Bun.Glob("src/**/*.rs").scanSync(".")) {
const mt = statSync(file).mtimeMs;
if (mt > newestSrcMtime) newestSrcMtime = mt;
}
for (const f of ["Cargo.toml", "Cargo.lock"]) {
if (existsSync(f)) {
const mt = statSync(f).mtimeMs;
if (mt > newestSrcMtime) newestSrcMtime = mt;
}
}
let newestBindingMtime = 0;
if (existsSync(BINDINGS_DIR)) {
for (const file of new Bun.Glob("**/*").scanSync(BINDINGS_DIR)) {
const mt = statSync(`${BINDINGS_DIR}/${file}`).mtimeMs;
if (mt > newestBindingMtime) newestBindingMtime = mt;
}
}
const stale = newestBindingMtime === 0 || newestSrcMtime > newestBindingMtime;
if (stale) {
const t = Date.now();
process.stdout.write(
c("1;36", "→ Regenerating TypeScript bindings (Rust sources changed)...") + "\n",
);
run(["cargo", "test", "--no-run"]);
rmSync(BINDINGS_DIR, { recursive: true, force: true });
run(["cargo", "test", "export_bindings"]);
const types = readdirSync(BINDINGS_DIR)
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
.map((f) => f.replace(/\.ts$/, ""))
.sort();
writeFileSync(
`${BINDINGS_DIR}/index.ts`,
types.map((t) => `export type { ${t} } from "./${t}";`).join("\n") + "\n",
);
process.stdout.write(c("32", "✓ bindings") + ` (${elapsed(t)}s, ${types.length} types)\n`);
} else {
process.stdout.write(c("2", "· bindings up-to-date, skipped") + "\n");
}
}
// ---------------------------------------------------------------------------
// Check definitions
// ---------------------------------------------------------------------------
interface Check {
name: string;
cmd: string[];
hint?: string;
}
const checks: Check[] = [
{
name: "rustfmt",
cmd: ["cargo", "fmt", "--all", "--", "--check"],
hint: "Run 'cargo fmt --all' to see and fix formatting issues.",
},
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
{ 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"] },
];
// ---------------------------------------------------------------------------
// Domain groups: formatter → { peers, format command, sanity rechecks }
// ---------------------------------------------------------------------------
const domains: Record<
string,
{
peers: string[];
format: () => ReturnType<typeof runPiped>;
recheck: Check[];
}
> = {
rustfmt: {
peers: ["clippy", "cargo-check", "rust-test"],
format: () => runPiped(["cargo", "fmt", "--all"]),
recheck: [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
],
},
biome: {
peers: ["svelte-check", "biome-lint", "web-test"],
format: () => runPiped(["bun", "run", "--cwd", "web", "format"]),
recheck: [
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
],
},
};
// ---------------------------------------------------------------------------
// Phase 1: run all checks in parallel, display in completion order
// ---------------------------------------------------------------------------
const start = Date.now();
const remaining = new Set(checks.map((ch) => ch.name));
const promises = checks.map(async (check) => ({
...check,
...(await spawnCollect(check.cmd, start)),
}));
const interval = isStderrTTY
? setInterval(() => {
process.stderr.write(`\r\x1b[K${elapsed(start)}s [${Array.from(remaining).join(", ")}]`);
}, 100)
: null;
const results: Record<string, Check & CollectResult> = {};
await raceInOrder(promises, checks, (r) => {
results[r.name] = r;
remaining.delete(r.name);
if (isStderrTTY) process.stderr.write("\r\x1b[K");
if (r.exitCode !== 0) {
process.stdout.write(c("31", `${r.name}`) + ` (${r.elapsed}s)\n`);
if (r.hint) {
process.stdout.write(c("2", ` ${r.hint}`) + "\n");
} else {
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
}
} else {
process.stdout.write(c("32", `${r.name}`) + ` (${r.elapsed}s)\n`);
}
});
if (interval) clearInterval(interval);
if (isStderrTTY) process.stderr.write("\r\x1b[K");
// ---------------------------------------------------------------------------
// Phase 2: auto-fix formatting if it's the only failure in its domain
// ---------------------------------------------------------------------------
const autoFixedDomains = new Set<string>();
for (const [fmtName, domain] of Object.entries(domains)) {
const fmtResult = results[fmtName];
if (!fmtResult || fmtResult.exitCode === 0) continue;
if (!domain.peers.every((p) => results[p]?.exitCode === 0)) continue;
process.stdout.write(
"\n" +
c("1;36", `→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...`) +
"\n",
);
const fmtOut = domain.format();
if (fmtOut.exitCode !== 0) {
process.stdout.write(c("31", `${fmtName} formatter failed`) + "\n");
if (fmtOut.stdout) process.stdout.write(fmtOut.stdout);
if (fmtOut.stderr) process.stderr.write(fmtOut.stderr);
continue;
}
const recheckStart = Date.now();
const recheckPromises = domain.recheck.map(async (ch) => ({
...ch,
...(await spawnCollect(ch.cmd, recheckStart)),
}));
let recheckFailed = false;
await raceInOrder(recheckPromises, domain.recheck, (r) => {
if (r.exitCode !== 0) {
recheckFailed = true;
process.stdout.write(c("31", `${r.name}`) + ` (${r.elapsed}s)\n`);
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
} else {
process.stdout.write(c("32", `${r.name}`) + ` (${r.elapsed}s)\n`);
}
});
if (!recheckFailed) {
process.stdout.write(c("32", `${fmtName} auto-fix succeeded`) + "\n");
autoFixedDomains.add(fmtName);
} else {
process.stdout.write(c("31", `${fmtName} auto-fix failed sanity check`) + "\n");
}
}
// ---------------------------------------------------------------------------
// Final verdict
// ---------------------------------------------------------------------------
const finalFailed = Object.entries(results).some(
([name, r]) => r.exitCode !== 0 && !autoFixedDomains.has(name),
);
if (autoFixedDomains.size > 0 && !finalFailed) {
process.stdout.write(
"\n" + c("1;32", "✓ All checks passed (formatting was auto-fixed)") + "\n",
);
}
process.exit(finalFailed ? 1 : 0);
+79
View File
@@ -0,0 +1,79 @@
/**
* PostgreSQL Docker container management.
*
* Usage: bun scripts/db.ts [start|reset|rm]
*/
import { readFile, writeFile } from "fs/promises";
import { spawnSync } from "child_process";
const NAME = "banner-postgres";
const USER = "banner";
const PASS = "banner";
const DB = "banner";
const PORT = "59489";
const ENV_FILE = ".env";
const cmd = process.argv[2] || "start";
function docker(...args: string[]) {
return spawnSync("docker", args, { encoding: "utf8" });
}
function getContainer() {
const res = docker("ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json");
return res.stdout.trim() ? JSON.parse(res.stdout) : null;
}
async function updateEnv() {
const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`;
try {
let content = await readFile(ENV_FILE, "utf8");
content = content.includes("DATABASE_URL=")
? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`)
: content.trim() + `\nDATABASE_URL=${url}\n`;
await writeFile(ENV_FILE, content);
} catch {
await writeFile(ENV_FILE, `DATABASE_URL=${url}\n`);
}
}
function create() {
docker(
"run", "-d", "--name", NAME,
"-e", `POSTGRES_USER=${USER}`,
"-e", `POSTGRES_PASSWORD=${PASS}`,
"-e", `POSTGRES_DB=${DB}`,
"-p", `${PORT}:5432`,
"postgres:17-alpine",
);
console.log("created");
}
const container = getContainer();
if (cmd === "rm") {
if (!container) process.exit(0);
docker("stop", NAME);
docker("rm", NAME);
console.log("removed");
} else if (cmd === "reset") {
if (!container) {
create();
} else {
docker("exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`);
docker("exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`);
console.log("reset");
}
await updateEnv();
} else {
if (!container) {
create();
} else if (container.State !== "running") {
docker("start", NAME);
console.log("started");
} else {
console.log("running");
}
await updateEnv();
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Dev server orchestrator.
*
* Usage: bun scripts/dev.ts [flags] [-- passthrough-args]
*
* Flags:
* -f, --frontend-only Frontend only (Vite dev server)
* -b, --backend-only Backend only (bacon watch)
* -W, --no-watch Build once + run (no watch)
* -n, --no-build Run last compiled binary (no rebuild)
* -r, --release Use release profile
* -e, --embed Embed assets (implies -b)
* --tracing <fmt> Tracing format (default: pretty)
*/
import { existsSync } from "fs";
import { parseFlags, c } from "./lib/fmt";
import { run, ProcessGroup } from "./lib/proc";
const { flags, passthrough } = parseFlags(
process.argv.slice(2),
{
"frontend-only": "bool",
"backend-only": "bool",
"no-watch": "bool",
"no-build": "bool",
release: "bool",
embed: "bool",
tracing: "string",
} as const,
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed" },
{
"frontend-only": false,
"backend-only": false,
"no-watch": false,
"no-build": false,
release: false,
embed: false,
tracing: "pretty",
},
);
let frontendOnly = flags["frontend-only"];
let backendOnly = flags["backend-only"];
let noWatch = flags["no-watch"];
const noBuild = flags["no-build"];
const release = flags.release;
const embed = flags.embed;
const tracing = flags.tracing as string;
// -e implies -b
if (embed) backendOnly = true;
// -n implies -W
if (noBuild) noWatch = true;
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together (or -e implies -b)");
process.exit(1);
}
const runFrontend = !backendOnly;
const runBackend = !frontendOnly;
const profile = release ? "release" : "dev";
const profileDir = release ? "release" : "debug";
const group = new ProcessGroup();
// Build frontend first when embedding assets
if (embed && !noBuild) {
console.log(c("1;36", "→ Building frontend (for embedding)..."));
run(["bun", "run", "--cwd", "web", "build"]);
}
// Frontend: Vite dev server
if (runFrontend) {
group.spawn(["bun", "run", "--cwd", "web", "dev"]);
}
// Backend
if (runBackend) {
const backendArgs = ["--tracing", tracing, ...passthrough];
const bin = `target/${profileDir}/banner`;
if (noWatch) {
if (!noBuild) {
console.log(c("1;36", `→ Building backend (${profile})...`));
const cargoArgs = ["cargo", "build", "--bin", "banner"];
if (!embed) cargoArgs.push("--no-default-features");
if (release) cargoArgs.push("--release");
run(cargoArgs);
}
if (!existsSync(bin)) {
console.error(`Binary not found: ${bin}`);
console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`);
await group.killAll();
process.exit(1);
}
console.log(c("1;36", `→ Running ${bin} (no watch)`));
group.spawn([bin, ...backendArgs]);
} else {
// Bacon watch mode
const baconArgs = ["bacon", "--headless", "run", "--"];
if (!embed) baconArgs.push("--no-default-features");
if (release) baconArgs.push("--profile", "release");
baconArgs.push("--", ...backendArgs);
group.spawn(baconArgs);
}
}
const code = await group.waitForFirst();
process.exit(code);
+96
View File
@@ -0,0 +1,96 @@
/**
* Shared formatting, color, and CLI argument parsing utilities.
*/
const isTTY = process.stdout.isTTY ?? false;
const isStderrTTY = process.stderr.isTTY ?? false;
/** ANSI color wrapper — no-op when stdout is not a TTY. */
export function c(code: string, text: string): string {
return isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
}
/** Elapsed seconds since `start` as a formatted string. */
export function elapsed(start: number): string {
return ((Date.now() - start) / 1000).toFixed(1);
}
/** Whether stderr is a TTY (for progress spinners). */
export { isStderrTTY };
/**
* Parse short and long CLI flags from a flat argument array.
*
* `spec` maps flag names to their type:
* - `"bool"` — presence sets the value to `true`
* - `"string"` — consumes the next argument as the value
*
* Short flags can be combined: `-fbW` expands to `-f -b -W`.
* Long flags: `--frontend-only`, `--tracing pretty`.
* `--` terminates flag parsing; remaining args go to `passthrough`.
*
* Returns `{ flags, passthrough }`.
*/
export function parseFlags<T extends Record<string, "bool" | "string">>(
argv: string[],
spec: T,
shortMap: Record<string, keyof T>,
defaults: { [K in keyof T]: T[K] extends "bool" ? boolean : string },
): { flags: typeof defaults; passthrough: string[] } {
const flags = { ...defaults };
const passthrough: string[] = [];
let i = 0;
while (i < argv.length) {
const arg = argv[i];
if (arg === "--") {
passthrough.push(...argv.slice(i + 1));
break;
}
if (arg.startsWith("--")) {
const name = arg.slice(2);
if (!(name in spec)) {
console.error(`Unknown flag: ${arg}`);
process.exit(1);
}
if (spec[name] === "string") {
(flags as Record<string, unknown>)[name] = argv[++i] || "";
} else {
(flags as Record<string, unknown>)[name] = true;
}
} else if (arg.startsWith("-") && arg.length > 1) {
for (const ch of arg.slice(1)) {
const mapped = shortMap[ch];
if (!mapped) {
console.error(`Unknown flag: -${ch}`);
process.exit(1);
}
if (spec[mapped as string] === "string") {
(flags as Record<string, unknown>)[mapped as string] = argv[++i] || "";
} else {
(flags as Record<string, unknown>)[mapped as string] = true;
}
}
} else {
console.error(`Unknown argument: ${arg}`);
process.exit(1);
}
i++;
}
return { flags, passthrough };
}
/**
* Simple positional-or-keyword argument parser.
* Returns the first positional arg, or empty string.
*/
export function parseArgs(raw: string): string[] {
return raw
.trim()
.split(/\s+/)
.filter(Boolean);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Shared process spawning utilities for project scripts.
*/
import { elapsed } from "./fmt";
export interface CollectResult {
stdout: string;
stderr: string;
exitCode: number;
elapsed: string;
}
/** Sync spawn with inherited stdio. Exits process on failure. */
export function run(cmd: string[]): void {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
}
/** Sync spawn with piped stdio. Returns captured output. */
export function runPiped(cmd: string[]): { exitCode: number; stdout: string; stderr: string } {
const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" });
return {
exitCode: proc.exitCode,
stdout: proc.stdout?.toString() ?? "",
stderr: proc.stderr?.toString() ?? "",
};
}
/**
* Async spawn that collects stdout/stderr. Returns a result object.
* Catches spawn failures (e.g. missing binary) instead of throwing.
*/
export async function spawnCollect(cmd: string[], startTime: number): Promise<CollectResult> {
try {
const proc = Bun.spawn(cmd, {
env: { ...process.env, FORCE_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return { stdout, stderr, exitCode: proc.exitCode, elapsed: elapsed(startTime) };
} catch (err) {
return { stdout: "", stderr: String(err), exitCode: 1, elapsed: elapsed(startTime) };
}
}
/**
* Race all promises, yielding results in completion order via callback.
* Spawn failures become results, not unhandled rejections.
*/
export async function raceInOrder<T extends { name: string }>(
promises: Promise<T & CollectResult>[],
fallbacks: T[],
onResult: (r: T & CollectResult) => void,
): Promise<void> {
const tagged = promises.map((p, i) =>
p
.then((r) => ({ i, r }))
.catch((err) => ({
i,
r: {
...fallbacks[i],
exitCode: 1,
stdout: "",
stderr: String(err),
elapsed: "?",
} as T & CollectResult,
})),
);
for (let n = 0; n < promises.length; n++) {
const { i, r } = await Promise.race(tagged);
tagged[i] = new Promise(() => {}); // sentinel: never resolves
onResult(r);
}
}
/** Spawn managed processes with coordinated cleanup on exit. */
export class ProcessGroup {
private procs: ReturnType<typeof Bun.spawn>[] = [];
constructor() {
const cleanup = async () => {
await this.killAll();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
}
spawn(cmd: string[]): ReturnType<typeof Bun.spawn> {
const proc = Bun.spawn(cmd, { stdio: ["inherit", "inherit", "inherit"] });
this.procs.push(proc);
return proc;
}
async killAll(): Promise<void> {
for (const p of this.procs) p.kill();
await Promise.all(this.procs.map((p) => p.exited));
}
/** Wait for any process to exit, kill the rest, return exit code. */
async waitForFirst(): Promise<number> {
const results = this.procs.map((p, i) => p.exited.then((code) => ({ i, code })));
const first = await Promise.race(results);
await this.killAll();
return first.code;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "banner-scripts",
"private": true,
"type": "module",
"devDependencies": {
"@types/bun": "^1.3.8"
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Run project tests.
*
* Usage: bun scripts/test.ts [rust|web|<nextest filter args>]
*/
import { run } from "./lib/proc";
const input = process.argv.slice(2).join(" ").trim();
if (input === "web") {
run(["bun", "run", "--cwd", "web", "test"]);
} else if (input === "rust") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
} else if (input === "") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
run(["bun", "run", "--cwd", "web", "test"]);
} else {
run(["cargo", "nextest", "run", ...input.split(/\s+/)]);
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun-types"],
"paths": {
"#lib/*": ["./lib/*"]
}
},
"include": ["**/*.ts"]
}
+125
View File
@@ -147,6 +147,37 @@ impl Term {
},
}
}
/// URL-friendly slug, e.g. "spring-2026"
pub fn slug(&self) -> String {
format!("{}-{}", self.season.slug(), self.year)
}
/// Parse a slug like "spring-2026" into a Term
pub fn from_slug(s: &str) -> Option<Self> {
let (season_str, year_str) = s.rsplit_once('-')?;
let season = Season::from_slug(season_str)?;
let year = year_str.parse::<u32>().ok()?;
if !VALID_YEARS.contains(&year) {
return None;
}
Some(Term { year, season })
}
/// Human-readable description, e.g. "Spring 2026"
pub fn description(&self) -> String {
format!("{} {}", self.season, self.year)
}
/// Resolve a string that is either a term code ("202620") or a slug ("spring-2026") to a term code.
pub fn resolve_to_code(s: &str) -> Option<String> {
// Try parsing as a 6-digit code first
if let Ok(term) = s.parse::<Term>() {
return Some(term.to_string());
}
// Try parsing as a slug
Term::from_slug(s).map(|t| t.to_string())
}
}
impl TermPoint {
@@ -195,6 +226,25 @@ impl Season {
Season::Summer => "30",
}
}
/// Returns the lowercase slug for URL-friendly representation
pub fn slug(self) -> &'static str {
match self {
Season::Fall => "fall",
Season::Spring => "spring",
Season::Summer => "summer",
}
}
/// Parse a slug like "spring", "summer", "fall" into a Season
pub fn from_slug(s: &str) -> Option<Self> {
match s {
"fall" => Some(Season::Fall),
"spring" => Some(Season::Spring),
"summer" => Some(Season::Summer),
_ => None,
}
}
}
impl std::fmt::Display for Season {
@@ -445,4 +495,79 @@ mod tests {
}
);
}
// --- Season::slug / from_slug ---
#[test]
fn test_season_slug_roundtrip() {
for season in [Season::Fall, Season::Spring, Season::Summer] {
assert_eq!(Season::from_slug(season.slug()), Some(season));
}
}
#[test]
fn test_season_from_slug_invalid() {
assert_eq!(Season::from_slug("winter"), None);
assert_eq!(Season::from_slug(""), None);
assert_eq!(Season::from_slug("Spring"), None); // case-sensitive
}
// --- Term::slug / from_slug ---
#[test]
fn test_term_slug() {
let term = Term {
year: 2026,
season: Season::Spring,
};
assert_eq!(term.slug(), "spring-2026");
}
#[test]
fn test_term_from_slug_roundtrip() {
for code in ["202510", "202520", "202530"] {
let term = Term::from_str(code).unwrap();
let slug = term.slug();
let parsed = Term::from_slug(&slug).unwrap();
assert_eq!(parsed, term);
}
}
#[test]
fn test_term_from_slug_invalid() {
assert_eq!(Term::from_slug("winter-2026"), None);
assert_eq!(Term::from_slug("spring"), None);
assert_eq!(Term::from_slug(""), None);
}
// --- Term::description ---
#[test]
fn test_term_description() {
let term = Term {
year: 2026,
season: Season::Spring,
};
assert_eq!(term.description(), "Spring 2026");
}
// --- Term::resolve_to_code ---
#[test]
fn test_resolve_to_code_from_code() {
assert_eq!(Term::resolve_to_code("202620"), Some("202620".to_string()));
}
#[test]
fn test_resolve_to_code_from_slug() {
assert_eq!(
Term::resolve_to_code("spring-2026"),
Some("202620".to_string())
);
}
#[test]
fn test_resolve_to_code_invalid() {
assert_eq!(Term::resolve_to_code("garbage"), None);
}
}
+10 -7
View File
@@ -2,7 +2,7 @@
use crate::banner::Course;
use crate::data::models::{DbMeetingTime, UpsertCounts};
use crate::data::names::parse_banner_name;
use crate::data::names::{decode_html_entities, parse_banner_name};
use crate::error::Result;
use sqlx::PgConnection;
use sqlx::PgPool;
@@ -276,14 +276,14 @@ fn compute_diffs(rows: &[UpsertDiffRow]) -> (Vec<AuditEntry>, Vec<MetricEntry>)
diff_field!(json audits, row, "meeting_times", old_meeting_times, new_meeting_times);
diff_field!(json audits, row, "attributes", old_attributes, new_attributes);
// Emit a metric entry when enrollment/wait_count/max_enrollment changed
// Skip fresh inserts (no old data to compare against)
// Emit a metric entry on fresh insert (baseline) or when enrollment data changed
let is_new = row.old_id.is_none();
let enrollment_changed = row.old_id.is_some()
&& (row.old_enrollment != Some(row.new_enrollment)
|| row.old_wait_count != Some(row.new_wait_count)
|| row.old_max_enrollment != Some(row.new_max_enrollment));
if enrollment_changed {
if is_new || enrollment_changed {
metrics.push(MetricEntry {
course_id: row.id,
enrollment: row.new_enrollment,
@@ -448,7 +448,10 @@ async fn upsert_courses(courses: &[Course], conn: &mut PgConnection) -> Result<V
.collect();
let subjects: Vec<&str> = courses.iter().map(|c| c.subject.as_str()).collect();
let course_numbers: Vec<&str> = courses.iter().map(|c| c.course_number.as_str()).collect();
let titles: Vec<&str> = courses.iter().map(|c| c.course_title.as_str()).collect();
let titles: Vec<String> = courses
.iter()
.map(|c| decode_html_entities(&c.course_title))
.collect();
let term_codes: Vec<&str> = courses.iter().map(|c| c.term.as_str()).collect();
let enrollments: Vec<i32> = courses.iter().map(|c| c.enrollment).collect();
let max_enrollments: Vec<i32> = courses.iter().map(|c| c.maximum_enrollment).collect();
@@ -628,7 +631,7 @@ async fn upsert_instructors(
conn: &mut PgConnection,
) -> Result<HashMap<String, i32>> {
let mut seen = HashSet::new();
let mut display_names: Vec<&str> = Vec::new();
let mut display_names: Vec<String> = Vec::new();
let mut first_names: Vec<Option<String>> = Vec::new();
let mut last_names: Vec<Option<String>> = Vec::new();
let mut emails_lower: Vec<String> = Vec::new();
@@ -640,7 +643,7 @@ async fn upsert_instructors(
let email_lower = email.to_lowercase();
if seen.insert(email_lower.clone()) {
let parts = parse_banner_name(&faculty.display_name);
display_names.push(faculty.display_name.as_str());
display_names.push(decode_html_entities(&faculty.display_name));
first_names.push(parts.as_ref().map(|p| p.first.clone()));
last_names.push(parts.as_ref().map(|p| p.last.clone()));
emails_lower.push(email_lower);
+1 -1
View File
@@ -30,7 +30,7 @@ pub struct NameParts {
///
/// Handles both named entities (`&amp;`, `&uuml;`) and numeric references
/// (`&#39;`, `&#x27;`).
fn decode_html_entities(s: &str) -> String {
pub(crate) fn decode_html_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
+49 -22
View File
@@ -9,13 +9,13 @@ use axum::{
routing::{get, post, put},
};
use crate::web::admin;
use crate::web::admin_rmp;
use crate::web::admin_scraper;
use crate::web::auth::{self, AuthConfig};
use crate::web::calendar;
use crate::web::timeline;
use crate::web::ws;
use crate::{data, web::admin};
use crate::{data::models, web::admin_rmp};
#[cfg(feature = "embed-assets")]
use axum::{
http::{HeaderMap, StatusCode, Uri},
@@ -468,7 +468,7 @@ pub struct CourseResponse {
link_identifier: Option<String>,
is_section_linked: Option<bool>,
part_of_term: Option<String>,
meeting_times: Vec<crate::data::models::DbMeetingTime>,
meeting_times: Vec<models::DbMeetingTime>,
attributes: Vec<String>,
instructors: Vec<InstructorResponse>,
}
@@ -505,10 +505,19 @@ pub struct CodeDescription {
description: String,
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TermResponse {
code: String,
slug: String,
description: String,
}
/// Build a `CourseResponse` from a DB course with pre-fetched instructor details.
fn build_course_response(
course: &crate::data::models::Course,
instructors: Vec<crate::data::models::CourseInstructorDetail>,
course: &models::Course,
instructors: Vec<models::CourseInstructorDetail>,
) -> CourseResponse {
let instructors = instructors
.into_iter()
@@ -557,12 +566,20 @@ async fn search_courses(
State(state): State<AppState>,
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
use crate::banner::models::terms::Term;
let term_code = Term::resolve_to_code(&params.term).ok_or_else(|| {
(
AxumStatusCode::BAD_REQUEST,
format!("Invalid term: {}", params.term),
)
})?;
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let (courses, total_count) = crate::data::courses::search_courses(
let (courses, total_count) = data::courses::search_courses(
&state.db_pool,
&params.term,
&term_code,
if params.subject.is_empty() {
None
} else {
@@ -591,7 +608,7 @@ async fn search_courses(
// Batch-fetch all instructors in a single query instead of N+1
let course_ids: Vec<i32> = courses.iter().map(|c| c.id).collect();
let mut instructor_map =
crate::data::courses::get_instructors_for_courses(&state.db_pool, &course_ids)
data::courses::get_instructors_for_courses(&state.db_pool, &course_ids)
.await
.unwrap_or_default();
@@ -616,7 +633,7 @@ async fn get_course(
State(state): State<AppState>,
Path((term, crn)): Path<(String, String)>,
) -> Result<Json<CourseResponse>, (AxumStatusCode, String)> {
let course = crate::data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
.await
.map_err(|e| {
tracing::error!(error = %e, "Course lookup failed");
@@ -627,7 +644,7 @@ async fn get_course(
})?
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id)
let instructors = data::courses::get_course_instructors(&state.db_pool, course.id)
.await
.unwrap_or_default();
Ok(Json(build_course_response(&course, instructors)))
@@ -636,9 +653,10 @@ async fn get_course(
/// `GET /api/terms`
async fn get_terms(
State(state): State<AppState>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
let cache = state.reference_cache.read().await;
let term_codes = crate::data::courses::get_available_terms(&state.db_pool)
) -> Result<Json<Vec<TermResponse>>, (AxumStatusCode, String)> {
use crate::banner::models::terms::Term;
let term_codes = data::courses::get_available_terms(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to get terms");
@@ -648,14 +666,15 @@ async fn get_terms(
)
})?;
let terms: Vec<CodeDescription> = term_codes
let terms: Vec<TermResponse> = term_codes
.into_iter()
.map(|code| {
let description = cache
.lookup("term", &code)
.unwrap_or("Unknown Term")
.to_string();
CodeDescription { code, description }
.filter_map(|code| {
let term: Term = code.parse().ok()?;
Some(TermResponse {
code,
slug: term.slug(),
description: term.description(),
})
})
.collect();
@@ -667,7 +686,15 @@ async fn get_subjects(
State(state): State<AppState>,
Query(params): Query<SubjectsParams>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
let rows = crate::data::courses::get_subjects_by_enrollment(&state.db_pool, &params.term)
use crate::banner::models::terms::Term;
let term_code = Term::resolve_to_code(&params.term).ok_or_else(|| {
(
AxumStatusCode::BAD_REQUEST,
format!("Invalid term: {}", params.term),
)
})?;
let rows = data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to get subjects");
@@ -696,7 +723,7 @@ async fn get_reference(
if entries.is_empty() {
// Fall back to DB query in case cache doesn't have this category
drop(cache);
let rows = crate::data::reference::get_by_category(&category, &state.db_pool)
let rows = data::reference::get_by_category(&category, &state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, category = %category, "Reference lookup failed");
+21 -10
View File
@@ -214,7 +214,7 @@ async fn test_batch_upsert_unique_constraint_crn_term(pool: PgPool) {
#[sqlx::test]
async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
// Insert initial data — should NOT create audits/metrics (it's a fresh insert)
// Insert initial data — should create a baseline metric but no audits
let initial = vec![helpers::make_course(
"50001",
"202510",
@@ -242,10 +242,21 @@ async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
.await
.unwrap();
assert_eq!(
metric_count, 0,
"initial insert should not create metric entries"
metric_count, 1,
"initial insert should create a baseline metric"
);
// Verify baseline metric values
let (enrollment, wait_count, seats): (i32, i32, i32) = sqlx::query_as(
"SELECT enrollment, wait_count, seats_available FROM course_metrics ORDER BY timestamp LIMIT 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 10);
assert_eq!(wait_count, 0);
assert_eq!(seats, 25); // 35 - 10
// Update enrollment and wait_count
let updated = vec![helpers::make_course(
"50001",
@@ -270,16 +281,16 @@ async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
"should have audit entries for enrollment and wait_count changes, got {audit_count}"
);
// Should have exactly 1 metric entry
// Should have 2 metric entries: baseline + change
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(metric_count, 1, "should have 1 metric snapshot");
assert_eq!(metric_count, 2, "should have baseline + 1 change metric");
// Verify metric values
// Verify the latest metric values
let (enrollment, wait_count, seats): (i32, i32, i32) = sqlx::query_as(
"SELECT enrollment, wait_count, seats_available FROM course_metrics LIMIT 1",
"SELECT enrollment, wait_count, seats_available FROM course_metrics ORDER BY timestamp DESC LIMIT 1",
)
.fetch_one(&pool)
.await
@@ -291,7 +302,7 @@ async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
#[sqlx::test]
async fn test_batch_upsert_no_change_no_audit(pool: PgPool) {
// Insert then re-insert identical data — should produce zero audits/metrics
// Insert then re-insert identical data — should produce baseline metric but no audits or extra metrics
let course = vec![helpers::make_course(
"60001",
"202510",
@@ -320,7 +331,7 @@ async fn test_batch_upsert_no_change_no_audit(pool: PgPool) {
.await
.unwrap();
assert_eq!(
metric_count, 0,
"identical re-upsert should not create metric entries"
metric_count, 1,
"identical re-upsert should only have the baseline metric"
);
}
+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;
+6 -4
View File
@@ -21,6 +21,7 @@ import type {
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -51,13 +52,14 @@ export type {
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
};
// Semantic aliases — these all share the CodeDescription shape
export type Term = CodeDescription;
// Semantic aliases
export type Term = TermResponse;
export type Subject = CodeDescription;
export type ReferenceEntry = CodeDescription;
@@ -268,8 +270,8 @@ export class BannerApiClient {
return this.request<Term[]>("/terms");
}
async getSubjects(termCode: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(termCode)}`);
async getSubjects(term: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(term)}`);
}
async getReference(category: string): Promise<ReferenceEntry[]> {
@@ -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>, };
+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 TermResponse = { code: string, slug: string, description: string, };
+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, };
+3
View File
@@ -20,6 +20,9 @@ export type { SubjectDetailResponse } from "./SubjectDetailResponse";
export type { SubjectResultEntry } from "./SubjectResultEntry";
export type { SubjectSummary } from "./SubjectSummary";
export type { SubjectsResponse } from "./SubjectsResponse";
export type { TermResponse } from "./TermResponse";
export type { TimelineResponse } from "./TimelineResponse";
export type { TimelineSlot } from "./TimelineSlot";
export type { TimeseriesPoint } from "./TimeseriesPoint";
export type { TimeseriesResponse } from "./TimeseriesResponse";
export type { TopCandidateResponse } from "./TopCandidateResponse";
+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();
+29 -23
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,
@@ -10,43 +14,40 @@ import {
formatTimeRange,
getDeliveryConcern,
getPrimaryInstructor,
isAsyncOnline,
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 {
@@ -611,7 +612,12 @@ const table = createSvelteTable({
)}
passthrough
>
{#if timeIsTBA(course)}
{#if isAsyncOnline(course)}
<span
class="text-xs text-muted-foreground/60"
>Async</span
>
{:else if timeIsTBA(course)}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
@@ -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,
+14 -15
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,
@@ -16,12 +16,11 @@ let open = $state(false);
let searchValue = $state("");
let containerEl = $state<HTMLDivElement>(null!);
const currentTermCode = $derived(
terms.find((t) => !t.description.includes("(View Only)"))?.code ?? ""
);
// The first term from the backend is the most current
const currentTermSlug = $derived(terms[0]?.slug ?? "");
const selectedLabel = $derived(
terms.find((t) => t.code === value)?.description ?? "Select term..."
terms.find((t) => t.slug === value)?.description ?? "Select term..."
);
const filteredTerms = $derived.by(() => {
@@ -29,8 +28,8 @@ const filteredTerms = $derived.by(() => {
const matched =
query === "" ? terms : terms.filter((t) => t.description.toLowerCase().includes(query));
const current = matched.find((t) => t.code === currentTermCode);
const rest = matched.filter((t) => t.code !== currentTermCode);
const current = matched.find((t) => t.slug === currentTermSlug);
const rest = matched.filter((t) => t.slug !== currentTermSlug);
return current ? [current, ...rest] : rest;
});
@@ -100,22 +99,22 @@ $effect(() => {
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredTerms as term, i (term.code)}
{#if i === 1 && term.code !== currentTermCode && filteredTerms[0]?.code === currentTermCode}
{#each filteredTerms as term, i (term.slug)}
{#if i === 1 && term.slug !== currentTermSlug && filteredTerms[0]?.slug === currentTermSlug}
<div class="mx-2 my-1 h-px bg-border"></div>
{/if}
<Combobox.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center px-2 text-sm
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
{term.code === value ? 'cursor-default' : 'cursor-pointer'}
{term.code === currentTermCode ? 'font-medium text-foreground' : 'text-foreground'}"
value={term.code}
{term.slug === value ? 'cursor-default' : 'cursor-pointer'}
{term.slug === currentTermSlug ? 'font-medium text-foreground' : 'text-foreground'}"
value={term.slug}
label={term.description}
>
{#snippet children({ selected })}
<span class="flex-1 truncate">
{term.description}
{#if term.code === currentTermCode}
{#if term.slug === currentTermSlug}
<span class="ml-1.5 text-xs text-muted-foreground font-normal">current</span>
{/if}
</span>
+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.
+100 -9
View File
@@ -1,22 +1,24 @@
import { describe, it, expect } from "vitest";
import type { CourseResponse, DbMeetingTime, InstructorResponse } from "$lib/api";
import {
formatTime,
formatTimeRange,
abbreviateInstructor,
formatCreditHours,
formatDate,
formatDateShort,
formatLocationDisplay,
formatMeetingDays,
formatMeetingDaysLong,
formatMeetingDaysVerbose,
formatMeetingTime,
formatMeetingTimeTooltip,
formatMeetingTimesTooltip,
abbreviateInstructor,
formatCreditHours,
formatTime,
formatTimeRange,
getPrimaryInstructor,
isAsyncOnline,
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 {
@@ -411,3 +413,92 @@ describe("formatMeetingTimesTooltip", () => {
expect(result).toContain("\n\n");
});
});
describe("isAsyncOnline", () => {
it("returns true for INT building with no times", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "INT",
building_description: "Internet Class",
begin_time: null,
end_time: null,
}),
],
} as CourseResponse;
expect(isAsyncOnline(course)).toBe(true);
});
it("returns false for INT building with meeting times", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "INT",
building_description: "Internet Class",
tuesday: true,
thursday: true,
begin_time: "1000",
end_time: "1115",
}),
],
} as CourseResponse;
expect(isAsyncOnline(course)).toBe(false);
});
it("returns false for non-INT building", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "MH",
building_description: "Main Hall",
begin_time: null,
end_time: null,
}),
],
} as CourseResponse;
expect(isAsyncOnline(course)).toBe(false);
});
it("returns false for empty meeting times", () => {
const course = { meetingTimes: [] } as unknown as CourseResponse;
expect(isAsyncOnline(course)).toBe(false);
});
});
describe("formatLocationDisplay", () => {
it("returns 'Online' for INT building", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "INT",
building_description: "Internet Class",
}),
],
campus: "9",
} as CourseResponse;
expect(formatLocationDisplay(course)).toBe("Online");
});
it("returns building and room for physical location", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "MH",
building_description: "Main Hall",
room: "2.206",
}),
],
campus: "11",
} as CourseResponse;
expect(formatLocationDisplay(course)).toBe("MH 2.206");
});
it("returns building only when no room", () => {
const course = {
meetingTimes: [
makeMeetingTime({
building: "MH",
building_description: "Main Hall",
room: null,
}),
],
campus: "11",
} as CourseResponse;
expect(formatLocationDisplay(course)).toBe("MH");
});
});
+18 -3
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 {
@@ -152,6 +152,13 @@ export function isTimeTBA(mt: DbMeetingTime): boolean {
return !mt.begin_time || mt.begin_time.length !== 4;
}
/** Check if course is asynchronous online (INT building with no meeting times) */
export function isAsyncOnline(course: CourseResponse): boolean {
if (course.meetingTimes.length === 0) return false;
const mt = course.meetingTimes[0];
return mt.building === "INT" && isTimeTBA(mt);
}
/** Format a date string to "January 20, 2026". Accepts YYYY-MM-DD or MM/DD/YYYY. */
export function formatDate(dateStr: string): string {
let year: number, month: number, day: number;
@@ -170,6 +177,8 @@ export function formatDate(dateStr: string): string {
/** Short location string from first meeting time: "MH 2.206" or campus fallback */
export function formatLocation(course: CourseResponse): string | null {
for (const mt of course.meetingTimes) {
// Skip INT building - handled by formatLocationDisplay
if (mt.building === "INT") continue;
if (mt.building && mt.room) return `${mt.building} ${mt.room}`;
if (mt.building) return mt.building;
}
@@ -307,13 +316,19 @@ export function concernAccentColor(concern: DeliveryConcern): string | null {
/**
* Location display text for the table cell.
* Falls back to "Online" for online courses instead of showing a dash.
* Shows "Online" for internet class (INT building) or other online courses.
*/
export function formatLocationDisplay(course: CourseResponse): string | null {
// Check for Internet Class building first
const hasIntBuilding = course.meetingTimes.some((mt) => mt.building === "INT");
if (hasIntBuilding) return "Online";
const loc = formatLocation(course);
if (loc) return loc;
const concern = getDeliveryConcern(course);
if (concern === "online") return "Online";
if (concern === "online" || concern === "internet") return "Online";
return null;
}
+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");
+175 -128
View File
@@ -31,6 +31,7 @@ import {
AlertCircle,
ChevronDown,
ChevronRight,
Info,
LoaderCircle,
ArrowUp,
ArrowDown,
@@ -184,11 +185,8 @@ function successRateColor(rate: number): string {
}
/** 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 emphasisClass(value: number): string {
return value === 0 ? "text-muted-foreground" : "text-foreground";
}
function xAxisFormat(period: ScraperPeriod) {
@@ -211,64 +209,64 @@ 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),
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: "Scrape in",
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: "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,
},
];
},
{
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() {
@@ -287,18 +285,19 @@ const table = createSvelteTable({
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 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;
const detailGridCols = "grid-cols-[7fr_5fr_3fr_4fr_4fr_3fr_4fr_minmax(6rem,1fr)]";
// --- Lifecycle ---
@@ -360,9 +359,6 @@ $effect(() => {
<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>
@@ -375,7 +371,12 @@ $effect(() => {
{/if}
</div>
<div class="bg-card border-border rounded-lg border p-3">
<p class="text-muted-foreground text-xs">Avg Duration</p>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Avg Duration</p>
<SimpleTooltip text="Average time per successful subject scrape (API fetch + database update)" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
{#if stats.avgDurationMs != null}
<p class="text-2xl font-bold">{formatDurationMs(stats.avgDurationMs)}</p>
{:else}
@@ -383,23 +384,48 @@ $effect(() => {
{/if}
</div>
<div class="bg-card border-border rounded-lg border p-3">
<p class="text-muted-foreground text-xs">Courses Changed</p>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Courses Changed</p>
<SimpleTooltip text="Total courses that had enrollment or schedule updates detected" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
<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>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Pending Jobs</p>
<SimpleTooltip text="Scrape jobs queued but not yet started (unlocked jobs waiting for a worker)" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
<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>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Locked Jobs</p>
<SimpleTooltip text="Scrape jobs currently being processed by a worker" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
<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>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Courses Fetched</p>
<SimpleTooltip text="Total courses retrieved from Banner API across all successful scrapes" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
<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>
<div class="flex items-center gap-1">
<p class="text-muted-foreground text-xs">Audits Generated</p>
<SimpleTooltip text="Change records created when course enrollment or schedule data changes" side="top" passthrough>
<Info class="size-3 text-muted-foreground/60" />
</SimpleTooltip>
</div>
<p class="text-2xl font-bold">{formatNumber(stats.totalAuditsGenerated)}</p>
</div>
</div>
@@ -527,13 +553,13 @@ $effect(() => {
Subjects ({subjects.length})
</h2>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<table class="w-full min-w-160 border-collapse 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="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider whitespace-nowrap"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
@@ -623,7 +649,7 @@ $effect(() => {
<span class="text-muted-foreground">read only</span>
{:else if subject.nextEligibleAt}
{@const remainingMs = new Date(subject.nextEligibleAt).getTime() - now.getTime()}
{#if remainingMs > 0}
{#if remainingMs >= 1000}
<span class="text-muted-foreground">{formatDuration(remainingMs)}</span>
{:else}
<span class="text-green-600 dark:text-green-400 font-medium">ready</span>
@@ -673,64 +699,85 @@ $effect(() => {
<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>
<div class="bg-muted/40 px-4 py-3">
<div class="text-xs overflow-x-auto">
<div class="min-w-fit">
<!-- Header (outside scroll region) -->
<div class="grid {detailGridCols} text-muted-foreground border-border/50 border-b">
<div class="px-3 py-1.5 font-medium">Time</div>
<div class="px-3 py-1.5 font-medium">Duration</div>
<div class="px-3 py-1.5 font-medium">Status</div>
<div class="px-3 py-1.5 font-medium">Fetched</div>
<div class="px-3 py-1.5 font-medium">Changed</div>
<div class="px-3 py-1.5 font-medium">%</div>
<div class="px-3 py-1.5 font-medium">Audits</div>
<div class="px-3 py-1.5 font-medium">Error</div>
</div>
<!-- Body (scrollable vertically, horizontal clipped to match header) -->
<div class="max-h-[280px] overflow-y-auto overflow-x-hidden">
{#if detailLoading}
{#each Array(8) as _}
<div class="grid {detailGridCols} border-border/50 border-t">
<div class="px-3 py-1.5"><div class="h-3.5 w-16 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-12 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-8 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-10 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-10 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-8 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-8 rounded bg-muted animate-pulse"></div></div>
<div class="px-3 py-1.5"><div class="h-3.5 w-16 rounded bg-muted animate-pulse"></div></div>
</div>
{/each}
{:else if subjectDetail && subjectDetail.results.length > 0}
{#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">
<div class="grid {detailGridCols} border-border/50 border-t">
<div 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>
<span class="inline-block min-w-[4.5rem] 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">
</div>
<div class="px-3 py-1.5">{formatDurationMs(result.durationMs)}</div>
<div 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">
</div>
<div class="px-3 py-1.5">
<span class={emphasisClass(result.coursesFetched ?? 0)}>{result.coursesFetched ?? "\u2014"}</span>
</td>
<td class="px-3 py-1.5">
</div>
<div 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">
</div>
<div class="px-3 py-1.5">
{#if result.coursesFetched != null && result.coursesFetched > 0 && result.coursesChanged != null}
<span class={emphasisClass(result.coursesChanged)}>{(result.coursesChanged / result.coursesFetched * 100).toFixed(1)}%</span>
{:else}
<span class="text-muted-foreground">{"\u2014"}</span>
{/if}
</div>
<div 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>
</div>
<div class="px-3 py-1.5">
{#if !result.success && result.errorMessage}
<SimpleTooltip text={result.errorMessage} side="top" passthrough>
<span class="text-red-600 dark:text-red-400 max-w-[12rem] truncate inline-block align-middle">{result.errorMessage}</span>
</SimpleTooltip>
{:else}
<span class="text-muted-foreground">{"\u2014"}</span>
{/if}
</div>
</div>
{/each}
</tbody>
</table>
{:else}
<div class="px-3 py-4 text-center text-muted-foreground text-sm">No recent results.</div>
{/if}
</div>
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">No recent results.</p>
{/if}
</div>
</div>
</td>
@@ -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();
+20 -9
View File
@@ -1,27 +1,33 @@
<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 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 ?? ""));
// The default term is the first one returned by the backend (most current)
const defaultTermSlug = data.terms[0]?.slug ?? "";
// Default to the first term when no URL param is present
const urlTerm = initialParams.get("term");
let selectedTerm = $state(
urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug
);
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
let query = $state(initialParams.get("q") ?? "");
let openOnly = $state(initialParams.get("open") === "true");
@@ -160,7 +166,6 @@ async function performSearch(
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
const params = new URLSearchParams();
params.set("term", term);
for (const s of subjects) {
params.append("subject", s);
}
@@ -169,6 +174,12 @@ async function performSearch(
if (off > 0) params.set("offset", String(off));
if (sortBy) params.set("sort_by", sortBy);
if (sortDir && sortBy) params.set("sort_dir", sortDir);
// Include term in URL only when it differs from the default or other params are active
const hasOtherParams = params.size > 0;
if (term !== defaultTermSlug || hasOtherParams) {
params.set("term", term);
}
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
const t0 = performance.now();
+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";