mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 04:23:40 -06:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c4fcd0c2 | |||
| 215703593b | |||
| 2123bcbe3e | |||
| 6c15f4082f | |||
| bbff2b7f36 | |||
| b37604f807 | |||
| d278498daa | |||
| bd2acee6f4 | |||
| 7e7fc1df94 | |||
| 005adb8792 | |||
| dfaaa88d54 | |||
| f387401a41 | |||
| 4e0140693b | |||
| e9209684eb | |||
| b562fe227e | |||
| 44260422d6 | |||
| 96a8c13125 | |||
| 567c4aec3c | |||
| f5a639e88b | |||
| d91f7ab342 | |||
| 7f0f08725a | |||
| 02b18f0c66 | |||
| 106bf232c4 | |||
| 239f7ee38c | |||
| 0ee4e8a8bc | |||
| 5729a821d5 | |||
| 5134ae9388 | |||
| 9e825cd113 | |||
| ac8dbb2eef | |||
| 5dd35ed215 | |||
| 2acf52a63b | |||
| 47132e71d7 | |||
| 87db1a4ccb | |||
| e203e8e182 | |||
| cbb0a51bca | |||
| c533768362 | |||
| 16039e02a9 | |||
| 7d2255a988 | |||
| 8bfc14e55c | |||
| 2689587dd5 | |||
| 1ad614dad0 | |||
| ebb7a97c11 | |||
| 2df0ba0ec5 | |||
| dd148e08a0 | |||
| 3494341e3f | |||
| acccaa54d4 | |||
| 6863ee58d0 | |||
| 550401b85c |
@@ -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",
|
||||
]
|
||||
Vendored
+34
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.6.3"
|
||||
}
|
||||
Vendored
+143
-27
@@ -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,160 @@ 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: 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
|
||||
|
||||
Vendored
+27
@@ -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
@@ -1,6 +1,5 @@
|
||||
.env
|
||||
/target
|
||||
/scripts/node_modules
|
||||
|
||||
|
||||
# ts-rs bindings
|
||||
web/src/lib/bindings/**/*.ts
|
||||
!web/src/lib/bindings/index.ts
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"nefrob.vscode-just-syntax"
|
||||
]
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# Changelog
|
||||
|
||||
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.3](https://github.com/Xevion/Banner/compare/v0.6.2...v0.6.3) (2026-02-01)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* Consolidate course data models into structured types ([2157035](https://github.com/Xevion/Banner/commit/215703593b6e6696f2dc478bd29644374fa1e787))
|
||||
|
||||
## [0.6.2](https://github.com/Xevion/Banner/compare/v0.6.1...v0.6.2) (2026-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **web:** Add dynamic range sliders with consolidated search options API ([f5a639e](https://github.com/Xevion/Banner/commit/f5a639e88bfe03dfc635f25e06fc22208ee0c855))
|
||||
* **web:** Batch rapid search query changes into history entries, allow for query history ([e920968](https://github.com/Xevion/Banner/commit/e9209684eb051f978607a31f237b19e883af5d5a))
|
||||
* **web:** Build responsive layout with mobile card view ([bd2acee](https://github.com/Xevion/Banner/commit/bd2acee6f40c0768898ab39e0524c0474ec4fd31))
|
||||
* **web:** Implement aligned course codes with jetbrains mono ([567c4ae](https://github.com/Xevion/Banner/commit/567c4aec3ca7baaeb548fff2005d83f7e6228d79))
|
||||
* **web:** Implement multi-dimensional course filtering system ([106bf23](https://github.com/Xevion/Banner/commit/106bf232c4b53f4ca8902a582f185e146878c54e))
|
||||
* **web:** Implement smooth view transitions for search results ([5729a82](https://github.com/Xevion/Banner/commit/5729a821d54d95a00e9f4ba736a2bd884c0c409b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** Add proper flag validation for check script ([2acf52a](https://github.com/Xevion/Banner/commit/2acf52a63b6dcd24ca826b99061bf7a51a9230b1))
|
||||
* **data:** Handle alphanumeric course numbers in range filtering ([96a8c13](https://github.com/Xevion/Banner/commit/96a8c13125428f1cc14e46d8f580719c17c029ef))
|
||||
* Re-add overflow hidden for page transitions, but with negative margin padding to avoid clipping ([9e825cd](https://github.com/Xevion/Banner/commit/9e825cd113bbc65c10f0386b5300b6aec50bf936))
|
||||
* Separate Biome format and lint checks to enable auto-format ([ac8dbb2](https://github.com/Xevion/Banner/commit/ac8dbb2eefe79ec5d898cfa719e270f4713125d5))
|
||||
* **web:** Ignore .svelte-kit/generated in vite watcher ([b562fe2](https://github.com/Xevion/Banner/commit/b562fe227e89a0826fe4587372e3eeca2ab6eb33))
|
||||
* **web:** Prevent duplicate searches and background fetching on navigation ([5dd35ed](https://github.com/Xevion/Banner/commit/5dd35ed215d3d1f3603e67a2aa59eaddf619f5c9))
|
||||
* **web:** Prevent interaction blocking during search transitions ([7f0f087](https://github.com/Xevion/Banner/commit/7f0f08725a668c5ac88c510f43791d90ce2f795e))
|
||||
* **web:** Skip view transitions for same-page navigations ([b37604f](https://github.com/Xevion/Banner/commit/b37604f8071741017a83f74a67b73cf7975827ae))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **api:** Extract toURLSearchParams helper for query param handling ([6c15f40](https://github.com/Xevion/Banner/commit/6c15f4082f1a4b6fb6c54c545c6e0ec47e191654))
|
||||
* **api:** Rename middleware and enable database query logging ([f387401](https://github.com/Xevion/Banner/commit/f387401a4174d4d0bdf74deccdda80b3af543b74))
|
||||
* Migrate API responses from manual JSON to type-safe bindings ([0ee4e8a](https://github.com/Xevion/Banner/commit/0ee4e8a8bc1fe0b079fea84ac303674083b43a59))
|
||||
* Standardize error responses with ApiError and ts-rs bindings ([239f7ee](https://github.com/Xevion/Banner/commit/239f7ee38cbc0e49d9041579fc9923fd4a4608bf))
|
||||
* **web:** Consolidate tooltip implementations with shared components ([d91f7ab](https://github.com/Xevion/Banner/commit/d91f7ab34299b26dc12d629bf99d502ee05e7cfa))
|
||||
* **web:** Extract FilterPopover component and upgrade range sliders ([4e01406](https://github.com/Xevion/Banner/commit/4e0140693b00686e8a57561b0811fdf25a614e65))
|
||||
* **web:** Replace component tooltips with delegated singleton ([d278498](https://github.com/Xevion/Banner/commit/d278498daa4afc82c877b536ecd1264970dc92a7))
|
||||
* **web:** Split CourseTable into modular component structure ([bbff2b7](https://github.com/Xevion/Banner/commit/bbff2b7f36744808b62ec130be2cfbdc96f87b69))
|
||||
* **web:** Streamline filter ui with simplified removal ([4426042](https://github.com/Xevion/Banner/commit/44260422d68e910ed4ad37e78cd8a1d1f8bb51a3))
|
||||
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* Add aliases to Justfile ([02b18f0](https://github.com/Xevion/Banner/commit/02b18f0c66dc8b876452f35999c027475df52462))
|
||||
* Add dev-build flag for embedded vite builds ([5134ae9](https://github.com/Xevion/Banner/commit/5134ae93881854ac722dc9e7f3f5040aee3e517a))
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- User authentication system with Discord OAuth, sessions, admin roles, and login page with FAQ.
|
||||
- Interactive timeline visualization with D3 canvas, pan/zoom, touch gestures, and enrollment aggregation API.
|
||||
- Scraper analytics dashboard with timeseries charts, subject monitoring, and per-subject detail views.
|
||||
- Adaptive scraper scheduling with admin endpoints for monitoring and configuration.
|
||||
- Scrape job result persistence for effectiveness tracking.
|
||||
- WebSocket support for real-time scrape job monitoring with connection status indicators.
|
||||
- Course change auditing with field-level tracking and time-series metrics endpoint.
|
||||
- Audit log UI with smart JSON diffing, conditional request caching, and auto-refresh.
|
||||
- Calendar export web endpoints for ICS download and Google Calendar redirect.
|
||||
- Confidence-based RMP matching with manual review workflow and admin instructor UI.
|
||||
- RMP profile links and confidence-aware rating display.
|
||||
- Name parsing and normalization for improved instructor-RMP matching.
|
||||
- Mobile touch controls with gesture detection for timeline.
|
||||
- Worker timeout protection and crash recovery for job queue.
|
||||
- Build-time asset compression with encoding negotiation (gzip, brotli, zstd).
|
||||
- Smart page transitions with theme-aware element transitions.
|
||||
- Search duration and result count feedback.
|
||||
- Root error page handling.
|
||||
- Login page with FAQ section and improved styling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated navigation with top nav bar and route groups.
|
||||
- Centralized number formatting with locale-aware utility.
|
||||
- Modernized Justfile commands and simplified service management.
|
||||
- Persisted audit log state in module scope for cross-navigation caching.
|
||||
- Relative time feedback and improved tooltip customization.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Instructor/course mismatching via build-order-independent map for association.
|
||||
- Page content clipping.
|
||||
- Backend startup delays with retry logic in auth.
|
||||
- Banner API timeouts increased to handle slow responses.
|
||||
- i64 serialization for JavaScript compatibility, fixing avatar URL display.
|
||||
- Frontend build ordering with `-e` embed flag in Justfile.
|
||||
- Login page centering and unnecessary scrollbar.
|
||||
- ts-rs serde warnings.
|
||||
|
||||
## [0.5.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
- Multi-select subject filtering with searchable comboboxes.
|
||||
- Smart instructor name abbreviation for compact table display.
|
||||
- Delivery mode indicators and tooltips in location column.
|
||||
- Page selector dropdown with animated pagination controls.
|
||||
- FLIP animations for smooth table row transitions during pagination.
|
||||
- Time tooltip with detailed meeting schedule and day abbreviations.
|
||||
- Reusable SimpleTooltip component for consistent UI hints.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated query logic and eliminated N+1 instructor loads via batch fetching.
|
||||
- Consolidated menu snippets and strengthened component type safety.
|
||||
- Enhanced table scrolling with OverlayScrollbars and theme-aware styling.
|
||||
- Eliminated initial theme flash on page load.
|
||||
|
||||
## [0.4.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- Web-based course search UI with interactive data table, multi-column sorting, and column visibility controls.
|
||||
- TypeScript type bindings generated from Rust types via ts-rs.
|
||||
- RateMyProfessors integration: bulk professor sync via GraphQL and inline rating display in search results.
|
||||
- Course detail expansion panel with enrollment, meeting times, and instructor info.
|
||||
- OverlayScrollbars integration for styled, theme-aware scrollable areas.
|
||||
- Pagination component for navigating large search result sets.
|
||||
- Footer component with version display.
|
||||
- API endpoints: `/api/courses/search`, `/api/courses/:term/:crn`, `/api/terms`, `/api/subjects`, `/api/reference/:category`.
|
||||
- Frontend API client with typed request/response handling and test coverage.
|
||||
- Course formatting utilities with comprehensive unit tests.
|
||||
|
||||
## [0.3.4] - 2026-01
|
||||
|
||||
### Added
|
||||
|
||||
- Live service status tracking on web dashboard with auto-refresh and health indicators.
|
||||
- DB operation extraction for improved testability.
|
||||
- Unit test suite foundation covering core functionality.
|
||||
- Docker support for PostgreSQL development environment.
|
||||
- ICS calendar export with comprehensive holiday exclusion coverage.
|
||||
- Google Calendar link generation with recurrence rules and meeting details.
|
||||
- Job queue with priority-based scheduling for background scraping.
|
||||
- Rate limiting with burst allowance for Banner API requests.
|
||||
- Session management and caching for Banner API interactions.
|
||||
- Discord bot commands: search, terms, ics, gcal.
|
||||
- Intelligent scraping system with priority queues and retry tracking.
|
||||
|
||||
### Changed
|
||||
|
||||
- Type consolidation and dead code removal across the codebase.
|
||||
Generated
+154
-317
@@ -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.3"
|
||||
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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "banner"
|
||||
version = "0.6.0"
|
||||
version = "0.6.3"
|
||||
edition = "2024"
|
||||
default-run = "banner"
|
||||
|
||||
|
||||
@@ -1,164 +1,22 @@
|
||||
set dotenv-load
|
||||
|
||||
# Aliases
|
||||
alias c := check
|
||||
alias d := dev
|
||||
alias t := test
|
||||
alias f := format
|
||||
alias fmt := format
|
||||
alias s := search
|
||||
alias bld := build
|
||||
alias bind := bindings
|
||||
alias b := bun
|
||||
|
||||
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,278 +24,31 @@ 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");
|
||||
bun scripts/db.ts {{cmd}}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
alias b := bun
|
||||
bun *ARGS:
|
||||
cd web && bun {{ ARGS }}
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
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/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.0] - 2026-01-30
|
||||
|
||||
### Added
|
||||
|
||||
- User authentication system with Discord OAuth, sessions, admin roles, and login page with FAQ.
|
||||
- Interactive timeline visualization with D3 canvas, pan/zoom, touch gestures, and enrollment aggregation API.
|
||||
- Scraper analytics dashboard with timeseries charts, subject monitoring, and per-subject detail views.
|
||||
- Adaptive scraper scheduling with admin endpoints for monitoring and configuration.
|
||||
- Scrape job result persistence for effectiveness tracking.
|
||||
- WebSocket support for real-time scrape job monitoring with connection status indicators.
|
||||
- Course change auditing with field-level tracking and time-series metrics endpoint.
|
||||
- Audit log UI with smart JSON diffing, conditional request caching, and auto-refresh.
|
||||
- Calendar export web endpoints for ICS download and Google Calendar redirect.
|
||||
- Confidence-based RMP matching with manual review workflow and admin instructor UI.
|
||||
- RMP profile links and confidence-aware rating display.
|
||||
- Name parsing and normalization for improved instructor-RMP matching.
|
||||
- Mobile touch controls with gesture detection for timeline.
|
||||
- Worker timeout protection and crash recovery for job queue.
|
||||
- Build-time asset compression with encoding negotiation (gzip, brotli, zstd).
|
||||
- Smart page transitions with theme-aware element transitions.
|
||||
- Search duration and result count feedback.
|
||||
- Root error page handling.
|
||||
- Login page with FAQ section and improved styling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated navigation with top nav bar and route groups.
|
||||
- Centralized number formatting with locale-aware utility.
|
||||
- Modernized Justfile commands and simplified service management.
|
||||
- Persisted audit log state in module scope for cross-navigation caching.
|
||||
- Relative time feedback and improved tooltip customization.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Instructor/course mismatching via build-order-independent map for association.
|
||||
- Page content clipping.
|
||||
- Backend startup delays with retry logic in auth.
|
||||
- Banner API timeouts increased to handle slow responses.
|
||||
- i64 serialization for JavaScript compatibility, fixing avatar URL display.
|
||||
- Frontend build ordering with `-e` embed flag in Justfile.
|
||||
- Login page centering and unnecessary scrollbar.
|
||||
- ts-rs serde warnings.
|
||||
|
||||
## [0.5.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
- Multi-select subject filtering with searchable comboboxes.
|
||||
- Smart instructor name abbreviation for compact table display.
|
||||
- Delivery mode indicators and tooltips in location column.
|
||||
- Page selector dropdown with animated pagination controls.
|
||||
- FLIP animations for smooth table row transitions during pagination.
|
||||
- Time tooltip with detailed meeting schedule and day abbreviations.
|
||||
- Reusable SimpleTooltip component for consistent UI hints.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated query logic and eliminated N+1 instructor loads via batch fetching.
|
||||
- Consolidated menu snippets and strengthened component type safety.
|
||||
- Enhanced table scrolling with OverlayScrollbars and theme-aware styling.
|
||||
- Eliminated initial theme flash on page load.
|
||||
|
||||
## [0.4.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- Web-based course search UI with interactive data table, multi-column sorting, and column visibility controls.
|
||||
- TypeScript type bindings generated from Rust types via ts-rs.
|
||||
- RateMyProfessors integration: bulk professor sync via GraphQL and inline rating display in search results.
|
||||
- Course detail expansion panel with enrollment, meeting times, and instructor info.
|
||||
- OverlayScrollbars integration for styled, theme-aware scrollable areas.
|
||||
- Pagination component for navigating large search result sets.
|
||||
- Footer component with version display.
|
||||
- API endpoints: `/api/courses/search`, `/api/courses/:term/:crn`, `/api/terms`, `/api/subjects`, `/api/reference/:category`.
|
||||
- Frontend API client with typed request/response handling and test coverage.
|
||||
- Course formatting utilities with comprehensive unit tests.
|
||||
|
||||
## [0.3.4] - 2026-01
|
||||
|
||||
### Added
|
||||
|
||||
- Live service status tracking on web dashboard with auto-refresh and health indicators.
|
||||
- DB operation extraction for improved testability.
|
||||
- Unit test suite foundation covering core functionality.
|
||||
- Docker support for PostgreSQL development environment.
|
||||
- ICS calendar export with comprehensive holiday exclusion coverage.
|
||||
- Google Calendar link generation with recurrence rules and meeting details.
|
||||
- Job queue with priority-based scheduling for background scraping.
|
||||
- Rate limiting with burst allowance for Banner API requests.
|
||||
- Session management and caching for Banner API interactions.
|
||||
- Discord bot commands: search, terms, ics, gcal.
|
||||
- Intelligent scraping system with priority queues and retry tracking.
|
||||
|
||||
### Changed
|
||||
|
||||
- Type consolidation and dead code removal across the codebase.
|
||||
@@ -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)`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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 args = process.argv.slice(2);
|
||||
let fix = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === "-f" || arg === "--fix") {
|
||||
fix = true;
|
||||
} else {
|
||||
console.error(`Unknown flag: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: "rust-format",
|
||||
cmd: ["cargo", "fmt", "--all", "--", "--check"],
|
||||
hint: "Run 'cargo fmt --all' to see and fix formatting issues.",
|
||||
},
|
||||
{ name: "rust-lint", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
|
||||
{ name: "rust-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: "web-format", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
|
||||
{ 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[];
|
||||
}
|
||||
> = {
|
||||
"rust-format": {
|
||||
peers: ["rust-lint", "rust-check", "rust-test"],
|
||||
format: () => runPiped(["cargo", "fmt", "--all"]),
|
||||
recheck: [
|
||||
{ name: "rust-format", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
|
||||
{ name: "rust-check", cmd: ["cargo", "check", "--all-features"] },
|
||||
],
|
||||
},
|
||||
"web-format": {
|
||||
peers: ["svelte-check", "web-test"],
|
||||
format: () => runPiped(["bun", "run", "--cwd", "web", "format"]),
|
||||
recheck: [
|
||||
{ name: "web-format", 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);
|
||||
@@ -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();
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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)
|
||||
* -d, --dev-build Use dev build for frontend (faster, no minification)
|
||||
* --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",
|
||||
"dev-build": "bool",
|
||||
tracing: "string",
|
||||
} as const,
|
||||
{ f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed", d: "dev-build" },
|
||||
{
|
||||
"frontend-only": false,
|
||||
"backend-only": false,
|
||||
"no-watch": false,
|
||||
"no-build": false,
|
||||
release: false,
|
||||
embed: false,
|
||||
"dev-build": 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 devBuild = flags["dev-build"];
|
||||
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) {
|
||||
const buildMode = devBuild ? "development" : "production";
|
||||
console.log(c("1;36", `→ Building frontend (${buildMode}, for embedding)...`));
|
||||
const buildArgs = ["bun", "run", "--cwd", "web", "build"];
|
||||
if (devBuild) buildArgs.push("--", "--mode", "development");
|
||||
run(buildArgs);
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "banner-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8"
|
||||
}
|
||||
}
|
||||
@@ -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+/)]);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
+8
-1
@@ -10,8 +10,10 @@ use crate::web::auth::AuthConfig;
|
||||
use anyhow::Context;
|
||||
use figment::value::UncasedStr;
|
||||
use figment::{Figment, providers::Env};
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::process::ExitCode;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -45,6 +47,11 @@ impl App {
|
||||
let slow_threshold = Duration::from_millis(if is_private { 200 } else { 500 });
|
||||
|
||||
// Create database connection pool
|
||||
let connect_options = sqlx::postgres::PgConnectOptions::from_str(&config.database_url)
|
||||
.context("Failed to parse database URL")?
|
||||
.log_statements(tracing::log::LevelFilter::Debug)
|
||||
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_secs(1));
|
||||
|
||||
let db_pool = PgPoolOptions::new()
|
||||
.min_connections(0)
|
||||
.max_connections(4)
|
||||
@@ -52,7 +59,7 @@ impl App {
|
||||
.acquire_timeout(Duration::from_secs(4))
|
||||
.idle_timeout(Duration::from_secs(60 * 2))
|
||||
.max_lifetime(Duration::from_secs(60 * 30))
|
||||
.connect(&config.database_url)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.context("Failed to create database pool")?;
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::banner::{
|
||||
SessionPool, create_shared_rate_limiter, errors::BannerApiError, json::parse_json_with_context,
|
||||
middleware::TransparentMiddleware, models::*, nonce, query::SearchQuery,
|
||||
middleware::LoggingMiddleware, models::*, nonce, query::SearchQuery,
|
||||
rate_limit_middleware::RateLimitMiddleware, util::user_agent,
|
||||
};
|
||||
use crate::config::RateLimitingConfig;
|
||||
@@ -46,7 +46,7 @@ impl BannerApi {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?,
|
||||
)
|
||||
.with(TransparentMiddleware)
|
||||
.with(LoggingMiddleware)
|
||||
.with(RateLimitMiddleware::new(rate_limiter.clone()))
|
||||
.build();
|
||||
|
||||
|
||||
+12
-27
@@ -5,13 +5,13 @@ use reqwest::{Request, Response};
|
||||
use reqwest_middleware::{Middleware, Next};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
pub struct TransparentMiddleware;
|
||||
pub struct LoggingMiddleware;
|
||||
|
||||
/// Threshold for logging slow requests at DEBUG level (in milliseconds)
|
||||
const SLOW_REQUEST_THRESHOLD_MS: u128 = 1000;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Middleware for TransparentMiddleware {
|
||||
impl Middleware for LoggingMiddleware {
|
||||
async fn handle(
|
||||
&self,
|
||||
req: Request,
|
||||
@@ -19,7 +19,8 @@ impl Middleware for TransparentMiddleware {
|
||||
next: Next<'_>,
|
||||
) -> std::result::Result<Response, reqwest_middleware::Error> {
|
||||
let method = req.method().to_string();
|
||||
let path = req.url().path().to_string();
|
||||
// Use the full URL (including query parameters) for logging
|
||||
let url = req.url().to_string();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let response_result = next.run(req, extensions).await;
|
||||
@@ -27,41 +28,25 @@ impl Middleware for TransparentMiddleware {
|
||||
|
||||
match response_result {
|
||||
Ok(response) => {
|
||||
let status = response.status().as_u16();
|
||||
let duration_ms = duration.as_millis();
|
||||
|
||||
if response.status().is_success() {
|
||||
let duration_ms = duration.as_millis();
|
||||
if duration_ms >= SLOW_REQUEST_THRESHOLD_MS {
|
||||
debug!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration_ms,
|
||||
"Request completed (slow)"
|
||||
);
|
||||
debug!(method, url, status, duration_ms, "Request completed (slow)");
|
||||
} else {
|
||||
trace!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration_ms,
|
||||
"Request completed"
|
||||
);
|
||||
trace!(method, url, status, duration_ms, "Request completed");
|
||||
}
|
||||
Ok(response)
|
||||
} else {
|
||||
warn!(
|
||||
method = method,
|
||||
path = path,
|
||||
status = response.status().as_u16(),
|
||||
duration_ms = duration.as_millis(),
|
||||
"Request failed"
|
||||
);
|
||||
warn!(method, url, status, duration_ms, "Request failed");
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
method = method,
|
||||
path = path,
|
||||
method,
|
||||
url,
|
||||
duration_ms = duration.as_millis(),
|
||||
"Request failed"
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
|
||||
use extension_traits::extension;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{cmp::Ordering, str::FromStr};
|
||||
use ts_rs::TS;
|
||||
|
||||
use super::terms::Term;
|
||||
|
||||
@@ -199,7 +200,8 @@ impl TryFrom<MeetingDays> for Weekday {
|
||||
}
|
||||
|
||||
/// Time range for meetings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[ts(export)]
|
||||
pub struct TimeRange {
|
||||
pub start: NaiveTime,
|
||||
pub end: NaiveTime,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-47
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Used by both the Discord bot commands and the web API endpoints.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime};
|
||||
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||
|
||||
/// Course metadata needed for calendar generation (shared interface between bot and web).
|
||||
pub struct CalendarCourse {
|
||||
@@ -36,42 +36,25 @@ impl CalendarCourse {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date parsing helpers
|
||||
// Day-of-week conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse an HHMM time string into `NaiveTime`.
|
||||
fn parse_hhmm(s: &str) -> Option<NaiveTime> {
|
||||
if s.len() != 4 {
|
||||
return None;
|
||||
/// Convert a `DayOfWeek` to a chrono `Weekday`.
|
||||
fn to_weekday(day: &DayOfWeek) -> Weekday {
|
||||
match day {
|
||||
DayOfWeek::Monday => Weekday::Mon,
|
||||
DayOfWeek::Tuesday => Weekday::Tue,
|
||||
DayOfWeek::Wednesday => Weekday::Wed,
|
||||
DayOfWeek::Thursday => Weekday::Thu,
|
||||
DayOfWeek::Friday => Weekday::Fri,
|
||||
DayOfWeek::Saturday => Weekday::Sat,
|
||||
DayOfWeek::Sunday => Weekday::Sun,
|
||||
}
|
||||
let hours = s[..2].parse::<u32>().ok()?;
|
||||
let minutes = s[2..].parse::<u32>().ok()?;
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0)
|
||||
}
|
||||
|
||||
/// Active weekdays for a meeting time.
|
||||
fn active_weekdays(mt: &DbMeetingTime) -> Vec<Weekday> {
|
||||
let mapping: [(bool, Weekday); 7] = [
|
||||
(mt.monday, Weekday::Mon),
|
||||
(mt.tuesday, Weekday::Tue),
|
||||
(mt.wednesday, Weekday::Wed),
|
||||
(mt.thursday, Weekday::Thu),
|
||||
(mt.friday, Weekday::Fri),
|
||||
(mt.saturday, Weekday::Sat),
|
||||
(mt.sunday, Weekday::Sun),
|
||||
];
|
||||
mapping
|
||||
.iter()
|
||||
.filter(|(active, _)| *active)
|
||||
.map(|(_, day)| *day)
|
||||
.collect()
|
||||
mt.days.iter().map(to_weekday).collect()
|
||||
}
|
||||
|
||||
/// ICS two-letter day code for RRULE BYDAY.
|
||||
@@ -90,11 +73,16 @@ fn ics_day_code(day: Weekday) -> &'static str {
|
||||
/// Location string from a `DbMeetingTime`.
|
||||
fn location_string(mt: &DbMeetingTime) -> String {
|
||||
let building = mt
|
||||
.building_description
|
||||
.as_deref()
|
||||
.or(mt.building.as_deref())
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.building_description.as_deref())
|
||||
.or_else(|| mt.location.as_ref().and_then(|loc| loc.building.as_deref()))
|
||||
.unwrap_or("");
|
||||
let room = mt
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.room.as_deref())
|
||||
.unwrap_or("");
|
||||
let room = mt.room.as_deref().unwrap_or("");
|
||||
let combined = format!("{building} {room}").trim().to_string();
|
||||
if combined.is_empty() {
|
||||
"Online".to_string()
|
||||
@@ -285,13 +273,11 @@ fn generate_ics_event(
|
||||
mt: &DbMeetingTime,
|
||||
index: usize,
|
||||
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
// DTSTART/DTEND: first occurrence with time, or all-day on start_date
|
||||
let (dtstart, dtend) = match (start_time, end_time) {
|
||||
@@ -396,13 +382,11 @@ pub fn generate_gcal_url(
|
||||
course: &CalendarCourse,
|
||||
mt: &DbMeetingTime,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let start_date = parse_date(&mt.start_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?;
|
||||
let end_date = parse_date(&mt.end_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?;
|
||||
let start_date = mt.date_range.start;
|
||||
let end_date = mt.date_range.end;
|
||||
|
||||
let start_time = mt.begin_time.as_deref().and_then(parse_hhmm);
|
||||
let end_time = mt.end_time.as_deref().and_then(parse_hhmm);
|
||||
let start_time = mt.time_range.as_ref().map(|tr| tr.start);
|
||||
let end_time = mt.time_range.as_ref().map(|tr| tr.end);
|
||||
|
||||
let dates_text = match (start_time, end_time) {
|
||||
(Some(st), Some(et)) => {
|
||||
|
||||
+118
-24
@@ -1,15 +1,23 @@
|
||||
//! Batch database operations for improved performance.
|
||||
|
||||
use crate::banner::Course;
|
||||
use crate::data::models::{DbMeetingTime, UpsertCounts};
|
||||
use crate::data::names::parse_banner_name;
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use crate::data::models::{DayOfWeek, DbMeetingTime, UpsertCounts};
|
||||
use crate::data::names::{decode_html_entities, parse_banner_name};
|
||||
use crate::error::Result;
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::PgConnection;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
/// Parse a date string in MM/DD/YYYY format to `NaiveDate`.
|
||||
fn parse_mm_dd_yyyy(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
}
|
||||
|
||||
/// Convert a Banner API course's meeting times to the DB JSONB shape.
|
||||
fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
let meetings: Vec<DbMeetingTime> = course
|
||||
@@ -17,22 +25,105 @@ fn to_db_meeting_times(course: &Course) -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|mf| {
|
||||
let mt = &mf.meeting_time;
|
||||
|
||||
// Build days BTreeSet from boolean flags
|
||||
let mut days = BTreeSet::new();
|
||||
if mt.monday {
|
||||
days.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if mt.tuesday {
|
||||
days.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if mt.wednesday {
|
||||
days.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if mt.thursday {
|
||||
days.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if mt.friday {
|
||||
days.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if mt.saturday {
|
||||
days.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if mt.sunday {
|
||||
days.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
|
||||
// Parse time range from HHMM strings
|
||||
let time_range = match (mt.begin_time.as_deref(), mt.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
begin, end,
|
||||
"failed to parse meeting time range"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse date range from MM/DD/YYYY strings
|
||||
let date_range = match (
|
||||
parse_mm_dd_yyyy(&mt.start_date),
|
||||
parse_mm_dd_yyyy(&mt.end_date),
|
||||
) {
|
||||
(Some(start), Some(end)) => DateRange::new(start, end).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
%err,
|
||||
"invalid date range, swapping start/end"
|
||||
);
|
||||
// Swap so the invariant holds
|
||||
DateRange {
|
||||
start: end,
|
||||
end: start,
|
||||
}
|
||||
}),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
crn = %mt.course_reference_number,
|
||||
start_date = %mt.start_date,
|
||||
end_date = %mt.end_date,
|
||||
"failed to parse meeting date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build location if any field is present
|
||||
let location = {
|
||||
let loc = MeetingLocation {
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
};
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
DbMeetingTime {
|
||||
begin_time: mt.begin_time.clone(),
|
||||
end_time: mt.end_time.clone(),
|
||||
start_date: mt.start_date.clone(),
|
||||
end_date: mt.end_date.clone(),
|
||||
monday: mt.monday,
|
||||
tuesday: mt.tuesday,
|
||||
wednesday: mt.wednesday,
|
||||
thursday: mt.thursday,
|
||||
friday: mt.friday,
|
||||
saturday: mt.saturday,
|
||||
sunday: mt.sunday,
|
||||
building: mt.building.clone(),
|
||||
building_description: mt.building_description.clone(),
|
||||
room: mt.room.clone(),
|
||||
campus: mt.campus.clone(),
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: mt.meeting_type.clone(),
|
||||
meeting_schedule_type: mt.meeting_schedule_type.clone(),
|
||||
}
|
||||
@@ -276,14 +367,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 +539,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 +722,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 +734,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);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Structured types for course API responses.
|
||||
//!
|
||||
//! These types replace scattered Option fields and parallel booleans with
|
||||
//! proper type-safe structures.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// An inclusive date range with the invariant that `start <= end`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DateRange {
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
impl DateRange {
|
||||
/// Creates a new `DateRange`, returning an error if `start` is after `end`.
|
||||
pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, String> {
|
||||
if start > end {
|
||||
return Err(format!(
|
||||
"invalid date range: start ({start}) is after end ({end})"
|
||||
));
|
||||
}
|
||||
Ok(Self { start, end })
|
||||
}
|
||||
|
||||
/// Number of days in the range (inclusive of both endpoints).
|
||||
#[allow(dead_code)]
|
||||
pub fn days(&self) -> i64 {
|
||||
(self.end - self.start).num_days() + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical location where a course section meets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MeetingLocation {
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
}
|
||||
|
||||
/// Credit hours for a course section — either a fixed value or a range.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export)]
|
||||
pub enum CreditHours {
|
||||
/// A single fixed credit hour value.
|
||||
Fixed { hours: i32 },
|
||||
/// A range of credit hours with the invariant that `low <= high`.
|
||||
Range { low: i32, high: i32 },
|
||||
}
|
||||
|
||||
impl CreditHours {
|
||||
/// Creates a `CreditHours::Range`, returning an error if `low > high`.
|
||||
#[allow(dead_code)]
|
||||
pub fn range(low: i32, high: i32) -> Result<Self, String> {
|
||||
if low > high {
|
||||
return Err(format!(
|
||||
"invalid credit hour range: low ({low}) is greater than high ({high})"
|
||||
));
|
||||
}
|
||||
Ok(Self::Range { low, high })
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-listed section information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CrossList {
|
||||
pub identifier: String,
|
||||
pub capacity: i32,
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
/// A linked section reference (e.g. lab linked to a lecture).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SectionLink {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
/// Enrollment counts for a course section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Enrollment {
|
||||
pub current: i32,
|
||||
pub max: i32,
|
||||
pub wait_count: i32,
|
||||
pub wait_capacity: i32,
|
||||
}
|
||||
|
||||
impl Enrollment {
|
||||
/// Number of open seats remaining (never negative).
|
||||
#[allow(dead_code)]
|
||||
pub fn open_seats(&self) -> i32 {
|
||||
(self.max - self.current).max(0)
|
||||
}
|
||||
|
||||
/// Whether the section is at or over capacity.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.current >= self.max
|
||||
}
|
||||
|
||||
/// Whether the section has at least one open seat.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_open(&self) -> bool {
|
||||
!self.is_full()
|
||||
}
|
||||
}
|
||||
|
||||
/// RateMyProfessors rating summary for an instructor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct RmpRating {
|
||||
pub avg_rating: f32,
|
||||
pub num_ratings: i32,
|
||||
pub legacy_id: i32,
|
||||
pub is_confident: bool,
|
||||
}
|
||||
+163
-30
@@ -4,10 +4,12 @@ use crate::data::models::{Course, CourseInstructorDetail};
|
||||
use crate::error::Result;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Column to sort search results by.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortColumn {
|
||||
CourseCode,
|
||||
Title,
|
||||
@@ -17,25 +19,74 @@ pub enum SortColumn {
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Aggregate min/max ranges for filter sliders, computed per-term.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct FilterRanges {
|
||||
pub course_number_min: i32,
|
||||
pub course_number_max: i32,
|
||||
pub credit_hour_min: i32,
|
||||
pub credit_hour_max: i32,
|
||||
pub wait_count_max: i32,
|
||||
}
|
||||
|
||||
/// Shared WHERE clause for course search filters.
|
||||
///
|
||||
/// Parameters $1-$8 match the bind order in `search_courses`.
|
||||
/// Parameters $1-$17 match the bind order in `search_courses`.
|
||||
///
|
||||
/// Note: Course number filtering extracts numeric prefix to support alphanumeric
|
||||
/// course numbers (e.g., "015X", "399H"). The numeric part is compared against
|
||||
/// the range, so "399H" matches a search for courses 300-400.
|
||||
const SEARCH_WHERE: &str = r#"
|
||||
WHERE term_code = $1
|
||||
AND ($2::text[] IS NULL OR subject = ANY($2))
|
||||
AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%')
|
||||
AND ($4::int IS NULL OR course_number::int >= $4)
|
||||
AND ($5::int IS NULL OR course_number::int <= $5)
|
||||
AND ($4::int IS NULL OR (substring(course_number from '^\d+'))::int >= $4)
|
||||
AND ($5::int IS NULL OR (substring(course_number from '^\d+'))::int <= $5)
|
||||
AND ($6::bool = false OR max_enrollment > enrollment)
|
||||
AND ($7::text IS NULL OR instructional_method = $7)
|
||||
AND ($8::text IS NULL OR campus = $8)
|
||||
AND ($7::text[] IS NULL OR instructional_method = ANY($7))
|
||||
AND ($8::text[] IS NULL OR campus = ANY($8))
|
||||
AND ($9::int IS NULL OR wait_count <= $9)
|
||||
AND ($10::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (NOT 'monday' = ANY($10) OR (mt->>'monday')::bool)
|
||||
AND (NOT 'tuesday' = ANY($10) OR (mt->>'tuesday')::bool)
|
||||
AND (NOT 'wednesday' = ANY($10) OR (mt->>'wednesday')::bool)
|
||||
AND (NOT 'thursday' = ANY($10) OR (mt->>'thursday')::bool)
|
||||
AND (NOT 'friday' = ANY($10) OR (mt->>'friday')::bool)
|
||||
AND (NOT 'saturday' = ANY($10) OR (mt->>'saturday')::bool)
|
||||
AND (NOT 'sunday' = ANY($10) OR (mt->>'sunday')::bool)
|
||||
))
|
||||
AND ($11::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'begin_time') >= $11
|
||||
))
|
||||
AND ($12::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(meeting_times) AS mt
|
||||
WHERE (mt->>'end_time') <= $12
|
||||
))
|
||||
AND ($13::text[] IS NULL OR part_of_term = ANY($13))
|
||||
AND ($14::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(attributes) a
|
||||
WHERE a = ANY($14)
|
||||
))
|
||||
AND ($15::int IS NULL OR COALESCE(credit_hours, credit_hour_low, 0) >= $15)
|
||||
AND ($16::int IS NULL OR COALESCE(credit_hours, credit_hour_high, 0) <= $16)
|
||||
AND ($17::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM course_instructors ci
|
||||
JOIN instructors i ON i.id = ci.instructor_id
|
||||
WHERE ci.course_id = courses.id
|
||||
AND i.display_name ILIKE '%' || $17 || '%'
|
||||
))
|
||||
"#;
|
||||
|
||||
/// Build a safe ORDER BY clause from typed sort parameters.
|
||||
@@ -83,8 +134,17 @@ pub async fn search_courses(
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
open_only: bool,
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
instructional_method: Option<&[String]>,
|
||||
campus: Option<&[String]>,
|
||||
wait_count_max: Option<i32>,
|
||||
days: Option<&[String]>,
|
||||
time_start: Option<&str>,
|
||||
time_end: Option<&str>,
|
||||
part_of_term: Option<&[String]>,
|
||||
attributes: Option<&[String]>,
|
||||
credit_hour_min: Option<i32>,
|
||||
credit_hour_max: Option<i32>,
|
||||
instructor: Option<&str>,
|
||||
limit: i32,
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
@@ -93,32 +153,50 @@ pub async fn search_courses(
|
||||
let order_by = sort_clause(sort_by, sort_dir);
|
||||
|
||||
let data_query =
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
|
||||
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $18 OFFSET $19");
|
||||
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
|
||||
|
||||
let courses = sqlx::query_as::<_, Course>(&data_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.bind(limit) // $18
|
||||
.bind(offset) // $19
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
let total: (i64,) = sqlx::query_as(&count_query)
|
||||
.bind(term_code)
|
||||
.bind(subject)
|
||||
.bind(title_query)
|
||||
.bind(course_number_low)
|
||||
.bind(course_number_high)
|
||||
.bind(open_only)
|
||||
.bind(instructional_method)
|
||||
.bind(campus)
|
||||
.bind(term_code) // $1
|
||||
.bind(subject) // $2
|
||||
.bind(title_query) // $3
|
||||
.bind(course_number_low) // $4
|
||||
.bind(course_number_high) // $5
|
||||
.bind(open_only) // $6
|
||||
.bind(instructional_method) // $7
|
||||
.bind(campus) // $8
|
||||
.bind(wait_count_max) // $9
|
||||
.bind(days) // $10
|
||||
.bind(time_start) // $11
|
||||
.bind(time_end) // $12
|
||||
.bind(part_of_term) // $13
|
||||
.bind(attributes) // $14
|
||||
.bind(credit_hour_min) // $15
|
||||
.bind(credit_hour_max) // $16
|
||||
.bind(instructor) // $17
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
@@ -147,7 +225,8 @@ pub async fn get_course_instructors(
|
||||
) -> Result<Vec<CourseInstructorDetail>> {
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
@@ -183,7 +262,8 @@ pub async fn get_instructors_for_courses(
|
||||
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.first_name, i.last_name,
|
||||
i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
@@ -247,3 +327,56 @@ pub async fn get_available_terms(db_pool: &PgPool) -> Result<Vec<String>> {
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(tc,)| tc).collect())
|
||||
}
|
||||
|
||||
type RangeRow = (
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
);
|
||||
|
||||
/// Get aggregate filter ranges for a term (course number, credit hours, waitlist).
|
||||
pub async fn get_filter_ranges(db_pool: &PgPool, term_code: &str) -> Result<FilterRanges> {
|
||||
let row: RangeRow = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
MIN(course_number::int),
|
||||
MAX(course_number::int),
|
||||
MIN(COALESCE(credit_hours, credit_hour_low, 0)),
|
||||
MAX(COALESCE(credit_hours, credit_hour_high, 0)),
|
||||
MAX(wait_count)
|
||||
FROM courses
|
||||
WHERE term_code = $1
|
||||
AND course_number ~ '^\d+$'
|
||||
"#,
|
||||
)
|
||||
.bind(term_code)
|
||||
.fetch_one(db_pool)
|
||||
.await?;
|
||||
|
||||
let cn_min = row.0.unwrap_or(1000);
|
||||
let cn_max = row.1.unwrap_or(9000);
|
||||
let ch_min = row.2.unwrap_or(0);
|
||||
let ch_max = row.3.unwrap_or(8);
|
||||
let wc_max_raw = row.4.unwrap_or(0);
|
||||
|
||||
// Round course number to hundreds: floor min, ceil max
|
||||
let cn_min_rounded = (cn_min / 100) * 100;
|
||||
let cn_max_rounded = ((cn_max + 99) / 100) * 100;
|
||||
|
||||
// Waitlist ceiling: (max / 10 + 1) * 10
|
||||
let wc_max = if wc_max_raw > 0 {
|
||||
(wc_max_raw / 10 + 1) * 10
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(FilterRanges {
|
||||
course_number_min: cn_min_rounded,
|
||||
course_number_max: cn_max_rounded,
|
||||
credit_hour_min: ch_min,
|
||||
credit_hour_max: ch_max,
|
||||
wait_count_max: wc_max,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Database models and schema.
|
||||
|
||||
pub mod batch;
|
||||
pub mod course_types;
|
||||
pub mod courses;
|
||||
pub mod models;
|
||||
pub mod names;
|
||||
|
||||
+227
-18
@@ -1,10 +1,15 @@
|
||||
//! `sqlx` models for the database schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::banner::models::meetings::TimeRange;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
|
||||
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
|
||||
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&value.to_string())
|
||||
@@ -41,29 +46,230 @@ fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
|
||||
deserializer.deserialize_any(I64OrStringVisitor)
|
||||
}
|
||||
|
||||
/// Day of the week.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum DayOfWeek {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
/// Represents a meeting time stored as JSONB in the courses table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DbMeetingTime {
|
||||
pub begin_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub monday: bool,
|
||||
pub tuesday: bool,
|
||||
pub wednesday: bool,
|
||||
pub thursday: bool,
|
||||
pub friday: bool,
|
||||
pub saturday: bool,
|
||||
pub sunday: bool,
|
||||
pub building: Option<String>,
|
||||
pub building_description: Option<String>,
|
||||
pub room: Option<String>,
|
||||
pub campus: Option<String>,
|
||||
/// Time range for the meeting; `None` means TBA.
|
||||
pub time_range: Option<TimeRange>,
|
||||
/// Date range over which the meeting recurs.
|
||||
pub date_range: DateRange,
|
||||
/// Active days of the week. Empty means days are TBA.
|
||||
pub days: BTreeSet<DayOfWeek>,
|
||||
/// Physical location; `None` when all location fields are absent.
|
||||
pub location: Option<MeetingLocation>,
|
||||
pub meeting_type: String,
|
||||
pub meeting_schedule_type: String,
|
||||
}
|
||||
|
||||
impl DbMeetingTime {
|
||||
/// Whether no days of the week are set (i.e. days are TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_days_tba(&self) -> bool {
|
||||
self.days.is_empty()
|
||||
}
|
||||
|
||||
/// Whether no time range is set (i.e. time is TBA).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_time_tba(&self) -> bool {
|
||||
self.time_range.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a date string to ISO-8601 (YYYY-MM-DD).
|
||||
///
|
||||
/// Accepts MM/DD/YYYY (from Banner API) and returns YYYY-MM-DD.
|
||||
/// Already-normalized dates are returned as-is.
|
||||
#[allow(dead_code)]
|
||||
fn normalize_date(s: &str) -> String {
|
||||
if let Some((month_day, year)) = s.rsplit_once('/')
|
||||
&& let Some((month, day)) = month_day.split_once('/')
|
||||
{
|
||||
return format!("{year}-{month:0>2}-{day:0>2}");
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
/// Parse a date string that may be in MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_flexible_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DbMeetingTime {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
/// Intermediate representation that accepts both old and new JSON formats.
|
||||
#[derive(Deserialize)]
|
||||
struct Raw {
|
||||
// New-format fields (camelCase in JSON)
|
||||
#[serde(rename = "timeRange")]
|
||||
time_range: Option<TimeRange>,
|
||||
#[serde(rename = "dateRange")]
|
||||
date_range: Option<DateRange>,
|
||||
days: Option<BTreeSet<DayOfWeek>>,
|
||||
location: Option<MeetingLocation>,
|
||||
|
||||
// Old-format fields (snake_case in JSON)
|
||||
begin_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
#[serde(default)]
|
||||
monday: bool,
|
||||
#[serde(default)]
|
||||
tuesday: bool,
|
||||
#[serde(default)]
|
||||
wednesday: bool,
|
||||
#[serde(default)]
|
||||
thursday: bool,
|
||||
#[serde(default)]
|
||||
friday: bool,
|
||||
#[serde(default)]
|
||||
saturday: bool,
|
||||
#[serde(default)]
|
||||
sunday: bool,
|
||||
building: Option<String>,
|
||||
building_description: Option<String>,
|
||||
room: Option<String>,
|
||||
campus: Option<String>,
|
||||
|
||||
// Always present (camelCase in new format, snake_case in old format)
|
||||
#[serde(rename = "meetingType", alias = "meeting_type")]
|
||||
meeting_type: String,
|
||||
#[serde(rename = "meetingScheduleType", alias = "meeting_schedule_type")]
|
||||
meeting_schedule_type: String,
|
||||
|
||||
// Legacy computed fields (ignored on read)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_days_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
is_time_tba: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
active_days: Vec<DayOfWeek>,
|
||||
}
|
||||
|
||||
let raw = Raw::deserialize(deserializer)?;
|
||||
|
||||
// Resolve time_range: prefer new field, fall back to old begin_time/end_time
|
||||
let time_range =
|
||||
raw.time_range.or_else(
|
||||
|| match (raw.begin_time.as_deref(), raw.end_time.as_deref()) {
|
||||
(Some(begin), Some(end)) => {
|
||||
let result = TimeRange::from_hhmm(begin, end);
|
||||
if result.is_none() {
|
||||
tracing::warn!(begin, end, "failed to parse old-format time range");
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve date_range: prefer new field, fall back to old start_date/end_date
|
||||
let date_range = if let Some(dr) = raw.date_range {
|
||||
dr
|
||||
} else {
|
||||
let start_str = raw.start_date.as_deref().unwrap_or("");
|
||||
let end_str = raw.end_date.as_deref().unwrap_or("");
|
||||
let start = parse_flexible_date(start_str);
|
||||
let end = parse_flexible_date(end_str);
|
||||
match (start, end) {
|
||||
(Some(s), Some(e)) => DateRange { start: s, end: e },
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
start_date = start_str,
|
||||
end_date = end_str,
|
||||
"failed to parse old-format date range, using epoch fallback"
|
||||
);
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
|
||||
DateRange {
|
||||
start: epoch,
|
||||
end: epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve days: prefer new field, fall back to old boolean flags
|
||||
let days = raw.days.unwrap_or_else(|| {
|
||||
let mut set = BTreeSet::new();
|
||||
if raw.monday {
|
||||
set.insert(DayOfWeek::Monday);
|
||||
}
|
||||
if raw.tuesday {
|
||||
set.insert(DayOfWeek::Tuesday);
|
||||
}
|
||||
if raw.wednesday {
|
||||
set.insert(DayOfWeek::Wednesday);
|
||||
}
|
||||
if raw.thursday {
|
||||
set.insert(DayOfWeek::Thursday);
|
||||
}
|
||||
if raw.friday {
|
||||
set.insert(DayOfWeek::Friday);
|
||||
}
|
||||
if raw.saturday {
|
||||
set.insert(DayOfWeek::Saturday);
|
||||
}
|
||||
if raw.sunday {
|
||||
set.insert(DayOfWeek::Sunday);
|
||||
}
|
||||
set
|
||||
});
|
||||
|
||||
// Resolve location: prefer new field, fall back to old building/room/campus fields
|
||||
let location = raw.location.or_else(|| {
|
||||
let loc = MeetingLocation {
|
||||
building: raw.building,
|
||||
building_description: raw.building_description,
|
||||
room: raw.room,
|
||||
campus: raw.campus,
|
||||
};
|
||||
// Only produce Some if at least one field is present
|
||||
if loc.building.is_some()
|
||||
|| loc.building_description.is_some()
|
||||
|| loc.room.is_some()
|
||||
|| loc.campus.is_some()
|
||||
{
|
||||
Some(loc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DbMeetingTime {
|
||||
time_range,
|
||||
date_range,
|
||||
days,
|
||||
location,
|
||||
meeting_type: raw.meeting_type,
|
||||
meeting_schedule_type: raw.meeting_schedule_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct Course {
|
||||
@@ -122,6 +328,8 @@ pub struct CourseInstructorDetail {
|
||||
pub instructor_id: i32,
|
||||
pub banner_id: String,
|
||||
pub display_name: String,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub email: String,
|
||||
pub is_primary: bool,
|
||||
pub avg_rating: Option<f32>,
|
||||
@@ -192,8 +400,9 @@ pub enum TargetType {
|
||||
}
|
||||
|
||||
/// Computed status for a scrape job, derived from existing fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub enum ScrapeJobStatus {
|
||||
Processing,
|
||||
StaleLock,
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ pub struct NameParts {
|
||||
///
|
||||
/// Handles both named entities (`&`, `ü`) and numeric references
|
||||
/// (`'`, `'`).
|
||||
fn decode_html_entities(s: &str) -> String {
|
||||
pub(crate) fn decode_html_entities(s: &str) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ use crate::web::schedule_cache::ScheduleCache;
|
||||
use crate::web::session_cache::{OAuthStateStore, SessionCache};
|
||||
use crate::web::ws::ScrapeJobEvent;
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
|
||||
/// In-memory cache for reference data (code→description lookups).
|
||||
@@ -79,6 +81,7 @@ pub struct AppState {
|
||||
pub oauth_state_store: OAuthStateStore,
|
||||
pub schedule_cache: ScheduleCache,
|
||||
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
|
||||
pub search_options_cache: Arc<DashMap<String, (Instant, serde_json::Value)>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -94,6 +97,7 @@ impl AppState {
|
||||
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
|
||||
schedule_cache,
|
||||
scrape_job_tx,
|
||||
search_options_cache: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+83
-51
@@ -6,18 +6,51 @@ use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::data::models::User;
|
||||
use crate::state::AppState;
|
||||
use crate::status::ServiceStatus;
|
||||
use crate::web::extractors::AdminUser;
|
||||
use crate::web::ws::ScrapeJobDto;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ScrapeJobsResponse {
|
||||
pub jobs: Vec<ScrapeJobDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AdminServiceInfo {
|
||||
name: String,
|
||||
status: ServiceStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AdminStatusResponse {
|
||||
#[ts(type = "number")]
|
||||
user_count: i64,
|
||||
#[ts(type = "number")]
|
||||
session_count: i64,
|
||||
#[ts(type = "number")]
|
||||
course_count: i64,
|
||||
#[ts(type = "number")]
|
||||
scrape_job_count: i64,
|
||||
services: Vec<AdminServiceInfo>,
|
||||
}
|
||||
|
||||
/// `GET /api/admin/status` — Enhanced system status for admins.
|
||||
pub async fn admin_status(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
) -> Result<Json<AdminStatusResponse>, (StatusCode, Json<Value>)> {
|
||||
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&state.db_pool)
|
||||
.await
|
||||
@@ -60,25 +93,20 @@ pub async fn admin_status(
|
||||
)
|
||||
})?;
|
||||
|
||||
let services: Vec<Value> = state
|
||||
let services: Vec<AdminServiceInfo> = state
|
||||
.service_statuses
|
||||
.all()
|
||||
.into_iter()
|
||||
.map(|(name, status)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.map(|(name, status)| AdminServiceInfo { name, status })
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"userCount": user_count,
|
||||
"sessionCount": session_count,
|
||||
"courseCount": course_count,
|
||||
"scrapeJobCount": scrape_job_count,
|
||||
"services": services,
|
||||
})))
|
||||
Ok(Json(AdminStatusResponse {
|
||||
user_count,
|
||||
session_count,
|
||||
course_count,
|
||||
scrape_job_count,
|
||||
services,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/admin/users` — List all users.
|
||||
@@ -136,7 +164,7 @@ pub async fn set_user_admin(
|
||||
pub async fn list_scrape_jobs(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
) -> Result<Json<ScrapeJobsResponse>, (StatusCode, Json<Value>)> {
|
||||
let rows = sqlx::query_as::<_, crate::data::models::ScrapeJob>(
|
||||
"SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100",
|
||||
)
|
||||
@@ -150,26 +178,9 @@ pub async fn list_scrape_jobs(
|
||||
)
|
||||
})?;
|
||||
|
||||
let jobs: Vec<Value> = rows
|
||||
.iter()
|
||||
.map(|j| {
|
||||
json!({
|
||||
"id": j.id,
|
||||
"targetType": format!("{:?}", j.target_type),
|
||||
"targetPayload": j.target_payload,
|
||||
"priority": format!("{:?}", j.priority),
|
||||
"executeAt": j.execute_at.to_rfc3339(),
|
||||
"createdAt": j.created_at.to_rfc3339(),
|
||||
"lockedAt": j.locked_at.map(|t| t.to_rfc3339()),
|
||||
"retryCount": j.retry_count,
|
||||
"maxRetries": j.max_retries,
|
||||
"queuedAt": j.queued_at.to_rfc3339(),
|
||||
"status": j.status(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let jobs: Vec<ScrapeJobDto> = rows.iter().map(ScrapeJobDto::from).collect();
|
||||
|
||||
Ok(Json(json!({ "jobs": jobs })))
|
||||
Ok(Json(ScrapeJobsResponse { jobs }))
|
||||
}
|
||||
|
||||
/// Row returned by the audit-log query (audit + joined course fields).
|
||||
@@ -188,6 +199,29 @@ struct AuditRow {
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: i32,
|
||||
pub course_id: i32,
|
||||
pub timestamp: String,
|
||||
pub field_changed: String,
|
||||
pub old_value: String,
|
||||
pub new_value: String,
|
||||
pub subject: Option<String>,
|
||||
pub course_number: Option<String>,
|
||||
pub crn: Option<String>,
|
||||
pub course_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AuditLogResponse {
|
||||
pub entries: Vec<AuditLogEntry>,
|
||||
}
|
||||
|
||||
/// Format a `DateTime<Utc>` as an HTTP-date (RFC 2822) for Last-Modified headers.
|
||||
fn to_http_date(dt: &DateTime<Utc>) -> String {
|
||||
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||
@@ -241,25 +275,23 @@ pub async fn list_audit_log(
|
||||
}
|
||||
}
|
||||
|
||||
let entries: Vec<Value> = rows
|
||||
let entries: Vec<AuditLogEntry> = rows
|
||||
.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"courseId": a.course_id,
|
||||
"timestamp": a.timestamp.to_rfc3339(),
|
||||
"fieldChanged": a.field_changed,
|
||||
"oldValue": a.old_value,
|
||||
"newValue": a.new_value,
|
||||
"subject": a.subject,
|
||||
"courseNumber": a.course_number,
|
||||
"crn": a.crn,
|
||||
"courseTitle": a.title,
|
||||
})
|
||||
.map(|a| AuditLogEntry {
|
||||
id: a.id,
|
||||
course_id: a.course_id,
|
||||
timestamp: a.timestamp.to_rfc3339(),
|
||||
field_changed: a.field_changed.clone(),
|
||||
old_value: a.old_value.clone(),
|
||||
new_value: a.new_value.clone(),
|
||||
subject: a.subject.clone(),
|
||||
course_number: a.course_number.clone(),
|
||||
crn: a.crn.clone(),
|
||||
course_title: a.title.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut resp = Json(json!({ "entries": entries })).into_response();
|
||||
let mut resp = Json(AuditLogResponse { entries }).into_response();
|
||||
if let Some(latest_ts) = latest
|
||||
&& let Ok(val) = to_http_date(&latest_ts).parse()
|
||||
{
|
||||
|
||||
+19
-10
@@ -14,25 +14,34 @@ use crate::web::extractors::AdminUser;
|
||||
// Query / body types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ListInstructorsParams {
|
||||
status: Option<String>,
|
||||
search: Option<String>,
|
||||
page: Option<i32>,
|
||||
per_page: Option<i32>,
|
||||
sort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub per_page: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MatchBody {
|
||||
rmp_legacy_id: i32,
|
||||
pub rmp_legacy_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct RejectCandidateBody {
|
||||
rmp_legacy_id: i32,
|
||||
pub rmp_legacy_id: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -77,10 +77,12 @@ fn default_bucket_for_period(period: &str) -> &'static str {
|
||||
// Endpoint 1: GET /api/admin/scraper/stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct StatsParams {
|
||||
#[serde(default = "default_period")]
|
||||
period: String,
|
||||
pub period: String,
|
||||
}
|
||||
|
||||
fn default_period() -> String {
|
||||
@@ -195,11 +197,14 @@ pub async fn scraper_stats(
|
||||
// Endpoint 2: GET /api/admin/scraper/timeseries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TimeseriesParams {
|
||||
#[serde(default = "default_period")]
|
||||
period: String,
|
||||
bucket: Option<String>,
|
||||
pub period: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -215,6 +220,8 @@ pub struct TimeseriesResponse {
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimeseriesPoint {
|
||||
/// ISO-8601 UTC timestamp for this data point (e.g., "2024-01-15T10:00:00Z")
|
||||
#[ts(type = "string")]
|
||||
timestamp: DateTime<Utc>,
|
||||
#[ts(type = "number")]
|
||||
scrape_count: i64,
|
||||
@@ -328,7 +335,11 @@ pub struct SubjectSummary {
|
||||
#[ts(type = "number")]
|
||||
current_interval_secs: u64,
|
||||
time_multiplier: u32,
|
||||
/// ISO-8601 UTC timestamp of last scrape (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
last_scraped: DateTime<Utc>,
|
||||
/// ISO-8601 UTC timestamp when next scrape is eligible (e.g., "2024-01-15T11:00:00Z")
|
||||
#[ts(type = "string | null")]
|
||||
next_eligible_at: Option<DateTime<Utc>>,
|
||||
#[ts(type = "number | null")]
|
||||
cooldown_remaining_secs: Option<u64>,
|
||||
@@ -439,10 +450,12 @@ pub async fn scraper_subjects(
|
||||
// Endpoint 4: GET /api/admin/scraper/subjects/{subject}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SubjectDetailParams {
|
||||
#[serde(default = "default_detail_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
fn default_detail_limit() -> i32 {
|
||||
@@ -463,6 +476,8 @@ pub struct SubjectDetailResponse {
|
||||
pub struct SubjectResultEntry {
|
||||
#[ts(type = "number")]
|
||||
id: i64,
|
||||
/// ISO-8601 UTC timestamp when the scrape job completed (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
completed_at: DateTime<Utc>,
|
||||
duration_ms: i32,
|
||||
success: bool,
|
||||
|
||||
+1
-10
@@ -112,16 +112,7 @@ pub async fn course_gcal(
|
||||
// Prefer the first meeting time that has actual days/times scheduled
|
||||
let mt = meeting_times
|
||||
.iter()
|
||||
.find(|mt| {
|
||||
mt.begin_time.is_some()
|
||||
&& (mt.monday
|
||||
|| mt.tuesday
|
||||
|| mt.wednesday
|
||||
|| mt.thursday
|
||||
|| mt.friday
|
||||
|| mt.saturday
|
||||
|| mt.sunday)
|
||||
})
|
||||
.find(|mt| mt.time_range.is_some() && !mt.days.is_empty())
|
||||
.unwrap_or(&meeting_times[0]);
|
||||
|
||||
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Delivery mode classification for course sections.
|
||||
//!
|
||||
//! Moves the delivery concern logic (previously in the TypeScript frontend)
|
||||
//! to the backend so it ships as part of the API response.
|
||||
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Banner instructional method codes for fully-online delivery.
|
||||
const ONLINE_METHODS: &[&str] = &["OA", "OS", "OH"];
|
||||
|
||||
/// Banner instructional method codes for hybrid delivery.
|
||||
const HYBRID_METHODS: &[&str] = &["HB", "H1", "H2"];
|
||||
|
||||
/// Banner campus code for the main (San Antonio) campus.
|
||||
const MAIN_CAMPUS: &str = "11";
|
||||
|
||||
/// Banner campus codes that represent online/virtual campuses.
|
||||
const ONLINE_CAMPUSES: &[&str] = &["9", "ONL"];
|
||||
|
||||
/// Delivery mode classification for visual accents on location cells.
|
||||
///
|
||||
/// `None` means normal in-person on the main campus (no accent needed).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[ts(export)]
|
||||
pub enum DeliveryMode {
|
||||
/// Fully online with no physical location (OA, OS, OH without INT building).
|
||||
Online,
|
||||
/// Internet campus with INT building code.
|
||||
Internet,
|
||||
/// Mix of online and in-person (HB, H1, H2).
|
||||
Hybrid,
|
||||
/// In-person but not on Main Campus.
|
||||
OffCampus,
|
||||
}
|
||||
|
||||
/// Classify the delivery mode for a course section.
|
||||
///
|
||||
/// Returns `None` for normal in-person sections on the main campus.
|
||||
pub fn classify_delivery_mode(
|
||||
instructional_method: Option<&str>,
|
||||
campus: Option<&str>,
|
||||
meeting_times: &[DbMeetingTime],
|
||||
) -> Option<DeliveryMode> {
|
||||
if let Some(method) = instructional_method {
|
||||
if ONLINE_METHODS.contains(&method) {
|
||||
let has_int_building = meeting_times.iter().any(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
});
|
||||
return Some(if has_int_building {
|
||||
DeliveryMode::Internet
|
||||
} else {
|
||||
DeliveryMode::Online
|
||||
});
|
||||
}
|
||||
if HYBRID_METHODS.contains(&method) {
|
||||
return Some(DeliveryMode::Hybrid);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(campus) = campus
|
||||
&& campus != MAIN_CAMPUS
|
||||
&& !ONLINE_CAMPUSES.contains(&campus)
|
||||
{
|
||||
return Some(DeliveryMode::OffCampus);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::course_types::{DateRange, MeetingLocation};
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn make_mt(building: Option<&str>) -> DbMeetingTime {
|
||||
DbMeetingTime {
|
||||
time_range: None,
|
||||
date_range: DateRange {
|
||||
start: NaiveDate::from_ymd_opt(2024, 8, 26).unwrap(),
|
||||
end: NaiveDate::from_ymd_opt(2024, 12, 12).unwrap(),
|
||||
},
|
||||
days: BTreeSet::new(),
|
||||
location: building.map(|b| MeetingLocation {
|
||||
building: Some(b.to_string()),
|
||||
building_description: None,
|
||||
room: None,
|
||||
campus: None,
|
||||
}),
|
||||
meeting_type: "CLAS".to_string(),
|
||||
meeting_schedule_type: "LEC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_without_int_building() {
|
||||
for method in &["OA", "OS", "OH"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("9"), &[make_mt(None)]),
|
||||
Some(DeliveryMode::Online),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_with_int_building() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some("OA"), Some("9"), &[make_mt(Some("INT"))]),
|
||||
Some(DeliveryMode::Internet),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_methods() {
|
||||
for method in &["HB", "H1", "H2"] {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(Some(method), Some("11"), &[]),
|
||||
Some(DeliveryMode::Hybrid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_campus() {
|
||||
assert_eq!(
|
||||
classify_delivery_mode(None, Some("22"), &[]),
|
||||
Some(DeliveryMode::OffCampus),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_campus_in_person() {
|
||||
assert_eq!(classify_delivery_mode(None, Some("11"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_campus_no_method_is_normal() {
|
||||
// Campus 9 or ONL without an online method → None (no accent)
|
||||
assert_eq!(classify_delivery_mode(None, Some("9"), &[]), None);
|
||||
assert_eq!(classify_delivery_mode(None, Some("ONL"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_method_no_campus() {
|
||||
assert_eq!(classify_delivery_mode(None, None, &[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Standardized API error responses.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Machine-readable error code for API responses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[ts(export)]
|
||||
pub enum ApiErrorCode {
|
||||
NotFound,
|
||||
BadRequest,
|
||||
InternalError,
|
||||
InvalidTerm,
|
||||
InvalidRange,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NoTerms,
|
||||
}
|
||||
|
||||
/// Standardized error response for all API endpoints.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ApiError {
|
||||
/// Machine-readable error code
|
||||
pub code: ApiErrorCode,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional additional details (validation errors, field info, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::NotFound, message)
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::BadRequest, message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ApiErrorCode::InternalError, message)
|
||||
}
|
||||
|
||||
pub fn invalid_term(term: impl std::fmt::Display) -> Self {
|
||||
Self::new(ApiErrorCode::InvalidTerm, format!("Invalid term: {}", term))
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.code {
|
||||
ApiErrorCode::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiErrorCode::BadRequest
|
||||
| ApiErrorCode::InvalidTerm
|
||||
| ApiErrorCode::InvalidRange
|
||||
| ApiErrorCode::NoTerms => StatusCode::BAD_REQUEST,
|
||||
ApiErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
ApiErrorCode::Forbidden => StatusCode::FORBIDDEN,
|
||||
ApiErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
(status, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `(StatusCode, String)` tuple errors to ApiError
|
||||
impl From<(StatusCode, String)> for ApiError {
|
||||
fn from((status, message): (StatusCode, String)) -> Self {
|
||||
let code = match status {
|
||||
StatusCode::NOT_FOUND => ApiErrorCode::NotFound,
|
||||
StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest,
|
||||
StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized,
|
||||
StatusCode::FORBIDDEN => ApiErrorCode::Forbidden,
|
||||
_ => ApiErrorCode::InternalError,
|
||||
};
|
||||
Self::new(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for converting database errors to ApiError
|
||||
pub fn db_error(context: &str, error: anyhow::Error) -> ApiError {
|
||||
tracing::error!(error = %error, context = context, "Database error");
|
||||
ApiError::internal_error(format!("{} failed", context))
|
||||
}
|
||||
@@ -7,8 +7,10 @@ pub mod admin_scraper;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod delivery;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod encoding;
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod routes;
|
||||
pub mod schedule_cache;
|
||||
|
||||
+442
-196
@@ -4,18 +4,20 @@ use axum::{
|
||||
Extension, Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode as AxumStatusCode,
|
||||
response::{Json, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
|
||||
use crate::web::admin;
|
||||
use crate::web::admin_rmp;
|
||||
use crate::data::course_types::{CreditHours, CrossList, Enrollment, RmpRating, SectionLink};
|
||||
use crate::web::admin_scraper;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::calendar;
|
||||
use crate::web::delivery::{DeliveryMode, classify_delivery_mode};
|
||||
use crate::web::error::{ApiError, ApiErrorCode, db_error};
|
||||
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},
|
||||
@@ -52,9 +54,8 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
||||
get(calendar::course_ics),
|
||||
)
|
||||
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
|
||||
.route("/terms", get(get_terms))
|
||||
.route("/subjects", get(get_subjects))
|
||||
.route("/reference/{category}", get(get_reference))
|
||||
.route("/search-options", get(get_search_options))
|
||||
.route("/timeline", post(timeline::timeline))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
@@ -291,7 +292,7 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
async fn metrics(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MetricsParams>,
|
||||
) -> Result<Json<Value>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<MetricsResponse>, ApiError> {
|
||||
let limit = params.limit.clamp(1, 5000);
|
||||
|
||||
// Parse range shorthand, defaulting to 24h
|
||||
@@ -303,8 +304,8 @@ async fn metrics(
|
||||
"7d" => chrono::Duration::days(7),
|
||||
"30d" => chrono::Duration::days(30),
|
||||
_ => {
|
||||
return Err((
|
||||
AxumStatusCode::BAD_REQUEST,
|
||||
return Err(ApiError::new(
|
||||
ApiErrorCode::InvalidRange,
|
||||
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
|
||||
));
|
||||
}
|
||||
@@ -321,13 +322,7 @@ async fn metrics(
|
||||
.bind(crn)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup for metrics failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Course lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Course lookup for metrics", e.into()))?;
|
||||
row.map(|(id,)| id)
|
||||
} else {
|
||||
None
|
||||
@@ -361,80 +356,120 @@ async fn metrics(
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Metrics query failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Metrics query failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Metrics query", e.into()))?;
|
||||
|
||||
let count = metrics.len();
|
||||
let metrics_json: Vec<Value> = metrics
|
||||
let metrics_entries: Vec<MetricEntry> = metrics
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"courseId": course_id,
|
||||
"timestamp": timestamp.to_rfc3339(),
|
||||
"enrollment": enrollment,
|
||||
"waitCount": wait_count,
|
||||
"seatsAvailable": seats_available,
|
||||
})
|
||||
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| MetricEntry {
|
||||
id,
|
||||
course_id,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
enrollment,
|
||||
wait_count,
|
||||
seats_available,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"metrics": metrics_json,
|
||||
"count": count,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})))
|
||||
Ok(Json(MetricsResponse {
|
||||
metrics: metrics_entries,
|
||||
count,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Course search & detail API
|
||||
// ============================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MetricsParams {
|
||||
course_id: Option<i32>,
|
||||
term: Option<String>,
|
||||
crn: Option<String>,
|
||||
/// Shorthand durations: "1h", "6h", "24h", "7d", "30d"
|
||||
range: Option<String>,
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricEntry {
|
||||
pub id: i32,
|
||||
pub course_id: i32,
|
||||
pub timestamp: String,
|
||||
pub enrollment: i32,
|
||||
pub wait_count: i32,
|
||||
pub seats_available: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsResponse {
|
||||
pub metrics: Vec<MetricEntry>,
|
||||
pub count: usize,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct MetricsParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub course_id: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub term: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crn: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub range: Option<String>,
|
||||
#[serde(default = "default_metrics_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
fn default_metrics_limit() -> i32 {
|
||||
500
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubjectsParams {
|
||||
term: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchParams {
|
||||
term: String,
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchParams {
|
||||
pub term: String,
|
||||
#[serde(default)]
|
||||
subject: Vec<String>,
|
||||
q: Option<String>,
|
||||
course_number_low: Option<i32>,
|
||||
course_number_high: Option<i32>,
|
||||
pub subject: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub q: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_low")]
|
||||
pub course_number_low: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "course_number_high")]
|
||||
pub course_number_high: Option<i32>,
|
||||
#[serde(default, alias = "open_only")]
|
||||
pub open_only: bool,
|
||||
#[serde(default, alias = "instructional_method")]
|
||||
pub instructional_method: Vec<String>,
|
||||
#[serde(default)]
|
||||
open_only: bool,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
pub campus: Vec<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i32,
|
||||
pub limit: i32,
|
||||
#[serde(default)]
|
||||
offset: i32,
|
||||
sort_by: Option<SortColumn>,
|
||||
sort_dir: Option<SortDirection>,
|
||||
pub offset: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_by")]
|
||||
pub sort_by: Option<SortColumn>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "sort_dir")]
|
||||
pub sort_dir: Option<SortDirection>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "wait_count_max")]
|
||||
pub wait_count_max: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub days: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_start")]
|
||||
pub time_start: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "time_end")]
|
||||
pub time_end: Option<String>,
|
||||
#[serde(default, alias = "part_of_term")]
|
||||
pub part_of_term: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub attributes: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_min")]
|
||||
pub credit_hour_min: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "credit_hour_max")]
|
||||
pub credit_hour_max: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructor: Option<String>,
|
||||
}
|
||||
|
||||
use crate::data::courses::{SortColumn, SortDirection};
|
||||
@@ -455,21 +490,20 @@ pub struct CourseResponse {
|
||||
sequence_number: Option<String>,
|
||||
instructional_method: Option<String>,
|
||||
campus: Option<String>,
|
||||
enrollment: i32,
|
||||
max_enrollment: i32,
|
||||
wait_count: i32,
|
||||
wait_capacity: i32,
|
||||
credit_hours: Option<i32>,
|
||||
credit_hour_low: Option<i32>,
|
||||
credit_hour_high: Option<i32>,
|
||||
cross_list: Option<String>,
|
||||
cross_list_capacity: Option<i32>,
|
||||
cross_list_count: Option<i32>,
|
||||
link_identifier: Option<String>,
|
||||
is_section_linked: Option<bool>,
|
||||
enrollment: Enrollment,
|
||||
credit_hours: Option<CreditHours>,
|
||||
cross_list: Option<CrossList>,
|
||||
section_link: Option<SectionLink>,
|
||||
part_of_term: Option<String>,
|
||||
meeting_times: Vec<crate::data::models::DbMeetingTime>,
|
||||
meeting_times: Vec<models::DbMeetingTime>,
|
||||
attributes: Vec<String>,
|
||||
is_async_online: bool,
|
||||
delivery_mode: Option<DeliveryMode>,
|
||||
/// Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
primary_location: Option<String>,
|
||||
/// Whether a physical (non-INT) building was found in meeting times.
|
||||
has_physical_location: bool,
|
||||
primary_instructor_id: Option<i32>,
|
||||
instructors: Vec<InstructorResponse>,
|
||||
}
|
||||
|
||||
@@ -480,11 +514,11 @@ pub struct InstructorResponse {
|
||||
instructor_id: i32,
|
||||
banner_id: String,
|
||||
display_name: String,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
email: String,
|
||||
is_primary: bool,
|
||||
rmp_rating: Option<f32>,
|
||||
rmp_num_ratings: Option<i32>,
|
||||
rmp_legacy_id: Option<i32>,
|
||||
rmp: Option<RmpRating>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -493,11 +527,9 @@ pub struct InstructorResponse {
|
||||
pub struct SearchResponse {
|
||||
courses: Vec<CourseResponse>,
|
||||
total_count: i32,
|
||||
offset: i32,
|
||||
limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CodeDescription {
|
||||
@@ -505,25 +537,178 @@ pub struct CodeDescription {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TermResponse {
|
||||
code: String,
|
||||
slug: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// Response for the consolidated search-options endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsResponse {
|
||||
pub terms: Vec<TermResponse>,
|
||||
pub subjects: Vec<CodeDescription>,
|
||||
pub reference: SearchOptionsReference,
|
||||
pub ranges: data::courses::FilterRanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SearchOptionsReference {
|
||||
pub instructional_methods: Vec<CodeDescription>,
|
||||
pub campuses: Vec<CodeDescription>,
|
||||
pub parts_of_term: Vec<CodeDescription>,
|
||||
pub attributes: Vec<CodeDescription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchOptionsParams {
|
||||
pub term: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimum number of ratings needed to consider RMP data reliable.
|
||||
const RMP_CONFIDENCE_THRESHOLD: i32 = 7;
|
||||
|
||||
/// 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
|
||||
let instructors: Vec<InstructorResponse> = instructors
|
||||
.into_iter()
|
||||
.map(|i| InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
email: i.email,
|
||||
is_primary: i.is_primary,
|
||||
rmp_rating: i.avg_rating,
|
||||
rmp_num_ratings: i.num_ratings,
|
||||
rmp_legacy_id: i.rmp_legacy_id,
|
||||
.map(|i| {
|
||||
// Filter out the (0.0, 0) sentinel — treat as unrated
|
||||
let has_rating =
|
||||
i.avg_rating.is_some_and(|r| r != 0.0) || i.num_ratings.is_some_and(|n| n != 0);
|
||||
let rmp = if has_rating {
|
||||
match (i.avg_rating, i.num_ratings, i.rmp_legacy_id) {
|
||||
(Some(avg_rating), Some(num_ratings), Some(legacy_id)) => Some(RmpRating {
|
||||
avg_rating,
|
||||
num_ratings,
|
||||
legacy_id,
|
||||
is_confident: num_ratings >= RMP_CONFIDENCE_THRESHOLD,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
first_name: i.first_name,
|
||||
last_name: i.last_name,
|
||||
email: i.email,
|
||||
is_primary: i.is_primary,
|
||||
rmp,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Primary = first with is_primary flag, or fall back to first instructor
|
||||
let primary_instructor_id = instructors
|
||||
.iter()
|
||||
.find(|i| i.is_primary)
|
||||
.or(instructors.first())
|
||||
.map(|i| i.instructor_id);
|
||||
|
||||
let meeting_times: Vec<models::DbMeetingTime> =
|
||||
serde_json::from_value(course.meeting_times.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize meeting_times JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let attributes = serde_json::from_value(course.attributes.clone())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
course_id = course.id,
|
||||
crn = %course.crn,
|
||||
term = %course.term_code,
|
||||
error = %e,
|
||||
"Failed to deserialize attributes JSONB"
|
||||
);
|
||||
e
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_async_online = meeting_times.first().is_some_and(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| loc.building.as_deref()) == Some("INT")
|
||||
&& mt.is_time_tba()
|
||||
});
|
||||
|
||||
let delivery_mode = classify_delivery_mode(
|
||||
course.instructional_method.as_deref(),
|
||||
course.campus.as_deref(),
|
||||
&meeting_times,
|
||||
);
|
||||
|
||||
// Compute primary_location: first non-INT building+room, else "Online" or campus fallback
|
||||
let physical_location = meeting_times
|
||||
.iter()
|
||||
.filter(|mt| mt.location.as_ref().and_then(|loc| loc.building.as_deref()) != Some("INT"))
|
||||
.find_map(|mt| {
|
||||
mt.location.as_ref().and_then(|loc| {
|
||||
loc.building.as_ref().map(|b| match &loc.room {
|
||||
Some(r) => format!("{b} {r}"),
|
||||
None => b.clone(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let has_physical_location = physical_location.is_some();
|
||||
|
||||
let primary_location = physical_location.or_else(|| match delivery_mode {
|
||||
Some(DeliveryMode::Online | DeliveryMode::Internet) => Some("Online".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let enrollment = Enrollment {
|
||||
current: course.enrollment,
|
||||
max: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
};
|
||||
|
||||
let credit_hours = match (
|
||||
course.credit_hours,
|
||||
course.credit_hour_low,
|
||||
course.credit_hour_high,
|
||||
) {
|
||||
(Some(fixed), _, _) => Some(CreditHours::Fixed { hours: fixed }),
|
||||
(None, Some(low), Some(high)) if low != high => Some(CreditHours::Range { low, high }),
|
||||
(None, Some(hours), None) | (None, None, Some(hours)) => Some(CreditHours::Fixed { hours }),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let cross_list = course.cross_list.as_ref().and_then(|identifier| {
|
||||
course.cross_list_capacity.and_then(|capacity| {
|
||||
course.cross_list_count.map(|count| CrossList {
|
||||
identifier: identifier.clone(),
|
||||
capacity,
|
||||
count,
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let section_link = course
|
||||
.link_identifier
|
||||
.clone()
|
||||
.map(|identifier| SectionLink { identifier });
|
||||
|
||||
CourseResponse {
|
||||
crn: course.crn.clone(),
|
||||
subject: course.subject.clone(),
|
||||
@@ -533,21 +718,18 @@ fn build_course_response(
|
||||
sequence_number: course.sequence_number.clone(),
|
||||
instructional_method: course.instructional_method.clone(),
|
||||
campus: course.campus.clone(),
|
||||
enrollment: course.enrollment,
|
||||
max_enrollment: course.max_enrollment,
|
||||
wait_count: course.wait_count,
|
||||
wait_capacity: course.wait_capacity,
|
||||
credit_hours: course.credit_hours,
|
||||
credit_hour_low: course.credit_hour_low,
|
||||
credit_hour_high: course.credit_hour_high,
|
||||
cross_list: course.cross_list.clone(),
|
||||
cross_list_capacity: course.cross_list_capacity,
|
||||
cross_list_count: course.cross_list_count,
|
||||
link_identifier: course.link_identifier.clone(),
|
||||
is_section_linked: course.is_section_linked,
|
||||
enrollment,
|
||||
credit_hours,
|
||||
cross_list,
|
||||
section_link,
|
||||
part_of_term: course.part_of_term.clone(),
|
||||
meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(),
|
||||
attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(),
|
||||
is_async_online,
|
||||
delivery_mode,
|
||||
primary_location,
|
||||
has_physical_location,
|
||||
primary_instructor_id,
|
||||
meeting_times,
|
||||
attributes,
|
||||
instructors,
|
||||
}
|
||||
}
|
||||
@@ -556,13 +738,17 @@ fn build_course_response(
|
||||
async fn search_courses(
|
||||
State(state): State<AppState>,
|
||||
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
|
||||
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<SearchResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
|
||||
let term_code =
|
||||
Term::resolve_to_code(¶ms.term).ok_or_else(|| ApiError::invalid_term(¶ms.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,
|
||||
¶ms.term,
|
||||
&term_code,
|
||||
if params.subject.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -572,26 +758,49 @@ async fn search_courses(
|
||||
params.course_number_low,
|
||||
params.course_number_high,
|
||||
params.open_only,
|
||||
params.instructional_method.as_deref(),
|
||||
params.campus.as_deref(),
|
||||
if params.instructional_method.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.instructional_method)
|
||||
},
|
||||
if params.campus.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.campus)
|
||||
},
|
||||
params.wait_count_max,
|
||||
if params.days.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.days)
|
||||
},
|
||||
params.time_start.as_deref(),
|
||||
params.time_end.as_deref(),
|
||||
if params.part_of_term.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.part_of_term)
|
||||
},
|
||||
if params.attributes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(¶ms.attributes)
|
||||
},
|
||||
params.credit_hour_min,
|
||||
params.credit_hour_max,
|
||||
params.instructor.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
params.sort_by,
|
||||
params.sort_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course search failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Search failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error("Course search", e))?;
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -606,8 +815,6 @@ async fn search_courses(
|
||||
Ok(Json(SearchResponse {
|
||||
courses: course_responses,
|
||||
total_count: total_count as i32,
|
||||
offset,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -615,96 +822,32 @@ async fn search_courses(
|
||||
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)
|
||||
) -> Result<Json<CourseResponse>, ApiError> {
|
||||
let course = data::courses::get_course_by_crn(&state.db_pool, &crn, &term)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Course lookup failed");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?;
|
||||
.map_err(|e| db_error("Course lookup", e))?
|
||||
.ok_or_else(|| ApiError::not_found("Course not found"))?;
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
/// `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)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get terms");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to get terms".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let terms: Vec<CodeDescription> = term_codes
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let description = cache
|
||||
.lookup("term", &code)
|
||||
.unwrap_or("Unknown Term")
|
||||
.to_string();
|
||||
CodeDescription { code, description }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(terms))
|
||||
}
|
||||
|
||||
/// `GET /api/subjects?term=202620`
|
||||
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, ¶ms.term)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get subjects");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to get subjects".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let subjects: Vec<CodeDescription> = rows
|
||||
.into_iter()
|
||||
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||
.collect();
|
||||
|
||||
Ok(Json(subjects))
|
||||
}
|
||||
|
||||
/// `GET /api/reference/:category`
|
||||
async fn get_reference(
|
||||
State(state): State<AppState>,
|
||||
Path(category): Path<String>,
|
||||
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
|
||||
) -> Result<Json<Vec<CodeDescription>>, ApiError> {
|
||||
let cache = state.reference_cache.read().await;
|
||||
let entries = cache.entries_for_category(&category);
|
||||
|
||||
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");
|
||||
(
|
||||
AxumStatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|e| db_error(&format!("Reference lookup for {}", category), e))?;
|
||||
|
||||
return Ok(Json(
|
||||
rows.into_iter()
|
||||
@@ -726,3 +869,106 @@ async fn get_reference(
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/search-options?term={slug}` (term optional, defaults to latest)
|
||||
async fn get_search_options(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchOptionsParams>,
|
||||
) -> Result<Json<SearchOptionsResponse>, ApiError> {
|
||||
use crate::banner::models::terms::Term;
|
||||
use std::time::Instant;
|
||||
|
||||
// If no term specified, get the latest term
|
||||
let term_slug = if let Some(ref t) = params.term {
|
||||
t.clone()
|
||||
} else {
|
||||
// Fetch available terms to get the default (latest)
|
||||
let term_codes = data::courses::get_available_terms(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("Get terms for default", e))?;
|
||||
|
||||
let first_term: Term = term_codes
|
||||
.first()
|
||||
.and_then(|code| code.parse().ok())
|
||||
.ok_or_else(|| ApiError::new(ApiErrorCode::NoTerms, "No terms available"))?;
|
||||
|
||||
first_term.slug()
|
||||
};
|
||||
|
||||
let term_code =
|
||||
Term::resolve_to_code(&term_slug).ok_or_else(|| ApiError::invalid_term(&term_slug))?;
|
||||
|
||||
// Check cache (10-minute TTL)
|
||||
if let Some(entry) = state.search_options_cache.get(&term_code) {
|
||||
let (cached_at, ref cached_value) = *entry;
|
||||
if cached_at.elapsed() < Duration::from_secs(600) {
|
||||
let response: SearchOptionsResponse = serde_json::from_value(cached_value.clone())
|
||||
.map_err(|e| {
|
||||
ApiError::internal_error(format!("Cache deserialization error: {e}"))
|
||||
})?;
|
||||
return Ok(Json(response));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all data in parallel
|
||||
let (term_codes, subject_rows, ranges) = tokio::try_join!(
|
||||
data::courses::get_available_terms(&state.db_pool),
|
||||
data::courses::get_subjects_by_enrollment(&state.db_pool, &term_code),
|
||||
data::courses::get_filter_ranges(&state.db_pool, &term_code),
|
||||
)
|
||||
.map_err(|e| db_error("Search options", e))?;
|
||||
|
||||
// Build terms
|
||||
let terms: Vec<TermResponse> = term_codes
|
||||
.into_iter()
|
||||
.filter_map(|code| {
|
||||
let term: Term = code.parse().ok()?;
|
||||
Some(TermResponse {
|
||||
code,
|
||||
slug: term.slug(),
|
||||
description: term.description(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build subjects
|
||||
let subjects: Vec<CodeDescription> = subject_rows
|
||||
.into_iter()
|
||||
.map(|(code, description, _enrollment)| CodeDescription { code, description })
|
||||
.collect();
|
||||
|
||||
// Build reference data from in-memory cache
|
||||
let ref_cache = state.reference_cache.read().await;
|
||||
let build_ref = |category: &str| -> Vec<CodeDescription> {
|
||||
ref_cache
|
||||
.entries_for_category(category)
|
||||
.into_iter()
|
||||
.map(|(code, desc)| CodeDescription {
|
||||
code: code.to_string(),
|
||||
description: desc.to_string(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let reference = SearchOptionsReference {
|
||||
instructional_methods: build_ref("instructional_method"),
|
||||
campuses: build_ref("campus"),
|
||||
parts_of_term: build_ref("part_of_term"),
|
||||
attributes: build_ref("attribute"),
|
||||
};
|
||||
|
||||
let response = SearchOptionsResponse {
|
||||
terms,
|
||||
subjects,
|
||||
reference,
|
||||
ranges,
|
||||
};
|
||||
|
||||
// Cache the response
|
||||
let cached_value = serde_json::to_value(&response).unwrap_or_default();
|
||||
state
|
||||
.search_options_cache
|
||||
.insert(term_code, (Instant::now(), cached_value));
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@@ -238,9 +238,11 @@ fn parse_hhmm(s: &str) -> Option<u16> {
|
||||
Some(hours * 60 + mins)
|
||||
}
|
||||
|
||||
/// Parse "MM/DD/YYYY" → NaiveDate.
|
||||
/// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format.
|
||||
fn parse_date(s: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y").ok()
|
||||
NaiveDate::parse_from_str(s, "%m/%d/%Y")
|
||||
.or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
// ── Slot matching ───────────────────────────────────────────────────
|
||||
|
||||
+23
-42
@@ -9,11 +9,7 @@
|
||||
//! [`ScheduleCache`]) that refreshes hourly in the background with
|
||||
//! stale-while-revalidate semantics.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use axum::{extract::State, response::Json};
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveTime, Timelike, Utc};
|
||||
use chrono_tz::US::Central;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::web::error::ApiError;
|
||||
use crate::web::schedule_cache::weekday_bit;
|
||||
|
||||
/// 15 minutes in seconds, matching the frontend `SLOT_INTERVAL_MS`.
|
||||
@@ -38,14 +35,22 @@ const MAX_TOTAL_SPAN: Duration = Duration::hours(168); // 1 week
|
||||
|
||||
// ── Request / Response types ────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct TimelineRequest {
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TimelineRequest {
|
||||
ranges: Vec<TimeRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct TimeRange {
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TimeRange {
|
||||
/// ISO-8601 UTC timestamp (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
start: DateTime<Utc>,
|
||||
/// ISO-8601 UTC timestamp (e.g., "2024-01-15T12:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -63,38 +68,14 @@ pub struct TimelineResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TimelineSlot {
|
||||
/// ISO-8601 timestamp at the start of this 15-minute bucket.
|
||||
/// ISO-8601 UTC timestamp at the start of this 15-minute bucket (e.g., "2024-01-15T10:30:00Z")
|
||||
#[ts(type = "string")]
|
||||
time: DateTime<Utc>,
|
||||
/// Subject code → total enrollment in this slot.
|
||||
#[ts(type = "Record<string, number>")]
|
||||
subjects: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
// ── Error type ──────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) struct TimelineError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl TimelineError {
|
||||
fn bad_request(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for TimelineError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
self.status,
|
||||
Json(serde_json::json!({ "error": self.message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alignment helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Floor a timestamp to the nearest 15-minute boundary.
|
||||
@@ -161,13 +142,13 @@ fn generate_slots(merged: &[AlignedRange]) -> BTreeSet<DateTime<Utc>> {
|
||||
pub(crate) async fn timeline(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<TimelineRequest>,
|
||||
) -> Result<Json<TimelineResponse>, TimelineError> {
|
||||
) -> Result<Json<TimelineResponse>, ApiError> {
|
||||
// ── Validate ────────────────────────────────────────────────────
|
||||
if body.ranges.is_empty() {
|
||||
return Err(TimelineError::bad_request("At least one range is required"));
|
||||
return Err(ApiError::bad_request("At least one range is required"));
|
||||
}
|
||||
if body.ranges.len() > MAX_RANGES {
|
||||
return Err(TimelineError::bad_request(format!(
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Too many ranges (max {MAX_RANGES})"
|
||||
)));
|
||||
}
|
||||
@@ -175,14 +156,14 @@ pub(crate) async fn timeline(
|
||||
let mut aligned: Vec<AlignedRange> = Vec::with_capacity(body.ranges.len());
|
||||
for r in &body.ranges {
|
||||
if r.end <= r.start {
|
||||
return Err(TimelineError::bad_request(format!(
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Range end ({}) must be after start ({})",
|
||||
r.end, r.start
|
||||
)));
|
||||
}
|
||||
let span = r.end - r.start;
|
||||
if span > MAX_RANGE_SPAN {
|
||||
return Err(TimelineError::bad_request(format!(
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Range span ({} hours) exceeds maximum ({} hours)",
|
||||
span.num_hours(),
|
||||
MAX_RANGE_SPAN.num_hours()
|
||||
@@ -199,7 +180,7 @@ pub(crate) async fn timeline(
|
||||
// Validate total span
|
||||
let total_span: Duration = merged.iter().map(|r| r.end - r.start).sum();
|
||||
if total_span > MAX_TOTAL_SPAN {
|
||||
return Err(TimelineError::bad_request(format!(
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Total time span ({} hours) exceeds maximum ({} hours)",
|
||||
total_span.num_hours(),
|
||||
MAX_TOTAL_SPAN.num_hours()
|
||||
|
||||
+8
-2
@@ -12,14 +12,16 @@ use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::debug;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::data::models::{ScrapeJob, ScrapeJobStatus};
|
||||
use crate::state::AppState;
|
||||
use crate::web::extractors::AdminUser;
|
||||
|
||||
/// A serializable DTO for `ScrapeJob` with computed `status`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ScrapeJobDto {
|
||||
pub id: i32,
|
||||
pub target_type: String,
|
||||
@@ -53,8 +55,9 @@ impl From<&ScrapeJob> for ScrapeJobDto {
|
||||
}
|
||||
|
||||
/// Events broadcast when scrape job state changes.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub enum ScrapeJobEvent {
|
||||
Init {
|
||||
jobs: Vec<ScrapeJobDto>,
|
||||
@@ -64,6 +67,7 @@ pub enum ScrapeJobEvent {
|
||||
},
|
||||
JobLocked {
|
||||
id: i32,
|
||||
#[serde(rename = "lockedAt")]
|
||||
locked_at: String,
|
||||
status: ScrapeJobStatus,
|
||||
},
|
||||
@@ -72,7 +76,9 @@ pub enum ScrapeJobEvent {
|
||||
},
|
||||
JobRetried {
|
||||
id: i32,
|
||||
#[serde(rename = "retryCount")]
|
||||
retry_count: i32,
|
||||
#[serde(rename = "queuedAt")]
|
||||
queued_at: String,
|
||||
status: ScrapeJobStatus,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Test course search with alphanumeric course numbers (e.g., "015X", "399H").
|
||||
|
||||
mod helpers;
|
||||
|
||||
use banner::data::batch::batch_upsert_courses;
|
||||
use banner::data::courses::search_courses;
|
||||
use helpers::make_course;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_search_alphanumeric_course_numbers(pool: sqlx::PgPool) {
|
||||
let term = "202620";
|
||||
|
||||
// Insert courses with both numeric and alphanumeric course numbers
|
||||
let courses = vec![
|
||||
make_course("10001", term, "CS", "0100", "Intro to CS", 20, 30, 0, 10),
|
||||
make_course("10002", term, "CS", "015X", "Special Topics", 15, 25, 0, 5),
|
||||
make_course(
|
||||
"10003",
|
||||
term,
|
||||
"CS",
|
||||
"0200",
|
||||
"Data Structures",
|
||||
25,
|
||||
30,
|
||||
0,
|
||||
10,
|
||||
),
|
||||
make_course("10004", term, "CS", "0399", "Advanced Topics", 18, 25, 0, 5),
|
||||
make_course("10005", term, "CS", "399H", "Honors Course", 12, 20, 0, 5),
|
||||
make_course(
|
||||
"10006",
|
||||
term,
|
||||
"CS",
|
||||
"5500",
|
||||
"Graduate Seminar",
|
||||
10,
|
||||
15,
|
||||
0,
|
||||
3,
|
||||
),
|
||||
];
|
||||
|
||||
batch_upsert_courses(&courses, &pool)
|
||||
.await
|
||||
.expect("Failed to insert test courses");
|
||||
|
||||
// Test: Search for course numbers 100-5500 (should include alphanumeric)
|
||||
let (results, _total) = search_courses(
|
||||
&pool,
|
||||
term,
|
||||
None, // subject
|
||||
None, // title_query
|
||||
Some(100), // course_number_low
|
||||
Some(5500), // course_number_high
|
||||
false, // open_only
|
||||
None, // instructional_method
|
||||
None, // campus
|
||||
None, // wait_count_max
|
||||
None, // days
|
||||
None, // time_start
|
||||
None, // time_end
|
||||
None, // part_of_term
|
||||
None, // attributes
|
||||
None, // credit_hour_min
|
||||
None, // credit_hour_max
|
||||
None, // instructor
|
||||
100, // limit
|
||||
0, // offset
|
||||
None, // sort_by
|
||||
None, // sort_dir
|
||||
)
|
||||
.await
|
||||
.expect("Search failed");
|
||||
|
||||
// Should include:
|
||||
// - 0100 (100 >= 100)
|
||||
// - 0200 (200 in range)
|
||||
// - 0399 (399 in range)
|
||||
// - 399H (numeric prefix 399 in range)
|
||||
// - 5500 (5500 <= 5500)
|
||||
//
|
||||
// Should exclude:
|
||||
// - 015X (numeric prefix 15 < 100)
|
||||
|
||||
let crns: Vec<&str> = results.iter().map(|c| c.crn.as_str()).collect();
|
||||
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
5,
|
||||
"Expected 5 courses in range, got {}: {:?}",
|
||||
results.len(),
|
||||
crns
|
||||
);
|
||||
assert!(crns.contains(&"10001"), "Should include CS 0100");
|
||||
assert!(crns.contains(&"10003"), "Should include CS 0200");
|
||||
assert!(crns.contains(&"10004"), "Should include CS 0399");
|
||||
assert!(
|
||||
crns.contains(&"10005"),
|
||||
"Should include CS 399H (numeric prefix 399)"
|
||||
);
|
||||
assert!(crns.contains(&"10006"), "Should include CS 5500");
|
||||
assert!(
|
||||
!crns.contains(&"10002"),
|
||||
"Should exclude CS 015X (numeric prefix 15 < 100)"
|
||||
);
|
||||
}
|
||||
+21
-10
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
+23
-16
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "banner-web",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@icons-pack/svelte-simple-icons": "^6.5.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
@@ -16,27 +18,28 @@
|
||||
},
|
||||
"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",
|
||||
"svelte-range-slider-pips": "^4.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -137,6 +140,8 @@
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
|
||||
|
||||
"@icons-pack/svelte-simple-icons": ["@icons-pack/svelte-simple-icons@6.5.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.5.0", "svelte": "^4.2.0 || ^5.0.0" } }, "sha512-Xj3PTioiV3TJ1NTKsXY95NFG8FUqw90oeyDZIlslWHs1KkuCheu1HOPrlHb0/IM0b4cldPgx/0TldzxzBlM8Cw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
|
||||
@@ -633,10 +638,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"svelte-range-slider-pips": ["svelte-range-slider-pips@4.1.0", "", { "peerDependencies": { "svelte": "^4.2.7 || ^5.0.0" } }, "sha512-2Zw7MngIuPeqdyJ3ueEp7jPSx0hce+Sx8r1eteCeUPxEWlNavKhBtqJyuoAdpvh5csPPFVZJ4TJ4MX9s4G70uw=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
+20
-15
@@ -7,35 +7,40 @@
|
||||
"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 lint .",
|
||||
"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",
|
||||
"svelte-range-slider-pips": "^4.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@icons-pack/svelte-simple-icons": "^6.5.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
import { extname, join } from "path";
|
||||
import { constants, brotliCompressSync, gzipSync } from "zlib";
|
||||
import { $ } from "bun";
|
||||
/**
|
||||
* Pre-compress static assets with maximum compression levels.
|
||||
* Run after `bun run build`.
|
||||
@@ -7,10 +10,7 @@
|
||||
* These are embedded alongside originals by rust-embed and served via
|
||||
* content negotiation in src/web/assets.rs.
|
||||
*/
|
||||
import { readdir, stat, readFile, writeFile } from "fs/promises";
|
||||
import { join, extname } from "path";
|
||||
import { gzipSync, brotliCompressSync, constants } from "zlib";
|
||||
import { $ } from "bun";
|
||||
import { readFile, readdir, stat, writeFile } from "fs/promises";
|
||||
|
||||
// Must match COMPRESSION_MIN_SIZE in src/web/encoding.rs
|
||||
const MIN_SIZE = 512;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatMeetingTimeSummary } from "$lib/course";
|
||||
import type { CourseResponse, DbMeetingTime } from "$lib/api";
|
||||
|
||||
function makeMeetingTime(overrides: Partial<DbMeetingTime> = {}): DbMeetingTime {
|
||||
const mt: DbMeetingTime = {
|
||||
timeRange: null,
|
||||
dateRange: { start: "2025-01-13", end: "2025-05-08" },
|
||||
days: [],
|
||||
location: null,
|
||||
meetingType: "CLAS",
|
||||
meetingScheduleType: "LEC",
|
||||
...overrides,
|
||||
};
|
||||
return mt;
|
||||
}
|
||||
|
||||
function makeCourse(overrides: Partial<CourseResponse> = {}): CourseResponse {
|
||||
return {
|
||||
crn: "12345",
|
||||
subject: "CS",
|
||||
courseNumber: "1234",
|
||||
title: "Test Course",
|
||||
termCode: "202510",
|
||||
sequenceNumber: null,
|
||||
instructionalMethod: null,
|
||||
campus: null,
|
||||
enrollment: { current: 10, max: 30, waitCount: 0, waitCapacity: 0 },
|
||||
creditHours: { type: "fixed", hours: 3 },
|
||||
crossList: null,
|
||||
sectionLink: null,
|
||||
partOfTerm: null,
|
||||
isAsyncOnline: false,
|
||||
deliveryMode: null,
|
||||
primaryLocation: null,
|
||||
hasPhysicalLocation: false,
|
||||
primaryInstructorId: null,
|
||||
meetingTimes: [],
|
||||
attributes: [],
|
||||
instructors: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatMeetingTimeSummary", () => {
|
||||
it("returns 'Async' for async online courses", () => {
|
||||
const course = makeCourse({
|
||||
isAsyncOnline: true,
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
location: { building: "INT", buildingDescription: null, room: null, campus: null },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("Async");
|
||||
});
|
||||
|
||||
it("returns 'TBA' for courses with no meeting times", () => {
|
||||
const course = makeCourse({ meetingTimes: [] });
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TBA");
|
||||
});
|
||||
|
||||
it("returns 'TBA' when days and times are all TBA", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [makeMeetingTime()],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TBA");
|
||||
});
|
||||
|
||||
it("returns formatted days and time for normal meeting", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
days: ["monday", "wednesday", "friday"],
|
||||
timeRange: { start: "09:00:00", end: "09:50:00" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(formatMeetingTimeSummary(course)).toBe("MWF 9:00–9:50 AM");
|
||||
});
|
||||
|
||||
it("returns formatted days with TBA time", () => {
|
||||
const course = makeCourse({
|
||||
meetingTimes: [
|
||||
makeMeetingTime({
|
||||
days: ["tuesday", "thursday"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
// Days are set but time is TBA — not both TBA, so it enters the final branch
|
||||
expect(formatMeetingTimeSummary(course)).toBe("TTh TBA");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseTimeInput, formatTime, toggleDay, toggleValue } from "$lib/filters";
|
||||
|
||||
describe("parseTimeInput", () => {
|
||||
it("parses AM time", () => {
|
||||
expect(parseTimeInput("10:30 AM")).toBe("1030");
|
||||
});
|
||||
|
||||
it("parses PM time", () => {
|
||||
expect(parseTimeInput("3:00 PM")).toBe("1500");
|
||||
});
|
||||
|
||||
it("parses 12:00 PM as noon", () => {
|
||||
expect(parseTimeInput("12:00 PM")).toBe("1200");
|
||||
});
|
||||
|
||||
it("parses 12:00 AM as midnight", () => {
|
||||
expect(parseTimeInput("12:00 AM")).toBe("0000");
|
||||
});
|
||||
|
||||
it("parses case-insensitive AM/PM", () => {
|
||||
expect(parseTimeInput("9:15 am")).toBe("0915");
|
||||
expect(parseTimeInput("2:45 Pm")).toBe("1445");
|
||||
});
|
||||
|
||||
it("parses military time", () => {
|
||||
expect(parseTimeInput("14:30")).toBe("1430");
|
||||
expect(parseTimeInput("9:05")).toBe("0905");
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseTimeInput("")).toBeNull();
|
||||
expect(parseTimeInput(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-time strings", () => {
|
||||
expect(parseTimeInput("abc")).toBeNull();
|
||||
expect(parseTimeInput("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses out-of-range military time (no validation beyond format)", () => {
|
||||
// The regex matches but doesn't validate hour/minute ranges
|
||||
expect(parseTimeInput("25:00")).toBe("2500");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseTimeInput(" 10:00 AM ")).toBe("1000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats morning time", () => {
|
||||
expect(formatTime("0930")).toBe("9:30 AM");
|
||||
});
|
||||
|
||||
it("formats afternoon time", () => {
|
||||
expect(formatTime("1500")).toBe("3:00 PM");
|
||||
});
|
||||
|
||||
it("formats noon", () => {
|
||||
expect(formatTime("1200")).toBe("12:00 PM");
|
||||
});
|
||||
|
||||
it("formats midnight", () => {
|
||||
expect(formatTime("0000")).toBe("12:00 AM");
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatTime(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for invalid length", () => {
|
||||
expect(formatTime("12")).toBe("");
|
||||
expect(formatTime("123456")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleDay", () => {
|
||||
it("adds a day not in the list", () => {
|
||||
expect(toggleDay(["monday"], "wednesday")).toEqual(["monday", "wednesday"]);
|
||||
});
|
||||
|
||||
it("removes a day already in the list", () => {
|
||||
expect(toggleDay(["monday", "wednesday"], "monday")).toEqual(["wednesday"]);
|
||||
});
|
||||
|
||||
it("adds to empty list", () => {
|
||||
expect(toggleDay([], "friday")).toEqual(["friday"]);
|
||||
});
|
||||
|
||||
it("removes last day", () => {
|
||||
expect(toggleDay(["monday"], "monday")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleValue", () => {
|
||||
it("adds a value not in the array", () => {
|
||||
expect(toggleValue(["OA"], "HB")).toEqual(["OA", "HB"]);
|
||||
});
|
||||
|
||||
it("removes a value already in the array", () => {
|
||||
expect(toggleValue(["OA", "HB"], "OA")).toEqual(["HB"]);
|
||||
});
|
||||
|
||||
it("adds to empty array", () => {
|
||||
expect(toggleValue([], "OA")).toEqual(["OA"]);
|
||||
});
|
||||
|
||||
it("removes last value", () => {
|
||||
expect(toggleValue(["OA"], "OA")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FADE_DISTANCE,
|
||||
FADE_PERCENT,
|
||||
leftOpacity,
|
||||
rightOpacity,
|
||||
maskGradient,
|
||||
type ScrollMetrics,
|
||||
} from "$lib/scroll-fade";
|
||||
|
||||
describe("leftOpacity", () => {
|
||||
it("returns 0 when scrollLeft is 0", () => {
|
||||
expect(leftOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 when scrollLeft >= FADE_DISTANCE", () => {
|
||||
expect(leftOpacity({ scrollLeft: FADE_DISTANCE, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
|
||||
expect(
|
||||
leftOpacity({ scrollLeft: FADE_DISTANCE + 50, scrollWidth: 1000, clientWidth: 500 })
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("returns proportional value for partial scroll", () => {
|
||||
const half = FADE_DISTANCE / 2;
|
||||
expect(leftOpacity({ scrollLeft: half, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rightOpacity", () => {
|
||||
it("returns 0 when content fits (no scroll needed)", () => {
|
||||
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 500, clientWidth: 500 })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when scrolled to the end", () => {
|
||||
expect(rightOpacity({ scrollLeft: 500, scrollWidth: 1000, clientWidth: 500 })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 when far from the end", () => {
|
||||
expect(rightOpacity({ scrollLeft: 0, scrollWidth: 1000, clientWidth: 500 })).toBe(1);
|
||||
});
|
||||
|
||||
it("returns proportional value near the end", () => {
|
||||
const maxScroll = 500; // scrollWidth(1000) - clientWidth(500)
|
||||
const remaining = FADE_DISTANCE / 2;
|
||||
const scrollLeft = maxScroll - remaining;
|
||||
expect(rightOpacity({ scrollLeft, scrollWidth: 1000, clientWidth: 500 })).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskGradient", () => {
|
||||
it("returns full transparent-to-transparent gradient when no scroll", () => {
|
||||
const metrics: ScrollMetrics = { scrollLeft: 0, scrollWidth: 500, clientWidth: 500 };
|
||||
const result = maskGradient(metrics);
|
||||
// leftOpacity=0, rightOpacity=0 → leftEnd=0%, rightStart=100%
|
||||
expect(result).toBe(
|
||||
"linear-gradient(to right, transparent 0%, black 0%, black 100%, transparent 100%)"
|
||||
);
|
||||
});
|
||||
|
||||
it("includes fade zones when scrolled to the middle", () => {
|
||||
const metrics: ScrollMetrics = {
|
||||
scrollLeft: FADE_DISTANCE,
|
||||
scrollWidth: 1000,
|
||||
clientWidth: 500,
|
||||
};
|
||||
const result = maskGradient(metrics);
|
||||
// leftOpacity=1 → leftEnd=FADE_PERCENT%, rightOpacity=1 → rightStart=100-FADE_PERCENT%
|
||||
expect(result).toContain(`black ${FADE_PERCENT}%`);
|
||||
expect(result).toContain(`black ${100 - FADE_PERCENT}%`);
|
||||
});
|
||||
});
|
||||
+3
-41
@@ -49,8 +49,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -60,15 +58,15 @@ describe("BannerApiClient", () => {
|
||||
|
||||
const result = await apiClient.searchCourses({
|
||||
term: "202420",
|
||||
subjects: ["CS"],
|
||||
subject: ["CS"],
|
||||
q: "data",
|
||||
open_only: true,
|
||||
openOnly: true,
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/courses/search?term=202420&subject=CS&q=data&open_only=true&limit=25&offset=50"
|
||||
"/api/courses/search?term=202420&subject=CS&q=data&openOnly=true&limit=25&offset=50"
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
@@ -77,8 +75,6 @@ describe("BannerApiClient", () => {
|
||||
const mockResponse = {
|
||||
courses: [],
|
||||
totalCount: 0,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -91,40 +87,6 @@ describe("BannerApiClient", () => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/courses/search?term=202420");
|
||||
});
|
||||
|
||||
it("should fetch terms", async () => {
|
||||
const mockTerms = [
|
||||
{ code: "202420", description: "Fall 2024" },
|
||||
{ code: "202510", description: "Spring 2025" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTerms),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getTerms();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/terms");
|
||||
expect(result).toEqual(mockTerms);
|
||||
});
|
||||
|
||||
it("should fetch subjects for a term", async () => {
|
||||
const mockSubjects = [
|
||||
{ code: "CS", description: "Computer Science" },
|
||||
{ code: "MAT", description: "Mathematics" },
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSubjects),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getSubjects("202420");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/subjects?term=202420");
|
||||
expect(result).toEqual(mockSubjects);
|
||||
});
|
||||
|
||||
it("should fetch reference data", async () => {
|
||||
const mockRef = [
|
||||
{ code: "F", description: "Face to Face" },
|
||||
|
||||
+166
-145
@@ -1,26 +1,53 @@
|
||||
import { authStore } from "$lib/auth.svelte";
|
||||
import type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
InstructorResponse,
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsParams as ListInstructorsParamsGenerated,
|
||||
ListInstructorsResponse,
|
||||
MatchBody,
|
||||
MetricEntry,
|
||||
MetricsParams as MetricsParamsGenerated,
|
||||
MetricsResponse,
|
||||
RejectCandidateBody,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
SearchParams as SearchParamsGenerated,
|
||||
SearchResponse as SearchResponseGenerated,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimeRange,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeseriesPoint,
|
||||
TimeseriesResponse,
|
||||
TopCandidateResponse,
|
||||
@@ -31,10 +58,18 @@ const API_BASE_URL = "/api";
|
||||
|
||||
// Re-export generated types under their canonical names
|
||||
export type {
|
||||
AdminStatusResponse,
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
AuditLogEntry,
|
||||
AuditLogResponse,
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DayOfWeek,
|
||||
DbMeetingTime,
|
||||
DeliveryMode,
|
||||
FilterRanges,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
@@ -42,141 +77,102 @@ export type {
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
MetricEntry,
|
||||
MetricsResponse,
|
||||
RescoreResponse,
|
||||
ScrapeJobDto,
|
||||
ScrapeJobEvent,
|
||||
ScrapeJobsResponse,
|
||||
ScraperStatsResponse,
|
||||
SearchOptionsReference,
|
||||
SearchOptionsResponse,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatusResponse,
|
||||
SubjectDetailResponse,
|
||||
SubjectResultEntry,
|
||||
SubjectSummary,
|
||||
SubjectsResponse,
|
||||
TermResponse,
|
||||
TimelineRequest,
|
||||
TimelineResponse,
|
||||
TimelineSlot,
|
||||
TimeRange,
|
||||
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;
|
||||
|
||||
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
|
||||
// Re-export with simplified names
|
||||
export type SearchResponse = SearchResponseGenerated;
|
||||
export type SearchParams = SearchParamsGenerated;
|
||||
export type MetricsParams = MetricsParamsGenerated;
|
||||
export type ListInstructorsParams = ListInstructorsParamsGenerated;
|
||||
|
||||
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
|
||||
// Client-side only — not generated from Rust
|
||||
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
/**
|
||||
* Converts a typed object to URLSearchParams, preserving camelCase keys.
|
||||
* Handles arrays, optional values, and primitives.
|
||||
*/
|
||||
function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
export interface AdminStatus {
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
courseCount: number;
|
||||
scrapeJobCount: number;
|
||||
services: { name: string; status: string }[];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === undefined || value === null) {
|
||||
continue; // Skip undefined/null values
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Append each array element
|
||||
for (const item of value) {
|
||||
if (item !== undefined && item !== null) {
|
||||
params.append(key, String(item));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Convert primitives to string
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface ScrapeJob {
|
||||
id: number;
|
||||
targetType: string;
|
||||
targetPayload: unknown;
|
||||
priority: string;
|
||||
executeAt: string;
|
||||
createdAt: string;
|
||||
lockedAt: string | null;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
queuedAt: string;
|
||||
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
|
||||
}
|
||||
/**
|
||||
* API error class that wraps the structured ApiError response from the backend.
|
||||
*/
|
||||
export class ApiErrorClass extends Error {
|
||||
public readonly code: ApiErrorCode;
|
||||
public readonly details: unknown | null;
|
||||
|
||||
export interface ScrapeJobsResponse {
|
||||
jobs: ScrapeJob[];
|
||||
}
|
||||
constructor(apiError: ApiError) {
|
||||
super(apiError.message);
|
||||
this.name = "ApiError";
|
||||
this.code = apiError.code;
|
||||
this.details = apiError.details;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
fieldChanged: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
subject: string | null;
|
||||
courseNumber: string | null;
|
||||
crn: string | null;
|
||||
courseTitle: string | null;
|
||||
}
|
||||
isNotFound(): boolean {
|
||||
return this.code === "NOT_FOUND";
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
}
|
||||
isBadRequest(): boolean {
|
||||
return (
|
||||
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
|
||||
);
|
||||
}
|
||||
|
||||
export interface MetricEntry {
|
||||
id: number;
|
||||
courseId: number;
|
||||
timestamp: string;
|
||||
enrollment: number;
|
||||
waitCount: number;
|
||||
seatsAvailable: number;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
metrics: MetricEntry[];
|
||||
count: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MetricsParams {
|
||||
course_id?: number;
|
||||
term?: string;
|
||||
crn?: string;
|
||||
range?: "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** A time range for timeline queries (ISO-8601 strings). */
|
||||
export interface TimelineRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** Request body for POST /api/timeline. */
|
||||
export interface TimelineRequest {
|
||||
ranges: TimelineRange[];
|
||||
}
|
||||
|
||||
/** A single 15-minute slot returned by the timeline API. */
|
||||
export interface TimelineSlot {
|
||||
time: string;
|
||||
subjects: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Response from POST /api/timeline. */
|
||||
export interface TimelineResponse {
|
||||
slots: TimelineSlot[];
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
term: string;
|
||||
subjects?: string[];
|
||||
q?: string;
|
||||
open_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort_by?: SortColumn;
|
||||
sort_dir?: SortDirection;
|
||||
}
|
||||
|
||||
// Admin instructor query params (client-only, not generated)
|
||||
export interface AdminInstructorListParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
sort?: string;
|
||||
isInternalError(): boolean {
|
||||
return this.code === "INTERNAL_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
export class BannerApiClient {
|
||||
@@ -218,7 +214,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
@@ -239,7 +245,17 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
let apiError: ApiError;
|
||||
try {
|
||||
apiError = (await response.json()) as ApiError;
|
||||
} catch {
|
||||
apiError = {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: `API request failed: ${response.status} ${response.statusText}`,
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
throw new ApiErrorClass(apiError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,20 +263,8 @@ export class BannerApiClient {
|
||||
return this.request<StatusResponse>("/status");
|
||||
}
|
||||
|
||||
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("term", params.term);
|
||||
if (params.subjects) {
|
||||
for (const s of params.subjects) {
|
||||
query.append("subject", s);
|
||||
}
|
||||
}
|
||||
if (params.q) query.set("q", params.q);
|
||||
if (params.open_only) query.set("open_only", "true");
|
||||
if (params.limit !== undefined) query.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) query.set("offset", String(params.offset));
|
||||
if (params.sort_by) query.set("sort_by", params.sort_by);
|
||||
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
|
||||
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -268,17 +272,36 @@ 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[]> {
|
||||
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
|
||||
}
|
||||
|
||||
// In-memory cache for search options per term
|
||||
private searchOptionsCache = new Map<
|
||||
string,
|
||||
{ data: SearchOptionsResponse; fetchedAt: number }
|
||||
>();
|
||||
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
|
||||
const cacheKey = term || "__default__";
|
||||
const cached = this.searchOptionsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
|
||||
const data = await this.request<SearchOptionsResponse>(url);
|
||||
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async getAdminStatus(): Promise<AdminStatus> {
|
||||
return this.request<AdminStatus>("/admin/status");
|
||||
async getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return this.request<AdminStatusResponse>("/admin/status");
|
||||
}
|
||||
|
||||
async getAdminUsers(): Promise<User[]> {
|
||||
@@ -329,33 +352,31 @@ export class BannerApiClient {
|
||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||
private _auditLastModified: string | null = null;
|
||||
|
||||
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
|
||||
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
|
||||
return this.request<TimelineResponse>("/timeline", {
|
||||
method: "POST",
|
||||
body: { ranges } satisfies TimelineRequest,
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||
if (params?.term) query.set("term", params.term);
|
||||
if (params?.crn) query.set("crn", params.crn);
|
||||
if (params?.range) query.set("range", params.range);
|
||||
if (params?.limit !== undefined) query.set("limit", String(params.limit));
|
||||
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
|
||||
if (!params) {
|
||||
return this.request<MetricsResponse>("/metrics");
|
||||
}
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
const qs = query.toString();
|
||||
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// Admin instructor endpoints
|
||||
|
||||
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set("status", params.status);
|
||||
if (params?.search) query.set("search", params.search);
|
||||
if (params?.page !== undefined) query.set("page", String(params.page));
|
||||
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
|
||||
if (params?.sort) query.set("sort", params.sort);
|
||||
async getAdminInstructors(
|
||||
params?: Partial<ListInstructorsParams>
|
||||
): Promise<ListInstructorsResponse> {
|
||||
if (!params) {
|
||||
return this.request<ListInstructorsResponse>("/admin/instructors");
|
||||
}
|
||||
const query = toURLSearchParams(params as Record<string, unknown>);
|
||||
const qs = query.toString();
|
||||
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
@@ -367,14 +388,14 @@ export class BannerApiClient {
|
||||
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
|
||||
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
body: { rmpLegacyId } satisfies MatchBody,
|
||||
});
|
||||
}
|
||||
|
||||
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
body: { rmpLegacyId } satisfies RejectCandidateBody,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,7 +408,7 @@ export class BannerApiClient {
|
||||
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
|
||||
method: "POST",
|
||||
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
|
||||
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } satisfies MatchBody } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 AdminServiceInfo = { name: string, status: ServiceStatus, };
|
||||
@@ -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 { AdminServiceInfo } from "./AdminServiceInfo";
|
||||
|
||||
export type AdminStatusResponse = { userCount: number, sessionCount: number, courseCount: number, scrapeJobCount: number, services: Array<AdminServiceInfo>, };
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ApiErrorCode } from "./ApiErrorCode";
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
/**
|
||||
* Standardized error response for all API endpoints.
|
||||
*/
|
||||
export type ApiError = {
|
||||
/**
|
||||
* Machine-readable error code
|
||||
*/
|
||||
code: ApiErrorCode,
|
||||
/**
|
||||
* Human-readable error message
|
||||
*/
|
||||
message: string,
|
||||
/**
|
||||
* Optional additional details (validation errors, field info, etc.)
|
||||
*/
|
||||
details: JsonValue | null, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Machine-readable error code for API responses.
|
||||
*/
|
||||
export type ApiErrorCode = "NOT_FOUND" | "BAD_REQUEST" | "INTERNAL_ERROR" | "INVALID_TERM" | "INVALID_RANGE" | "UNAUTHORIZED" | "FORBIDDEN" | "NO_TERMS";
|
||||
@@ -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 AuditLogEntry = { id: number, courseId: number, timestamp: string, fieldChanged: string, oldValue: string, newValue: string, subject: string | null, courseNumber: string | null, crn: string | null, courseTitle: string | null, };
|
||||
@@ -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 { AuditLogEntry } from "./AuditLogEntry";
|
||||
|
||||
export type AuditLogResponse = { entries: Array<AuditLogEntry>, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -0,0 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CreditHours } from "./CreditHours";
|
||||
import type { CrossList } from "./CrossList";
|
||||
import type { DbMeetingTime } from "./DbMeetingTime";
|
||||
import type { DeliveryMode } from "./DeliveryMode";
|
||||
import type { Enrollment } from "./Enrollment";
|
||||
import type { InstructorResponse } from "./InstructorResponse";
|
||||
import type { SectionLink } from "./SectionLink";
|
||||
|
||||
export type CourseResponse = { crn: string, subject: string, courseNumber: string, title: string, termCode: string, sequenceNumber: string | null, instructionalMethod: string | null, campus: string | null, enrollment: Enrollment, creditHours: CreditHours | null, crossList: CrossList | null, sectionLink: SectionLink | null, partOfTerm: string | null, meetingTimes: Array<DbMeetingTime>, attributes: Array<string>, isAsyncOnline: boolean, deliveryMode: DeliveryMode | null,
|
||||
/**
|
||||
* Best display-ready location: physical room ("MH 2.206"), "Online", or campus fallback.
|
||||
*/
|
||||
primaryLocation: string | null,
|
||||
/**
|
||||
* Whether a physical (non-INT) building was found in meeting times.
|
||||
*/
|
||||
hasPhysicalLocation: boolean, primaryInstructorId: number | null, instructors: Array<InstructorResponse>, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Credit hours for a course section — either a fixed value or a range.
|
||||
*/
|
||||
export type CreditHours = { "type": "fixed", hours: number, } | { "type": "range", low: number, high: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Cross-listed section information.
|
||||
*/
|
||||
export type CrossList = { identifier: string, capacity: number, count: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* An inclusive date range with the invariant that `start <= end`.
|
||||
*/
|
||||
export type DateRange = { start: string, end: string, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Day of the week.
|
||||
*/
|
||||
export type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
|
||||
@@ -0,0 +1,26 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DateRange } from "./DateRange";
|
||||
import type { DayOfWeek } from "./DayOfWeek";
|
||||
import type { MeetingLocation } from "./MeetingLocation";
|
||||
import type { TimeRange } from "./TimeRange";
|
||||
|
||||
/**
|
||||
* Represents a meeting time stored as JSONB in the courses table.
|
||||
*/
|
||||
export type DbMeetingTime = {
|
||||
/**
|
||||
* Time range for the meeting; `None` means TBA.
|
||||
*/
|
||||
timeRange: TimeRange | null,
|
||||
/**
|
||||
* Date range over which the meeting recurs.
|
||||
*/
|
||||
dateRange: DateRange,
|
||||
/**
|
||||
* Active days of the week. Empty means days are TBA.
|
||||
*/
|
||||
days: Array<DayOfWeek>,
|
||||
/**
|
||||
* Physical location; `None` when all location fields are absent.
|
||||
*/
|
||||
location: MeetingLocation | null, meetingType: string, meetingScheduleType: string, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Delivery mode classification for visual accents on location cells.
|
||||
*
|
||||
* `None` means normal in-person on the main campus (no accent needed).
|
||||
*/
|
||||
export type DeliveryMode = "online" | "internet" | "hybrid" | "off-campus";
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Enrollment counts for a course section.
|
||||
*/
|
||||
export type Enrollment = { current: number, max: number, waitCount: number, waitCapacity: number, };
|
||||
@@ -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 min/max ranges for filter sliders, computed per-term.
|
||||
*/
|
||||
export type FilterRanges = { courseNumberMin: number, courseNumberMax: number, creditHourMin: number, creditHourMax: number, waitCountMax: number, };
|
||||
@@ -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,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RmpRating } from "./RmpRating";
|
||||
|
||||
export type InstructorResponse = { instructorId: number, bannerId: string, displayName: string, firstName: string | null, lastName: string | null, email: string, isPrimary: boolean, rmp: RmpRating | null, };
|
||||
@@ -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, };
|
||||
@@ -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,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ListInstructorsParams = { status: string | null, search: string | null, page: number | null, perPage: number | null, sort: string | 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, };
|
||||
@@ -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 MatchBody = { rmpLegacyId: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Physical location where a course section meets.
|
||||
*/
|
||||
export type MeetingLocation = { building: string | null, buildingDescription: string | null, room: string | null, campus: string | 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 MetricEntry = { id: number, courseId: number, timestamp: string, enrollment: number, waitCount: number, seatsAvailable: 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 MetricsParams = { courseId: number | null, term: string | null, crn: string | null, range: string | null, limit: 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 { MetricEntry } from "./MetricEntry";
|
||||
|
||||
export type MetricsResponse = { metrics: Array<MetricEntry>, count: number, timestamp: string, };
|
||||
@@ -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, };
|
||||
@@ -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 RejectCandidateBody = { rmpLegacyId: number, };
|
||||
@@ -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,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* RateMyProfessors rating summary for an instructor.
|
||||
*/
|
||||
export type RmpRating = { avgRating: number, numRatings: number, legacyId: number, isConfident: boolean, };
|
||||
@@ -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 { ScrapeJobStatus } from "./ScrapeJobStatus";
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
/**
|
||||
* A serializable DTO for `ScrapeJob` with computed `status`.
|
||||
*/
|
||||
export type ScrapeJobDto = { id: number, targetType: string, targetPayload: JsonValue, priority: string, executeAt: string, createdAt: string, lockedAt: string | null, retryCount: number, maxRetries: number, queuedAt: string, status: ScrapeJobStatus, };
|
||||
@@ -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 { ScrapeJobDto } from "./ScrapeJobDto";
|
||||
import type { ScrapeJobStatus } from "./ScrapeJobStatus";
|
||||
|
||||
/**
|
||||
* Events broadcast when scrape job state changes.
|
||||
*/
|
||||
export type ScrapeJobEvent = { "type": "init", jobs: Array<ScrapeJobDto>, } | { "type": "jobCreated", job: ScrapeJobDto, } | { "type": "jobLocked", id: number, lockedAt: string, status: ScrapeJobStatus, } | { "type": "jobCompleted", id: number, } | { "type": "jobRetried", id: number, retryCount: number, queuedAt: string, status: ScrapeJobStatus, } | { "type": "jobExhausted", id: number, } | { "type": "jobDeleted", id: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Computed status for a scrape job, derived from existing fields.
|
||||
*/
|
||||
export type ScrapeJobStatus = "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
|
||||
@@ -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 { ScrapeJobDto } from "./ScrapeJobDto";
|
||||
|
||||
export type ScrapeJobsResponse = { jobs: Array<ScrapeJobDto>, };
|
||||
@@ -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, };
|
||||
@@ -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 { CodeDescription } from "./CodeDescription";
|
||||
|
||||
export type SearchOptionsReference = { instructionalMethods: Array<CodeDescription>, campuses: Array<CodeDescription>, partsOfTerm: Array<CodeDescription>, attributes: Array<CodeDescription>, };
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CodeDescription } from "./CodeDescription";
|
||||
import type { FilterRanges } from "./FilterRanges";
|
||||
import type { SearchOptionsReference } from "./SearchOptionsReference";
|
||||
import type { TermResponse } from "./TermResponse";
|
||||
|
||||
/**
|
||||
* Response for the consolidated search-options endpoint.
|
||||
*/
|
||||
export type SearchOptionsResponse = { terms: Array<TermResponse>, subjects: Array<CodeDescription>, reference: SearchOptionsReference, ranges: FilterRanges, };
|
||||
@@ -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 { SortColumn } from "./SortColumn";
|
||||
import type { SortDirection } from "./SortDirection";
|
||||
|
||||
export type SearchParams = { term: string, subject: Array<string>, q: string | null, courseNumberLow: number | null, courseNumberHigh: number | null, openOnly: boolean, instructionalMethod: Array<string>, campus: Array<string>, limit: number, offset: number, sortBy: SortColumn | null, sortDir: SortDirection | null, waitCountMax: number | null, days: Array<string>, timeStart: string | null, timeEnd: string | null, partOfTerm: Array<string>, attributes: Array<string>, creditHourMin: number | null, creditHourMax: number | null, instructor: string | null, };
|
||||
@@ -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, };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user