From 7e50e9e391356e2527635bd4c8b2518b20a46aa6 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 30 Sep 2026 20:56:32 -0500 Subject: [PATCH 01/29] feat: working with offline sync --- .gitignore | 4 +- android/app/capacitor.build.gradle | 3 + android/capacitor.settings.gradle | 9 + drizzle.config.ts | 7 + .../xcshareddata/swiftpm/Package.resolved | 20 +- ios/App/CapApp-SPM/Package.swift | 6 + package.json | 27 +- pnpm-lock.yaml | 1798 ++++++++++++++++- src/app.d.ts | 2 + src/lib/api/client.ts | 34 + src/lib/api/openapi.d.ts | 511 +++++ src/lib/api/websocket.ts | 49 + src/lib/components/Timetable.svelte | 14 +- src/lib/components/UpdateRequired.svelte | 16 + src/lib/components/map/MapElements.svelte | 11 +- src/lib/components/map/popup/StopPopup.svelte | 34 +- src/lib/data/api/aggie_spirit.ts | 378 ++-- src/lib/data/api/route_planning.ts | 124 -- src/lib/data/app.ts | 365 ++-- src/lib/data/route_planning.ts | 122 +- src/lib/data/structure/aggie_spirit.ts | 400 ---- src/lib/data/structure/route_planning.ts | 132 -- src/lib/data/timepoints.ts | 38 - src/lib/data/types.ts | 84 +- src/lib/db/database.test.ts | 41 + src/lib/db/database.ts | 117 ++ src/lib/db/migrations/0000_initial_tables.sql | 67 + src/lib/db/migrations/meta/0000_snapshot.json | 406 ++++ src/lib/db/migrations/meta/_journal.json | 13 + src/lib/db/schema.ts | 117 ++ src/lib/managers/live-data.manager.svelte.ts | 152 ++ src/lib/managers/sync.manager.svelte.ts | 115 ++ src/lib/sync/live-socket.ts | 104 + src/lib/sync/sync-stream.test.ts | 106 + src/lib/sync/sync-stream.ts | 146 ++ src/lib/utils/app-version.ts | 17 + src/lib/utils/logger.ts | 5 + src/lib/utils/queries.ts | 196 +- src/lib/utils/timetable.ts | 80 +- src/lib/utils/tints.ts | 31 +- src/routes/+layout.svelte | 15 +- src/routes/+page.svelte | 11 +- src/routes/route/[id]/+page.svelte | 22 +- src/routes/route/[id]/EstimateRow.svelte | 13 +- src/routes/route/[id]/StopRow.svelte | 42 +- src/routes/route/[id]/alerts/+page.svelte | 10 +- .../timetable/[stop]/[direction]/+page.svelte | 52 +- vite.config.ts | 8 +- 48 files changed, 4487 insertions(+), 1587 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 src/lib/api/client.ts create mode 100644 src/lib/api/openapi.d.ts create mode 100644 src/lib/api/websocket.ts create mode 100644 src/lib/components/UpdateRequired.svelte delete mode 100644 src/lib/data/api/route_planning.ts delete mode 100644 src/lib/data/structure/aggie_spirit.ts delete mode 100644 src/lib/data/structure/route_planning.ts delete mode 100644 src/lib/data/timepoints.ts create mode 100644 src/lib/db/database.test.ts create mode 100644 src/lib/db/database.ts create mode 100644 src/lib/db/migrations/0000_initial_tables.sql create mode 100644 src/lib/db/migrations/meta/0000_snapshot.json create mode 100644 src/lib/db/migrations/meta/_journal.json create mode 100644 src/lib/db/schema.ts create mode 100644 src/lib/managers/live-data.manager.svelte.ts create mode 100644 src/lib/managers/sync.manager.svelte.ts create mode 100644 src/lib/sync/live-socket.ts create mode 100644 src/lib/sync/sync-stream.test.ts create mode 100644 src/lib/sync/sync-stream.ts create mode 100644 src/lib/utils/app-version.ts diff --git a/.gitignore b/.gitignore index 8f528a5a..93e2da50 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ vite.config.ts.timestamp-* *.pmtiles -dev.config.ts \ No newline at end of file +dev.config.ts +# Copied from sql.js by `pnpm sqlite:wasm` +static/assets/sql-wasm.wasm diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 95bad483..78392e2c 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,6 +9,9 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-community-sqlite') + implementation project(':capacitor-app') + implementation project(':capacitor-app-launcher') implementation project(':capacitor-filesystem') implementation project(':capacitor-geolocation') implementation project(':capacitor-preferences') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 8f2abece..29af60d1 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,6 +2,15 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.3.0_patch_hash=5f151b861cf2cde88167eece495aecb0862a61bea5115b27870_874123a7c3556940d002a867e09a2b84/node_modules/@capacitor/android/capacitor') +include ':capacitor-community-sqlite' +project(':capacitor-community-sqlite').projectDir = new File('../node_modules/.pnpm/@capacitor-community+sqlite@8.1.1_@capacitor+core@8.3.0/node_modules/@capacitor-community/sqlite/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.3.0/node_modules/@capacitor/app/android') + +include ':capacitor-app-launcher' +project(':capacitor-app-launcher').projectDir = new File('../node_modules/.pnpm/@capacitor+app-launcher@8.0.1_@capacitor+core@8.3.0/node_modules/@capacitor/app-launcher/android') + include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.0/node_modules/@capacitor/filesystem/android') diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..c9397d7e --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'sqlite', + schema: './src/lib/db/schema.ts', + out: './src/lib/db/migrations', +}); diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 67863273..e208a442 100644 --- a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1bce83be7f64ec4d30594f7d0593d7d6dcc88b5a6ffd38fd9e47d8a529f380d7", + "originHash" : "bcee7d7a6a0cc7f55e12b7e800803c65261aa213e97c6b6410ed0ebf9d376fb7", "pins" : [ { "identity" : "capacitor-swift-pm", @@ -27,6 +27,24 @@ "revision" : "5b722138d4ddc81275e0405acfd812a497519970", "version" : "2.0.0" } + }, + { + "identity" : "sqlcipher.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sqlcipher/SQLCipher.swift.git", + "state" : { + "revision" : "39f212458aeb88e33bdac2200a793a3f0d55d32b", + "version" : "4.19.0" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } } ], "version" : 3 diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 47eafb89..10da492e 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -12,6 +12,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.0"), + .package(name: "CapacitorCommunitySqlite", path: "../../../node_modules/.pnpm/@capacitor-community+sqlite@8.1.1_@capacitor+core@8.3.0/node_modules/@capacitor-community/sqlite"), + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.3.0/node_modules/@capacitor/app"), + .package(name: "CapacitorAppLauncher", path: "../../../node_modules/.pnpm/@capacitor+app-launcher@8.0.1_@capacitor+core@8.3.0/node_modules/@capacitor/app-launcher"), .package(name: "CapacitorFilesystem", path: "../../../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.0/node_modules/@capacitor/filesystem"), .package(name: "CapacitorGeolocation", path: "../../../node_modules/.pnpm/@capacitor+geolocation@8.0.0_@capacitor+core@8.3.0/node_modules/@capacitor/geolocation"), .package(name: "CapacitorPreferences", path: "../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.0/node_modules/@capacitor/preferences") @@ -22,6 +25,9 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorCommunitySqlite", package: "CapacitorCommunitySqlite"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorAppLauncher", package: "CapacitorAppLauncher"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), .product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"), .product(name: "CapacitorPreferences", package: "CapacitorPreferences") diff --git a/package.json b/package.json index d988dd0b..d0efa931 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,22 @@ { "name": "maroonrides", "private": true, - "version": "2.0.3", + "version": "3.0.0", "type": "module", "scripts": { "dev": "vite dev --host 0.0.0.0", "build": "vite build", "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", + "prepare": "svelte-kit sync && pnpm sqlite:wasm || echo ''", + "sqlite:wasm": "cp node_modules/sql.js/dist/sql-wasm.wasm static/assets/sql-wasm.wasm", + "api:generate": "openapi-typescript ../api/openapi.json -o src/lib/api/openapi.d.ts", + "db:generate": "drizzle-kit generate", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check .", "format": "prettier --write .", - "pmtiles": "./pmtiles/cstat.sh" + "pmtiles": "./pmtiles/cstat.sh", + "test": "vitest run" }, "devDependencies": { "@capacitor/cli": "^8.0.2", @@ -24,11 +28,16 @@ "@tailwindcss/vite": "^4.1.18", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.7.2", + "@types/semver": "^7.8.0", + "@types/sql.js": "^1.4.11", "bits-ui": "^2.18.1", "clsx": "^2.1.1", + "drizzle-kit": "^0.31.11", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "prettier-plugin-tailwindcss": "^0.7.2", + "sql.js": "1.11.0", "svelte": "^5.49.2", "svelte-check": "^4.3.6", "tailwind-merge": "^3.4.0", @@ -36,10 +45,14 @@ "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^5.0.2" }, "dependencies": { + "@capacitor-community/sqlite": "^8.1.1", "@capacitor/android": "8.3.0", + "@capacitor/app": "^8.1.1", + "@capacitor/app-launcher": "^8.0.1", "@capacitor/core": "^8.3.0", "@capacitor/filesystem": "^8.1.2", "@capacitor/geolocation": "^8.0.0", @@ -49,12 +62,16 @@ "@leveluptuts/svelte-fit": "^1.0.3", "@tanstack/svelte-query": "^6.0.18", "aggie-spirit-api": "^1.3.4", + "drizzle-orm": "^0.45.3", + "jeep-sqlite": "^2.8.0", "lodash-es": "^4.18.1", "luxon": "^3.7.2", "maplibre-gl": "^5.17.0", "mode-watcher": "^1.1.0", "moment": "^2.30.1", + "openapi-fetch": "^0.17.0", "pmtiles": "^4.4.0", + "semver": "^7.8.5", "zod": "^4.3.6" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cdaa62..5fe75a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,18 @@ importers: .: dependencies: + '@capacitor-community/sqlite': + specifier: ^8.1.1 + version: 8.1.1(@capacitor/core@8.3.0) '@capacitor/android': specifier: 8.3.0 version: 8.3.0(patch_hash=5f151b861cf2cde88167eece495aecb0862a61bea5115b27870240ddf729efdb)(@capacitor/core@8.3.0) + '@capacitor/app': + specifier: ^8.1.1 + version: 8.1.1(@capacitor/core@8.3.0) + '@capacitor/app-launcher': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.3.0) '@capacitor/core': specifier: ^8.3.0 version: 8.3.0 @@ -41,6 +50,12 @@ importers: aggie-spirit-api: specifier: ^1.3.4 version: 1.3.4 + drizzle-orm: + specifier: ^0.45.3 + version: 0.45.3(@types/sql.js@1.4.11)(sql.js@1.11.0) + jeep-sqlite: + specifier: ^2.8.0 + version: 2.8.0 lodash-es: specifier: ^4.18.1 version: 4.18.1 @@ -56,16 +71,22 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 pmtiles: specifier: ^4.4.0 version: 4.4.0 + semver: + specifier: ^7.8.5 + version: 7.8.5 zod: specifier: ^4.3.6 version: 4.3.6 devDependencies: '@capacitor/cli': specifier: ^8.0.2 - version: 8.0.2 + version: 8.0.2(supports-color@10.2.2) '@internationalized/date': specifier: ^3.12.2 version: 3.12.2 @@ -74,28 +95,40 @@ importers: version: 0.561.0(svelte@5.50.0) '@sveltejs/adapter-static': specifier: ^3.0.10 - version: 3.0.10(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2))) + version: 3.0.10(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))) '@sveltejs/kit': specifier: ^2.50.2 - version: 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 '@types/luxon': specifier: ^3.7.2 version: 3.7.2 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + '@types/sql.js': + specifier: ^1.4.11 + version: 1.4.11 bits-ui: specifier: ^2.18.1 - version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0) + version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0) clsx: specifier: ^2.1.1 version: 2.1.1 + drizzle-kit: + specifier: ^0.31.11 + version: 0.31.11 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -105,6 +138,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.7.2 version: 0.7.2(prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.0))(prettier@3.8.1) + sql.js: + specifier: 1.11.0 + version: 1.11.0 svelte: specifier: ^5.49.2 version: 5.50.0 @@ -128,15 +164,42 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@25.2.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@capacitor-community/sqlite@8.1.1': + resolution: {integrity: sha512-wsqscMOMveDeIvB9WHLm38jkUV2317LmuWvvbhvmOB1REdr8GH6weFZpAiKmLimbpP2CQ2eVs8nmRcrTqbd9ng==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/android@8.3.0': resolution: {integrity: sha512-EQy6ByUuKayQBJmMm/e0byJiHavqsQHrvW23BuT2GNVQvenAvipqwaePiJHzrv2PZr7A0T0+se4kgDCeROj0mQ==} peerDependencies: '@capacitor/core': ^8.3.0 + '@capacitor/app-launcher@8.0.1': + resolution: {integrity: sha512-23D8zi74sn7kxvISix8qYwgqdxGJN+4NImcNGvHen98LB1zb4eZfkJvFvp7pwntxGw4OjIE7yuf4wbzZxQHpog==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + + '@capacitor/app@8.1.1': + resolution: {integrity: sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/cli@8.0.2': resolution: {integrity: sha512-/8qLYxhytMyUKTHK8i6YU+DMD3AuFiQgSuJCyMltcg9MN3W9En7zqQZSo/WN4eC7qif/oyZACzm7OkAZKani7g==} engines: {node: '>=22.0.0'} @@ -168,162 +231,617 @@ packages: '@capacitor/synapse@1.0.4': resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -396,6 +914,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -447,6 +968,16 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.20': + resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -457,11 +988,21 @@ packages: cpu: [arm64] os: [android] + '@rollup/rollup-darwin-arm64@4.44.0': + resolution: {integrity: sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-arm64@4.57.1': resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-x64@4.44.0': + resolution: {integrity: sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.57.1': resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} cpu: [x64] @@ -489,12 +1030,24 @@ packages: os: [linux] libc: [musl] + '@rollup/rollup-linux-arm64-gnu@4.44.0': + resolution: {integrity: sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-arm64-musl@4.44.0': + resolution: {integrity: sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] @@ -543,12 +1096,24 @@ packages: os: [linux] libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.44.0': + resolution: {integrity: sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-x64-musl@4.44.0': + resolution: {integrity: sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] @@ -565,6 +1130,11 @@ packages: cpu: [arm64] os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.44.0': + resolution: {integrity: sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.57.1': resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} cpu: [arm64] @@ -580,6 +1150,11 @@ packages: cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.44.0': + resolution: {integrity: sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.57.1': resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} cpu: [x64] @@ -588,6 +1163,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stencil/core@4.45.1': + resolution: {integrity: sha512-/b8CItmSwOzZwafxXwomJnN9X/Ic/Fxum9ka/RDHmADVf4DA4152uY7zitzLV+vk8JdXxKA7FKyLT3GKNo9R3A==} + engines: {node: '>=16.0.0', npm: '>=7.10.0'} + hasBin: true + '@sveltejs/acorn-typescript@1.0.8': resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==} peerDependencies: @@ -734,9 +1314,18 @@ packages: peerDependencies: svelte: ^5.25.0 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/emscripten@1.41.6': + resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -758,12 +1347,32 @@ packages: '@types/node@25.2.2': resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/slice-ansi@4.0.0': resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} + '@types/sql.js@1.4.11': + resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==} + '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -774,9 +1383,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + aggie-spirit-api@1.3.4: resolution: {integrity: sha512-OlVEnN+4ikAYj4dugJJzmUlyT58lS59ULjZJBgUAd98stvSzF0D5J9CIb6az+aEbqbl54ksyq4f5CIeKEAxQrw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -785,10 +1402,17 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -801,6 +1425,9 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -819,9 +1446,25 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + browser-fs-access@0.35.0: + resolution: {integrity: sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -841,6 +1484,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -849,6 +1495,9 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -881,6 +1530,105 @@ packages: devalue@5.6.2: resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.3: + resolution: {integrity: sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -899,17 +1647,45 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} esrap@2.2.2: resolution: {integrity: sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -942,6 +1718,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + gl-matrix@3.4.4: resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} @@ -952,6 +1731,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -978,19 +1768,42 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jeep-sqlite@2.8.0: + resolution: {integrity: sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stringify-pretty-compact@4.0.0: resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} + kdbush@4.0.2: resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} @@ -1002,6 +1815,12 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1076,6 +1895,9 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -1097,6 +1919,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + maplibre-gl@5.17.0: resolution: {integrity: sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} @@ -1105,6 +1930,10 @@ packages: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1151,13 +1980,36 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1180,10 +2032,18 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + pmtiles@4.4.0: resolution: {integrity: sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==} @@ -1260,6 +2120,9 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -1270,6 +2133,9 @@ packages: quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1278,6 +2144,13 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -1317,6 +2190,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1327,14 +2203,17 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true set-cookie-parser@3.0.1: resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1361,14 +2240,33 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sql.js@1.11.0: + resolution: {integrity: sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==} + + sql.js@1.14.2: + resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1382,6 +2280,10 @@ packages: supercluster@8.0.1: resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + svelte-check@4.3.6: resolution: {integrity: sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==} engines: {node: '>= 18.0.0'} @@ -1437,10 +2339,22 @@ packages: through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -1455,9 +2369,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1474,6 +2397,9 @@ packages: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1525,11 +2451,57 @@ packages: vite: optional: true + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1550,6 +2522,13 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -1561,26 +2540,47 @@ packages: snapshots: + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@capacitor-community/sqlite@8.1.1(@capacitor/core@8.3.0)': + dependencies: + '@capacitor/core': 8.3.0 + jeep-sqlite: 2.8.0 + '@capacitor/android@8.3.0(patch_hash=5f151b861cf2cde88167eece495aecb0862a61bea5115b27870240ddf729efdb)(@capacitor/core@8.3.0)': dependencies: '@capacitor/core': 8.3.0 - '@capacitor/cli@8.0.2': + '@capacitor/app-launcher@8.0.1(@capacitor/core@8.3.0)': + dependencies: + '@capacitor/core': 8.3.0 + + '@capacitor/app@8.1.1(@capacitor/core@8.3.0)': + dependencies: + '@capacitor/core': 8.3.0 + + '@capacitor/cli@8.0.2(supports-color@10.2.2)': dependencies: - '@ionic/cli-framework-output': 2.2.8 - '@ionic/utils-subprocess': 3.0.1 - '@ionic/utils-terminal': 2.3.5 + '@ionic/cli-framework-output': 2.2.8(supports-color@10.2.2) + '@ionic/utils-subprocess': 3.0.1(supports-color@10.2.2) + '@ionic/utils-terminal': 2.3.5(supports-color@10.2.2) commander: 12.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 11.3.3 kleur: 4.1.5 - native-run: 2.0.3 + native-run: 2.0.3(supports-color@10.2.2) open: 8.4.2 plist: 3.1.0 prompts: 2.4.2 rimraf: 6.1.2 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.7 tslib: 2.8.1 xml2js: 0.6.2 @@ -1609,86 +2609,320 @@ snapshots: dependencies: '@capacitor/core': 8.3.0 - '@capacitor/synapse@1.0.4': {} + '@capacitor/synapse@1.0.4': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/linux-arm64@0.18.20': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/linux-arm@0.18.20': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-ia32@0.18.20': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -1706,72 +2940,72 @@ snapshots: dependencies: '@swc/helpers': 0.5.18 - '@ionic/cli-framework-output@2.2.8': + '@ionic/cli-framework-output@2.2.8(supports-color@10.2.2)': dependencies: - '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + '@ionic/utils-terminal': 2.3.5(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-array@2.1.6': + '@ionic/utils-array@2.1.6(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-fs@3.1.7': + '@ionic/utils-fs@3.1.7(supports-color@10.2.2)': dependencies: '@types/fs-extra': 8.1.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-object@2.1.6': + '@ionic/utils-object@2.1.6(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-process@2.1.12': + '@ionic/utils-process@2.1.12(supports-color@10.2.2)': dependencies: - '@ionic/utils-object': 2.1.6 - '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + '@ionic/utils-object': 2.1.6(supports-color@10.2.2) + '@ionic/utils-terminal': 2.3.5(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 tree-kill: 1.2.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-stream@3.1.7': + '@ionic/utils-stream@3.1.7(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-subprocess@3.0.1': + '@ionic/utils-subprocess@3.0.1(supports-color@10.2.2)': dependencies: - '@ionic/utils-array': 2.1.6 - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-process': 2.1.12 - '@ionic/utils-stream': 3.1.7 - '@ionic/utils-terminal': 2.3.5 + '@ionic/utils-array': 2.1.6(supports-color@10.2.2) + '@ionic/utils-fs': 3.1.7(supports-color@10.2.2) + '@ionic/utils-process': 2.1.12(supports-color@10.2.2) + '@ionic/utils-stream': 3.1.7(supports-color@10.2.2) + '@ionic/utils-terminal': 2.3.5(supports-color@10.2.2) cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-terminal@2.3.5': + '@ionic/utils-terminal@2.3.5(supports-color@10.2.2)': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -1806,6 +3040,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -1866,15 +3102,44 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.20(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.2 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true '@rollup/rollup-android-arm64@4.57.1': optional: true + '@rollup/rollup-darwin-arm64@4.44.0': + optional: true + '@rollup/rollup-darwin-arm64@4.57.1': optional: true + '@rollup/rollup-darwin-x64@4.44.0': + optional: true + '@rollup/rollup-darwin-x64@4.57.1': optional: true @@ -1890,9 +3155,15 @@ snapshots: '@rollup/rollup-linux-arm-musleabihf@4.57.1': optional: true + '@rollup/rollup-linux-arm64-gnu@4.44.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.57.1': optional: true + '@rollup/rollup-linux-arm64-musl@4.44.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.57.1': optional: true @@ -1917,9 +3188,15 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.57.1': optional: true + '@rollup/rollup-linux-x64-gnu@4.44.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.57.1': optional: true + '@rollup/rollup-linux-x64-musl@4.44.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.57.1': optional: true @@ -1929,6 +3206,9 @@ snapshots: '@rollup/rollup-openharmony-arm64@4.57.1': optional: true + '@rollup/rollup-win32-arm64-msvc@4.44.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.57.1': optional: true @@ -1938,24 +3218,38 @@ snapshots: '@rollup/rollup-win32-x64-gnu@4.57.1': optional: true + '@rollup/rollup-win32-x64-msvc@4.44.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true '@standard-schema/spec@1.1.0': {} + '@stencil/core@4.45.1': + optionalDependencies: + '@rollup/rollup-darwin-arm64': 4.44.0 + '@rollup/rollup-darwin-x64': 4.44.0 + '@rollup/rollup-linux-arm64-gnu': 4.44.0 + '@rollup/rollup-linux-arm64-musl': 4.44.0 + '@rollup/rollup-linux-x64-gnu': 4.44.0 + '@rollup/rollup-linux-x64-musl': 4.44.0 + '@rollup/rollup-win32-arm64-msvc': 4.44.0 + '@rollup/rollup-win32-x64-msvc': 4.44.0 + '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))': dependencies: - '@sveltejs/kit': 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/kit': 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) - '@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -1968,26 +3262,26 @@ snapshots: set-cookie-parser: 3.0.1 sirv: 3.0.2 svelte: 5.50.0 - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) optionalDependencies: typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) obug: 2.1.1 svelte: 5.50.0 - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.50.0 - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) - vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) '@swc/helpers@0.5.18': dependencies: @@ -2054,12 +3348,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) '@tanstack/query-core@5.90.20': {} @@ -2068,8 +3362,17 @@ snapshots: '@tanstack/query-core': 5.90.20 svelte: 5.50.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + + '@types/emscripten@1.41.6': {} + '@types/estree@1.0.8': {} '@types/fs-extra@8.1.5': @@ -2090,47 +3393,75 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/semver@7.8.0': {} + '@types/slice-ansi@4.0.0': {} + '@types/sql.js@1.4.11': + dependencies: + '@types/emscripten': 1.41.6 + '@types/node': 25.2.2 + '@types/supercluster@7.1.3': dependencies: '@types/geojson': 7946.0.16 + '@vitest/mocker@5.0.2(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) + + '@vitest/spy@5.0.2': {} + '@xmldom/xmldom@0.8.11': {} acorn@8.15.0: {} + agent-base@7.1.4: {} + aggie-spirit-api@1.3.4: dependencies: moment: 2.30.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + argparse@2.0.1: {} + aria-query@5.3.2: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} at-least-node@1.0.0: {} axobject-query@4.1.0: {} + balanced-match@1.0.2: {} + base64-js@1.5.1: {} big-integer@1.6.52: {} - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0): dependencies: '@floating-ui/core': 1.7.4 '@floating-ui/dom': 1.7.5 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0) svelte: 5.50.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -2139,8 +3470,20 @@ snapshots: dependencies: big-integer: 1.6.52 + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + browser-fs-access@0.35.0: {} + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + + chai@6.2.2: {} + + change-case@5.4.4: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2155,19 +3498,25 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + commander@12.1.0: {} cookie@0.6.0: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 deepmerge@4.3.1: {} @@ -2179,6 +3528,18 @@ snapshots: devalue@5.6.2: {} + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.15 + + drizzle-orm@0.45.3(@types/sql.js@1.4.11)(sql.js@1.11.0): + optionalDependencies: + '@types/sql.js': 1.4.11 + sql.js: 1.11.0 + earcut@3.0.2: {} elementtree@0.1.7: @@ -2194,6 +3555,62 @@ snapshots: env-paths@2.2.1: {} + es-module-lexer@2.3.2: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -2223,12 +3640,49 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + esm-env@1.2.2: {} esrap@2.2.2: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -2237,6 +3691,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fflate@0.8.2: {} fs-extra@11.3.3: @@ -2257,6 +3715,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + gl-matrix@3.4.4: {} glob@13.0.1: @@ -2267,6 +3729,17 @@ snapshots: graceful-fs@4.2.11: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + immediate@3.0.6: {} + + index-to-position@1.2.0: {} + inherits@2.0.4: {} ini@4.1.3: {} @@ -2285,10 +3758,30 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isexe@2.0.0: {} + jeep-sqlite@2.8.0: + dependencies: + '@stencil/core': 4.45.1 + browser-fs-access: 0.35.0 + jszip: 3.10.2 + localforage: 1.10.0 + sql.js: 1.14.2 + jiti@2.6.1: {} + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + json-stringify-pretty-compact@4.0.0: {} jsonfile@6.2.0: @@ -2297,12 +3790,27 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.2: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + kdbush@4.0.2: {} kleur@3.0.3: {} kleur@4.1.5: {} + lie@3.1.1: + dependencies: + immediate: 3.0.6 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -2352,6 +3860,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + localforage@1.10.0: + dependencies: + lie: 3.1.1 + locate-character@3.0.0: {} lodash-es@4.18.1: {} @@ -2366,6 +3878,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + maplibre-gl@5.17.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 @@ -2395,6 +3911,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.1 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} minipass@7.1.2: {} @@ -2421,12 +3941,12 @@ snapshots: nanoid@3.3.11: {} - native-run@2.0.3: + native-run@2.0.3(supports-color@10.2.2): dependencies: - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-terminal': 2.3.5 + '@ionic/utils-fs': 3.1.7(supports-color@10.2.2) + '@ionic/utils-terminal': 2.3.5(supports-color@10.2.2) bplist-parser: 0.3.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) elementtree: 0.1.7 ini: 4.1.3 plist: 3.1.0 @@ -2439,14 +3959,40 @@ snapshots: obug@2.1.1: {} + obug@2.2.1: {} + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + path-key@3.1.1: {} path-scurry@2.0.1: @@ -2464,12 +4010,16 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.7: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 base64-js: 1.5.1 xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + pmtiles@4.4.0: dependencies: fflate: 0.8.2 @@ -2495,6 +4045,8 @@ snapshots: prettier@3.8.1: {} + process-nextick-args@2.0.1: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -2504,6 +4056,16 @@ snapshots: quickselect@3.0.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2512,6 +4074,10 @@ snapshots: readdirp@4.1.2: {} + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 @@ -2562,14 +4128,14 @@ snapshots: esm-env: 1.2.2 svelte: 5.50.0 - runed@0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0): + runed@0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.50.0 optionalDependencies: - '@sveltejs/kit': 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/kit': 2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) rw@1.3.3: {} @@ -2577,16 +4143,20 @@ snapshots: dependencies: mri: 1.2.0 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} sax@1.1.4: {} sax@1.4.4: {} - semver@7.7.4: {} + semver@7.8.5: {} set-cookie-parser@3.0.1: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2611,14 +4181,31 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + split2@4.2.0: {} + sql.js@1.11.0: {} + + sql.js@1.14.2: {} + + std-env@4.3.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -2635,6 +4222,8 @@ snapshots: dependencies: kdbush: 4.0.2 + supports-color@10.2.2: {} + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -2647,10 +4236,10 @@ snapshots: transitivePeerDependencies: - picomatch - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)))(svelte@5.50.0) style-to-object: 1.0.14 svelte: 5.50.0 transitivePeerDependencies: @@ -2707,11 +4296,20 @@ snapshots: dependencies: readable-stream: 3.6.2 + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinyqueue@3.0.0: {} totalist@3.0.1: {} @@ -2720,8 +4318,16 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tw-animate-css@1.4.0: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} undici-types@7.16.0: {} @@ -2730,9 +4336,11 @@ snapshots: untildify@4.0.0: {} + uri-js-replace@1.0.1: {} + util-deprecate@1.0.2: {} - vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2745,15 +4353,39 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + tsx: 4.23.15 - vitefu@1.1.1(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)): optionalDependencies: - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) + + vitest@5.0.2(@types/node@25.2.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 25.2.2 + transitivePeerDependencies: + - msw which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.2: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2771,6 +4403,10 @@ snapshots: yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 diff --git a/src/app.d.ts b/src/app.d.ts index faf8f879..4248e0e3 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,4 +1,6 @@ declare global { + const __APP_VERSION__: string; + interface WindowEventMap { dynamictypechange: CustomEvent<{ bodyPointSize: number }>; } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 00000000..0d9dce61 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,34 @@ +import createClient from 'openapi-fetch'; +import type { components, paths } from './openapi'; + +const PRODUCTION_API_URL = 'https://api.maroonrides.app'; +const DEV_SERVER_API_URL = 'http://100.89.139.58:3000'; +const LINE_SEPARATOR = '\n'; + +export const API_URL: string = + import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? DEV_SERVER_API_URL : PRODUCTION_API_URL); +export const WEBSOCKET_URL = `${API_URL.replace(/^http/, 'ws')}/api/ws`; + +type Schemas = components['schemas']; +export type SyncRequest = Schemas['SyncRequest']; +export type SyncRequestType = Schemas['SyncRequestType']; +export type SyncEntityType = Schemas['SyncEntityType']; +export type SyncStreamLine = Schemas['SyncStreamLine']; + +const client = createClient({ baseUrl: API_URL }); + +export async function getSupportedVersion(): Promise { + const { data, error } = await client.GET('/api/version/supported'); + if (error) throw new Error(`version check failed: ${error.detail}`); + return data.version; +} + +export async function syncStream(body: SyncRequest): Promise { + const { data, error } = await client.POST('/api/sync/stream', { body, parseAs: 'text' }); + if (error) throw new Error(`sync stream failed: ${error.detail}`); + + return data + .split(LINE_SEPARATOR) + .filter(Boolean) + .map((line) => JSON.parse(line) as SyncStreamLine); +} diff --git a/src/lib/api/openapi.d.ts b/src/lib/api/openapi.d.ts new file mode 100644 index 00000000..cd79be0f --- /dev/null +++ b/src/lib/api/openapi.d.ts @@ -0,0 +1,511 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/api/sync/stream': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stream sync changes + * @description #### Controller: + * + * `github.com/MaroonRides/api/apps/gateway/controllers.(*SyncController).stream` + * + * #### Middlewares: + * + * - `github.com/MaroonRides/api/apps/gateway/controllers.gzipped` + * + * --- + * + * Streams every change after the given acks as JSON lines. Each line is one SyncStreamLine. + */ + post: operations['syncStream']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/version/supported': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the minimum supported version that the server supports communicating with + * @description #### Controller: + * + * `github.com/MaroonRides/api/apps/gateway/controllers.(*VersionController).supportedVersion` + * + * --- + */ + get: operations['getSupportedVersion']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AlertDeleteV1Line: { + ack: string; + data: components['schemas']['SyncAlertDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'AlertDeleteV1'; + }; + AlertDirectionDeleteV1Line: { + ack: string; + data: components['schemas']['SyncAlertDirectionDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'AlertDirectionDeleteV1'; + }; + AlertDirectionV1Line: { + ack: string; + data: components['schemas']['SyncAlertDirectionV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'AlertDirectionV1'; + }; + AlertV1Line: { + ack: string; + data: components['schemas']['SyncAlertV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'AlertV1'; + }; + DirectionDeleteV1Line: { + ack: string; + data: components['schemas']['SyncDirectionDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'DirectionDeleteV1'; + }; + DirectionStopDeleteV1Line: { + ack: string; + data: components['schemas']['SyncDirectionStopDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'DirectionStopDeleteV1'; + }; + DirectionStopV1Line: { + ack: string; + data: components['schemas']['SyncDirectionStopV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'DirectionStopV1'; + }; + DirectionV1Line: { + ack: string; + data: components['schemas']['SyncDirectionV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'DirectionV1'; + }; + ErrorItem: { + /** @description Additional information about the error */ + more?: { + [key: string]: unknown; + } | null; + /** @description For example, name of the parameter that caused the error */ + name: string; + /** @description Human readable error message */ + reason: string; + }; + /** @description HTTPError schema */ + HTTPError: { + /** @description Human readable error message */ + detail?: string; + errors?: components['schemas']['ErrorItem'][] | null; + instance?: string; + /** + * @description HTTP status code + * @example 403 + */ + status?: number; + /** @description Short title of the error */ + title?: string; + /** @description URL of the error type. Can be used to lookup the error in a documentation */ + type?: string; + }; + RouteDeleteV1Line: { + ack: string; + data: components['schemas']['SyncRouteDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'RouteDeleteV1'; + }; + RouteV1Line: { + ack: string; + data: components['schemas']['SyncRouteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'RouteV1'; + }; + StopDeleteV1Line: { + ack: string; + data: components['schemas']['SyncStopDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'StopDeleteV1'; + }; + StopV1Line: { + ack: string; + data: components['schemas']['SyncStopV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'StopV1'; + }; + /** @description SupportedVersionResponse schema */ + SupportedVersionResponse: { + version: string; + }; + /** @description SyncAlertDeleteV1 schema */ + SyncAlertDeleteV1: { + /** Format: uuid */ + alertId: string; + }; + /** @description SyncAlertDirectionDeleteV1 schema */ + SyncAlertDirectionDeleteV1: { + /** Format: uuid */ + alertDirectionId: string; + }; + /** @description SyncAlertDirectionV1 schema */ + SyncAlertDirectionV1: { + /** Format: uuid */ + alertId: string; + /** Format: uuid */ + directionId: string; + /** Format: uuid */ + id: string; + }; + /** @description SyncAlertV1 schema */ + SyncAlertV1: { + dailyEndTime: string; + dailyStartTime: string; + description: string; + /** Format: date-time */ + endsAt: string | null; + /** Format: uuid */ + id: string; + /** Format: date-time */ + startsAt: string; + timeRangeText: string; + title: string; + }; + /** @description SyncCompleteV1 schema */ + SyncCompleteV1: unknown; + SyncCompleteV1Line: { + ack: string; + data: components['schemas']['SyncCompleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'SyncCompleteV1'; + }; + /** @description SyncDirectionDeleteV1 schema */ + SyncDirectionDeleteV1: { + /** Format: uuid */ + directionId: string; + }; + /** @description SyncDirectionStopDeleteV1 schema */ + SyncDirectionStopDeleteV1: { + /** Format: uuid */ + directionStopId: string; + }; + /** @description SyncDirectionStopV1 schema */ + SyncDirectionStopV1: { + /** Format: uuid */ + directionId: string; + /** Format: uuid */ + id: string; + isTimepoint: boolean; + sequence: number; + /** Format: uuid */ + stopId: string; + }; + /** @description SyncDirectionV1 schema */ + SyncDirectionV1: { + destination: string; + /** Format: uuid */ + id: string; + path: string; + /** Format: uuid */ + routeId: string; + sequence: number; + }; + /** + * SyncEntityType + * @description SyncEntityType schema + * @enum {string} + */ + SyncEntityType: + | 'RouteV1' + | 'RouteDeleteV1' + | 'DirectionV1' + | 'DirectionDeleteV1' + | 'StopV1' + | 'StopDeleteV1' + | 'DirectionStopV1' + | 'DirectionStopDeleteV1' + | 'AlertV1' + | 'AlertDeleteV1' + | 'AlertDirectionV1' + | 'AlertDirectionDeleteV1' + | 'TimetableV1' + | 'TimetableDeleteV1' + | 'SyncResetV1' + | 'SyncCompleteV1'; + /** @enum {integer} */ + SyncProtocolVersion: 1; + /** @description SyncRequest schema */ + SyncRequest: { + acks: string[] | null; + protocol: components['schemas']['SyncProtocolVersion']; + types: components['schemas']['SyncRequestType'][] | null; + }; + /** @enum {string} */ + SyncRequestType: + | 'RoutesV1' + | 'DirectionsV1' + | 'StopsV1' + | 'DirectionStopsV1' + | 'AlertsV1' + | 'AlertDirectionsV1' + | 'TimetablesV1'; + /** @description SyncResetV1 schema */ + SyncResetV1: unknown; + SyncResetV1Line: { + ack: string; + data: components['schemas']['SyncResetV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'SyncResetV1'; + }; + /** @description SyncRouteDeleteV1 schema */ + SyncRouteDeleteV1: { + /** Format: uuid */ + routeId: string; + }; + /** @description SyncRouteV1 schema */ + SyncRouteV1: { + active: boolean; + darkColor: string; + /** Format: uuid */ + id: string; + lightColor: string; + longName: string; + shortName: string; + }; + /** @description SyncStopDeleteV1 schema */ + SyncStopDeleteV1: { + /** Format: uuid */ + stopId: string; + }; + /** @description SyncStopV1 schema */ + SyncStopV1: { + amenities: string[] | null; + /** Format: uuid */ + id: string; + /** Format: double */ + lat: number; + /** Format: double */ + lon: number; + name: string; + }; + SyncStreamLine: + | components['schemas']['RouteV1Line'] + | components['schemas']['RouteDeleteV1Line'] + | components['schemas']['DirectionV1Line'] + | components['schemas']['DirectionDeleteV1Line'] + | components['schemas']['StopV1Line'] + | components['schemas']['StopDeleteV1Line'] + | components['schemas']['DirectionStopV1Line'] + | components['schemas']['DirectionStopDeleteV1Line'] + | components['schemas']['AlertV1Line'] + | components['schemas']['AlertDeleteV1Line'] + | components['schemas']['AlertDirectionV1Line'] + | components['schemas']['AlertDirectionDeleteV1Line'] + | components['schemas']['TimetableV1Line'] + | components['schemas']['TimetableDeleteV1Line'] + | components['schemas']['SyncResetV1Line'] + | components['schemas']['SyncCompleteV1Line']; + /** @description SyncTimetableDeleteV1 schema */ + SyncTimetableDeleteV1: { + /** Format: uuid */ + timetableId: string; + }; + /** @description SyncTimetableV1 schema */ + SyncTimetableV1: { + departures: string[] | null; + /** Format: uuid */ + directionId: string; + /** Format: uuid */ + id: string; + /** Format: date */ + serviceDate: string; + /** Format: uuid */ + stopId: string; + }; + TimetableDeleteV1Line: { + ack: string; + data: components['schemas']['SyncTimetableDeleteV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'TimetableDeleteV1'; + }; + TimetableV1Line: { + ack: string; + data: components['schemas']['SyncTimetableV1']; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'TimetableV1'; + }; + /** @description unknown-interface schema */ + 'unknown-interface': unknown; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + syncStream: { + parameters: { + query?: never; + header?: { + Accept?: string; + }; + path?: never; + cookie?: never; + }; + /** @description Request body for dtos.SyncRequest */ + requestBody: { + content: { + 'application/json': components['schemas']['SyncRequest']; + }; + }; + responses: { + /** @description One SyncStreamLine per line */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/jsonlines+json': components['schemas']['SyncStreamLine']; + }; + }; + /** @description Bad Request _(validation or deserialization error)_ */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPError']; + 'application/xml': components['schemas']['HTTPError']; + }; + }; + /** @description Internal Server Error _(panics)_ */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPError']; + 'application/xml': components['schemas']['HTTPError']; + }; + }; + }; + }; + getSupportedVersion: { + parameters: { + query?: never; + header?: { + Accept?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SupportedVersionResponse']; + }; + }; + /** @description Bad Request _(validation or deserialization error)_ */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPError']; + 'application/xml': components['schemas']['HTTPError']; + }; + }; + /** @description Internal Server Error _(panics)_ */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPError']; + 'application/xml': components['schemas']['HTTPError']; + }; + }; + }; + }; +} diff --git a/src/lib/api/websocket.ts b/src/lib/api/websocket.ts new file mode 100644 index 00000000..e87e0f54 --- /dev/null +++ b/src/lib/api/websocket.ts @@ -0,0 +1,49 @@ +// The gateway does not publish these in openapi.json, they mirror apps/gateway/dtos/websocket.go. + +export enum WebsocketMessageType { + SUBSCRIBE = 'subscribe', + UNSUBSCRIBE = 'unsubscribe', + PING = 'ping', + PONG = 'pong', + ERROR = 'error', + VEHICLES = 'vehicles', + DEPARTURES = 'departures', +} + +export type WebsocketClientMessage = + | { type: WebsocketMessageType.SUBSCRIBE; routeId: string } + | { type: WebsocketMessageType.UNSUBSCRIBE } + | { type: WebsocketMessageType.PING }; + +export interface WebsocketVehicle { + id: string; + directionId: string; + name: string; + lat: number; + lon: number; + heading: number; + speed: number; + passengers: number; + capacity: number; + amenities: string[] | null; + seenAt: string; +} + +export interface WebsocketDeparture { + id: string; + stopId: string; + directionId: string; + scheduledAt: string; + estimatedAt: string | null; + isCancelled: boolean; +} + +export type WebsocketServerMessage = + | { type: WebsocketMessageType.PONG } + | { type: WebsocketMessageType.ERROR; message: string } + | { type: WebsocketMessageType.VEHICLES; routeId: string; vehicles: WebsocketVehicle[] | null } + | { + type: WebsocketMessageType.DEPARTURES; + routeId: string; + departures: WebsocketDeparture[] | null; + }; diff --git a/src/lib/components/Timetable.svelte b/src/lib/components/Timetable.svelte index 2a3fe0f2..443149a1 100644 --- a/src/lib/components/Timetable.svelte +++ b/src/lib/components/Timetable.svelte @@ -1,6 +1,5 @@ -
+
{#each timetable as row, i}
+ import Button from '$lib/components/ui/button/button.svelte'; + import { openAppStore } from '$lib/utils/app-version'; + import { CircleArrowUp } from '@lucide/svelte'; + + +
+ +

Update Required

+

+ This version of Maroon Rides is no longer supported. Update the app to keep tracking buses. +

+ +
diff --git a/src/lib/components/map/MapElements.svelte b/src/lib/components/map/MapElements.svelte index 6d627bde..80eff428 100644 --- a/src/lib/components/map/MapElements.svelte +++ b/src/lib/components/map/MapElements.svelte @@ -6,7 +6,6 @@ type MapCircleStyle, } from '$lib/components/ui/map/MapCircleLayer.svelte'; import { useRoutes, useVehicles } from '$lib/data/app'; - import { isTimepoint, useTimepointsAPI } from '$lib/data/timepoints'; import { type Bus, type Direction, type Location, type Route, type Stop } from '$lib/data/types'; import { mapManager } from '$lib/managers/map.manager.svelte'; import { themeManager } from '$lib/managers/theme.manager.svelte'; @@ -26,7 +25,6 @@ const STOPS_ID = 'stops'; const routes = useRoutes(); - const timepoints = useTimepointsAPI(); const busLocations = $derived(useVehicles(() => ({ route: mapManager.selectedRoute }))); let userLocation: Location | null = $state(null); @@ -84,16 +82,11 @@ })), ); - function isStopTimepoint(stop: Stop) { - const routeCode = mapManager.selectedRoute?.routeCode; - return !!routeCode && isTimepoint(timepoints.data ?? {}, routeCode, stop.id); - } - // Timepoints are square DOM markers, not circles. const stopCircles = $derived( stopsByDirection.flatMap(({ direction, stops }) => stops - .filter((stop) => !isStopTimepoint(stop)) + .filter((stop) => !stop.isTimepoint) .map( (stop): MapCircle => ({ id: `${direction.id}-${stop.id}`, @@ -106,7 +99,7 @@ const timepointStops = $derived( stopsByDirection.flatMap(({ direction, stops }) => - stops.filter(isStopTimepoint).map((stop) => ({ direction, stop })), + stops.filter((stop) => stop.isTimepoint).map((stop) => ({ direction, stop })), ), ); diff --git a/src/lib/components/map/popup/StopPopup.svelte b/src/lib/components/map/popup/StopPopup.svelte index c7d83a39..21f2019f 100644 --- a/src/lib/components/map/popup/StopPopup.svelte +++ b/src/lib/components/map/popup/StopPopup.svelte @@ -3,8 +3,15 @@ import MapPopup from '$lib/components/ui/map/MapPopup.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; import TimeBubble from '$lib/components/TimeBubble.svelte'; - import { useStopAmenities, useStopEstimate } from '$lib/data/app'; - import { Amenity, type Direction, type Route, type Stop } from '$lib/data/types'; + import { useStopEstimate } from '$lib/data/app'; + import { + Amenity, + ESTIMATES_UNAVAILABLE_MESSAGE, + EstimateSource, + type Direction, + type Route, + type Stop, + } from '$lib/data/types'; import { themeManager } from '$lib/managers/theme.manager.svelte'; import { getRouteTint } from '$lib/utils/tints'; @@ -19,10 +26,9 @@ const tint = $derived(getRouteTint(route, themeManager.theme)); - const { data: amenities } = $derived(useStopAmenities(() => ({ route, direction, stop }))); - const { data: estimates, isLoading } = $derived( - useStopEstimate(() => ({ route, direction, stop })), - ); + const stopEstimates = $derived(useStopEstimate(() => ({ direction, stop }))); + const source = $derived(stopEstimates.data?.source ?? EstimateSource.LOADING); + const estimates = $derived(stopEstimates.data?.estimates ?? []);
- {#each (amenities ?? []).filter((amenity) => amenity !== Amenity.TIME_POINT) as amenity} - {@const AmenityIcon = Amenity.getIcon(amenity)} - + {#each stop.amenities as amenity} + {#if amenity !== Amenity.TIME_POINT} + {@const AmenityIcon = Amenity.getIcon(amenity)} + + {/if} {/each}
- {#if isLoading} + {#if source === EstimateSource.LOADING} - {:else if estimates?.length === 0} + {:else if source === EstimateSource.UNAVAILABLE} +

{ESTIMATES_UNAVAILABLE_MESSAGE}

+ {:else if estimates.length === 0}

No upcoming departures

{/if} - {#each estimates ?? [] as estimate, i} + {#each estimates as estimate, i} {/each}
diff --git a/src/lib/data/api/aggie_spirit.ts b/src/lib/data/api/aggie_spirit.ts index 563f5690..6da15537 100644 --- a/src/lib/data/api/aggie_spirit.ts +++ b/src/lib/data/api/aggie_spirit.ts @@ -1,285 +1,139 @@ import { GetBaseDataResponseSchema, - GetNextDepartTimesResponseSchema, GetPatternPathsResponseSchema, - GetStopEstimatesResponseSchema, - GetStopSchedulesResponseSchema, - GetVehiclesResponseSchema, - type IGetBaseDataResponse, - type IGetNextDepartTimesResponse, - type IGetPatternPathsResponse, - type IGetStopEstimatesResponse, - type IGetVehiclesResponse, - type IMapServiceInterruption, + GetTripPlanResponseSchema, + type IFoundStop, + type IPatternPoint, + type ITripPlanResponse, } from '$lib/data/typecheck/aggie_spirit'; -import { appLogger } from '$lib/utils/logger'; -// import '@bacons/text-decoder/install'; -import { createDependencyQuery, createLoggingQuery } from '$lib/utils/queries'; -import { - getBaseData, - getNextDepartureTimes, - getPatternPaths, - getStopEstimates, - getStopSchedules, - getVehicles, -} from 'aggie-spirit-api'; +import { type SearchSuggestion } from '$lib/utils/route-planning'; +import { findBusStops, getBaseData, getPatternPaths, getTripPlan } from 'aggie-spirit-api'; +import { keyBy } from 'lodash-es'; import moment from 'moment'; +// Only route planning still talks to Aggie Spirit, since the Maroon Rides API has no trip planner. + export type Headers = { [key: string]: string }; -enum ASAPIQueryKey { - AUTH_CODE = 'ASAPIAuthCode', - AUTH_TOKEN = 'ASAPIAuthToken', - ROUTE_PLAN_AUTH_TOKEN = 'ASAPIRoutePlanAuthToken', - BASE_DATA = 'ASAPIBaseData', - PATTERN_PATHS = 'ASAPIPatternPaths', - SERVICE_INTERRUPTIONS = 'ASAPIServiceInterruptions', - STOP_ESTIMATE = 'ASAPIStopEstimate', - TIMETABLE_ESTIMATE = 'ASAPITimetableEstimate', - STOP_AMENITIES = 'ASAPIStopAmenities', - STOP_SCHEDULE = 'ASAPIStopSchedule', - VEHICLES = 'ASAPIVehicles', +const AUTH_URL = 'https://auth.maroonrides.app'; +const AGGIE_SPIRIT_URL = 'https://aggiespirit.ts.tamu.edu'; +const AUTH_TOKEN_TTL = moment.duration(15, 'minutes'); +const STOP_LOCATIONS_TTL = moment.duration(30, 'minutes'); +const AUTH_CODE_TTL = moment.duration(1, 'day'); + +/** Reuses one in-flight or recent result, and drops it on failure so the next call retries. */ +function cached(ttl: moment.Duration, load: () => Promise): () => Promise { + let value: Promise | null = null; + let loadedAt = 0; + + return () => { + if (!value || moment.now() - loadedAt > ttl.asMilliseconds()) { + loadedAt = moment.now(); + value = load().catch((error) => { + value = null; + throw error; + }); + } + return value; + }; } -export const useAuthCodeAPI = () => { - const query = createLoggingQuery(() => ({ - label: ASAPIQueryKey.AUTH_CODE, - queryKey: [ASAPIQueryKey.AUTH_CODE], - queryFn: async () => { - const authCodeB64 = await (await fetch('https://auth.maroonrides.app')).text(); - return atob(authCodeB64); - }, - staleTime: Infinity, - })); - - return query; -}; - -export const useAuthTokenAPI = () => { - const authCodeQuery = useAuthCodeAPI(); - - const query = createDependencyQuery(() => ({ - queryKey: [ASAPIQueryKey.AUTH_TOKEN], - queryFn: async () => { - var res = await fetch('https://aggiespirit.ts.tamu.edu/', { credentials: 'omit' }); - - var verificationToken = extractRequestVerificationToken(await res.text()); - return { - Requestverificationtoken: verificationToken, - 'X-Requested-With': 'XMLHttpRequest', - }; - }, - staleTime: moment.duration(15, 'minutes'), - refetchInterval: moment.duration(15, 'minutes'), - dependents: [authCodeQuery], - })); - - return query; -}; - -export const useRoutePlanAuthTokenAPI = (params: () => { queryString: string }) => { - const authCodeQuery = useAuthCodeAPI(); - - const query = createDependencyQuery(() => { - const { queryString } = params(); - return { - queryKey: [ASAPIQueryKey.ROUTE_PLAN_AUTH_TOKEN], - queryFn: async () => { - var res = await fetch(`https://aggiespirit.ts.tamu.edu/TripPlanner/${queryString}`, { - credentials: 'omit', - }); - - var verificationToken = extractRequestVerificationToken(await res.text()); - - return { - Requestverificationtoken: verificationToken, - 'X-Requested-With': 'XMLHttpRequest', - }; - }, - staleTime: moment.duration(15, 'minutes'), - refetchInterval: moment.duration(15, 'minutes'), - enabled: queryString !== '', - dependents: [authCodeQuery], - }; - }); - - return query; -}; - -export const useBaseDataAPI = () => { - const authTokenQuery = useAuthTokenAPI(); - - const query = createDependencyQuery(() => ({ - queryKey: [ASAPIQueryKey.BASE_DATA], - queryFn: async () => { - const baseData = await getBaseData(authTokenQuery.data!); - GetBaseDataResponseSchema.parse(baseData); - - return baseData; - }, - staleTime: Infinity, - dependents: [authTokenQuery], - })); - - return query; -}; - -export const usePatternPathsAPI = () => { - const authTokenQuery = useAuthTokenAPI(); - const baseDataQuery = useBaseDataAPI(); - - const query = createDependencyQuery(() => ({ - queryKey: [ASAPIQueryKey.PATTERN_PATHS], - queryFn: async () => { - const baseData = baseDataQuery.data as IGetBaseDataResponse; - - const patternPaths = await getPatternPaths( - baseData.routes.map((route) => route.key), - authTokenQuery.data!, - ); - GetPatternPathsResponseSchema.parse(patternPaths); - - return patternPaths; - }, - dependents: [authTokenQuery, baseDataQuery], - staleTime: moment.duration(30, 'minutes'), - refetchInterval: moment.duration(30, 'minutes'), - })); - - return query; -}; - -export const useServiceInterruptionsAPI = () => { - const baseDataQuery = useBaseDataAPI(); - - return createDependencyQuery(() => ({ - queryKey: [ASAPIQueryKey.SERVICE_INTERRUPTIONS], - queryFn: async () => { - const baseData = baseDataQuery.data as IGetBaseDataResponse; - return baseData.serviceInterruptions; - }, - enabled: baseDataQuery.isSuccess, - staleTime: moment.duration(30, 'minutes'), - refetchInterval: moment.duration(30, 'minutes'), - dependents: [baseDataQuery], - })); -}; - -export const useStopEstimateAPI = ( - params: () => { routeKey: string; directionKey: string; stopCode: string }, -) => { - const authTokenQuery = useAuthTokenAPI(); - - return createDependencyQuery(() => { - const { routeKey, directionKey, stopCode } = params(); - return { - queryKey: [ASAPIQueryKey.STOP_ESTIMATE, routeKey, directionKey, stopCode], - queryFn: async () => { - const response = await getNextDepartureTimes( - routeKey, - [directionKey], - stopCode, - authTokenQuery.data!, - ); - GetNextDepartTimesResponseSchema.parse(response); - - return response as IGetNextDepartTimesResponse; - }, - enabled: routeKey !== '' && directionKey !== '' && stopCode !== '', - dependents: [authTokenQuery], - staleTime: moment.duration(30, 'seconds'), - refetchInterval: moment.duration(30, 'seconds'), - }; - }); -}; - -export const useTimetableEstimateAPI = (params: () => { stopCode: string; date: Date }) => { - const authTokenQuery = useAuthTokenAPI(); - - return createDependencyQuery(() => { - const { stopCode, date } = params(); - return { - queryKey: [ASAPIQueryKey.TIMETABLE_ESTIMATE, stopCode, moment(date).format('YYYY-MM-DD')], - queryFn: async () => { - const response = await getStopEstimates(stopCode, date, authTokenQuery.data!); - GetStopEstimatesResponseSchema.parse(response); - - return response; - }, - enabled: authTokenQuery.isSuccess && stopCode !== '' && date !== null, - staleTime: 30000, - refetchInterval: 30000, - dependents: [authTokenQuery], - }; - }); -}; +function extractRequestVerificationToken(html: string): string { + const matches = html.match(/"[a-zA-Z0-9]{288}MQ=="/g); + if (matches === null) { + throw new Error('Could not find verification token'); + } -export const useStopScheduleAPI = (params: () => { stopCode: string; date: Date }) => { - const authTokenQuery = useAuthTokenAPI(); + return atob(matches[0].slice(1, -1)); +} - return createDependencyQuery(() => { - const { stopCode, date } = params(); - return { - queryKey: [ASAPIQueryKey.STOP_SCHEDULE, stopCode, moment(date).format('YYYY-MM-DD')], - queryFn: async () => { - const response = await getStopSchedules(stopCode, date, authTokenQuery.data!); - GetStopSchedulesResponseSchema.parse(response); +const ensureAuthCode = cached(AUTH_CODE_TTL, () => fetch(AUTH_URL)); - return response; - }, - enabled: stopCode !== '' && date !== null, - dependents: [authTokenQuery], - staleTime: moment.duration(30, 'seconds'), - refetchInterval: moment.duration(30, 'seconds'), - }; - }); -}; +async function fetchVerificationHeaders(path: string): Promise { + await ensureAuthCode(); + const res = await fetch(`${AGGIE_SPIRIT_URL}/${path}`, { credentials: 'omit' }); -export const useVehiclesAPI = (params: () => { routeKey: string }) => { - const authTokenQuery = useAuthTokenAPI(); + return { + Requestverificationtoken: extractRequestVerificationToken(await res.text()), + 'X-Requested-With': 'XMLHttpRequest', + }; +} - return createDependencyQuery(() => { - const { routeKey } = params(); +const getAuthHeaders = cached(AUTH_TOKEN_TTL, () => fetchVerificationHeaders('')); + +const getStopLocations = cached(STOP_LOCATIONS_TTL, async () => { + const headers = await getAuthHeaders(); + const baseData = GetBaseDataResponseSchema.parse(await getBaseData(headers)); + const patternPaths = GetPatternPathsResponseSchema.parse( + await getPatternPaths( + baseData.routes.map((route) => route.key), + headers, + ), + ); + + const stopPoints = patternPaths + .flatMap((route) => route.patternPaths) + .flatMap((path) => path.patternPoints) + .filter((point): point is IPatternPoint & { stop: NonNullable } => + Boolean(point.stop), + ); + + return keyBy(stopPoints, (point) => point.stop.stopCode); +}); + +export async function searchBusStops(query: string): Promise { + const headers = await getAuthHeaders(); + const [stops, stopLocations] = await Promise.all([ + findBusStops(query, { + Cookie: headers['Cookie']!, + 'X-Requested-With': headers['X-Requested-With']!, + }), + getStopLocations(), + ]); + + return stops.map((stop: IFoundStop) => { + const point = stopLocations[stop.stopCode]; return { - queryKey: [ASAPIQueryKey.VEHICLES, routeKey], - queryFn: async () => { - let busesResponse = (await getVehicles( - [routeKey], - authTokenQuery.data!, - )) as IGetVehiclesResponse; - - GetVehiclesResponseSchema.parse(busesResponse); - - if (busesResponse.length === 0) { - appLogger.w(`No vehicles data returned for route: ${routeKey}`); - return null; - } - - return busesResponse[0]; - }, - enabled: routeKey !== '', - dependents: [authTokenQuery], - staleTime: moment.duration(10, 'seconds'), - refetchInterval: moment.duration(10, 'seconds'), + type: 'stop', + title: point?.stop.name ?? '', + subtitle: 'ID: ' + point?.stop.stopCode, + stopCode: point?.stop.stopCode, + lat: point?.latitude, + long: point?.longitude, }; }); -}; +} -function extractRequestVerificationToken(html: string): string { - // - // grab the encoded request verification token - // - const regex = new RegExp('"[a-zA-Z0-9]{288}MQ=="', 'g'); +function tripPlannerPath(origin: SearchSuggestion, destination: SearchSuggestion, date: Date) { + const time = (date.getTime() / 1000).toFixed(0); - // check for a string - const matches = html.match(regex); - if (matches === null) { - throw new Error('Could not find verification token'); + if (origin.title === 'My Location') { + return `TripPlanner/Results?o1=${origin.title}&ola=${origin.lat}&olo=${origin.long}&og=1&d1=${destination.title}&dsc=${destination.stopCode}&dt=${time}&ro=0`; } + if (destination.title === 'My Location') { + return `TripPlanner/Results?o1=${origin.title}&osc=${origin.stopCode}&d1=${destination.title}&dla=${origin.lat}&dlo=${origin.long}&dg=true&dt=${time}&ro=0`; + } + return `TripPlanner/Results?o1=${origin.title}&osc=${origin.stopCode}&d1=${destination.title}&dsc=${destination.stopCode}&dt=${time}&ro=0`; +} - // get a nice string - const encoded_token = matches[0].slice(1, -1); - - // b64 decode - return atob(encoded_token); +export async function planTrip( + origin: SearchSuggestion, + destination: SearchSuggestion, + date: Date, + deadline: 'leave' | 'arrive', +): Promise { + const headers = await fetchVerificationHeaders(tripPlannerPath(origin, destination, date)); + const response = await getTripPlan( + headers, + origin, + destination, + 0, + deadline === 'arrive' ? date : undefined, + deadline === 'leave' ? date : undefined, + ); + + GetTripPlanResponseSchema.parse(response); + + // @ts-expect-error: Types are wrong in lib + return response as ITripPlanResponse; } diff --git a/src/lib/data/api/route_planning.ts b/src/lib/data/api/route_planning.ts deleted file mode 100644 index 3eb8f6aa..00000000 --- a/src/lib/data/api/route_planning.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - GetTripPlanResponseSchema, - type IFoundStop, - type ITripPlanResponse, -} from '$lib/data/typecheck/aggie_spirit'; -import { type SearchSuggestion } from '$lib/utils/route-planning'; -import { findBusStops, getTripPlan } from 'aggie-spirit-api'; -import { keyBy } from 'lodash-es'; -import { createLoggingQuery } from '../../utils/queries'; -import { useASRoutes } from '../structure/aggie_spirit'; -import { useAuthTokenAPI, useRoutePlanAuthTokenAPI } from './aggie_spirit'; - -export enum ASRoutePlanQueryKey { - SEARCH_SUGGESTION = 'ASAPISearchSuggestion', - TRIP_PLAN = 'ASAPITripPlan', -} - -export { useRoutePlanAuthTokenAPI }; - -export const useSearchSuggestionAPI = (params: () => { query: string }) => { - const authTokenQuery = useAuthTokenAPI(); - const routesQuery = useASRoutes(); - - return createLoggingQuery(() => { - const { query } = params(); - return { - queryKey: [ASRoutePlanQueryKey.SEARCH_SUGGESTION, query], - queryFn: async () => { - // we need data from pattern paths to get the stop GPS locations - // This is limitation of the API where we can't get the GPS location of a stop directly - // we can just ignore the bus stops if we don't have the pattern paths - // since Google already has most buildings in their search - let queryData = authTokenQuery.data!; - queryData = { - Cookie: queryData['Cookie']!, - 'X-Requested-With': queryData['X-Requested-With']!, - }; - - const stops = await findBusStops(query, queryData); - - const stopsById = keyBy( - routesQuery.data?.flatMap((route) => route.directions).flatMap((path) => path.stops) ?? - [], - 'id', - ); - - // handle bus stops - let busStops: SearchSuggestion[] = []; - - busStops = stops.map((stop: IFoundStop) => { - const foundLocation = stopsById[stop.stopCode]; - - return { - type: 'stop', - title: foundLocation?.name ?? '', - subtitle: 'ID: ' + foundLocation?.id, - stopCode: foundLocation?.id, - lat: foundLocation?.location.latitude, - long: foundLocation?.location.longitude, - }; - }); - - return busStops; - }, - enabled: authTokenQuery.isSuccess && routesQuery.isSuccess && query !== '', - staleTime: Infinity, - }; - }); -}; - -export const useTripPlanAPI = ( - params: () => { - origin: SearchSuggestion | null; - destination: SearchSuggestion | null; - date: Date; - deadline: 'leave' | 'arrive'; - }, -) => { - const routePlanAuthToken = useRoutePlanAuthTokenAPI(() => { - const { origin, destination, date } = params(); - // build the query string - // ?o1=Commons&osc=0100&d1=HEEP&dsc=0108&dt=1736392200&ro=0 - let queryString = ''; - if (origin && destination) { - if (origin.title === 'My Location') { - queryString = `Results?o1=${origin.title}&ola=${origin.lat}&olo=${origin.long}&og=1&d1=${destination.title}&dsc=${destination.stopCode}&dt=${(date.getTime() / 1000).toFixed(0)}&ro=0`; - } else if (destination.title === 'My Location') { - queryString = `Results?o1=${origin.title}&osc=${origin.stopCode}&d1=${destination.title}&dla=${origin.lat}&dlo=${origin.long}&dg=true&dt=${(date.getTime() / 1000).toFixed(0)}&ro=0`; - } else { - queryString = `Results?o1=${origin.title}&osc=${origin.stopCode}&d1=${destination.title}&dsc=${destination.stopCode}&dt=${(date.getTime() / 1000).toFixed(0)}&ro=0`; - } - } - return { queryString }; - }); - - return createLoggingQuery(() => { - const { origin, destination, date, deadline } = params(); - return { - queryKey: [ASRoutePlanQueryKey.TRIP_PLAN, origin, destination, date, deadline], - queryFn: async () => { - let response = await getTripPlan( - routePlanAuthToken.data!, - origin!, - destination!, - 0, - deadline === 'arrive' ? date : undefined, - deadline === 'leave' ? date : undefined, - ); - - GetTripPlanResponseSchema.parse(response); - - // @ts-expect-error: Types are wrong in lib - return response as ITripPlanResponse; - }, - enabled: - routePlanAuthToken.isSuccess && - origin !== null && - destination !== null && - date !== null && - deadline !== null, - staleTime: 60000, // 2 minutes - }; - }); -}; diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 52e07a81..662652f1 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -1,149 +1,282 @@ import { + EstimateSource, type Alert, - Amenity, + type Amenity, type Bus, - DataSource, type Direction, type Route, type Stop, - type StopSchedule, + type StopEstimates, type TimeEstimate, + type TimetableDeparture, } from '$lib/data/types'; -import { queryLogger } from '$lib/utils/logger'; +import { db } from '$lib/db/database'; +import { alert, alertDirection, direction, directionStop, route, timetable } from '$lib/db/schema'; +import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; +import { findBoundingBox } from '$lib/utils/geo'; +import { createDbQuery } from '$lib/utils/queries'; +import { decode } from '@googlemaps/polyline-codec'; +import { and, asc, eq, gte, isNull, or, sql, type SQL } from 'drizzle-orm'; import moment from 'moment'; -import { createDependencyQuery, createSelectableQuery } from '../utils/queries'; -import { - useASAlerts, - useASRoutes, - useASStopAmenities, - useASStopEstimate, - useASStopSchedule, - useASTimetableEstimate, - useASVehicles, -} from './structure/aggie_spirit'; - -export enum QueryKey { - ROUTE_LIST = 'MRRouteList', - VEHICLES = 'MRVehicles', - STOP_ESTIMATE = 'MRStopEstimate', - TIMETABLE_ESTIMATE = 'MRTimetableEstimate', - STOP_AMENITIES = 'MRStopAmenities', - STOP_SCHEDULE = 'MRStopSchedule', - ALERTS = 'MRAlerts', -} -// prevents having to reprocess data every time theme or favorites change -export const useRoutes = () => { - const asRouteList = useASRoutes(); +// Upcoming times are relative to "now", so they refresh even when nothing is synced. +const SCHEDULE_REFRESH_INTERVAL = moment.duration(30, 'seconds'); +const STOP_ESTIMATE_LIMIT = 3; +// A late bus is still coming after its scheduled time, so recent times stay in play for live estimates. +const LATE_DEPARTURE_WINDOW = '-30 minutes'; +const UPCOMING_TIME_LIMIT = 10; +// A bus that left a minute ago is often still at the stop. +const DEPARTED_GRACE = moment.duration(1, 'minute'); - const query = createDependencyQuery(() => ({ - queryKey: [QueryKey.ROUTE_LIST], - queryFn: async () => { - queryLogger.i(`Loaded ${asRouteList.data?.length} routes from Aggie Spirit`); - return asRouteList.data!; +const slotTime = sql`slot.value`; + +function routeTree(where?: SQL) { + return db.query.route.findMany({ + where: and(eq(route.active, true), where), + orderBy: asc(route.shortName), + with: { + directions: { + orderBy: asc(direction.sequence), + with: { + directionStops: { orderBy: asc(directionStop.sequence), with: { stop: true } }, + }, + }, }, - dependents: [asRouteList], - })); + }); +} - return query; -}; +type RouteRow = Awaited>[number]; +type DirectionRow = RouteRow['directions'][number]; +type DirectionStopRow = DirectionRow['directionStops'][number]; -export const useVehicles = (params: () => { route: Route | null }) => { - const asVehiclesQuery = useASVehicles(params); +function toStop(row: DirectionStopRow, index: number, all: DirectionStopRow[]): Stop { + return { + id: row.stop.id, + name: row.stop.name, + location: { latitude: row.stop.lat, longitude: row.stop.lon }, + amenities: row.stop.amenities as Amenity[], + isTimepoint: row.isTimepoint, + isLastOnDirection: index === all.length - 1, + }; +} - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.VEHICLES, params().route?.id], - selector: params().route?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asVehiclesQuery, - }, - unsupportedValue: [], - enabled: params().route !== null, +function toDirection(row: DirectionRow, _: number, all: DirectionRow[]): Direction { + return { + id: row.id, + name: row.destination, + pathPoints: decode(row.path).map(([latitude, longitude]) => ({ latitude, longitude })), + stops: row.directionStops.map(toStop), + isOnlyDirection: all.length === 1, + }; +} + +function toRoute(row: RouteRow): Route { + const directions = row.directions.map(toDirection); + return { + id: row.id, + name: row.longName, + routeCode: row.shortName, + lightColor: row.lightColor, + darkColor: row.darkColor, + directions, + bounds: findBoundingBox(directions.flatMap((d) => d.pathPoints)), + }; +} + +export const useRoutes = () => + createDbQuery(() => ({ + key: ['routes'], + queryFn: async () => (await routeTree()).map(toRoute), })); - return query; -}; +export const useRoute = (params: () => { routeId: string }) => + createDbQuery(() => { + const { routeId } = params(); + return { + key: ['route', routeId], + queryFn: async () => (await routeTree(eq(route.id, routeId))).map(toRoute)[0] ?? null, + }; + }); -export const useStopEstimate = ( - params: () => { route: Route; direction: Direction; stop: Stop }, -) => { - const asEstimateQuery = useASStopEstimate(params); +export const useAlerts = (params: () => { routeId: string | null }) => + createDbQuery(() => { + const { routeId } = params(); + return { + key: ['alerts', routeId], + enabled: routeId !== null, + queryFn: () => + db + .selectDistinct({ id: alert.id, title: alert.title, description: alert.description }) + .from(alert) + .innerJoin(alertDirection, eq(alertDirection.alertId, alert.id)) + .innerJoin(direction, eq(direction.id, alertDirection.directionId)) + .where( + and( + eq(direction.routeId, routeId!), + or(isNull(alert.endsAt), gte(sql`unixepoch(${alert.endsAt})`, sql`unixepoch('now')`)), + ), + ), + }; + }); - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.STOP_ESTIMATE, params().route.id, params().direction.id, params().stop.id], - selector: params().route?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asEstimateQuery, - }, - unsupportedValue: [], - enabled: params().route !== null, - })); +/** Every scheduled time at a stop on one day. */ +function scheduledTimes(direction: Direction, stop: Stop, serviceDate: string | SQL, where?: SQL) { + return db + .select({ scheduledAt: slotTime }) + .from(timetable) + .innerJoin(sql`json_each(${timetable.departures}) slot`, sql`true`) + .where( + and( + eq(timetable.directionId, direction.id), + eq(timetable.stopId, stop.id), + eq(timetable.serviceDate, serviceDate), + where, + ), + ) + .orderBy(sql`unixepoch(${slotTime})`); +} - return query; -}; +interface UpcomingTimes { + hasTimetable: boolean; + times: moment.Moment[]; +} -export const useTimetableEstimate = (params: () => { stop: Stop | null; date: moment.Moment }) => { - const asTimetableEstimateQuery = useASTimetableEstimate(params); +/** Today's scheduled times at a stop that may still be coming, including late ones. */ +const useUpcomingTimes = (params: () => { direction: Direction; stop: Stop }) => + createDbQuery(() => { + const { direction, stop } = params(); + const today = sql`date('now', 'localtime')`; + return { + key: ['upcomingTimes', direction.id, stop.id], + refetchInterval: SCHEDULE_REFRESH_INTERVAL, + queryFn: async () => { + const [rows, timetables] = await Promise.all([ + scheduledTimes( + direction, + stop, + today, + gte(sql`unixepoch(${slotTime})`, sql`unixepoch('now', ${LATE_DEPARTURE_WINDOW})`), + ).limit(UPCOMING_TIME_LIMIT), + db + .select({ id: timetable.id }) + .from(timetable) + .where( + and( + eq(timetable.directionId, direction.id), + eq(timetable.stopId, stop.id), + eq(timetable.serviceDate, today), + ), + ) + .limit(1), + ]); - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.TIMETABLE_ESTIMATE, params().stop?.id, params().date.format('YYYY-MM-DD')], - selector: params().stop?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asTimetableEstimateQuery, - }, - unsupportedValue: [], - enabled: params().stop !== null, - })); + return { + hasTimetable: timetables.length > 0, + times: rows.map((row) => moment(row.scheduledAt)), + }; + }, + }; + }); - return query; -}; +/** Marks each scheduled time with its live estimate, dropping cancelled and departed buses. */ +function withLiveEstimates( + times: moment.Moment[], + direction: Direction, + stop: Stop, +): TimeEstimate[] { + const earliest = moment().subtract(DEPARTED_GRACE); + return times + .flatMap((scheduledTime) => { + const live = liveDataManager.departureAt(stop.id, direction.id, scheduledTime); + if (live?.isCancelled) return []; -export const useStopAmenities = ( - params: () => { route: Route; direction: Direction; stop: Stop }, -) => { - const asStopAmenities = useASStopAmenities(params); + const estimatedTime = live?.estimatedAt ? moment(live.estimatedAt) : null; + if ((estimatedTime ?? scheduledTime).isBefore(earliest)) return []; + return [{ scheduledTime, estimatedTime, isRealTime: estimatedTime !== null }]; + }) + .sort((a, b) => (a.estimatedTime ?? a.scheduledTime).diff(b.estimatedTime ?? b.scheduledTime)) + .slice(0, STOP_ESTIMATE_LIMIT); +} - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.STOP_AMENITIES, params().route.id, params().direction.id, params().stop.id], - selector: params().stop?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asStopAmenities, - }, - unsupportedValue: [], - enabled: params().stop !== null, - })); +/** A stop's next departures from its timetable, updated with live estimates once they arrive. */ +export function useStopEstimate(params: () => { direction: Direction; stop: Stop }) { + const upcoming = useUpcomingTimes(params); - return query; -}; + return { + get data(): StopEstimates { + const { direction, stop } = params(); + const isLive = liveDataManager.status === LiveDataStatus.LIVE; + if (!upcoming.data) return { source: EstimateSource.LOADING, estimates: [] }; -export const useStopSchedule = (params: () => { stop: Stop | null; date: moment.Moment }) => { - const asStopScheduleQuery = useASStopSchedule(params); + if (!upcoming.data.hasTimetable) { + const live = isLive ? liveDataManager.estimatesFor(direction.id, stop.id) : []; + return live.length > 0 + ? { source: EstimateSource.LIVE, estimates: live } + : { source: EstimateSource.UNAVAILABLE, estimates: [] }; + } - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.STOP_SCHEDULE, params().stop?.id, params().date.format('YYYY-MM-DD')], - selector: params().stop?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asStopScheduleQuery, + return { + source: isLive ? EstimateSource.LIVE : EstimateSource.SCHEDULE, + estimates: withLiveEstimates(upcoming.data.times, direction, stop), + }; }, - unsupportedValue: [], - enabled: params().stop !== null, - })); - - return query; -}; + }; +} -export const useAlerts = (params: () => { route: Route | null }) => { - const asAlertQuery = useASAlerts(params); +/** A stop's timetable for one day, marked with live estimates and cancellations. */ +export function useTimetable( + params: () => { direction: Direction | null; stop: Stop | null; date: moment.Moment }, +) { + const times = createDbQuery(() => { + const { direction, stop, date } = params(); + const serviceDate = date.format('YYYY-MM-DD'); + return { + key: ['timetable', direction?.id, stop?.id, serviceDate], + enabled: direction !== null && stop !== null, + queryFn: async () => + (await scheduledTimes(direction!, stop!, serviceDate)).map((row) => + moment(row.scheduledAt), + ), + }; + }); - const query = createSelectableQuery(() => ({ - queryKey: [QueryKey.ALERTS, params().route?.id], - selector: params().route?.dataSource, - queries: { - [DataSource.AGGIE_SPIRIT]: asAlertQuery, + return { + get isLoading() { + return times.isLoading; }, - unsupportedValue: [], - enabled: params().route !== null, - })); + get data(): TimetableDeparture[] | undefined { + const { direction, stop } = params(); + return times.data?.map((scheduledTime) => { + const live = liveDataManager.departureAt(stop!.id, direction!.id, scheduledTime); + return { + scheduledTime, + estimatedTime: live?.estimatedAt ? moment(live.estimatedAt) : null, + isCancelled: live?.isCancelled ?? false, + }; + }); + }, + }; +} - return query; -}; +/** The subscribed route's buses. */ +export function useVehicles(params: () => { route: Route | null }) { + return { + get data(): Bus[] { + const { route } = params(); + if (!route) return []; + return liveDataManager.vehicles.map((vehicle) => ({ + id: vehicle.id, + name: vehicle.name, + location: { latitude: vehicle.lat, longitude: vehicle.lon }, + heading: vehicle.heading, + speed: vehicle.speed, + amenities: (vehicle.amenities ?? []) as Amenity[], + capacity: + vehicle.capacity > 0 ? Math.round((vehicle.passengers / vehicle.capacity) * 100) : 0, + direction: + route.directions.find((d) => d.id === vehicle.directionId) ?? route.directions[0], + route, + })); + }, + }; +} diff --git a/src/lib/data/route_planning.ts b/src/lib/data/route_planning.ts index d9646e33..c21ae934 100644 --- a/src/lib/data/route_planning.ts +++ b/src/lib/data/route_planning.ts @@ -1,24 +1,58 @@ -import { DataSource, type PlaceSuggestion, type PlanItem } from '$lib/data/types'; -import { createSelectableQuery } from '../utils/queries'; -import { useASSearchSuggestions, useASTripPlan } from './structure/route_planning'; +import { planTrip, searchBusStops } from '$lib/data/api/aggie_spirit'; +import { + DataSource, + MovementType, + MY_LOCATION_ID, + type PlaceSuggestion, + type PlanInstruction, + type PlanItem, +} from '$lib/data/types'; +import { createLoggingQuery } from '$lib/utils/queries'; +import { type SearchSuggestion } from '$lib/utils/route-planning'; +import { decode } from '@googlemaps/polyline-codec'; +import moment from 'moment'; export enum QueryKey { SEARCH_SUGGESTIONS = 'MRSearchSuggestions', TRIP_PLAN = 'MRTripPlan', } -export const useSearchSuggestions = (params: () => { query: string }) => { - const asSearchSuggestions = useASSearchSuggestions(params); - return createSelectableQuery(() => ({ - queryKey: [QueryKey.SEARCH_SUGGESTIONS, params().query], - selector: DataSource.AGGIE_SPIRIT, - queries: { - [DataSource.AGGIE_SPIRIT]: asSearchSuggestions, - }, - unsupportedValue: [], - enabled: params().query.length > 0, - })); -}; +const TRIP_PLAN_STALE_TIME = moment.duration(1, 'minute'); + +function toSearchSuggestion(place: PlaceSuggestion): SearchSuggestion { + return { + title: place.name, + subtitle: place.description, + stopCode: place.id !== MY_LOCATION_ID ? place.id : undefined, + lat: place.location?.latitude, + long: place.location?.longitude, + type: place.type as 'stop' | 'my-location' | 'map', + }; +} + +export const useSearchSuggestions = (params: () => { query: string }) => + createLoggingQuery(() => { + const { query } = params(); + return { + queryKey: [QueryKey.SEARCH_SUGGESTIONS, query], + queryFn: async () => + (await searchBusStops(query)).map( + (suggestion) => + ({ + dataSource: DataSource.AGGIE_SPIRIT, + id: suggestion.stopCode ?? MY_LOCATION_ID, + name: suggestion.title, + description: suggestion.subtitle, + location: suggestion.stopCode + ? { latitude: suggestion.lat!, longitude: suggestion.long! } + : null, + type: suggestion.type, + }) as PlaceSuggestion, + ), + enabled: query.length > 0, + staleTime: Infinity, + }; + }); export const useTripPlan = ( params: () => { @@ -27,21 +61,43 @@ export const useTripPlan = ( date: Date; deadline: 'leave' | 'arrive'; }, -) => { - const asTripPlan = useASTripPlan(params); - return createSelectableQuery(() => ({ - queryKey: [ - QueryKey.TRIP_PLAN, - params().origin?.id, - params().destination?.id, - params().date.toISOString(), - params().deadline, - ], - selector: DataSource.AGGIE_SPIRIT, - queries: { - [DataSource.AGGIE_SPIRIT]: asTripPlan, - }, - unsupportedValue: [], - enabled: !!params().origin && !!params().destination, - })); -}; +) => + createLoggingQuery(() => { + const { origin, destination, date, deadline } = params(); + return { + queryKey: [QueryKey.TRIP_PLAN, origin?.id, destination?.id, date.toISOString(), deadline], + queryFn: async () => { + const plan = await planTrip( + toSearchSuggestion(origin!), + toSearchSuggestion(destination!), + date, + deadline, + ); + + return plan.options.map( + (option): PlanItem => ({ + dataSource: DataSource.AGGIE_SPIRIT, + startTime: option.startTime, + endTime: option.endTime, + endTimeText: option.endTimeText, + instructions: option.instructions.map( + (instruction): PlanInstruction => ({ + movementType: instruction.className as MovementType, + time: instruction.startTime, + instruction: instruction.instruction ?? '', + pathPoints: (instruction.polyline ? decode(instruction.polyline) : []).map( + ([latitude, longitude]) => ({ latitude, longitude }), + ), + detailedWalkingInstructions: instruction.walkingInstructions.map((step) => ({ + stepNumber: step.index, + instruction: step.instruction, + })), + }), + ), + }), + ); + }, + enabled: origin !== null && destination !== null, + staleTime: TRIP_PLAN_STALE_TIME, + }; + }); diff --git a/src/lib/data/structure/aggie_spirit.ts b/src/lib/data/structure/aggie_spirit.ts deleted file mode 100644 index 0f3fcd2f..00000000 --- a/src/lib/data/structure/aggie_spirit.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { - Amenity, - DataSource, - type Alert, - type Bus, - type Direction, - type Location, - type PathLocation, - type Route, - type Stop, - type StopSchedule, - type TimeEstimate, -} from '$lib/data/types'; -import { isTimepoint, useTimepointsAPI } from '$lib/data/timepoints'; -import { findBoundingBox } from '$lib/utils/geo'; -import { compact, keyBy } from 'lodash-es'; -import moment from 'moment'; -import { createDependencyQuery } from '../../utils/queries'; -import { - useBaseDataAPI, - usePatternPathsAPI, - useServiceInterruptionsAPI, - useStopEstimateAPI, - useStopScheduleAPI, - useTimetableEstimateAPI, - useVehiclesAPI, -} from '../api/aggie_spirit'; - -export enum ASQueryKey { - ROUTE_LIST = 'ASRouteList', - VEHICLES = 'ASVehicles', - STOP_ESTIMATE = 'ASStopEstimate', - TIMETABLE_ESTIMATE = 'ASTimetableEstimate', - STOP_AMENITIES = 'ASStopAmenities', - STOP_SCHEDULE = 'ASStopSchedule', - ALERTS = 'ASAlerts', -} - -export const useASRoutes = () => { - const apiBaseDataQuery = useBaseDataAPI(); - const apiPatternPathsQuery = usePatternPathsAPI(); - - const query = createDependencyQuery(() => ({ - // TODO: add back theme.mode to query key when theme support is added back - queryKey: [ASQueryKey.ROUTE_LIST], - queryFn: async () => { - let apiBaseData = apiBaseDataQuery.data!; - let apiPatternPathData = apiPatternPathsQuery.data!; - - return apiBaseData.routes.map((baseRoute): Route => { - // Find matching pattern paths for this route - const matchingPatternPaths = apiPatternPathData.find( - (path) => path.routeKey === baseRoute.key, - )!; - - // process directions - const directions: Direction[] = baseRoute.directionList.map((dir) => { - const patternPathForDirection = matchingPatternPaths?.patternPaths.find( - (pp) => pp.directionKey === dir.direction.key, - )!; - - const points: PathLocation[] = - patternPathForDirection.patternPoints.map((pt) => ({ - latitude: pt.latitude, - longitude: pt.longitude, - isStop: !!pt.stop, - })) || []; - - const filteredPoints = patternPathForDirection.patternPoints.filter((pt) => !!pt.stop); - - const stops: Stop[] = filteredPoints.map((pt, i) => { - // Build Stop - return { - dataSource: DataSource.AGGIE_SPIRIT, - name: pt.stop!.name || '', - id: pt.stop!.stopCode || '', - location: { - latitude: pt.latitude, - longitude: pt.longitude, - } as Location, - isLastOnDirection: i === filteredPoints.length - 1, - }; - }); - - return { - dataSource: DataSource.AGGIE_SPIRIT, - name: dir.direction.name, - id: dir.direction.key, - pathPoints: points, - stops: stops, - isOnlyDirection: baseRoute.directionList.length === 1, - }; - }); - - const tintColor = baseRoute.directionList[0].lineColor; - - const directionBounds: Location[] = directions.flatMap((direction) => - findBoundingBox(direction.pathPoints), - ); - const bounds: Location[] = findBoundingBox(directionBounds); - - return { - dataSource: DataSource.AGGIE_SPIRIT, - name: baseRoute.name, - id: baseRoute.key, - routeCode: baseRoute.shortName, - tintColor: tintColor, - directions: directions, - bounds: bounds, - }; - }); - }, - dependents: [apiBaseDataQuery, apiPatternPathsQuery], - })); - - return query; -}; - -export const useASVehicles = (params: () => { route: Route | null }) => { - const apiBusesQuery = useVehiclesAPI(() => { - const { route } = params(); - return { routeKey: route?.id ?? '' }; - }); - - const query = createDependencyQuery(() => ({ - queryKey: [ASQueryKey.VEHICLES, params().route?.id ?? ''], - queryFn: async () => { - let apiBuses = apiBusesQuery.data!; - - if (!apiBuses.vehiclesByDirections) return []; - const { route: currentRoute } = params(); - if (!currentRoute || currentRoute.directions.length === 0) return []; - - return apiBuses.vehiclesByDirections.flatMap((vehicleDirection): Bus[] => { - let direction = - currentRoute.directions.find((dir) => dir.id === vehicleDirection.directionKey) ?? - currentRoute.directions[0]; - - const directionBuses: Bus[] = vehicleDirection.vehicles.map((vehicle) => { - return { - dataSource: DataSource.AGGIE_SPIRIT, - location: { - latitude: vehicle.location.latitude, - longitude: vehicle.location.longitude, - }, - heading: vehicle.location.heading, - amenities: Amenity.fromAPI(vehicle.amenities), - capacity: Math.round((vehicle.passengersOnboard / vehicle.passengerCapacity) * 100), - speed: vehicle.location.speed, - id: vehicle.key, - direction: direction, - name: vehicle.name, - route: currentRoute, - }; - }); - - return directionBuses; - }); - }, - dependents: [apiBusesQuery], - })); - - return query; -}; - -export const useASStopEstimate = ( - params: () => { route: Route; direction: Direction; stop: Stop }, -) => { - const apiStopEstimateQuery = useStopEstimateAPI(() => { - const { route, direction, stop } = params(); - return { routeKey: route.id, directionKey: direction.id, stopCode: stop.id }; - }); - - const query = createDependencyQuery(() => ({ - queryKey: [ - ASQueryKey.STOP_ESTIMATE, - params().route.id, - params().direction.id, - params().stop.id, - ], - queryFn: async () => { - const stopEstimates = apiStopEstimateQuery.data!; - - return stopEstimates.routeDirectionTimes[0].nextDeparts.map((e) => { - return { - dataSource: DataSource.AGGIE_SPIRIT, - estimatedTime: e.estimatedDepartTimeUtc - ? moment.utc(e.estimatedDepartTimeUtc).local() - : null, - scheduledTime: moment.utc(e.scheduledDepartTimeUtc).local(), - isRealTime: e.estimatedDepartTimeUtc != null, - } as TimeEstimate; - }); - }, - dependents: [apiStopEstimateQuery], - })); - - return query; -}; - -export const useASTimetableEstimate = ( - params: () => { stop: Stop | null; date: moment.Moment }, -) => { - const apiTimetableEstimateQuery = useTimetableEstimateAPI(() => { - const { stop, date } = params(); - return { stopCode: stop?.id ?? '', date: date.toDate() }; - }); - - const apiRoutes = useASRoutes(); - - const query = createDependencyQuery(() => ({ - queryKey: [ASQueryKey.TIMETABLE_ESTIMATE, params().stop?.id ?? '', params().date.toISOString()], - queryFn: async () => { - const timetableEstimateData = apiTimetableEstimateQuery.data!; - const { stop: currentStop } = params(); - - return timetableEstimateData.routeStopSchedules.map((routeStop) => { - const timeEstimates = routeStop.stopTimes.map( - (stopTime) => - ({ - dataSource: DataSource.AGGIE_SPIRIT, - estimatedTime: stopTime.estimatedDepartTimeUtc - ? moment.utc(stopTime.estimatedDepartTimeUtc).local() - : null, - scheduledTime: moment.utc(stopTime.scheduledDepartTimeUtc).local(), - tripPointId: stopTime.tripPointId, - isRealTime: stopTime.estimatedDepartTimeUtc != null, - isCancelled: stopTime.isCancelled, - }) as TimeEstimate, - ); - - const route = apiRoutes.data?.find((r) => r.routeCode === routeStop.routeNumber)!; - - const direction = - route?.directions.length === 1 - ? route.directions[0] - : route?.directions.find((d) => { - return d.name === routeStop.directionName; - }); - - return { - dataSource: DataSource.AGGIE_SPIRIT, - route: route, - direction: direction, - stop: currentStop!, - timetable: timeEstimates, - } as StopSchedule; - }); - }, - dependents: [apiTimetableEstimateQuery, apiRoutes], - })); - - return query; -}; - -export const useASStopAmenities = ( - params: () => { route: Route; direction: Direction; stop: Stop }, -) => { - const apiStopEstimateQuery = useStopEstimateAPI(() => { - const { route, direction, stop } = params(); - return { routeKey: route.id, directionKey: direction.id, stopCode: stop.id }; - }); - - const apiTimepointsQuery = useTimepointsAPI(); - - const query = createDependencyQuery(() => ({ - queryKey: [ - ASQueryKey.STOP_AMENITIES, - params().route.id, - params().direction.id, - params().stop.id, - ], - queryFn: async () => { - const stopEstimates = apiStopEstimateQuery.data!; - const { route, stop } = params(); - - return [ - ...Amenity.fromAPI(stopEstimates.amenities), - ...(isTimepoint(apiTimepointsQuery.data!, route.routeCode, stop.id) - ? [Amenity.TIME_POINT] - : []), - ]; - }, - dependents: [apiStopEstimateQuery, apiTimepointsQuery], - })); - - return query; -}; - -export const useASStopSchedule = (params: () => { stop: Stop | null; date: moment.Moment }) => { - const apiStopScheduleQuery = useStopScheduleAPI(() => { - const { stop, date } = params(); - return { stopCode: stop?.id ?? '', date: date.toDate() }; - }); - - const apiRoutes = useASRoutes(); - - const query = createDependencyQuery(() => ({ - queryKey: [ASQueryKey.STOP_SCHEDULE, params().stop?.id ?? '', params().date.toISOString()], - queryFn: async () => { - const stopScheduleData = apiStopScheduleQuery.data!; - const { stop: currentStop } = params(); - - return stopScheduleData.routeStopSchedules.map((routeStop) => { - const timeEstimates = routeStop.stopTimes.map( - (stopTime) => - ({ - dataSource: DataSource.AGGIE_SPIRIT, - estimatedTime: stopTime.estimatedDepartTimeUtc - ? moment.utc(stopTime.estimatedDepartTimeUtc).local() - : null, - scheduledTime: moment.utc(stopTime.scheduledDepartTimeUtc).local(), - tripPointId: stopTime.tripPointId, - isRealTime: stopTime.estimatedDepartTimeUtc != null, - isCancelled: stopTime.isCancelled, - }) as TimeEstimate, - ); - - const route = apiRoutes.data?.find((r) => r.routeCode === routeStop.routeNumber); - - const direction = - route?.directions.length === 1 - ? route.directions[0] - : route?.directions.find((d) => { - return d.name === routeStop.directionName; - }); - - return { - dataSource: DataSource.AGGIE_SPIRIT, - route: route, - direction: direction, - stop: currentStop!, - timetable: timeEstimates, - isEndOfRoute: routeStop.isEndOfRoute, - } as StopSchedule; - }); - }, - dependents: [apiStopScheduleQuery, apiRoutes], - enabled: params().stop !== null, - })); - - return query; -}; - -export const useASAlerts = (params: () => { route: Route | null }) => { - const apiServiceInterruptionsQuery = useServiceInterruptionsAPI(); - const apiBaseDataQuery = useBaseDataAPI(); - const routesQuery = useASRoutes(); - - // Aggie Spirit does not have alerts - const query = createDependencyQuery(() => ({ - queryKey: [ASQueryKey.ALERTS, params().route?.id], - queryFn: async () => { - const { route: currentRoute } = params(); - if (!currentRoute) return []; - - const alerts = apiServiceInterruptionsQuery.data!; - const baseData = apiBaseDataQuery.data!; - const routes = routesQuery.data!; - - const selectedAPIRoute = baseData.routes.find((r) => r.key === currentRoute.id); - if (!selectedAPIRoute) return []; - - // Find any service interruptions that apply to this route - const routeAlertKeys = selectedAPIRoute.directionList - .flatMap((d) => d.serviceInterruptionKeys) - .map((s) => s.toString()); - - const routeAlerts = alerts.filter((si) => routeAlertKeys.includes(si.key)); - const routesById = keyBy(routes, 'id'); - - return routeAlerts.map((alert): Alert => { - // Find all routes affected by this alert - // used for showing all routes affected on map when tapped - const affectedAPIRoutes = compact( - baseData.routes - .filter((r) => - r.directionList.some((d) => - d.serviceInterruptionKeys.some((s) => s.toString() === alert.key), - ), - ) - .map((r) => routesById[r.key]), - ); - - return { - dataSource: DataSource.AGGIE_SPIRIT, - id: alert.key, - title: alert.name, - description: alert.description ?? '', - affectedRoutes: affectedAPIRoutes, - originalRoute: currentRoute, - }; - }); - }, - enabled: params().route !== null, - dependents: [apiServiceInterruptionsQuery, apiBaseDataQuery, routesQuery], - })); - - return query; -}; diff --git a/src/lib/data/structure/route_planning.ts b/src/lib/data/structure/route_planning.ts deleted file mode 100644 index 7aa3e184..00000000 --- a/src/lib/data/structure/route_planning.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - DataSource, - MovementType, - MY_LOCATION_ID, - type PlaceSuggestion, - type PlanInstruction, - type PlanItem, -} from '$lib/data/types'; -import { type SearchSuggestion } from '$lib/utils/route-planning'; -import { decode } from '@googlemaps/polyline-codec'; -import { createDependencyQuery } from '../../utils/queries'; -import { useSearchSuggestionAPI, useTripPlanAPI } from '../api/route_planning'; - -export enum ASQueryKeyRoutePlanning { - SEARCH_SUGGESTIONS = 'ASSearchSuggestions', - TRIP_PLAN = 'ASTripPlan', -} - -export const useASSearchSuggestions = (params: () => { query: string }) => { - const apiSearchSuggestionsQuery = useSearchSuggestionAPI(params); - - const suggestionQuery = createDependencyQuery(() => ({ - queryKey: [ASQueryKeyRoutePlanning.SEARCH_SUGGESTIONS, params().query], - queryFn: async () => { - if (!apiSearchSuggestionsQuery.data) { - return []; - } - - const apiSearchSuggestions = apiSearchSuggestionsQuery.data; - return apiSearchSuggestions.map((suggestion) => ({ - dataSource: DataSource.AGGIE_SPIRIT, - id: suggestion.stopCode ?? MY_LOCATION_ID, // should just use stop or my location - name: suggestion.title, - description: suggestion.subtitle, - location: suggestion.stopCode - ? { - latitude: suggestion.lat!, - longitude: suggestion.long!, - } - : null, - type: suggestion.type, - })) as PlaceSuggestion[]; - }, - dependents: [apiSearchSuggestionsQuery], - })); - return suggestionQuery; -}; - -export const useASTripPlan = ( - params: () => { - origin: PlaceSuggestion | null; - destination: PlaceSuggestion | null; - date: Date; - deadline: 'leave' | 'arrive'; - }, -) => { - // Convert PlaceSuggestion to SearchSuggestion in a thunk - const apiTripPlanQuery = useTripPlanAPI(() => { - const { origin, destination, date, deadline } = params(); - - const originSuggestion: SearchSuggestion | null = origin - ? { - title: origin.name, - subtitle: origin.description, - stopCode: origin.id !== MY_LOCATION_ID ? origin.id : undefined, - lat: origin.location?.latitude, - long: origin.location?.longitude, - type: origin.type as 'stop' | 'my-location' | 'map', - } - : null; - - const destinationSuggestion: SearchSuggestion | null = destination - ? { - title: destination.name, - subtitle: destination.description, - stopCode: destination.id !== MY_LOCATION_ID ? destination.id : undefined, - lat: destination.location?.latitude, - long: destination.location?.longitude, - type: destination.type as 'stop' | 'my-location' | 'map', - } - : null; - - return { - origin: originSuggestion, - destination: destinationSuggestion, - date, - deadline, - }; - }); - - const tripPlanQuery = createDependencyQuery(() => ({ - queryKey: [ - ASQueryKeyRoutePlanning.TRIP_PLAN, - params().origin?.id, - params().destination?.id, - params().date.toISOString(), - params().deadline, - ], - queryFn: async () => { - const apiTripPlan = apiTripPlanQuery.data!; - return apiTripPlan.options.map( - (plan): PlanItem => ({ - dataSource: DataSource.AGGIE_SPIRIT, - startTime: plan.startTime, - endTime: plan.endTime, - endTimeText: plan.endTimeText, - instructions: plan.instructions.map((instruction): PlanInstruction => { - const polyline = instruction.polyline ? decode(instruction.polyline) : []; - - const pathPoints = polyline.map((point) => ({ - latitude: point[0], - longitude: point[1], - })); - - return { - movementType: instruction.className as MovementType, - time: instruction.startTime, - instruction: instruction.instruction ?? '', - pathPoints: pathPoints, - detailedWalkingInstructions: instruction.walkingInstructions.map((step) => ({ - stepNumber: step.index, - instruction: step.instruction, - })), - }; - }), - }), - ); - }, - dependents: [apiTripPlanQuery], - })); - return tripPlanQuery; -}; diff --git a/src/lib/data/timepoints.ts b/src/lib/data/timepoints.ts deleted file mode 100644 index 01cfdc5c..00000000 --- a/src/lib/data/timepoints.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createLoggingQuery } from '$lib/utils/queries'; -import moment from 'moment'; -import { z } from 'zod'; - -const TIMEPOINTS_URL = 'https://auth.maroonrides.app/timepoints.json'; - -export const TimepointStopsSchema = z.record(z.string(), z.array(z.string())); -export type TimepointStops = z.infer; - -export const useTimepointsAPI = () => { - const query = createLoggingQuery(() => ({ - label: 'MRTimepoints', - queryKey: ['MRTimepoints'], - // never rejects: the route list depends on this, so a missing or malformed - // file has to degrade to "no timepoints" rather than block every route - queryFn: async () => { - try { - const res = await fetch(TIMEPOINTS_URL); - if (!res.ok) return {}; - - return TimepointStopsSchema.parse(await res.json()); - } catch { - return {}; - } - }, - staleTime: moment.duration(1, 'day'), - })); - - return query; -}; - -export function isTimepoint( - timepoints: TimepointStops, - routeCode: string, - stopId: string, -): boolean { - return timepoints[routeCode]?.includes(stopId) ?? false; -} diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 293e9a5f..17f58e77 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -1,4 +1,3 @@ -import { type IAmenity } from '$lib/data/typecheck/aggie_spirit'; import { AccessibilityIcon, AirVentIcon, @@ -14,23 +13,15 @@ import { import moment from 'moment'; export enum Amenity { - AIR_CONDITIONING = 'Air Conditioning', - WHEELCHAIR_ACCESSIBLE = 'Wheelchair Accessible', - WHEELCHAIR_LIFT = 'Wheelchair Lift', - BICYCLE_RACK = 'Bicycle Rack', - SHELTER = 'Shelter', - TIME_POINT = 'Time Point', + AIR_CONDITIONING = 'AIR_CONDITIONING', + WHEELCHAIR_ACCESSIBLE = 'WHEELCHAIR_ACCESSIBLE', + WHEELCHAIR_LIFT = 'WHEELCHAIR_LIFT', + BICYCLE_RACK = 'BICYCLE_RACK', + SHELTER = 'SHELTER', + TIME_POINT = 'TIME_POINT', } export namespace Amenity { - export function fromAPI(api: IAmenity[]): Amenity[] { - return api - .map((item) => { - return Object.values(Amenity).find((amenity) => amenity === item.name); - }) - .filter((amenity): amenity is Amenity => amenity !== undefined); - } - export function getIcon(amenity: Amenity) { switch (amenity) { case Amenity.AIR_CONDITIONING: @@ -63,68 +54,75 @@ export interface FromDataSource { dataSource: DataSource; } -export interface Stop extends FromDataSource { - name: string; +export interface Stop { id: string; + name: string; location: Location; + amenities: Amenity[]; + isTimepoint: boolean; isLastOnDirection: boolean; } -export interface PathLocation extends Location { - isStop: boolean; -} - -export interface Route extends FromDataSource { - name: string; +export interface Route { id: string; + name: string; routeCode: string; - tintColor: string; + lightColor: string; + darkColor: string; directions: Direction[]; bounds: Location[]; } -export interface Direction extends FromDataSource { - pathPoints: PathLocation[]; - name: string; +export interface Direction { id: string; + name: string; + pathPoints: Location[]; stops: Stop[]; isOnlyDirection: boolean; } -export interface Bus extends FromDataSource { +export interface Bus { + id: string; + name: string; location: Location; heading: number; + speed: number; amenities: Amenity[]; capacity: number; - speed: number; - id: string; direction: Direction; - name: string; route: Route; } -export interface TimeEstimate extends FromDataSource { +export interface TimeEstimate { estimatedTime: moment.Moment | null; scheduledTime: moment.Moment; - tripPointId?: string; isRealTime: boolean; - isCancelled?: boolean; } -export interface StopSchedule extends FromDataSource { - route: Route; - direction: Direction; - stop: Stop; - timetable: TimeEstimate[]; - isEndOfRoute: boolean; +export enum EstimateSource { + LOADING = 'loading', + LIVE = 'live', + SCHEDULE = 'schedule', + UNAVAILABLE = 'unavailable', +} + +export const ESTIMATES_UNAVAILABLE_MESSAGE = 'Stop estimates unavailable'; + +export interface StopEstimates { + source: EstimateSource; + estimates: TimeEstimate[]; +} + +export interface TimetableDeparture { + scheduledTime: moment.Moment; + estimatedTime: moment.Moment | null; + isCancelled: boolean; } -export interface Alert extends FromDataSource { +export interface Alert { id: string; title: string; description: string; - affectedRoutes: Route[]; - originalRoute: Route; } // Route Planning diff --git a/src/lib/db/database.test.ts b/src/lib/db/database.test.ts new file mode 100644 index 00000000..60766af4 --- /dev/null +++ b/src/lib/db/database.test.ts @@ -0,0 +1,41 @@ +import { route } from '$lib/db/schema'; +import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import { describe, expect, it } from 'vitest'; +import { TRANSACTION_SQL } from './database'; + +function recordingDb() { + const statements: string[] = []; + const db = drizzle(async (sql) => { + statements.push(sql); + return { rows: [] }; + }); + return { db, statements }; +} + +describe('drizzle transactions', () => { + it('send the statements the database maps to plugin transactions', async () => { + const { db, statements } = recordingDb(); + + await db.transaction(async (tx) => { + await tx.delete(route); + }); + + expect(statements).toEqual([ + TRANSACTION_SQL.BEGIN, + 'delete from "route"', + TRANSACTION_SQL.COMMIT, + ]); + }); + + it('roll back with the mapped statement when the callback throws', async () => { + const { db, statements } = recordingDb(); + + await expect( + db.transaction(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(statements).toEqual([TRANSACTION_SQL.BEGIN, TRANSACTION_SQL.ROLLBACK]); + }); +}); diff --git a/src/lib/db/database.ts b/src/lib/db/database.ts new file mode 100644 index 00000000..c1b495b2 --- /dev/null +++ b/src/lib/db/database.ts @@ -0,0 +1,117 @@ +import { + CapacitorSQLite, + SQLiteConnection, + type capSQLiteVersionUpgrade, + type SQLiteDBConnection, +} from '@capacitor-community/sqlite'; +import { Capacitor } from '@capacitor/core'; +import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import journal from './migrations/meta/_journal.json'; +import * as schema from './schema'; + +const DB_NAME = 'maroonrides.db'; +const DB_ENCRYPTION_MODE = 'no-encryption'; +const MIGRATION_BREAKPOINT = '--> statement-breakpoint'; +const WEB_STORE_ELEMENT = 'jeep-sqlite'; +const IS_WEB = Capacitor.getPlatform() === 'web'; + +// The statements drizzle's db.transaction() sends through the proxy. +export const TRANSACTION_SQL = { BEGIN: 'begin', COMMIT: 'commit', ROLLBACK: 'rollback' } as const; + +const migrationFiles = import.meta.glob('./migrations/*.sql', { + query: '?raw', + import: 'default', + eager: true, +}); + +function migrations(): capSQLiteVersionUpgrade[] { + return journal.entries.map((entry) => ({ + toVersion: entry.idx + 1, + statements: migrationFiles[`./migrations/${entry.tag}.sql`] + .split(MIGRATION_BREAKPOINT) + .map((statement) => statement.trim()) + .filter(Boolean), + })); +} + +async function installWebStore(sqlite: SQLiteConnection) { + const { defineCustomElements } = await import('jeep-sqlite/loader'); + defineCustomElements(window); + + const element = document.body.appendChild(document.createElement(WEB_STORE_ELEMENT)); + element.autoSave = true; + + // whenDefined resolves before the lazy component loads and opens its IndexedDB store. + await customElements.whenDefined(WEB_STORE_ELEMENT); + await element.componentOnReady(); + await sqlite.initWebStore(); +} + +class Database { + readonly #sqlite = new SQLiteConnection(CapacitorSQLite); + #connection: Promise | null = null; + + // sqlite-proxy expects rows as arrays in select order, the plugin returns objects keyed by column. + readonly db = drizzle( + async (sql, params, method) => { + const connection = await this.#connect(); + if (method === 'run') { + await this.#run(connection, sql, params); + return { rows: [] }; + } + + const { values = [] } = await connection.query(sql, params); + const rows = values.map((row) => Object.values(row)); + return { rows: method === 'get' ? rows[0] : rows }; + }, + { schema }, + ); + + // The plugin's own transaction calls let web hold off saving to IndexedDB until the commit. + async #run(connection: SQLiteDBConnection, sql: string, params: unknown[]) { + switch (sql) { + case TRANSACTION_SQL.BEGIN: + return connection.beginTransaction(); + case TRANSACTION_SQL.COMMIT: + await connection.commitTransaction(); + // saveToStore is the one call that does not strip ".db" from the name, so pass the connection's. + if (IS_WEB) await this.#sqlite.saveToStore(connection.getConnectionDBName()); + return; + case TRANSACTION_SQL.ROLLBACK: + return connection.rollbackTransaction(); + default: + return connection.run(sql, params, false); + } + } + + #connect(): Promise { + this.#connection ??= this.#open().catch((error) => { + this.#connection = null; + throw error; + }); + return this.#connection; + } + + async #open() { + if (IS_WEB) await installWebStore(this.#sqlite); + + const upgrades = migrations(); + await this.#sqlite.addUpgradeStatement(DB_NAME, upgrades); + + // A reload leaves the last page's native connection open, and reusing it would skip new + // migrations. With no connections of its own yet, this closes it. + await this.#sqlite.checkConnectionsConsistency(); + + const connection = await this.#sqlite.createConnection( + DB_NAME, + false, + DB_ENCRYPTION_MODE, + upgrades.length, + false, + ); + await connection.open(); + return connection; + } +} + +export const db = new Database().db; diff --git a/src/lib/db/migrations/0000_initial_tables.sql b/src/lib/db/migrations/0000_initial_tables.sql new file mode 100644 index 00000000..3568c327 --- /dev/null +++ b/src/lib/db/migrations/0000_initial_tables.sql @@ -0,0 +1,67 @@ +CREATE TABLE `alert` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `description` text NOT NULL, + `time_range_text` text NOT NULL, + `daily_start_time` text NOT NULL, + `daily_end_time` text NOT NULL, + `starts_at` text NOT NULL, + `ends_at` text +); +--> statement-breakpoint +CREATE TABLE `alert_direction` ( + `id` text PRIMARY KEY NOT NULL, + `alert_id` text NOT NULL, + `direction_id` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `alert_direction_direction_id_idx` ON `alert_direction` (`direction_id`);--> statement-breakpoint +CREATE TABLE `direction` ( + `id` text PRIMARY KEY NOT NULL, + `route_id` text NOT NULL, + `destination` text NOT NULL, + `sequence` integer NOT NULL, + `path` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `direction_route_id_idx` ON `direction` (`route_id`);--> statement-breakpoint +CREATE TABLE `direction_stop` ( + `id` text PRIMARY KEY NOT NULL, + `direction_id` text NOT NULL, + `stop_id` text NOT NULL, + `sequence` integer NOT NULL, + `is_timepoint` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `direction_stop_direction_id_idx` ON `direction_stop` (`direction_id`,`sequence`);--> statement-breakpoint +CREATE TABLE `route` ( + `id` text PRIMARY KEY NOT NULL, + `short_name` text NOT NULL, + `long_name` text NOT NULL, + `light_color` text NOT NULL, + `dark_color` text NOT NULL, + `active` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `stop` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `lat` real NOT NULL, + `lon` real NOT NULL, + `amenities` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sync_ack` ( + `key` text PRIMARY KEY NOT NULL, + `ack` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `timetable` ( + `id` text PRIMARY KEY NOT NULL, + `stop_id` text NOT NULL, + `direction_id` text NOT NULL, + `service_date` text NOT NULL, + `departures` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `timetable_slot_idx` ON `timetable` (`stop_id`,`direction_id`,`service_date`); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0000_snapshot.json b/src/lib/db/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..90a55e1c --- /dev/null +++ b/src/lib/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,406 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "748312ff-2ac1-4fd2-b379-3b922f0cf6d5", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "alert": { + "name": "alert", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_range_text": { + "name": "time_range_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_start_time": { + "name": "daily_start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_end_time": { + "name": "daily_end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_direction": { + "name": "alert_direction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "alert_direction_direction_id_idx": { + "name": "alert_direction_direction_id_idx", + "columns": [ + "direction_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "direction": { + "name": "direction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "direction_route_id_idx": { + "name": "direction_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "direction_stop": { + "name": "direction_stop", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stop_id": { + "name": "stop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_timepoint": { + "name": "is_timepoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "direction_stop_direction_id_idx": { + "name": "direction_stop_direction_id_idx", + "columns": [ + "direction_id", + "sequence" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route": { + "name": "route", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "light_color": { + "name": "light_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dark_color": { + "name": "dark_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stop": { + "name": "stop", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lat": { + "name": "lat", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lon": { + "name": "lon", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amenities": { + "name": "amenities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_ack": { + "name": "sync_ack", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ack": { + "name": "ack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "timetable": { + "name": "timetable", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "stop_id": { + "name": "stop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_date": { + "name": "service_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "departures": { + "name": "departures", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "timetable_slot_idx": { + "name": "timetable_slot_idx", + "columns": [ + "stop_id", + "direction_id", + "service_date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json new file mode 100644 index 00000000..9387fda8 --- /dev/null +++ b/src/lib/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1790814773563, + "tag": "0000_initial_tables", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 00000000..25837702 --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,117 @@ +import { relations } from 'drizzle-orm'; +import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const route = sqliteTable('route', { + id: text('id').primaryKey(), + shortName: text('short_name').notNull(), + longName: text('long_name').notNull(), + lightColor: text('light_color').notNull(), + darkColor: text('dark_color').notNull(), + active: integer('active', { mode: 'boolean' }).notNull(), +}); + +export const direction = sqliteTable( + 'direction', + { + id: text('id').primaryKey(), + routeId: text('route_id').notNull(), + destination: text('destination').notNull(), + sequence: integer('sequence').notNull(), + path: text('path').notNull(), + }, + (t) => [index('direction_route_id_idx').on(t.routeId)], +); + +export const stop = sqliteTable('stop', { + id: text('id').primaryKey(), + name: text('name').notNull(), + lat: real('lat').notNull(), + lon: real('lon').notNull(), + amenities: text('amenities', { mode: 'json' }).$type().notNull(), +}); + +export const directionStop = sqliteTable( + 'direction_stop', + { + id: text('id').primaryKey(), + directionId: text('direction_id').notNull(), + stopId: text('stop_id').notNull(), + sequence: integer('sequence').notNull(), + isTimepoint: integer('is_timepoint', { mode: 'boolean' }).notNull(), + }, + (t) => [index('direction_stop_direction_id_idx').on(t.directionId, t.sequence)], +); + +export const alert = sqliteTable('alert', { + id: text('id').primaryKey(), + title: text('title').notNull(), + description: text('description').notNull(), + timeRangeText: text('time_range_text').notNull(), + dailyStartTime: text('daily_start_time').notNull(), + dailyEndTime: text('daily_end_time').notNull(), + startsAt: text('starts_at').notNull(), + endsAt: text('ends_at'), +}); + +export const alertDirection = sqliteTable( + 'alert_direction', + { + id: text('id').primaryKey(), + alertId: text('alert_id').notNull(), + directionId: text('direction_id').notNull(), + }, + (t) => [index('alert_direction_direction_id_idx').on(t.directionId)], +); + +export const timetable = sqliteTable( + 'timetable', + { + id: text('id').primaryKey(), + stopId: text('stop_id').notNull(), + directionId: text('direction_id').notNull(), + serviceDate: text('service_date').notNull(), + departures: text('departures', { mode: 'json' }).$type().notNull(), + }, + (t) => [index('timetable_slot_idx').on(t.stopId, t.directionId, t.serviceDate)], +); + +/** The newest ack per entity key, e.g. `RouteV1` or `TimetableV1:`. */ +export const syncAck = sqliteTable('sync_ack', { + key: text('key').primaryKey(), + ack: text('ack').notNull(), +}); + +export const routeRelations = relations(route, ({ many }) => ({ + directions: many(direction), +})); + +export const directionRelations = relations(direction, ({ one, many }) => ({ + route: one(route, { fields: [direction.routeId], references: [route.id] }), + directionStops: many(directionStop), + alertDirections: many(alertDirection), +})); + +export const directionStopRelations = relations(directionStop, ({ one }) => ({ + direction: one(direction, { fields: [directionStop.directionId], references: [direction.id] }), + stop: one(stop, { fields: [directionStop.stopId], references: [stop.id] }), +})); + +export const alertRelations = relations(alert, ({ many }) => ({ + alertDirections: many(alertDirection), +})); + +export const alertDirectionRelations = relations(alertDirection, ({ one }) => ({ + alert: one(alert, { fields: [alertDirection.alertId], references: [alert.id] }), + direction: one(direction, { fields: [alertDirection.directionId], references: [direction.id] }), +})); + +/** Tables filled by the sync stream. A reset wipes all of them along with their acks. */ +export const SYNCED_TABLES = [ + route, + direction, + stop, + directionStop, + alert, + alertDirection, + timetable, +] as const; diff --git a/src/lib/managers/live-data.manager.svelte.ts b/src/lib/managers/live-data.manager.svelte.ts new file mode 100644 index 00000000..401cb689 --- /dev/null +++ b/src/lib/managers/live-data.manager.svelte.ts @@ -0,0 +1,152 @@ +import { WEBSOCKET_URL } from '$lib/api/client'; +import { + WebsocketMessageType, + type WebsocketDeparture, + type WebsocketServerMessage, + type WebsocketVehicle, +} from '$lib/api/websocket'; +import type { TimeEstimate } from '$lib/data/types'; +import { LiveSocket } from '$lib/sync/live-socket'; +import { appLogger } from '$lib/utils/logger'; +import { App } from '@capacitor/app'; +import moment from 'moment'; + +export enum LiveDataStatus { + LOADING = 'loading', + LIVE = 'live', + OFFLINE = 'offline', +} + +enum SocketState { + CONNECTING = 'connecting', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', +} + +// A bus that left a minute ago is often still at the stop. +const DEPARTED_GRACE = moment.duration(1, 'minute'); + +function departsAt(departure: WebsocketDeparture) { + return moment(departure.estimatedAt ?? departure.scheduledAt); +} + +function slotKey(stopId: string, directionId: string, scheduledAt: moment.MomentInput) { + return `${stopId}|${directionId}|${moment(scheduledAt).valueOf()}`; +} + +/** Holds the subscribed route's vehicles and departures, straight from the websocket. */ +class LiveDataManager { + vehicles = $state.raw([]); + departures = $state.raw([]); + + #socketState = $state(SocketState.CONNECTING); + #hasDepartures = $state(false); + + /** Whether the subscribed route's departures are live, still on their way, or unreachable. */ + readonly status = $derived.by(() => { + if (this.#socketState === SocketState.DISCONNECTED) return LiveDataStatus.OFFLINE; + return this.#hasDepartures ? LiveDataStatus.LIVE : LiveDataStatus.LOADING; + }); + + readonly #departuresBySlot = $derived( + new Map(this.departures.map((d) => [slotKey(d.stopId, d.directionId, d.scheduledAt), d])), + ); + + #routeId: string | null = null; + #hasOpened = false; + #onReconnected = () => {}; + + readonly #socket = new LiveSocket(WEBSOCKET_URL, { + onOpen: () => { + this.#socketState = SocketState.CONNECTED; + if (this.#hasOpened) this.#onReconnected(); + this.#hasOpened = true; + }, + onClose: () => { + this.#socketState = SocketState.DISCONNECTED; + this.#clear(); + }, + onMessage: (message) => this.#onMessage(message), + }); + + /** Opens the socket and keeps it open while the app is in the foreground. */ + connect(onReconnected: () => void) { + this.#onReconnected = onReconnected; + App.addListener('pause', () => this.#socket.disconnect()); + App.addListener('resume', () => this.#connectSocket()); + this.#connectSocket(); + } + + subscribe(routeId: string | null) { + if (routeId === this.#routeId) return; + this.#routeId = routeId; + this.#clear(); + this.#socket.subscribe(routeId); + } + + /** Upcoming departures at a stop, soonest first. */ + estimatesFor(directionId: string, stopId: string): TimeEstimate[] { + const earliest = moment().subtract(DEPARTED_GRACE); + return this.departures + .filter( + (d) => + d.directionId === directionId && + d.stopId === stopId && + !d.isCancelled && + departsAt(d).isSameOrAfter(earliest), + ) + .sort((a, b) => departsAt(a).diff(departsAt(b))) + .map((d) => ({ + scheduledTime: moment(d.scheduledAt), + estimatedTime: d.estimatedAt ? moment(d.estimatedAt) : null, + isRealTime: d.estimatedAt !== null, + })); + } + + /** The live departure for one scheduled time at a stop, if the server sent one. */ + departureAt(stopId: string, directionId: string, scheduledAt: moment.Moment) { + return this.#departuresBySlot.get(slotKey(stopId, directionId, scheduledAt)); + } + + // Retries after a drop stay disconnected, so the offline fallback does not flicker between attempts. + #connectSocket() { + if (this.#socketState !== SocketState.CONNECTED) this.#socketState = SocketState.CONNECTING; + this.#socket.connect(); + } + + #clear() { + this.vehicles = []; + this.departures = []; + this.#hasDepartures = false; + } + + #onMessage(message: WebsocketServerMessage) { + switch (message.type) { + case WebsocketMessageType.VEHICLES: + if (message.routeId === this.#routeId) this.vehicles = message.vehicles ?? []; + break; + case WebsocketMessageType.DEPARTURES: + if (message.routeId !== this.#routeId) return; + this.departures = message.departures ?? []; + this.#hasDepartures = true; + break; + case WebsocketMessageType.ERROR: + appLogger.e(`Live socket error: ${message.message}`); + break; + } + } +} + +// Persist liveDataManager across Svelte HMRs +let liveDataManager: LiveDataManager; + +if (import.meta.hot && import.meta.hot.data) { + if (!import.meta.hot.data.liveDataManager) { + import.meta.hot.data.liveDataManager = new LiveDataManager(); + } + liveDataManager = import.meta.hot.data.liveDataManager; +} else { + liveDataManager = new LiveDataManager(); +} + +export { liveDataManager }; diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts new file mode 100644 index 00000000..69ce8e36 --- /dev/null +++ b/src/lib/managers/sync.manager.svelte.ts @@ -0,0 +1,115 @@ +import { getSupportedVersion, syncStream } from '$lib/api/client'; +import { db } from '$lib/db/database'; +import { syncAck, timetable } from '$lib/db/schema'; +import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; +import { + planSync, + SYNC_PROTOCOL_V1, + SYNC_REQUEST_TYPES, + type Statement, +} from '$lib/sync/sync-stream'; +import { isAppVersionSupported } from '$lib/utils/app-version'; +import { appLogger, describeError } from '$lib/utils/logger'; +import { refreshDbQueries } from '$lib/utils/queries'; +import { lt, sql } from 'drizzle-orm'; + +export enum VersionStatus { + UNKNOWN = 'unknown', + SUPPORTED = 'supported', + UNSUPPORTED = 'unsupported', +} + +function applyAll(statements: Statement[]) { + return db.transaction(async () => { + for (const statement of statements) await statement; + }); +} + +/** Owns every write to the local database, which is the sync stream and its cleanup. */ +class SyncManager { + versionStatus = $state(VersionStatus.UNKNOWN); + isSyncing = $state(false); + + #started = false; + #syncQueued = false; + // Writes run one at a time, since web SQLite has no locking of its own. + #queue: Promise = Promise.resolve(); + + async start() { + if (this.#started) return; + this.#started = true; + + this.#enqueue(() => + db.delete(timetable).where(lt(timetable.serviceDate, sql`date('now', 'localtime')`)), + ); + await this.sync(); + if (this.versionStatus !== VersionStatus.UNSUPPORTED) { + liveDataManager.connect(() => this.sync()); + } + } + + sync(): Promise { + if (this.#syncQueued) return this.#queue; + this.#syncQueued = true; + + return this.#enqueue(async () => { + this.#syncQueued = false; + this.isSyncing = true; + try { + if (await this.#checkVersion()) await this.#runSync(); + } finally { + this.isSyncing = false; + } + }); + } + + // Every task may have changed data on screen, so each one refreshes the database queries. + #enqueue(task: () => PromiseLike): Promise { + this.#queue = this.#queue + .then(task) + .then(refreshDbQueries) + .catch((error) => appLogger.e(`Database task failed: ${describeError(error)}`)); + return this.#queue; + } + + async #checkVersion(): Promise { + if (this.versionStatus === VersionStatus.UNKNOWN) { + const minimumVersion = await getSupportedVersion(); + this.versionStatus = isAppVersionSupported(minimumVersion) + ? VersionStatus.SUPPORTED + : VersionStatus.UNSUPPORTED; + } + return this.versionStatus === VersionStatus.SUPPORTED; + } + + async #runSync() { + const acks = await db.select({ ack: syncAck.ack }).from(syncAck); + + const lines = await syncStream({ + protocol: SYNC_PROTOCOL_V1, + types: SYNC_REQUEST_TYPES, + acks: acks.map(({ ack }) => ack), + }); + + const plan = planSync(lines); + await applyAll(plan.statements); + appLogger.i(`Applied ${lines.length} sync lines`); + + // A reset leaves no acks behind, so the next stream is a full sync. + if (plan.reset) await this.#runSync(); + } +} + +// Persist syncManager across Svelte HMRs +let syncManager: SyncManager; + +if (import.meta.hot && import.meta.hot.data) { + if (!import.meta.hot.data.syncManager) { + import.meta.hot.data.syncManager = new SyncManager(); + } + syncManager = import.meta.hot.data.syncManager; +} else { + syncManager = new SyncManager(); +} + +export { syncManager }; diff --git a/src/lib/sync/live-socket.ts b/src/lib/sync/live-socket.ts new file mode 100644 index 00000000..b0342ab0 --- /dev/null +++ b/src/lib/sync/live-socket.ts @@ -0,0 +1,104 @@ +import { + WebsocketMessageType, + type WebsocketClientMessage, + type WebsocketServerMessage, +} from '$lib/api/websocket'; +import { appLogger } from '$lib/utils/logger'; + +const INITIAL_RETRY_DELAY_MS = 1_000; +const MAX_RETRY_DELAY_MS = 30_000; +const RETRY_BACKOFF_FACTOR = 2; +// The gateway drops a client that sends nothing for 60 seconds. +const PING_INTERVAL_MS = 20_000; + +interface LiveSocketHandlers { + onOpen(): void; + onClose(): void; + onMessage(message: WebsocketServerMessage): void; +} + +/** One websocket to the gateway that reconnects on its own and keeps the chosen route subscribed. */ +export class LiveSocket { + readonly #url: string; + readonly #handlers: LiveSocketHandlers; + + #socket: WebSocket | null = null; + #wanted = false; + #routeId: string | null = null; + #retryDelay = INITIAL_RETRY_DELAY_MS; + #retryTimer: ReturnType | undefined; + #pingTimer: ReturnType | undefined; + + constructor(url: string, handlers: LiveSocketHandlers) { + this.#url = url; + this.#handlers = handlers; + } + + connect() { + this.#wanted = true; + if (!this.#socket) this.#open(); + } + + disconnect() { + this.#wanted = false; + clearTimeout(this.#retryTimer); + this.#socket?.close(); + } + + subscribe(routeId: string | null) { + this.#routeId = routeId; + this.#sendSubscription(); + } + + #open() { + clearTimeout(this.#retryTimer); + const socket = new WebSocket(this.#url); + this.#socket = socket; + + socket.onopen = () => { + this.#retryDelay = INITIAL_RETRY_DELAY_MS; + this.#pingTimer = setInterval( + () => this.#send({ type: WebsocketMessageType.PING }), + PING_INTERVAL_MS, + ); + this.#sendSubscription(); + this.#handlers.onOpen(); + }; + + socket.onmessage = (event) => { + this.#handlers.onMessage(JSON.parse(event.data) as WebsocketServerMessage); + }; + + socket.onerror = () => socket.close(); + + socket.onclose = () => { + if (this.#socket !== socket) return; + clearInterval(this.#pingTimer); + this.#socket = null; + this.#handlers.onClose(); + if (this.#wanted) this.#scheduleReconnect(); + }; + } + + #scheduleReconnect() { + // Jitter keeps every phone from reconnecting at once after a gateway restart. + const delay = this.#retryDelay / 2 + Math.random() * (this.#retryDelay / 2); + this.#retryDelay = Math.min(this.#retryDelay * RETRY_BACKOFF_FACTOR, MAX_RETRY_DELAY_MS); + + appLogger.i(`Live socket closed, reconnecting in ${Math.round(delay)} ms`); + this.#retryTimer = setTimeout(() => this.#open(), delay); + } + + #sendSubscription() { + this.#send( + this.#routeId + ? { type: WebsocketMessageType.SUBSCRIBE, routeId: this.#routeId } + : { type: WebsocketMessageType.UNSUBSCRIBE }, + ); + } + + #send(message: WebsocketClientMessage) { + if (this.#socket?.readyState !== WebSocket.OPEN) return; + this.#socket.send(JSON.stringify(message)); + } +} diff --git a/src/lib/sync/sync-stream.test.ts b/src/lib/sync/sync-stream.test.ts new file mode 100644 index 00000000..483e7807 --- /dev/null +++ b/src/lib/sync/sync-stream.test.ts @@ -0,0 +1,106 @@ +import type { SyncStreamLine } from '$lib/api/client'; +import initSqlJs, { type Database } from 'sql.js'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { planSync, type SyncPlan } from './sync-stream'; + +const MIGRATION_BREAKPOINT = '--> statement-breakpoint'; +const migrationFiles = import.meta.glob('../db/migrations/*.sql', { + query: '?raw', + import: 'default', + eager: true, +}); + +const ROUTE_ID = '01990000-0000-7000-8000-000000000001'; +const DIRECTION_ID = '01990000-0000-7000-8000-000000000002'; +const STOP_ID = '01990000-0000-7000-8000-000000000003'; +const ACK_ID = '01990000-0000-7000-8000-0000000000aa'; + +let sqlite: Database; + +function apply(plan: SyncPlan) { + for (const statement of plan.statements) { + const { sql, params } = statement.toSQL(); + sqlite.run(sql, params as never); + } +} + +function rows(sql: string) { + return sqlite.exec(sql)[0]?.values ?? []; +} + +const routeLine = (longName: string, ack: string): SyncStreamLine => ({ + type: 'RouteV1', + ack: `RouteV1|${ack}`, + data: { + id: ROUTE_ID, + shortName: '01', + longName, + lightColor: '#500000', + darkColor: '#ff5555', + active: true, + }, +}); + +beforeEach(async () => { + const SQL = await initSqlJs(); + sqlite = new SQL.Database(); + for (const file of Object.keys(migrationFiles).sort()) { + migrationFiles[file].split(MIGRATION_BREAKPOINT).forEach((statement) => sqlite.run(statement)); + } +}); + +describe('planSync', () => { + it('stores rows as the server sends them', () => { + apply( + planSync([ + routeLine('Bonfire', ACK_ID), + { + type: 'StopV1', + ack: `StopV1|${ACK_ID}`, + data: { id: STOP_ID, name: 'MSC', lat: 30.6, lon: -96.3, amenities: null }, + }, + { type: 'SyncCompleteV1', ack: `SyncCompleteV1|${ACK_ID}`, data: {} }, + ]), + ); + + expect(rows('SELECT id, short_name, long_name, active FROM route')).toEqual([ + [ROUTE_ID, '01', 'Bonfire', 1], + ]); + expect(rows('SELECT amenities FROM stop')).toEqual([['[]']]); + }); + + it('keeps only the newest ack per entity key', () => { + const newerAck = '01990000-0000-7000-8000-0000000000bb'; + apply(planSync([routeLine('Bonfire', ACK_ID), routeLine('Bonfire Express', newerAck)])); + + expect(rows('SELECT long_name FROM route')).toEqual([['Bonfire Express']]); + expect(rows('SELECT key, ack FROM sync_ack')).toEqual([['RouteV1', `RouteV1|${newerAck}`]]); + }); + + it('applies each run of lines in the order it arrived', () => { + apply(planSync([routeLine('Bonfire', ACK_ID)])); + apply( + planSync([ + { + type: 'DirectionV1', + ack: `DirectionV1|${ACK_ID}`, + data: { id: DIRECTION_ID, routeId: ROUTE_ID, destination: 'MSC', sequence: 0, path: '' }, + }, + { type: 'RouteDeleteV1', ack: `RouteDeleteV1|${ACK_ID}`, data: { routeId: ROUTE_ID } }, + ]), + ); + + expect(rows('SELECT id FROM route')).toEqual([]); + expect(rows('SELECT id FROM direction')).toEqual([[DIRECTION_ID]]); + }); + + it('wipes synced tables and acks on reset', () => { + apply(planSync([routeLine('Bonfire', ACK_ID)])); + const plan = planSync([{ type: 'SyncResetV1', ack: `SyncResetV1|${ACK_ID}`, data: {} }]); + apply(plan); + + expect(plan.reset).toBe(true); + expect(rows('SELECT id FROM route')).toEqual([]); + expect(rows('SELECT key FROM sync_ack')).toEqual([]); + }); +}); diff --git a/src/lib/sync/sync-stream.ts b/src/lib/sync/sync-stream.ts new file mode 100644 index 00000000..3931738c --- /dev/null +++ b/src/lib/sync/sync-stream.ts @@ -0,0 +1,146 @@ +import type { SyncEntityType, SyncRequestType, SyncStreamLine } from '$lib/api/client'; +import { db } from '$lib/db/database'; +import { + alert, + alertDirection, + direction, + directionStop, + route, + stop, + SYNCED_TABLES, + syncAck, + timetable, +} from '$lib/db/schema'; +import { getTableColumns, inArray, sql } from 'drizzle-orm'; +import type { SQLiteColumn, SQLiteTable, SQLiteUpdateSetSource } from 'drizzle-orm/sqlite-core'; +import { chunk } from 'lodash-es'; + +export const SYNC_PROTOCOL_V1 = 1; + +export const SYNC_REQUEST_TYPES: SyncRequestType[] = [ + 'RoutesV1', + 'DirectionsV1', + 'StopsV1', + 'DirectionStopsV1', + 'AlertsV1', + 'AlertDirectionsV1', + 'TimetablesV1', +]; + +const ACK_SEPARATOR = '|'; +const RESET: SyncEntityType = 'SyncResetV1'; + +// Keeps each insert under SQLite's bound parameter limit on older builds. +const MAX_ROWS_PER_STATEMENT = 100; + +type LineData = Extract['data']; +type TableWithId = SQLiteTable & { id: SQLiteColumn }; + +/** A built drizzle write. Awaiting it runs it. */ +export type Statement = PromiseLike & { toSQL(): { sql: string; params: unknown[] } }; + +type EntityHandler = (rows: D[]) => Statement[]; + +function excluded(table: T): SQLiteUpdateSetSource { + return Object.fromEntries( + Object.entries(getTableColumns(table)).map(([key, column]) => [ + key, + sql.raw(`excluded."${column.name}"`), + ]), + ) as SQLiteUpdateSetSource; +} + +function upsert( + table: T, + toRow: (data: D) => T['$inferInsert'], +): EntityHandler { + return (rows) => + chunk(rows, MAX_ROWS_PER_STATEMENT).map((batch) => + db + .insert(table) + .values(batch.map(toRow)) + .onConflictDoUpdate({ target: table.id, set: excluded(table) }), + ); +} + +function remove(table: T, toId: (data: D) => string): EntityHandler { + return (rows) => + chunk(rows, MAX_ROWS_PER_STATEMENT).map((batch) => + db.delete(table).where(inArray(table.id, batch.map(toId))), + ); +} + +const controlLine: EntityHandler = () => []; + +// Go encodes an empty slice as null, the local columns are not nullable. +const HANDLERS: { [T in SyncEntityType]: EntityHandler> } = { + RouteV1: upsert(route, (data: LineData<'RouteV1'>) => data), + RouteDeleteV1: remove(route, (data: LineData<'RouteDeleteV1'>) => data.routeId), + DirectionV1: upsert(direction, (data: LineData<'DirectionV1'>) => data), + DirectionDeleteV1: remove(direction, (data: LineData<'DirectionDeleteV1'>) => data.directionId), + StopV1: upsert(stop, (data: LineData<'StopV1'>) => ({ + ...data, + amenities: data.amenities ?? [], + })), + StopDeleteV1: remove(stop, (data: LineData<'StopDeleteV1'>) => data.stopId), + DirectionStopV1: upsert(directionStop, (data: LineData<'DirectionStopV1'>) => data), + DirectionStopDeleteV1: remove( + directionStop, + (data: LineData<'DirectionStopDeleteV1'>) => data.directionStopId, + ), + AlertV1: upsert(alert, (data: LineData<'AlertV1'>) => data), + AlertDeleteV1: remove(alert, (data: LineData<'AlertDeleteV1'>) => data.alertId), + AlertDirectionV1: upsert(alertDirection, (data: LineData<'AlertDirectionV1'>) => data), + AlertDirectionDeleteV1: remove( + alertDirection, + (data: LineData<'AlertDirectionDeleteV1'>) => data.alertDirectionId, + ), + TimetableV1: upsert(timetable, (data: LineData<'TimetableV1'>) => ({ + ...data, + departures: data.departures ?? [], + })), + TimetableDeleteV1: remove(timetable, (data: LineData<'TimetableDeleteV1'>) => data.timetableId), + SyncResetV1: controlLine, + SyncCompleteV1: controlLine, +}; + +export interface SyncPlan { + reset: boolean; + statements: Statement[]; +} + +export function resetPlan(): SyncPlan { + const tables = [...SYNCED_TABLES, syncAck]; + return { reset: true, statements: tables.map((table) => db.delete(table)) }; +} + +// The server orders lines so parents come before children and deletes before upserts, +// so runs of one entity type are applied in the order they arrived. +export function planSync(lines: SyncStreamLine[]): SyncPlan { + if (lines[0]?.type === RESET) return resetPlan(); + + const runs: { type: SyncEntityType; rows: unknown[] }[] = []; + const acks = new Map(); + + for (const line of lines) { + const run = runs.at(-1); + if (run?.type === line.type) { + run.rows.push(line.data); + } else { + runs.push({ type: line.type, rows: [line.data] }); + } + acks.set(line.ack.split(ACK_SEPARATOR)[0], line.ack); + } + + const statements = runs.flatMap((run) => HANDLERS[run.type](run.rows as never[])); + if (acks.size > 0) { + statements.push( + db + .insert(syncAck) + .values([...acks].map(([key, ack]) => ({ key, ack }))) + .onConflictDoUpdate({ target: syncAck.key, set: excluded(syncAck) }), + ); + } + + return { reset: false, statements }; +} diff --git a/src/lib/utils/app-version.ts b/src/lib/utils/app-version.ts new file mode 100644 index 00000000..bf2bccaa --- /dev/null +++ b/src/lib/utils/app-version.ts @@ -0,0 +1,17 @@ +import { AppLauncher } from '@capacitor/app-launcher'; +import { Capacitor } from '@capacitor/core'; +import semver from 'semver'; + +export const APP_VERSION = __APP_VERSION__; + +const APP_STORE_URL = 'https://apps.apple.com/app/id6475358068'; +const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.maroonrides.maroonrides'; + +export function isAppVersionSupported(minimumVersion: string): boolean { + return semver.gte(APP_VERSION, minimumVersion); +} + +export async function openAppStore() { + const url = Capacitor.getPlatform() === 'android' ? PLAY_STORE_URL : APP_STORE_URL; + await AppLauncher.openUrl({ url }); +} diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts index 21bef160..bf066d3d 100644 --- a/src/lib/utils/logger.ts +++ b/src/lib/utils/logger.ts @@ -40,3 +40,8 @@ class Logger { const logLevel = LogLevel.INFO; export const queryLogger = new Logger('QUERY', logLevel); export const appLogger = new Logger('APP', logLevel); + +// drizzle wraps driver errors, so the SQLite message is only on the cause. +export function describeError(error: unknown): string { + return error instanceof Error && error.cause ? `${error} (cause: ${error.cause})` : `${error}`; +} diff --git a/src/lib/utils/queries.ts b/src/lib/utils/queries.ts index 19d116e3..cd7255e0 100644 --- a/src/lib/utils/queries.ts +++ b/src/lib/utils/queries.ts @@ -1,95 +1,23 @@ -import { queryLogger } from '$lib/utils/logger'; -import { createQuery, type CreateQueryResult } from '@tanstack/svelte-query'; +import { describeError, queryLogger } from '$lib/utils/logger'; +import { createQuery, QueryClient, type NetworkMode } from '@tanstack/svelte-query'; import moment from 'moment'; -interface Params { +interface LoggingQueryParams { queryFn: () => Promise; - queryKey: any[]; + queryKey: unknown[]; + label?: string; staleTime?: moment.Duration | number; refetchInterval?: moment.Duration | number; enabled?: boolean; -} - -interface DependencyQueryParams extends Params { - dependents: CreateQueryResult[]; + networkMode?: NetworkMode; } function parseTime(time?: moment.Duration | number) { - if (typeof time === 'number') { - return time; - } else if (moment.isDuration(time)) { - return time.asMilliseconds(); - } -} - -export function createDependencyQuery(params: () => DependencyQueryParams) { - const query = createQuery(() => { - const p = params(); - const label = p.queryKey.join('/'); - const enabled = p.dependents.every((q) => q.isSuccess); - - return { - queryKey: [...p.queryKey, ...p.dependents.map((q) => q.data)], - queryFn: async () => { - try { - const start = moment.now(); - const data = await p.queryFn(); - - queryLogger.d(`Query ${label} succeeded in ${moment.now() - start} ms`); - return data; - } catch (e) { - queryLogger.e(`Query ${label} failed: ${e}`); - throw e; - } - }, - enabled: enabled && (p.enabled ?? true), - staleTime: parseTime(p.staleTime), - refetchInterval: parseTime(p.refetchInterval), - }; - }); - - // Return a getter that accesses params() reactively - return { - get data() { - return query.data; - }, - get error() { - const p = params(); - return (query.error || (p.dependents.find((q) => q.isError)?.error as Error | null))!; - }, - get isError() { - const p = params(); - return query.isError || p.dependents.some((q) => q.isError); - }, - get isSuccess() { - return query.isSuccess; - }, - get isPending() { - return query.isPending; - }, - get isFetching() { - return query.isFetching; - }, - get isLoading() { - const p = params(); - return query.isLoading || p.dependents.some((q) => q.isLoading); - }, - get status() { - return query.status; - }, - get fetchStatus() { - return query.fetchStatus; - }, - refetch: query.refetch, - } as CreateQueryResult; + return moment.isDuration(time) ? time.asMilliseconds() : time; } -interface LoggingQueryParams extends Params { - label?: string; -} - -export function createLoggingQuery(params: () => LoggingQueryParams) { - const query = createQuery(() => { +export function createLoggingQuery(params: () => LoggingQueryParams) { + return createQuery(() => { const p = params(); const label = p.label || p.queryKey.join('/'); @@ -103,106 +31,46 @@ export function createLoggingQuery(params: () => LoggingQueryParams) { queryLogger.d(`Query ${label} succeeded in ${moment.now() - start} ms`); return data; } catch (e) { - queryLogger.e(`Query ${label} failed: ${e}`); + queryLogger.e(`Query ${label} failed: ${describeError(e)}`); throw e; } }, - enabled: p.enabled ?? true, staleTime: parseTime(p.staleTime), refetchInterval: parseTime(p.refetchInterval), + networkMode: p.networkMode, }; }); - - return query; } -// Enum type shim -type Enum = string | number | symbol; +const DB_QUERY_KEY = 'db'; -interface SelectableQueryParams { - queryKey: any[]; - staleTime?: moment.Duration | number; - refetchInterval?: moment.Duration | number; +export const queryClient = new QueryClient(); + +interface DbQueryParams { + key: unknown[]; + queryFn: () => Promise; enabled?: boolean; - queries: Partial>>; - unsupportedValue?: T; - selector?: S; + refetchInterval?: moment.Duration | number; } -export function createSelectableQuery( - params: () => SelectableQueryParams, -) { - const query = createQuery(() => { +/** A query on the local database. It re-runs when the sync manager changes the data. */ +export function createDbQuery(params: () => DbQueryParams) { + return createLoggingQuery(() => { const p = params(); - const label = p.queryKey.join('/'); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return { - queryKey: [...p.queryKey, selectedQuery?.data], - queryFn: async () => { - try { - const start = moment.now(); - const data = selectedQuery?.data ?? p.unsupportedValue!; - - queryLogger.d(`Query ${label} succeeded in ${moment.now() - start} ms`); - return data; - } catch (e) { - queryLogger.e(`Query ${label} failed: ${e}`); - throw e; - } - }, - enabled: (p.enabled ?? true) && (selectedQuery?.isSuccess ?? false), - staleTime: parseTime(p.staleTime), - refetchInterval: parseTime(p.refetchInterval), + label: String(p.key[0]), + queryKey: [DB_QUERY_KEY, ...p.key], + queryFn: p.queryFn, + enabled: p.enabled, + staleTime: Infinity, + refetchInterval: p.refetchInterval, + // TanStack pauses queries while the device is offline, but these read local SQLite. + networkMode: 'always', }; }); +} - // Return a getter that accesses params() reactively - return { - get data() { - return query.data; - }, - get error() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return (query.error || (selectedQuery?.error as Error | null))!; - }, - get isError() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return query.isError || (selectedQuery?.isError ?? false); - }, - get isSuccess() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return query.isSuccess && (selectedQuery?.isSuccess ?? false); - }, - get isPending() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return query.isPending || (selectedQuery?.isPending ?? false); - }, - get isFetching() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return query.isFetching || (selectedQuery?.isFetching ?? false); - }, - get isLoading() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return query.isLoading || (selectedQuery?.isLoading ?? false); - }, - get status() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return selectedQuery?.isError ? 'error' : selectedQuery?.isPending ? 'pending' : query.status; - }, - get fetchStatus() { - const p = params(); - const selectedQuery: CreateQueryResult | undefined = p.queries[p.selector]; - return selectedQuery?.isFetching ? 'fetching' : query.fetchStatus; - }, - refetch: query.refetch, - } as CreateQueryResult; +export function refreshDbQueries() { + return queryClient.invalidateQueries({ queryKey: [DB_QUERY_KEY] }); } diff --git a/src/lib/utils/timetable.ts b/src/lib/utils/timetable.ts index 45033e83..5dc81382 100644 --- a/src/lib/utils/timetable.ts +++ b/src/lib/utils/timetable.ts @@ -1,7 +1,9 @@ -import type { StopSchedule } from '$lib/data/types'; +import type { TimetableDeparture } from '$lib/data/types'; import { chunk } from 'lodash-es'; import moment from 'moment'; +const ITEMS_PER_ROW = 5; + interface TableItem { time: string; highlighted: boolean; @@ -15,64 +17,26 @@ export interface TableItemRow { highlighted: boolean; } -export default function buildTimetable( - fullSchedule: StopSchedule, - estimates: StopSchedule[] | undefined, -) { +export default function buildTimetable(departures: TimetableDeparture[]): TableItemRow[] { const now = moment(); - const ITEMS_PER_ROW = 5; - - // Find the estimate data for this specific route and direction - const routeEstimate = estimates?.find( - (estimate) => - estimate.direction.id === fullSchedule.direction.id && - estimate.route.id === fullSchedule.route.id, - ); - - // Step 1: Process all times and compute departure times - const processedTimes = fullSchedule.timetable.map((scheduledItem) => { - // Find live estimate for this trip point - const liveEstimate = routeEstimate?.timetable.find( - (stopTime) => stopTime.tripPointId === scheduledItem.tripPointId, - ); - - // Determine actual departure time (live estimate or scheduled) - const departureTime = - liveEstimate && moment(liveEstimate.estimatedTime).isValid() - ? moment(liveEstimate.estimatedTime) - : scheduledItem.scheduledTime; - - const hasLiveEstimate = Boolean(liveEstimate?.isRealTime); - const isCancelled = Boolean(liveEstimate?.isCancelled); - const isExpired = departureTime.isBefore(now, 'minute'); - - return { - time: departureTime.format('h:mm'), - departureTime, - live: hasLiveEstimate, - cancelled: isCancelled, - expired: isExpired, - highlighted: false, // Will be set in next step - }; - }); - - // Step 2: Find the next (first non-expired, non-cancelled) time and highlight only that one - // Only highlight if we have live estimates available - const hasEstimates = routeEstimate && routeEstimate.timetable.length > 0; - const nextTimeIndex = hasEstimates - ? processedTimes.findIndex((item) => !item.expired && !item.cancelled) - : -1; - - // Explicitly set highlighted status: only the next time should be true, all others false - processedTimes.forEach((item, index) => { - item.highlighted = index === nextTimeIndex && nextTimeIndex !== -1; - }); - - // Step 3: Chunk into rows and highlight the row containing the next time - const rows: TableItemRow[] = chunk(processedTimes, ITEMS_PER_ROW).map((rowItems) => ({ - items: rowItems.map(({ departureTime, ...item }) => item), // Remove departureTime (only needed for processing) - highlighted: rowItems.some((item) => item.highlighted), + const hasLiveData = departures.some((d) => d.estimatedTime !== null); + const departsAt = (d: TimetableDeparture) => d.estimatedTime ?? d.scheduledTime; + + // Only point at the next bus when live data can confirm it. + const next = hasLiveData + ? departures.find((d) => !d.isCancelled && !departsAt(d).isBefore(now, 'minute')) + : undefined; + + const items = departures.map((d) => ({ + time: departsAt(d).format('h:mm'), + highlighted: d === next, + live: d.estimatedTime !== null, + cancelled: d.isCancelled, + expired: departsAt(d).isBefore(now, 'minute'), })); - return rows; + return chunk(items, ITEMS_PER_ROW).map((row) => ({ + items: row, + highlighted: row.some((item) => item.highlighted), + })); } diff --git a/src/lib/utils/tints.ts b/src/lib/utils/tints.ts index d3e15e1a..628ca0f7 100644 --- a/src/lib/utils/tints.ts +++ b/src/lib/utils/tints.ts @@ -1,34 +1,5 @@ import type { Route } from '$lib/data/types'; -export const busTints: Record = { - '01-04': '#fe453a', - '03': '#03a8e4', - '03-05': '#0285ff', - '04': '#ff9500', - '05': '#a387ff', - '06': '#d15cff', - '07': '#e9b11a', - '08': '#619bff', - '12': '#04bcef', - '15': '#34d070', - '22': '#dca200', - '27': '#29c753', - G31: '#28c3d8', - '34': '#ff649a', - '35': '#ff9500', - G35: '#ff9500', - '36': '#00a2ff', - '40': '#2da9de', - '47-48': '#d187ff', - '47': '#05d56d', - '48': '#05d56d', - WR: '#05d56d', - PARA: '#7bd1ff', - BRY: '#2da9de', -}; - export function getRouteTint(route: Route, theme: 'light' | 'dark'): string { - if (theme === 'light') return route.tintColor; - - return busTints[route.routeCode] || route.tintColor; + return theme === 'light' ? route.lightColor : route.darkColor; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 33db27e9..89283b78 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,21 +2,27 @@ import favicon from '$lib/assets/favicon.svg'; import MapElements from '$lib/components/map/MapElements.svelte'; import PlanMapElements from '$lib/components/map/PlanMapElements.svelte'; + import UpdateRequired from '$lib/components/UpdateRequired.svelte'; import Map from '$lib/components/ui/map/Map.svelte'; import MapControls from '$lib/components/ui/map/MapControls.svelte'; import { frontPageManager } from '$lib/managers/frontpage.manager.svelte'; import { mapManager } from '$lib/managers/map.manager.svelte'; + import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; + import { syncManager, VersionStatus } from '$lib/managers/sync.manager.svelte'; import { installDynamicType } from '$lib/utils/dynamic-type'; import { installInterceptor } from '$lib/utils/interceptor'; import { migratePrefs } from '$lib/utils/prefs'; - import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; + import { queryClient } from '$lib/utils/queries'; + import { QueryClientProvider } from '@tanstack/svelte-query'; + import { page } from '$app/state'; import { ModeWatcher } from 'mode-watcher'; import { onDestroy, onMount } from 'svelte'; import './layout.css'; let { children } = $props(); - const queryClient = new QueryClient(); + // Every page under /route/[id] shows live data for that route. + $effect(() => liveDataManager.subscribe(page.params.id ?? null)); onDestroy(() => { mapManager.unregisterMap(); @@ -26,6 +32,7 @@ // Pages read prefs on load, so migrating first keeps them from flickering. await migratePrefs(); await frontPageManager.load(); + await syncManager.start(); }); installInterceptor(); @@ -57,3 +64,7 @@ {@render children()}
+ +{#if syncManager.versionStatus === VersionStatus.UNSUPPORTED} + +{/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5da15f6b..b4d0e1c1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,11 +10,16 @@ import { useRoutes } from '$lib/data/app'; import { frontPageManager } from '$lib/managers/frontpage.manager.svelte'; import { mapManager } from '$lib/managers/map.manager.svelte'; + import { syncManager } from '$lib/managers/sync.manager.svelte'; import { Preferences } from '@capacitor/preferences'; import { Cog, Route } from '@lucide/svelte'; import { onMount } from 'svelte'; let routes = useRoutes(); + // On first launch the database stays empty until the first sync lands. + const isLoading = $derived( + routes.isLoading || (routes.data?.length === 0 && syncManager.isSyncing), + ); const favRoutes = $derived( routes.data?.filter((route) => frontPageManager.favorites.includes(route.routeCode)) ?? [], ); @@ -67,7 +72,7 @@ {#if frontPageManager.selectedTab === 'all'} - {#if routes.isLoading} + {#if isLoading} {:else if routes.isError}

Error loading routes: {routes.error.message}

@@ -79,11 +84,11 @@
{:else if frontPageManager.selectedTab === 'favorites'} - {#if favRoutes.length === 0 && !routes.isLoading} + {#if favRoutes.length === 0 && !isLoading}

There are no favorited routes.

{/if} - {#if routes.isLoading} + {#if isLoading} {/if} diff --git a/src/routes/route/[id]/+page.svelte b/src/routes/route/[id]/+page.svelte index 152408c1..035aa0b2 100644 --- a/src/routes/route/[id]/+page.svelte +++ b/src/routes/route/[id]/+page.svelte @@ -8,30 +8,32 @@ import Button from '$lib/components/ui/button/button.svelte'; import * as Tabs from '$lib/components/ui/tabs'; import Toggle from '$lib/components/ui/toggle/toggle.svelte'; - import { useAlerts, useRoutes } from '$lib/data/app'; + import { useAlerts, useRoute } from '$lib/data/app'; import { mapManager } from '$lib/managers/map.manager.svelte'; import { toggleFavorite } from '$lib/utils/prefs'; import { Preferences } from '@capacitor/preferences'; import { Bell, BellRing, Star } from '@lucide/svelte'; - import { onMount, untrack } from 'svelte'; + import { untrack } from 'svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); - const routes = useRoutes(); - const route = $derived(routes.data?.find((r) => r.id === data.routeId) ?? null); - const alerts = useAlerts(() => ({ route })); + const routeQuery = useRoute(() => ({ routeId: data.routeId })); + const route = $derived(routeQuery.data ?? null); + const alerts = useAlerts(() => ({ routeId: data.routeId })); let isFavorite = $state(false); const selectedDirection = $derived( route?.directions.find((d) => d.id === mapManager.selectedDirectionId) ?? null, ); - onMount(async () => { - const favoritedRoutes = JSON.parse( - (await Preferences.get({ key: 'favorites' })).value ?? '[]', - ) as string[]; + // The route loads from the database after mount, so wait for its code. + $effect(() => { + const routeCode = route?.routeCode; + if (!routeCode) return; - isFavorite = favoritedRoutes.includes(route?.routeCode ?? ''); + Preferences.get({ key: 'favorites' }).then(({ value }) => { + isFavorite = (JSON.parse(value ?? '[]') as string[]).includes(routeCode); + }); }); $effect(() => { diff --git a/src/routes/route/[id]/EstimateRow.svelte b/src/routes/route/[id]/EstimateRow.svelte index f8fac318..999e22b8 100644 --- a/src/routes/route/[id]/EstimateRow.svelte +++ b/src/routes/route/[id]/EstimateRow.svelte @@ -2,7 +2,7 @@ import TimeBubble from '$lib/components/TimeBubble.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; import { useStopEstimate } from '$lib/data/app'; - import type { Direction, Route, Stop } from '$lib/data/types'; + import { EstimateSource, type Direction, type Route, type Stop } from '$lib/data/types'; import { themeManager } from '$lib/managers/theme.manager.svelte'; import { getRouteTint } from '$lib/utils/tints'; @@ -14,13 +14,8 @@ let { route, direction, stop }: Props = $props(); - const { data: estimates, isLoading } = $derived( - useStopEstimate(() => ({ - route: route, - direction: direction, - stop, - })), - ); + const estimates = $derived(useStopEstimate(() => ({ direction, stop }))); + const isLoading = $derived(estimates.data.source === EstimateSource.LOADING);
{/if} - {#each estimates as estimate, i} + {#each estimates.data?.estimates ?? [] as estimate, i} {/each}
diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[id]/StopRow.svelte index acdea868..5d264d7b 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[id]/StopRow.svelte @@ -6,8 +6,16 @@ import TimepointAttribute from '$lib/components/TimepointAttribute.svelte'; import Button from '$lib/components/ui/button/button.svelte'; import { mapManager } from '$lib/managers/map.manager.svelte'; - import { useStopAmenities, useStopEstimate } from '$lib/data/app'; - import { Amenity, type Direction, type Route, type Stop } from '$lib/data/types'; + import { useStopEstimate } from '$lib/data/app'; + import { + Amenity, + ESTIMATES_UNAVAILABLE_MESSAGE, + EstimateSource, + type Direction, + type Route, + type Stop, + } from '$lib/data/types'; + import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { CalendarIcon } from '@lucide/svelte'; import { getContext } from 'svelte'; @@ -26,20 +34,31 @@ const effectiveDirection = $derived(estimateDirection ?? direction); const effectiveStop = $derived(estimateStop ?? stop); - const { data: estimates, isLoading } = $derived( + const stopEstimates = $derived( useStopEstimate(() => ({ - route: route, direction: effectiveDirection, stop: effectiveStop, })), ); - const { data: stopAmenities } = $derived(useStopAmenities(() => ({ route, direction, stop }))); - let subtitle = $derived.by(() => { - if (!estimates || estimates.length === 0) { + const source = stopEstimates.data?.source ?? EstimateSource.LOADING; + const estimates = stopEstimates.data?.estimates ?? []; + + if (source === EstimateSource.LOADING) { + return 'Loading departures'; + } + if (source === EstimateSource.UNAVAILABLE) { + return ESTIMATES_UNAVAILABLE_MESSAGE; + } + if (estimates.length === 0) { return 'No upcoming departures'; } + if (source === EstimateSource.SCHEDULE) { + return liveDataManager.status === LiveDataStatus.OFFLINE + ? 'Offline, showing scheduled times' + : 'Scheduled times'; + } const firstEstimate = estimates.find((estimate) => estimate.estimatedTime); if (!firstEstimate?.estimatedTime) { @@ -77,10 +96,11 @@
- {#each stopAmenities ?? [] as amenity} - {#if amenity === Amenity.TIME_POINT} - - {:else} + {#if stop.isTimepoint} + + {/if} + {#each stop.amenities as amenity} + {#if amenity !== Amenity.TIME_POINT} {@const AmenityIcon = Amenity.getIcon(amenity)} {/if} diff --git a/src/routes/route/[id]/alerts/+page.svelte b/src/routes/route/[id]/alerts/+page.svelte index 694960ee..953fc1c9 100644 --- a/src/routes/route/[id]/alerts/+page.svelte +++ b/src/routes/route/[id]/alerts/+page.svelte @@ -5,18 +5,18 @@ import * as Card from '$lib/components/ui/card'; import Separator from '$lib/components/ui/separator/separator.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; - import { useAlerts, useRoutes } from '$lib/data/app'; + import { useAlerts, useRoute } from '$lib/data/app'; import { TriangleAlert } from '@lucide/svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); - const routes = useRoutes(); - const route = $derived(routes.data?.find((r) => r.id === data.routeId) ?? null); - const alerts = useAlerts(() => ({ route })); + const routeQuery = useRoute(() => ({ routeId: data.routeId })); + const route = $derived(routeQuery.data ?? null); + const alerts = useAlerts(() => ({ routeId: data.routeId })); function onClose() { - goto(`/route/${route?.id}`); + goto(`/route/${data.routeId}`); } const descriptionClasses = diff --git a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte b/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte index 25580f48..c370e965 100644 --- a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte +++ b/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte @@ -6,7 +6,7 @@ import * as BottomSheet from '$lib/components/ui/bottom-sheet'; import DateStepper from '$lib/components/ui/date-stepper/date-stepper.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; - import { useRoutes, useStopSchedule } from '$lib/data/app'; + import { useRoute, useTimetable } from '$lib/data/app'; import moment from 'moment'; import type { PageData } from './$types'; @@ -19,16 +19,16 @@ let date = $state(moment()); - const routes = useRoutes(); + const routeQuery = useRoute(() => ({ routeId: data.routeId })); - const route = $derived(routes.data?.find((r) => r.id === data.routeId) ?? null); + const route = $derived(routeQuery.data ?? null); const direction = $derived(route?.directions.find((d) => d.id === data.directionId) ?? null); const stop = $derived(direction?.stops.find((s) => s.id === data.stopId) ?? null); - const timetable = useStopSchedule(() => ({ stop, date })); + const timetable = useTimetable(() => ({ direction, stop, date })); function onClose() { - goto(`/route/${route?.id}`); + goto(`/route/${data.routeId}`); } @@ -45,32 +45,26 @@
- {#if timetable.isLoading} + {#if timetable.isLoading || routeQuery.isLoading} - {:else if routes.isError} -

Error loading routes: {routes.error.message}

- {:else if timetable.data?.length === 0} -

- No scheduled arrivals for this stop on this day. -

+ {:else if routeQuery.isError} +

Error loading route: {routeQuery.error.message}

+ {:else if route && direction} +
+ goto(`/route/${route.id}`)} + /> + {#if (timetable.data?.length ?? 0) === 0} +

+ No scheduled arrivals for this stop on this day. +

+ {:else} + + {/if} +
{/if} - - {#each timetable.data as table} - {#if table.route.id == route?.id && table.direction.id == direction?.id} -
- goto(`/route/${table.route.id}`)} - /> - {#if table.timetable.length === 0} -

No timetable for selected day

- {:else} - - {/if} -
- {/if} - {/each}
{/key} diff --git a/vite.config.ts b/vite.config.ts index 5237e9e2..9e43e804 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,11 @@ import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; +import { version } from './package.json'; -export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + define: { __APP_VERSION__: JSON.stringify(version) }, + // Pre-bundling splits the Stencil runtime so the lazy component can load a second copy of it. + optimizeDeps: { exclude: ['jeep-sqlite', 'jeep-sqlite/loader'] }, +}); From e150e97b8667a09a9c4877d4702c025cd54083c3 Mon Sep 17 00:00:00 2001 From: bwees Date: Thu, 1 Oct 2026 11:32:00 -0500 Subject: [PATCH 02/29] chore: format --- .prettierignore | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 7c928225..3b65aca2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,3 +31,7 @@ bun.lockb # Managed by release-please /.release-please-manifest.json + + +# generated migration snapshots +/src/lib/db/migrations/meta \ No newline at end of file diff --git a/package.json b/package.json index d0efa931..f7a30abc 100644 --- a/package.json +++ b/package.json @@ -74,4 +74,4 @@ "semver": "^7.8.5", "zod": "^4.3.6" } -} \ No newline at end of file +} From ec687fb49bb5e543d2ff0a0a96a35bbe7336c845 Mon Sep 17 00:00:00 2001 From: bwees Date: Thu, 1 Oct 2026 23:29:20 -0500 Subject: [PATCH 03/29] fix: stops are unique in directions --- src/lib/api/client.ts | 2 +- src/lib/api/openapi.d.ts | 45 +-------- src/lib/api/websocket.ts | 1 - src/lib/components/map/MapElements.svelte | 12 +-- src/lib/components/map/popup/StopPopup.svelte | 6 +- src/lib/data/app.ts | 78 ++++++--------- src/lib/db/migrations/0000_initial_tables.sql | 18 ++-- src/lib/db/migrations/meta/0000_snapshot.json | 96 +++++++------------ src/lib/db/migrations/meta/_journal.json | 2 +- src/lib/db/schema.ts | 39 +++----- src/lib/managers/live-data.manager.svelte.ts | 20 ++-- src/lib/sync/sync-stream.test.ts | 11 ++- src/lib/sync/sync-stream.ts | 7 -- src/routes/route/[id]/+page.svelte | 1 - src/routes/route/[id]/EstimateRow.svelte | 7 +- src/routes/route/[id]/StopRow.svelte | 13 +-- .../timetable/[stop]/[direction]/+page.svelte | 2 +- 17 files changed, 114 insertions(+), 246 deletions(-) diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 0d9dce61..f4aac72d 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -6,7 +6,7 @@ const DEV_SERVER_API_URL = 'http://100.89.139.58:3000'; const LINE_SEPARATOR = '\n'; export const API_URL: string = - import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? DEV_SERVER_API_URL : PRODUCTION_API_URL); + import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? PRODUCTION_API_URL : PRODUCTION_API_URL); export const WEBSOCKET_URL = `${API_URL.replace(/^http/, 'ws')}/api/ws`; type Schemas = components['schemas']; diff --git a/src/lib/api/openapi.d.ts b/src/lib/api/openapi.d.ts index cd79be0f..d42dca7e 100644 --- a/src/lib/api/openapi.d.ts +++ b/src/lib/api/openapi.d.ts @@ -107,24 +107,6 @@ export interface components { */ type: 'DirectionDeleteV1'; }; - DirectionStopDeleteV1Line: { - ack: string; - data: components['schemas']['SyncDirectionStopDeleteV1']; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: 'DirectionStopDeleteV1'; - }; - DirectionStopV1Line: { - ack: string; - data: components['schemas']['SyncDirectionStopV1']; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: 'DirectionStopV1'; - }; DirectionV1Line: { ack: string; data: components['schemas']['SyncDirectionV1']; @@ -249,22 +231,6 @@ export interface components { /** Format: uuid */ directionId: string; }; - /** @description SyncDirectionStopDeleteV1 schema */ - SyncDirectionStopDeleteV1: { - /** Format: uuid */ - directionStopId: string; - }; - /** @description SyncDirectionStopV1 schema */ - SyncDirectionStopV1: { - /** Format: uuid */ - directionId: string; - /** Format: uuid */ - id: string; - isTimepoint: boolean; - sequence: number; - /** Format: uuid */ - stopId: string; - }; /** @description SyncDirectionV1 schema */ SyncDirectionV1: { destination: string; @@ -287,8 +253,6 @@ export interface components { | 'DirectionDeleteV1' | 'StopV1' | 'StopDeleteV1' - | 'DirectionStopV1' - | 'DirectionStopDeleteV1' | 'AlertV1' | 'AlertDeleteV1' | 'AlertDirectionV1' @@ -310,7 +274,6 @@ export interface components { | 'RoutesV1' | 'DirectionsV1' | 'StopsV1' - | 'DirectionStopsV1' | 'AlertsV1' | 'AlertDirectionsV1' | 'TimetablesV1'; @@ -349,12 +312,16 @@ export interface components { SyncStopV1: { amenities: string[] | null; /** Format: uuid */ + directionId: string; + /** Format: uuid */ id: string; + isTimepoint: boolean; /** Format: double */ lat: number; /** Format: double */ lon: number; name: string; + sequence: number; }; SyncStreamLine: | components['schemas']['RouteV1Line'] @@ -363,8 +330,6 @@ export interface components { | components['schemas']['DirectionDeleteV1Line'] | components['schemas']['StopV1Line'] | components['schemas']['StopDeleteV1Line'] - | components['schemas']['DirectionStopV1Line'] - | components['schemas']['DirectionStopDeleteV1Line'] | components['schemas']['AlertV1Line'] | components['schemas']['AlertDeleteV1Line'] | components['schemas']['AlertDirectionV1Line'] @@ -382,8 +347,6 @@ export interface components { SyncTimetableV1: { departures: string[] | null; /** Format: uuid */ - directionId: string; - /** Format: uuid */ id: string; /** Format: date */ serviceDate: string; diff --git a/src/lib/api/websocket.ts b/src/lib/api/websocket.ts index e87e0f54..75eed45f 100644 --- a/src/lib/api/websocket.ts +++ b/src/lib/api/websocket.ts @@ -32,7 +32,6 @@ export interface WebsocketVehicle { export interface WebsocketDeparture { id: string; stopId: string; - directionId: string; scheduledAt: string; estimatedAt: string | null; isCancelled: boolean; diff --git a/src/lib/components/map/MapElements.svelte b/src/lib/components/map/MapElements.svelte index 80eff428..8097364f 100644 --- a/src/lib/components/map/MapElements.svelte +++ b/src/lib/components/map/MapElements.svelte @@ -150,13 +150,10 @@ if (!route || mapManager.selected?.type !== 'stop') return null; const stopId = mapManager.selected.id; - const matches = stopsByDirection.flatMap(({ direction, stops }) => { - const stop = stops.find((candidate) => candidate.id === stopId); - return stop ? [{ route, direction, stop }] : []; - }); - - // a stop belongs to several directions; the shown one supplies the estimates - return matches.find((match) => isDirectionSelected(match.direction.id)) ?? matches[0] ?? null; + const stop = stopsByDirection + .flatMap(({ stops }) => stops) + .find((candidate) => candidate.id === stopId); + return stop ? { route, stop } : null; }); @@ -246,7 +243,6 @@ {:else if selectedStop} (mapManager.selected = null)} /> diff --git a/src/lib/components/map/popup/StopPopup.svelte b/src/lib/components/map/popup/StopPopup.svelte index 21f2019f..c0775b7b 100644 --- a/src/lib/components/map/popup/StopPopup.svelte +++ b/src/lib/components/map/popup/StopPopup.svelte @@ -8,7 +8,6 @@ Amenity, ESTIMATES_UNAVAILABLE_MESSAGE, EstimateSource, - type Direction, type Route, type Stop, } from '$lib/data/types'; @@ -18,15 +17,14 @@ type Props = { route: Route; stop: Stop; - direction: Direction; onclose?: () => void; }; - let { stop, route, direction, onclose }: Props = $props(); + let { stop, route, onclose }: Props = $props(); const tint = $derived(getRouteTint(route, themeManager.theme)); - const stopEstimates = $derived(useStopEstimate(() => ({ direction, stop }))); + const stopEstimates = $derived(useStopEstimate(() => ({ stop }))); const source = $derived(stopEstimates.data?.source ?? EstimateSource.LOADING); const estimates = $derived(stopEstimates.data?.estimates ?? []); diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 662652f1..5444b3f6 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -11,7 +11,7 @@ import { type TimetableDeparture, } from '$lib/data/types'; import { db } from '$lib/db/database'; -import { alert, alertDirection, direction, directionStop, route, timetable } from '$lib/db/schema'; +import { alert, alertDirection, direction, route, stop, timetable } from '$lib/db/schema'; import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { findBoundingBox } from '$lib/utils/geo'; import { createDbQuery } from '$lib/utils/queries'; @@ -38,7 +38,7 @@ function routeTree(where?: SQL) { directions: { orderBy: asc(direction.sequence), with: { - directionStops: { orderBy: asc(directionStop.sequence), with: { stop: true } }, + stops: { orderBy: asc(stop.sequence) }, }, }, }, @@ -47,14 +47,14 @@ function routeTree(where?: SQL) { type RouteRow = Awaited>[number]; type DirectionRow = RouteRow['directions'][number]; -type DirectionStopRow = DirectionRow['directionStops'][number]; +type StopRow = DirectionRow['stops'][number]; -function toStop(row: DirectionStopRow, index: number, all: DirectionStopRow[]): Stop { +function toStop(row: StopRow, index: number, all: StopRow[]): Stop { return { - id: row.stop.id, - name: row.stop.name, - location: { latitude: row.stop.lat, longitude: row.stop.lon }, - amenities: row.stop.amenities as Amenity[], + id: row.id, + name: row.name, + location: { latitude: row.lat, longitude: row.lon }, + amenities: row.amenities as Amenity[], isTimepoint: row.isTimepoint, isLastOnDirection: index === all.length - 1, }; @@ -65,7 +65,7 @@ function toDirection(row: DirectionRow, _: number, all: DirectionRow[]): Directi id: row.id, name: row.destination, pathPoints: decode(row.path).map(([latitude, longitude]) => ({ latitude, longitude })), - stops: row.directionStops.map(toStop), + stops: row.stops.map(toStop), isOnlyDirection: all.length === 1, }; } @@ -120,19 +120,12 @@ export const useAlerts = (params: () => { routeId: string | null }) => }); /** Every scheduled time at a stop on one day. */ -function scheduledTimes(direction: Direction, stop: Stop, serviceDate: string | SQL, where?: SQL) { +function scheduledTimes(stop: Stop, serviceDate: string | SQL, where?: SQL) { return db .select({ scheduledAt: slotTime }) .from(timetable) .innerJoin(sql`json_each(${timetable.departures}) slot`, sql`true`) - .where( - and( - eq(timetable.directionId, direction.id), - eq(timetable.stopId, stop.id), - eq(timetable.serviceDate, serviceDate), - where, - ), - ) + .where(and(eq(timetable.stopId, stop.id), eq(timetable.serviceDate, serviceDate), where)) .orderBy(sql`unixepoch(${slotTime})`); } @@ -142,17 +135,16 @@ interface UpcomingTimes { } /** Today's scheduled times at a stop that may still be coming, including late ones. */ -const useUpcomingTimes = (params: () => { direction: Direction; stop: Stop }) => +const useUpcomingTimes = (params: () => { stop: Stop }) => createDbQuery(() => { - const { direction, stop } = params(); + const { stop } = params(); const today = sql`date('now', 'localtime')`; return { - key: ['upcomingTimes', direction.id, stop.id], + key: ['upcomingTimes', stop.id], refetchInterval: SCHEDULE_REFRESH_INTERVAL, queryFn: async () => { const [rows, timetables] = await Promise.all([ scheduledTimes( - direction, stop, today, gte(sql`unixepoch(${slotTime})`, sql`unixepoch('now', ${LATE_DEPARTURE_WINDOW})`), @@ -160,13 +152,7 @@ const useUpcomingTimes = (params: () => { direction: Direction; stop: Stop }) => db .select({ id: timetable.id }) .from(timetable) - .where( - and( - eq(timetable.directionId, direction.id), - eq(timetable.stopId, stop.id), - eq(timetable.serviceDate, today), - ), - ) + .where(and(eq(timetable.stopId, stop.id), eq(timetable.serviceDate, today))) .limit(1), ]); @@ -179,15 +165,11 @@ const useUpcomingTimes = (params: () => { direction: Direction; stop: Stop }) => }); /** Marks each scheduled time with its live estimate, dropping cancelled and departed buses. */ -function withLiveEstimates( - times: moment.Moment[], - direction: Direction, - stop: Stop, -): TimeEstimate[] { +function withLiveEstimates(times: moment.Moment[], stop: Stop): TimeEstimate[] { const earliest = moment().subtract(DEPARTED_GRACE); return times .flatMap((scheduledTime) => { - const live = liveDataManager.departureAt(stop.id, direction.id, scheduledTime); + const live = liveDataManager.departureAt(stop.id, scheduledTime); if (live?.isCancelled) return []; const estimatedTime = live?.estimatedAt ? moment(live.estimatedAt) : null; @@ -199,17 +181,17 @@ function withLiveEstimates( } /** A stop's next departures from its timetable, updated with live estimates once they arrive. */ -export function useStopEstimate(params: () => { direction: Direction; stop: Stop }) { +export function useStopEstimate(params: () => { stop: Stop }) { const upcoming = useUpcomingTimes(params); return { get data(): StopEstimates { - const { direction, stop } = params(); + const { stop } = params(); const isLive = liveDataManager.status === LiveDataStatus.LIVE; if (!upcoming.data) return { source: EstimateSource.LOADING, estimates: [] }; if (!upcoming.data.hasTimetable) { - const live = isLive ? liveDataManager.estimatesFor(direction.id, stop.id) : []; + const live = isLive ? liveDataManager.estimatesFor(stop.id) : []; return live.length > 0 ? { source: EstimateSource.LIVE, estimates: live } : { source: EstimateSource.UNAVAILABLE, estimates: [] }; @@ -217,26 +199,22 @@ export function useStopEstimate(params: () => { direction: Direction; stop: Stop return { source: isLive ? EstimateSource.LIVE : EstimateSource.SCHEDULE, - estimates: withLiveEstimates(upcoming.data.times, direction, stop), + estimates: withLiveEstimates(upcoming.data.times, stop), }; }, }; } /** A stop's timetable for one day, marked with live estimates and cancellations. */ -export function useTimetable( - params: () => { direction: Direction | null; stop: Stop | null; date: moment.Moment }, -) { +export function useTimetable(params: () => { stop: Stop | null; date: moment.Moment }) { const times = createDbQuery(() => { - const { direction, stop, date } = params(); + const { stop, date } = params(); const serviceDate = date.format('YYYY-MM-DD'); return { - key: ['timetable', direction?.id, stop?.id, serviceDate], - enabled: direction !== null && stop !== null, + key: ['timetable', stop?.id, serviceDate], + enabled: stop !== null, queryFn: async () => - (await scheduledTimes(direction!, stop!, serviceDate)).map((row) => - moment(row.scheduledAt), - ), + (await scheduledTimes(stop!, serviceDate)).map((row) => moment(row.scheduledAt)), }; }); @@ -245,9 +223,9 @@ export function useTimetable( return times.isLoading; }, get data(): TimetableDeparture[] | undefined { - const { direction, stop } = params(); + const { stop } = params(); return times.data?.map((scheduledTime) => { - const live = liveDataManager.departureAt(stop!.id, direction!.id, scheduledTime); + const live = liveDataManager.departureAt(stop!.id, scheduledTime); return { scheduledTime, estimatedTime: live?.estimatedAt ? moment(live.estimatedAt) : null, diff --git a/src/lib/db/migrations/0000_initial_tables.sql b/src/lib/db/migrations/0000_initial_tables.sql index 3568c327..6eda4f87 100644 --- a/src/lib/db/migrations/0000_initial_tables.sql +++ b/src/lib/db/migrations/0000_initial_tables.sql @@ -25,15 +25,6 @@ CREATE TABLE `direction` ( ); --> statement-breakpoint CREATE INDEX `direction_route_id_idx` ON `direction` (`route_id`);--> statement-breakpoint -CREATE TABLE `direction_stop` ( - `id` text PRIMARY KEY NOT NULL, - `direction_id` text NOT NULL, - `stop_id` text NOT NULL, - `sequence` integer NOT NULL, - `is_timepoint` integer NOT NULL -); ---> statement-breakpoint -CREATE INDEX `direction_stop_direction_id_idx` ON `direction_stop` (`direction_id`,`sequence`);--> statement-breakpoint CREATE TABLE `route` ( `id` text PRIMARY KEY NOT NULL, `short_name` text NOT NULL, @@ -45,12 +36,16 @@ CREATE TABLE `route` ( --> statement-breakpoint CREATE TABLE `stop` ( `id` text PRIMARY KEY NOT NULL, + `direction_id` text NOT NULL, `name` text NOT NULL, `lat` real NOT NULL, `lon` real NOT NULL, - `amenities` text NOT NULL + `amenities` text NOT NULL, + `sequence` integer NOT NULL, + `is_timepoint` integer NOT NULL ); --> statement-breakpoint +CREATE INDEX `stop_direction_id_idx` ON `stop` (`direction_id`,`sequence`);--> statement-breakpoint CREATE TABLE `sync_ack` ( `key` text PRIMARY KEY NOT NULL, `ack` text NOT NULL @@ -59,9 +54,8 @@ CREATE TABLE `sync_ack` ( CREATE TABLE `timetable` ( `id` text PRIMARY KEY NOT NULL, `stop_id` text NOT NULL, - `direction_id` text NOT NULL, `service_date` text NOT NULL, `departures` text NOT NULL ); --> statement-breakpoint -CREATE INDEX `timetable_slot_idx` ON `timetable` (`stop_id`,`direction_id`,`service_date`); \ No newline at end of file +CREATE INDEX `timetable_slot_idx` ON `timetable` (`stop_id`,`service_date`); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0000_snapshot.json b/src/lib/db/migrations/meta/0000_snapshot.json index 90a55e1c..5fa26497 100644 --- a/src/lib/db/migrations/meta/0000_snapshot.json +++ b/src/lib/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "748312ff-2ac1-4fd2-b379-3b922f0cf6d5", + "id": "866d4740-22cf-47f1-b6df-4174a0c9def6", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "alert": { @@ -162,60 +162,6 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "direction_stop": { - "name": "direction_stop", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "direction_id": { - "name": "direction_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stop_id": { - "name": "stop_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_timepoint": { - "name": "is_timepoint", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "direction_stop_direction_id_idx": { - "name": "direction_stop_direction_id_idx", - "columns": [ - "direction_id", - "sequence" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, "route": { "name": "route", "columns": { @@ -278,6 +224,13 @@ "notNull": true, "autoincrement": false }, + "direction_id": { + "name": "direction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "name": { "name": "name", "type": "text", @@ -305,9 +258,32 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_timepoint": { + "name": "is_timepoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stop_direction_id_idx": { + "name": "stop_direction_id_idx", + "columns": [ + "direction_id", + "sequence" + ], + "isUnique": false } }, - "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, @@ -354,13 +330,6 @@ "notNull": true, "autoincrement": false }, - "direction_id": { - "name": "direction_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "service_date": { "name": "service_date", "type": "text", @@ -381,7 +350,6 @@ "name": "timetable_slot_idx", "columns": [ "stop_id", - "direction_id", "service_date" ], "isUnique": false diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 9387fda8..ce34a295 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "6", - "when": 1790814773563, + "when": 1790915145562, "tag": "0000_initial_tables", "breakpoints": true } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 25837702..748fe22a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -22,24 +22,19 @@ export const direction = sqliteTable( (t) => [index('direction_route_id_idx').on(t.routeId)], ); -export const stop = sqliteTable('stop', { - id: text('id').primaryKey(), - name: text('name').notNull(), - lat: real('lat').notNull(), - lon: real('lon').notNull(), - amenities: text('amenities', { mode: 'json' }).$type().notNull(), -}); - -export const directionStop = sqliteTable( - 'direction_stop', +export const stop = sqliteTable( + 'stop', { id: text('id').primaryKey(), directionId: text('direction_id').notNull(), - stopId: text('stop_id').notNull(), + name: text('name').notNull(), + lat: real('lat').notNull(), + lon: real('lon').notNull(), + amenities: text('amenities', { mode: 'json' }).$type().notNull(), sequence: integer('sequence').notNull(), isTimepoint: integer('is_timepoint', { mode: 'boolean' }).notNull(), }, - (t) => [index('direction_stop_direction_id_idx').on(t.directionId, t.sequence)], + (t) => [index('stop_direction_id_idx').on(t.directionId, t.sequence)], ); export const alert = sqliteTable('alert', { @@ -68,11 +63,10 @@ export const timetable = sqliteTable( { id: text('id').primaryKey(), stopId: text('stop_id').notNull(), - directionId: text('direction_id').notNull(), serviceDate: text('service_date').notNull(), departures: text('departures', { mode: 'json' }).$type().notNull(), }, - (t) => [index('timetable_slot_idx').on(t.stopId, t.directionId, t.serviceDate)], + (t) => [index('timetable_slot_idx').on(t.stopId, t.serviceDate)], ); /** The newest ack per entity key, e.g. `RouteV1` or `TimetableV1:`. */ @@ -87,13 +81,12 @@ export const routeRelations = relations(route, ({ many }) => ({ export const directionRelations = relations(direction, ({ one, many }) => ({ route: one(route, { fields: [direction.routeId], references: [route.id] }), - directionStops: many(directionStop), + stops: many(stop), alertDirections: many(alertDirection), })); -export const directionStopRelations = relations(directionStop, ({ one }) => ({ - direction: one(direction, { fields: [directionStop.directionId], references: [direction.id] }), - stop: one(stop, { fields: [directionStop.stopId], references: [stop.id] }), +export const stopRelations = relations(stop, ({ one }) => ({ + direction: one(direction, { fields: [stop.directionId], references: [direction.id] }), })); export const alertRelations = relations(alert, ({ many }) => ({ @@ -106,12 +99,4 @@ export const alertDirectionRelations = relations(alertDirection, ({ one }) => ({ })); /** Tables filled by the sync stream. A reset wipes all of them along with their acks. */ -export const SYNCED_TABLES = [ - route, - direction, - stop, - directionStop, - alert, - alertDirection, - timetable, -] as const; +export const SYNCED_TABLES = [route, direction, stop, alert, alertDirection, timetable] as const; diff --git a/src/lib/managers/live-data.manager.svelte.ts b/src/lib/managers/live-data.manager.svelte.ts index 401cb689..d3e5108e 100644 --- a/src/lib/managers/live-data.manager.svelte.ts +++ b/src/lib/managers/live-data.manager.svelte.ts @@ -30,8 +30,8 @@ function departsAt(departure: WebsocketDeparture) { return moment(departure.estimatedAt ?? departure.scheduledAt); } -function slotKey(stopId: string, directionId: string, scheduledAt: moment.MomentInput) { - return `${stopId}|${directionId}|${moment(scheduledAt).valueOf()}`; +function slotKey(stopId: string, scheduledAt: moment.MomentInput) { + return `${stopId}|${moment(scheduledAt).valueOf()}`; } /** Holds the subscribed route's vehicles and departures, straight from the websocket. */ @@ -49,7 +49,7 @@ class LiveDataManager { }); readonly #departuresBySlot = $derived( - new Map(this.departures.map((d) => [slotKey(d.stopId, d.directionId, d.scheduledAt), d])), + new Map(this.departures.map((d) => [slotKey(d.stopId, d.scheduledAt), d])), ); #routeId: string | null = null; @@ -85,16 +85,10 @@ class LiveDataManager { } /** Upcoming departures at a stop, soonest first. */ - estimatesFor(directionId: string, stopId: string): TimeEstimate[] { + estimatesFor(stopId: string): TimeEstimate[] { const earliest = moment().subtract(DEPARTED_GRACE); return this.departures - .filter( - (d) => - d.directionId === directionId && - d.stopId === stopId && - !d.isCancelled && - departsAt(d).isSameOrAfter(earliest), - ) + .filter((d) => d.stopId === stopId && !d.isCancelled && departsAt(d).isSameOrAfter(earliest)) .sort((a, b) => departsAt(a).diff(departsAt(b))) .map((d) => ({ scheduledTime: moment(d.scheduledAt), @@ -104,8 +98,8 @@ class LiveDataManager { } /** The live departure for one scheduled time at a stop, if the server sent one. */ - departureAt(stopId: string, directionId: string, scheduledAt: moment.Moment) { - return this.#departuresBySlot.get(slotKey(stopId, directionId, scheduledAt)); + departureAt(stopId: string, scheduledAt: moment.Moment) { + return this.#departuresBySlot.get(slotKey(stopId, scheduledAt)); } // Retries after a drop stay disconnected, so the offline fallback does not flicker between attempts. diff --git a/src/lib/sync/sync-stream.test.ts b/src/lib/sync/sync-stream.test.ts index 483e7807..285a2c2f 100644 --- a/src/lib/sync/sync-stream.test.ts +++ b/src/lib/sync/sync-stream.test.ts @@ -57,7 +57,16 @@ describe('planSync', () => { { type: 'StopV1', ack: `StopV1|${ACK_ID}`, - data: { id: STOP_ID, name: 'MSC', lat: 30.6, lon: -96.3, amenities: null }, + data: { + id: STOP_ID, + directionId: DIRECTION_ID, + name: 'MSC', + lat: 30.6, + lon: -96.3, + amenities: null, + sequence: 0, + isTimepoint: false, + }, }, { type: 'SyncCompleteV1', ack: `SyncCompleteV1|${ACK_ID}`, data: {} }, ]), diff --git a/src/lib/sync/sync-stream.ts b/src/lib/sync/sync-stream.ts index 3931738c..86f78eff 100644 --- a/src/lib/sync/sync-stream.ts +++ b/src/lib/sync/sync-stream.ts @@ -4,7 +4,6 @@ import { alert, alertDirection, direction, - directionStop, route, stop, SYNCED_TABLES, @@ -21,7 +20,6 @@ export const SYNC_REQUEST_TYPES: SyncRequestType[] = [ 'RoutesV1', 'DirectionsV1', 'StopsV1', - 'DirectionStopsV1', 'AlertsV1', 'AlertDirectionsV1', 'TimetablesV1', @@ -83,11 +81,6 @@ const HANDLERS: { [T in SyncEntityType]: EntityHandler> } = { amenities: data.amenities ?? [], })), StopDeleteV1: remove(stop, (data: LineData<'StopDeleteV1'>) => data.stopId), - DirectionStopV1: upsert(directionStop, (data: LineData<'DirectionStopV1'>) => data), - DirectionStopDeleteV1: remove( - directionStop, - (data: LineData<'DirectionStopDeleteV1'>) => data.directionStopId, - ), AlertV1: upsert(alert, (data: LineData<'AlertV1'>) => data), AlertDeleteV1: remove(alert, (data: LineData<'AlertDeleteV1'>) => data.alertId), AlertDirectionV1: upsert(alertDirection, (data: LineData<'AlertDirectionV1'>) => data), diff --git a/src/routes/route/[id]/+page.svelte b/src/routes/route/[id]/+page.svelte index 035aa0b2..2385a49b 100644 --- a/src/routes/route/[id]/+page.svelte +++ b/src/routes/route/[id]/+page.svelte @@ -114,7 +114,6 @@ {stop} direction={selectedDirection} {route} - estimateDirection={isLast ? altDirection : undefined} estimateStop={isLast ? altDirection?.stops[0] : undefined} /> {#if i < (selectedDirection?.stops.length ?? 0) - 1} diff --git a/src/routes/route/[id]/EstimateRow.svelte b/src/routes/route/[id]/EstimateRow.svelte index 999e22b8..7ad0166f 100644 --- a/src/routes/route/[id]/EstimateRow.svelte +++ b/src/routes/route/[id]/EstimateRow.svelte @@ -2,19 +2,18 @@ import TimeBubble from '$lib/components/TimeBubble.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; import { useStopEstimate } from '$lib/data/app'; - import { EstimateSource, type Direction, type Route, type Stop } from '$lib/data/types'; + import { EstimateSource, type Route, type Stop } from '$lib/data/types'; import { themeManager } from '$lib/managers/theme.manager.svelte'; import { getRouteTint } from '$lib/utils/tints'; type Props = { route: Route; - direction: Direction; stop: Stop; }; - let { route, direction, stop }: Props = $props(); + let { route, stop }: Props = $props(); - const estimates = $derived(useStopEstimate(() => ({ direction, stop }))); + const estimates = $derived(useStopEstimate(() => ({ stop }))); const isLoading = $derived(estimates.data.source === EstimateSource.LOADING); diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[id]/StopRow.svelte index 5d264d7b..7b0653c6 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[id]/StopRow.svelte @@ -23,23 +23,16 @@ stop: Stop; route: Route; direction: Direction; - estimateDirection?: Direction; estimateStop?: Stop; }; - let { stop, route, direction, estimateDirection, estimateStop }: Props = $props(); + let { stop, route, direction, estimateStop }: Props = $props(); const sheet = getContext('bottom-sheet'); - const effectiveDirection = $derived(estimateDirection ?? direction); const effectiveStop = $derived(estimateStop ?? stop); - const stopEstimates = $derived( - useStopEstimate(() => ({ - direction: effectiveDirection, - stop: effectiveStop, - })), - ); + const stopEstimates = $derived(useStopEstimate(() => ({ stop: effectiveStop }))); let subtitle = $derived.by(() => { const source = stopEstimates.data?.source ?? EstimateSource.LOADING; @@ -109,7 +102,7 @@
{#if route && direction} - + {/if}
+ + {#if syncManager.versionStatus === VersionStatus.UNSUPPORTED} {/if} From 3436e964ba27918ee6f4d7773be3b9f49df1f56e Mon Sep 17 00:00:00 2001 From: bwees Date: Mon, 19 Oct 2026 01:17:52 -0500 Subject: [PATCH 09/29] refactor: extract retry scheduler from live socket --- src/lib/sync/live-socket.ts | 20 +++---- src/lib/utils/retry-scheduler.test.ts | 81 +++++++++++++++++++++++++++ src/lib/utils/retry-scheduler.ts | 43 ++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 src/lib/utils/retry-scheduler.test.ts create mode 100644 src/lib/utils/retry-scheduler.ts diff --git a/src/lib/sync/live-socket.ts b/src/lib/sync/live-socket.ts index dfba9860..8831eb5d 100644 --- a/src/lib/sync/live-socket.ts +++ b/src/lib/sync/live-socket.ts @@ -4,10 +4,10 @@ import { type WebsocketServerMessage, } from '$lib/api/websocket'; import { appLogger } from '$lib/utils/logger'; +import { RetryScheduler } from '$lib/utils/retry-scheduler'; const INITIAL_RETRY_DELAY_MS = 1_000; const MAX_RETRY_DELAY_MS = 30_000; -const RETRY_BACKOFF_FACTOR = 2; // The gateway drops a client that sends nothing for 60 seconds. const PING_INTERVAL_MS = 20_000; @@ -25,8 +25,10 @@ export class LiveSocket { #socket: WebSocket | null = null; #wanted = false; #routeId: string | null = null; - #retryDelay = INITIAL_RETRY_DELAY_MS; - #retryTimer: ReturnType | undefined; + readonly #retry = new RetryScheduler(() => this.#open(), { + initialDelayMs: INITIAL_RETRY_DELAY_MS, + maxDelayMs: MAX_RETRY_DELAY_MS, + }); #pingTimer: ReturnType | undefined; constructor(url: string, handlers: LiveSocketHandlers) { @@ -41,7 +43,7 @@ export class LiveSocket { disconnect() { this.#wanted = false; - clearTimeout(this.#retryTimer); + this.#retry.cancel(); this.#socket?.close(); } @@ -51,12 +53,12 @@ export class LiveSocket { } #open() { - clearTimeout(this.#retryTimer); + this.#retry.cancel(); const socket = new WebSocket(this.#url); this.#socket = socket; socket.onopen = () => { - this.#retryDelay = INITIAL_RETRY_DELAY_MS; + this.#retry.reset(); this.#pingTimer = setInterval( () => this.#send({ type: WebsocketMessageType.PING }), PING_INTERVAL_MS, @@ -81,12 +83,8 @@ export class LiveSocket { } #scheduleReconnect() { - // Jitter keeps every phone from reconnecting at once after a gateway restart. - const delay = this.#retryDelay / 2 + Math.random() * (this.#retryDelay / 2); - this.#retryDelay = Math.min(this.#retryDelay * RETRY_BACKOFF_FACTOR, MAX_RETRY_DELAY_MS); - + const delay = this.#retry.schedule(); appLogger.i(`Live socket closed, reconnecting in ${Math.round(delay)} ms`); - this.#retryTimer = setTimeout(() => this.#open(), delay); } #sendSubscription() { diff --git a/src/lib/utils/retry-scheduler.test.ts b/src/lib/utils/retry-scheduler.test.ts new file mode 100644 index 00000000..bd9990e0 --- /dev/null +++ b/src/lib/utils/retry-scheduler.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RetryScheduler } from './retry-scheduler'; + +const INITIAL_DELAY_MS = 1_000; +const MAX_DELAY_MS = 4_000; + +function scheduler() { + const task = vi.fn(); + const retry = new RetryScheduler(task, { + initialDelayMs: INITIAL_DELAY_MS, + maxDelayMs: MAX_DELAY_MS, + }); + return { task, retry }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('RetryScheduler', () => { + it('doubles the wait after each failure up to the cap', () => { + const { retry } = scheduler(); + + expect([retry.schedule(), retry.schedule(), retry.schedule(), retry.schedule()]).toEqual([ + 1_000, 2_000, 4_000, 4_000, + ]); + }); + + it('runs the task once the wait is over', () => { + const { task, retry } = scheduler(); + retry.schedule(); + + vi.advanceTimersByTime(INITIAL_DELAY_MS - 1); + expect(task).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(task).toHaveBeenCalledOnce(); + }); + + it('replaces a pending attempt when scheduled again', () => { + const { task, retry } = scheduler(); + retry.schedule(); + retry.schedule(); + + vi.advanceTimersByTime(MAX_DELAY_MS); + expect(task).toHaveBeenCalledOnce(); + }); + + it('keeps the current wait when cancelled', () => { + const { task, retry } = scheduler(); + retry.schedule(); + retry.cancel(); + + vi.advanceTimersByTime(MAX_DELAY_MS); + expect(task).not.toHaveBeenCalled(); + expect(retry.schedule()).toBe(2_000); + }); + + it('cancels the pending attempt and starts over on reset', () => { + const { task, retry } = scheduler(); + retry.schedule(); + retry.schedule(); + retry.reset(); + + vi.advanceTimersByTime(MAX_DELAY_MS); + expect(task).not.toHaveBeenCalled(); + expect(retry.schedule()).toBe(INITIAL_DELAY_MS); + }); + + it('waits at least half the delay', () => { + vi.mocked(Math.random).mockReturnValue(0); + const { retry } = scheduler(); + + expect(retry.schedule()).toBe(INITIAL_DELAY_MS / 2); + }); +}); diff --git a/src/lib/utils/retry-scheduler.ts b/src/lib/utils/retry-scheduler.ts new file mode 100644 index 00000000..e2e171ec --- /dev/null +++ b/src/lib/utils/retry-scheduler.ts @@ -0,0 +1,43 @@ +const BACKOFF_FACTOR = 2; + +interface RetrySchedulerOptions { + initialDelayMs: number; + maxDelayMs: number; +} + +/** Re-runs a task after each failure, doubling the wait up to a cap. */ +export class RetryScheduler { + readonly #task: () => void; + readonly #initialDelayMs: number; + readonly #maxDelayMs: number; + + #delayMs: number; + #timer: ReturnType | undefined; + + constructor(task: () => void, { initialDelayMs, maxDelayMs }: RetrySchedulerOptions) { + this.#task = task; + this.#initialDelayMs = initialDelayMs; + this.#maxDelayMs = maxDelayMs; + this.#delayMs = initialDelayMs; + } + + /** Schedules the next attempt, replacing any pending one, and returns how long it waits. */ + schedule(): number { + clearTimeout(this.#timer); + // Jitter keeps every phone from retrying at once after a server outage. + const delay = this.#delayMs / 2 + Math.random() * (this.#delayMs / 2); + this.#delayMs = Math.min(this.#delayMs * BACKOFF_FACTOR, this.#maxDelayMs); + this.#timer = setTimeout(this.#task, delay); + return delay; + } + + /** Drops the pending attempt but keeps the current wait for the next failure. */ + cancel() { + clearTimeout(this.#timer); + } + + reset() { + this.cancel(); + this.#delayMs = this.#initialDelayMs; + } +} From 2025595664d53a52690ab13d92166a195c80ddc2 Mon Sep 17 00:00:00 2001 From: bwees Date: Mon, 19 Oct 2026 01:17:58 -0500 Subject: [PATCH 10/29] feat: retry failed syncs and fall back to full sync --- src/lib/api/client.ts | 17 ++++++ src/lib/managers/sync.manager.svelte.ts | 79 +++++++++++++++++++------ src/lib/sync/sync-stream.test.ts | 39 ++++++++---- src/lib/sync/sync-stream.ts | 22 ++++--- 4 files changed, 117 insertions(+), 40 deletions(-) diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 59d8aa3f..54794135 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,3 +1,4 @@ +import { appLogger } from '$lib/utils/logger'; import createClient from 'openapi-fetch'; import type { components, paths } from './openapi'; @@ -29,6 +30,22 @@ export async function getSupportedVersion(): Promise { return data.version; } +export enum SyncMode { + INCREMENTAL = 'incremental', + FULL = 'full', +} + +export interface SyncFailureReport { + mode: SyncMode; + appVersion: string; + error: string; +} + +// TODO: send to the gateway once it has an endpoint for sync failures. +export async function reportSyncFailure(report: SyncFailureReport): Promise { + appLogger.w(`Sync failure report: ${JSON.stringify(report)}`); +} + export async function syncStream(body: SyncRequest): Promise { const { data, error } = await client.POST('/api/sync/stream', { body, parseAs: 'text' }); if (error) throw new Error(`sync stream failed: ${error.detail}`); diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index 69ce8e36..d0764cb8 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -1,17 +1,24 @@ -import { getSupportedVersion, syncStream } from '$lib/api/client'; +import { getSupportedVersion, reportSyncFailure, syncStream, SyncMode } from '$lib/api/client'; import { db } from '$lib/db/database'; import { syncAck, timetable } from '$lib/db/schema'; import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { + isResetStream, + planFullSync, planSync, SYNC_PROTOCOL_V1, SYNC_REQUEST_TYPES, type Statement, } from '$lib/sync/sync-stream'; -import { isAppVersionSupported } from '$lib/utils/app-version'; +import { APP_VERSION, isAppVersionSupported } from '$lib/utils/app-version'; import { appLogger, describeError } from '$lib/utils/logger'; import { refreshDbQueries } from '$lib/utils/queries'; +import { RetryScheduler } from '$lib/utils/retry-scheduler'; import { lt, sql } from 'drizzle-orm'; +import moment from 'moment'; + +const INITIAL_RETRY_DELAY_MS = 5_000; +const MAX_RETRY_DELAY_MS = 5 * 60_000; export enum VersionStatus { UNKNOWN = 'unknown', @@ -25,16 +32,33 @@ function applyAll(statements: Statement[]) { }); } +function fetchLines(acks: string[]) { + return syncStream({ protocol: SYNC_PROTOCOL_V1, types: SYNC_REQUEST_TYPES, acks }); +} + +function reportFailure(mode: SyncMode, error: unknown) { + return reportSyncFailure({ mode, appVersion: APP_VERSION, error: describeError(error) }); +} + /** Owns every write to the local database, which is the sync stream and its cleanup. */ class SyncManager { versionStatus = $state(VersionStatus.UNKNOWN); isSyncing = $state(false); + #lastSyncedAt = $state(null); #started = false; #syncQueued = false; + readonly #retry = new RetryScheduler(() => this.sync(), { + initialDelayMs: INITIAL_RETRY_DELAY_MS, + maxDelayMs: MAX_RETRY_DELAY_MS, + }); // Writes run one at a time, since web SQLite has no locking of its own. #queue: Promise = Promise.resolve(); + get hasSyncedToday(): boolean { + return this.#lastSyncedAt?.isSame(moment(), 'day') ?? false; + } + async start() { if (this.#started) return; this.#started = true; @@ -42,10 +66,13 @@ class SyncManager { this.#enqueue(() => db.delete(timetable).where(lt(timetable.serviceDate, sql`date('now', 'localtime')`)), ); + await this.#checkVersion().catch((error) => + appLogger.w(`Version check failed: ${describeError(error)}`), + ); + if (this.versionStatus === VersionStatus.UNSUPPORTED) return; + + liveDataManager.connect(() => this.sync()); await this.sync(); - if (this.versionStatus !== VersionStatus.UNSUPPORTED) { - liveDataManager.connect(() => this.sync()); - } } sync(): Promise { @@ -56,7 +83,15 @@ class SyncManager { this.#syncQueued = false; this.isSyncing = true; try { - if (await this.#checkVersion()) await this.#runSync(); + if (await this.#checkVersion()) { + await this.#runSync(); + this.#lastSyncedAt = moment(); + } + this.#retry.reset(); + } catch (error) { + const delay = this.#retry.schedule(); + appLogger.w(`Sync failed, retrying in ${Math.round(delay)} ms`); + throw error; } finally { this.isSyncing = false; } @@ -84,19 +119,27 @@ class SyncManager { async #runSync() { const acks = await db.select({ ack: syncAck.ack }).from(syncAck); + const lines = await fetchLines(acks.map(({ ack }) => ack)); + if (isResetStream(lines)) return this.#fullSync(); + + try { + await applyAll(planSync(lines)); + appLogger.i(`Applied ${lines.length} sync lines`); + } catch (error) { + await reportFailure(SyncMode.INCREMENTAL, error); + await this.#fullSync(); + } + } - const lines = await syncStream({ - protocol: SYNC_PROTOCOL_V1, - types: SYNC_REQUEST_TYPES, - acks: acks.map(({ ack }) => ack), - }); - - const plan = planSync(lines); - await applyAll(plan.statements); - appLogger.i(`Applied ${lines.length} sync lines`); - - // A reset leaves no acks behind, so the next stream is a full sync. - if (plan.reset) await this.#runSync(); + async #fullSync() { + const lines = await fetchLines([]); + try { + await applyAll(planFullSync(lines)); + appLogger.i(`Replaced local data with ${lines.length} sync lines`); + } catch (error) { + await reportFailure(SyncMode.FULL, error); + throw error; + } } } diff --git a/src/lib/sync/sync-stream.test.ts b/src/lib/sync/sync-stream.test.ts index 285a2c2f..cbe37406 100644 --- a/src/lib/sync/sync-stream.test.ts +++ b/src/lib/sync/sync-stream.test.ts @@ -1,7 +1,7 @@ import type { SyncStreamLine } from '$lib/api/client'; import initSqlJs, { type Database } from 'sql.js'; import { beforeEach, describe, expect, it } from 'vitest'; -import { planSync, type SyncPlan } from './sync-stream'; +import { isResetStream, planFullSync, planSync, type Statement } from './sync-stream'; const MIGRATION_BREAKPOINT = '--> statement-breakpoint'; const migrationFiles = import.meta.glob('../db/migrations/*.sql', { @@ -17,8 +17,8 @@ const ACK_ID = '01990000-0000-7000-8000-0000000000aa'; let sqlite: Database; -function apply(plan: SyncPlan) { - for (const statement of plan.statements) { +function apply(statements: Statement[]) { + for (const statement of statements) { const { sql, params } = statement.toSQL(); sqlite.run(sql, params as never); } @@ -103,13 +103,32 @@ describe('planSync', () => { expect(rows('SELECT id FROM direction')).toEqual([[DIRECTION_ID]]); }); - it('wipes synced tables and acks on reset', () => { - apply(planSync([routeLine('Bonfire', ACK_ID)])); - const plan = planSync([{ type: 'SyncResetV1', ack: `SyncResetV1|${ACK_ID}`, data: {} }]); - apply(plan); + it('drops rows and acks missing from a full sync', () => { + const fullAck = '01990000-0000-7000-8000-0000000000bb'; + apply( + planSync([ + routeLine('Bonfire', ACK_ID), + { + type: 'DirectionV1', + ack: `DirectionV1|${ACK_ID}`, + data: { id: DIRECTION_ID, routeId: ROUTE_ID, destination: 'MSC', sequence: 0, path: '' }, + }, + ]), + ); + apply(planFullSync([routeLine('Bonfire Express', fullAck)])); - expect(plan.reset).toBe(true); - expect(rows('SELECT id FROM route')).toEqual([]); - expect(rows('SELECT key FROM sync_ack')).toEqual([]); + expect(rows('SELECT long_name FROM route')).toEqual([['Bonfire Express']]); + expect(rows('SELECT id FROM direction')).toEqual([]); + expect(rows('SELECT ack FROM sync_ack')).toEqual([[`RouteV1|${fullAck}`]]); + }); +}); + +describe('isResetStream', () => { + it('is true only when the stream opens with a reset', () => { + const reset: SyncStreamLine = { type: 'SyncResetV1', ack: `SyncResetV1|${ACK_ID}`, data: {} }; + + expect(isResetStream([reset])).toBe(true); + expect(isResetStream([routeLine('Bonfire', ACK_ID), reset])).toBe(false); + expect(isResetStream([])).toBe(false); }); }); diff --git a/src/lib/sync/sync-stream.ts b/src/lib/sync/sync-stream.ts index 86f78eff..cf08ea19 100644 --- a/src/lib/sync/sync-stream.ts +++ b/src/lib/sync/sync-stream.ts @@ -97,21 +97,13 @@ const HANDLERS: { [T in SyncEntityType]: EntityHandler> } = { SyncCompleteV1: controlLine, }; -export interface SyncPlan { - reset: boolean; - statements: Statement[]; -} - -export function resetPlan(): SyncPlan { - const tables = [...SYNCED_TABLES, syncAck]; - return { reset: true, statements: tables.map((table) => db.delete(table)) }; +export function isResetStream(lines: SyncStreamLine[]): boolean { + return lines[0]?.type === RESET; } // The server orders lines so parents come before children and deletes before upserts, // so runs of one entity type are applied in the order they arrived. -export function planSync(lines: SyncStreamLine[]): SyncPlan { - if (lines[0]?.type === RESET) return resetPlan(); - +export function planSync(lines: SyncStreamLine[]): Statement[] { const runs: { type: SyncEntityType; rows: unknown[] }[] = []; const acks = new Map(); @@ -135,5 +127,11 @@ export function planSync(lines: SyncStreamLine[]): SyncPlan { ); } - return { reset: false, statements }; + return statements; +} + +/** Swaps every synced row and ack for a full sync's lines. Apply it in one transaction. */ +export function planFullSync(lines: SyncStreamLine[]): Statement[] { + const wipe = [...SYNCED_TABLES, syncAck].map((table) => db.delete(table)); + return [...wipe, ...planSync(lines)]; } From b3d3058aae0dfe243317d3b0220a7d01807fba77 Mon Sep 17 00:00:00 2001 From: bwees Date: Mon, 19 Oct 2026 01:18:01 -0500 Subject: [PATCH 11/29] fix: only mark stop estimates unavailable when timetables are current --- src/lib/data/app.ts | 31 +++++++++++-------- src/lib/data/stop_estimates.test.ts | 48 +++++++++++++++++++++++++++++ src/lib/data/stop_estimates.ts | 28 +++++++++++++++++ 3 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 src/lib/data/stop_estimates.test.ts create mode 100644 src/lib/data/stop_estimates.ts diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 5444b3f6..7dcb65c5 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -12,7 +12,9 @@ import { } from '$lib/data/types'; import { db } from '$lib/db/database'; import { alert, alertDirection, direction, route, stop, timetable } from '$lib/db/schema'; +import { resolveStopEstimates } from '$lib/data/stop_estimates'; import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; +import { syncManager } from '$lib/managers/sync.manager.svelte'; import { findBoundingBox } from '$lib/utils/geo'; import { createDbQuery } from '$lib/utils/queries'; import { decode } from '@googlemaps/polyline-codec'; @@ -131,6 +133,7 @@ function scheduledTimes(stop: Stop, serviceDate: string | SQL, where?: SQL) { interface UpcomingTimes { hasTimetable: boolean; + hasTimetablesFromToday: boolean; times: moment.Moment[]; } @@ -143,7 +146,7 @@ const useUpcomingTimes = (params: () => { stop: Stop }) => key: ['upcomingTimes', stop.id], refetchInterval: SCHEDULE_REFRESH_INTERVAL, queryFn: async () => { - const [rows, timetables] = await Promise.all([ + const [rows, timetables, timetablesFromToday] = await Promise.all([ scheduledTimes( stop, today, @@ -154,10 +157,16 @@ const useUpcomingTimes = (params: () => { stop: Stop }) => .from(timetable) .where(and(eq(timetable.stopId, stop.id), eq(timetable.serviceDate, today))) .limit(1), + db + .select({ id: timetable.id }) + .from(timetable) + .where(gte(timetable.serviceDate, today)) + .limit(1), ]); return { hasTimetable: timetables.length > 0, + hasTimetablesFromToday: timetablesFromToday.length > 0, times: rows.map((row) => moment(row.scheduledAt)), }; }, @@ -190,17 +199,15 @@ export function useStopEstimate(params: () => { stop: Stop }) { const isLive = liveDataManager.status === LiveDataStatus.LIVE; if (!upcoming.data) return { source: EstimateSource.LOADING, estimates: [] }; - if (!upcoming.data.hasTimetable) { - const live = isLive ? liveDataManager.estimatesFor(stop.id) : []; - return live.length > 0 - ? { source: EstimateSource.LIVE, estimates: live } - : { source: EstimateSource.UNAVAILABLE, estimates: [] }; - } - - return { - source: isLive ? EstimateSource.LIVE : EstimateSource.SCHEDULE, - estimates: withLiveEstimates(upcoming.data.times, stop), - }; + // The server skips days a stop has no service, so a missing timetable is only + // trustworthy if some later day was synced or we synced today. + return resolveStopEstimates({ + hasTimetable: upcoming.data.hasTimetable, + timetablesCurrent: upcoming.data.hasTimetablesFromToday || syncManager.hasSyncedToday, + isLive, + scheduled: withLiveEstimates(upcoming.data.times, stop), + live: isLive ? liveDataManager.estimatesFor(stop.id) : [], + }); }, }; } diff --git a/src/lib/data/stop_estimates.test.ts b/src/lib/data/stop_estimates.test.ts new file mode 100644 index 00000000..533630ef --- /dev/null +++ b/src/lib/data/stop_estimates.test.ts @@ -0,0 +1,48 @@ +import { EstimateSource, type TimeEstimate } from '$lib/data/types'; +import moment from 'moment'; +import { describe, expect, it } from 'vitest'; +import { resolveStopEstimates, type StopEstimateInputs } from './stop_estimates'; + +const estimate: TimeEstimate = { + scheduledTime: moment('2026-10-10T12:00:00'), + estimatedTime: null, + isRealTime: false, +}; + +const inputs = (overrides: Partial): StopEstimateInputs => ({ + hasTimetable: false, + timetablesCurrent: true, + isLive: false, + scheduled: [], + live: [], + ...overrides, +}); + +describe('resolveStopEstimates', () => { + it('shows scheduled times when the stop has a timetable today', () => { + expect(resolveStopEstimates(inputs({ hasTimetable: true, scheduled: [estimate] }))).toEqual({ + source: EstimateSource.SCHEDULE, + estimates: [estimate], + }); + }); + + it('shows no departures when current timetables have nothing for the stop today', () => { + expect(resolveStopEstimates(inputs({}))).toEqual({ + source: EstimateSource.SCHEDULE, + estimates: [], + }); + }); + + it('is unavailable when the timetables are out of date', () => { + expect(resolveStopEstimates(inputs({ timetablesCurrent: false }))).toEqual({ + source: EstimateSource.UNAVAILABLE, + estimates: [], + }); + }); + + it('falls back to the live feed for a stop with no timetable', () => { + expect( + resolveStopEstimates(inputs({ timetablesCurrent: false, isLive: true, live: [estimate] })), + ).toEqual({ source: EstimateSource.LIVE, estimates: [estimate] }); + }); +}); diff --git a/src/lib/data/stop_estimates.ts b/src/lib/data/stop_estimates.ts new file mode 100644 index 00000000..901f3c4a --- /dev/null +++ b/src/lib/data/stop_estimates.ts @@ -0,0 +1,28 @@ +import { EstimateSource, type StopEstimates, type TimeEstimate } from '$lib/data/types'; + +export interface StopEstimateInputs { + /** The stop has a timetable for today. */ + hasTimetable: boolean; + /** The local timetables are recent enough that a missing one means no service today. */ + timetablesCurrent: boolean; + isLive: boolean; + /** Today's scheduled times, marked with live estimates. */ + scheduled: TimeEstimate[]; + /** Departures the live feed sent for the stop. */ + live: TimeEstimate[]; +} + +export function resolveStopEstimates({ + hasTimetable, + timetablesCurrent, + isLive, + scheduled, + live, +}: StopEstimateInputs): StopEstimates { + const source = isLive ? EstimateSource.LIVE : EstimateSource.SCHEDULE; + if (hasTimetable) return { source, estimates: scheduled }; + + if (isLive && live.length > 0) return { source: EstimateSource.LIVE, estimates: live }; + if (timetablesCurrent) return { source, estimates: [] }; + return { source: EstimateSource.UNAVAILABLE, estimates: [] }; +} From e0ccdb56a7e2d74fdccd329c6f2b56bca0321ce1 Mon Sep 17 00:00:00 2001 From: bwees Date: Mon, 19 Oct 2026 01:18:01 -0500 Subject: [PATCH 12/29] chore: point dev builds at production api --- src/lib/api/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 54794135..01406cf9 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -7,7 +7,7 @@ const DEV_SERVER_API_URL = 'http://100.89.139.58:3000'; const LINE_SEPARATOR = '\n'; export const API_URL: string = - import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? DEV_SERVER_API_URL : PRODUCTION_API_URL); + import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? PRODUCTION_API_URL : PRODUCTION_API_URL); export const WEBSOCKET_URL = `${API_URL.replace(/^http/, 'ws')}/api/ws`; type Schemas = components['schemas']; From 935fec298b9c595732ae35e25e4217111877fc0d Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:18:16 -0500 Subject: [PATCH 13/29] refactor: replace moment with luxon --- package.json | 1 - pnpm-lock.yaml | 8 --- src/lib/components/TimeBubble.svelte | 3 +- .../ui/date-stepper/date-stepper.svelte | 25 +++++---- src/lib/data/app.ts | 30 +++++------ src/lib/data/route_planning.ts | 4 +- src/lib/data/stop_estimates.test.ts | 4 +- src/lib/data/types.ts | 10 ++-- src/lib/managers/live-data.manager.svelte.ts | 24 ++++----- src/lib/managers/sync.manager.svelte.ts | 8 +-- src/lib/utils/queries.ts | 16 +++--- src/lib/utils/timetable.test.ts | 52 +++++++++++++++++++ src/lib/utils/timetable.ts | 34 ++++++------ src/routes/route/[id]/StopRow.svelte | 2 +- .../timetable/[stop]/[direction]/+page.svelte | 6 +-- 15 files changed, 140 insertions(+), 87 deletions(-) create mode 100644 src/lib/utils/timetable.test.ts diff --git a/package.json b/package.json index 6d490f52..602c5787 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "luxon": "^3.7.2", "maplibre-gl": "^5.17.0", "mode-watcher": "^1.1.0", - "moment": "^2.30.1", "openapi-fetch": "^0.17.0", "pmtiles": "^4.4.0", "runed": "^0.37.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7866f87f..e9ff8ec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,9 +65,6 @@ importers: mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.50.0) - moment: - specifier: ^2.30.1 - version: 2.30.1 openapi-fetch: specifier: ^0.17.0 version: 0.17.0 @@ -1947,9 +1944,6 @@ packages: peerDependencies: svelte: ^5.27.0 - moment@2.30.1: - resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3934,8 +3928,6 @@ snapshots: svelte: 5.50.0 svelte-toolbelt: 0.7.1(svelte@5.50.0) - moment@2.30.1: {} - mri@1.2.0: {} mrmime@2.0.1: {} diff --git a/src/lib/components/TimeBubble.svelte b/src/lib/components/TimeBubble.svelte index 7af02fdf..6820fd8e 100644 --- a/src/lib/components/TimeBubble.svelte +++ b/src/lib/components/TimeBubble.svelte @@ -2,7 +2,6 @@ import type { TimeEstimate } from '$lib/data/types'; import { cn } from '$lib/utils'; import { Rss } from '@lucide/svelte'; - import moment from 'moment'; import { tv } from 'tailwind-variants'; type Props = { @@ -15,7 +14,7 @@ let { estimate, isNext, class: className, type = 'default' }: Props = $props(); const date = $derived(estimate.estimatedTime ?? estimate.scheduledTime); - const relative = $derived(date.diff(moment(), 'minutes')); + const relative = $derived(Math.trunc(date.diffNow('minutes').minutes)); const styles = tv({ base: 'flex flex-col items-center px-2 py-0.5 dark:text-white', diff --git a/src/lib/components/ui/date-stepper/date-stepper.svelte b/src/lib/components/ui/date-stepper/date-stepper.svelte index e477e51e..49bd0fbf 100644 --- a/src/lib/components/ui/date-stepper/date-stepper.svelte +++ b/src/lib/components/ui/date-stepper/date-stepper.svelte @@ -1,20 +1,27 @@ @@ -24,12 +31,12 @@ size="sm" class="mt-2 rounded-full" onclick={() => offsetDate(-1)} - disabled={minDate ? selectedDate.isSameOrBefore(minDate ?? moment(), 'day') : false} + disabled={minDate ? selectedDate.startOf('day') <= minDate.startOf('day') : false} > diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 7dcb65c5..5d3674ea 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -19,16 +19,16 @@ import { findBoundingBox } from '$lib/utils/geo'; import { createDbQuery } from '$lib/utils/queries'; import { decode } from '@googlemaps/polyline-codec'; import { and, asc, eq, gte, isNull, or, sql, type SQL } from 'drizzle-orm'; -import moment from 'moment'; +import { DateTime, Duration } from 'luxon'; // Upcoming times are relative to "now", so they refresh even when nothing is synced. -const SCHEDULE_REFRESH_INTERVAL = moment.duration(30, 'seconds'); +const SCHEDULE_REFRESH_INTERVAL = Duration.fromObject({ seconds: 30 }); const STOP_ESTIMATE_LIMIT = 3; // A late bus is still coming after its scheduled time, so recent times stay in play for live estimates. const LATE_DEPARTURE_WINDOW = '-30 minutes'; const UPCOMING_TIME_LIMIT = 10; // A bus that left a minute ago is often still at the stop. -const DEPARTED_GRACE = moment.duration(1, 'minute'); +const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); const slotTime = sql`slot.value`; @@ -134,7 +134,7 @@ function scheduledTimes(stop: Stop, serviceDate: string | SQL, where?: SQL) { interface UpcomingTimes { hasTimetable: boolean; hasTimetablesFromToday: boolean; - times: moment.Moment[]; + times: DateTime[]; } /** Today's scheduled times at a stop that may still be coming, including late ones. */ @@ -167,25 +167,25 @@ const useUpcomingTimes = (params: () => { stop: Stop }) => return { hasTimetable: timetables.length > 0, hasTimetablesFromToday: timetablesFromToday.length > 0, - times: rows.map((row) => moment(row.scheduledAt)), + times: rows.map((row) => DateTime.fromISO(row.scheduledAt)), }; }, }; }); /** Marks each scheduled time with its live estimate, dropping cancelled and departed buses. */ -function withLiveEstimates(times: moment.Moment[], stop: Stop): TimeEstimate[] { - const earliest = moment().subtract(DEPARTED_GRACE); +function withLiveEstimates(times: DateTime[], stop: Stop): TimeEstimate[] { + const earliest = DateTime.now().minus(DEPARTED_GRACE); return times .flatMap((scheduledTime) => { const live = liveDataManager.departureAt(stop.id, scheduledTime); if (live?.isCancelled) return []; - const estimatedTime = live?.estimatedAt ? moment(live.estimatedAt) : null; - if ((estimatedTime ?? scheduledTime).isBefore(earliest)) return []; + const estimatedTime = live?.estimatedAt ? DateTime.fromISO(live.estimatedAt) : null; + if ((estimatedTime ?? scheduledTime) < earliest) return []; return [{ scheduledTime, estimatedTime, isRealTime: estimatedTime !== null }]; }) - .sort((a, b) => (a.estimatedTime ?? a.scheduledTime).diff(b.estimatedTime ?? b.scheduledTime)) + .sort((a, b) => +(a.estimatedTime ?? a.scheduledTime) - +(b.estimatedTime ?? b.scheduledTime)) .slice(0, STOP_ESTIMATE_LIMIT); } @@ -213,15 +213,15 @@ export function useStopEstimate(params: () => { stop: Stop }) { } /** A stop's timetable for one day, marked with live estimates and cancellations. */ -export function useTimetable(params: () => { stop: Stop | null; date: moment.Moment }) { - const times = createDbQuery(() => { +export function useTimetable(params: () => { stop: Stop | null; date: DateTime }) { + const times = createDbQuery(() => { const { stop, date } = params(); - const serviceDate = date.format('YYYY-MM-DD'); + const serviceDate = date.toISODate()!; return { key: ['timetable', stop?.id, serviceDate], enabled: stop !== null, queryFn: async () => - (await scheduledTimes(stop!, serviceDate)).map((row) => moment(row.scheduledAt)), + (await scheduledTimes(stop!, serviceDate)).map((row) => DateTime.fromISO(row.scheduledAt)), }; }); @@ -235,7 +235,7 @@ export function useTimetable(params: () => { stop: Stop | null; date: moment.Mom const live = liveDataManager.departureAt(stop!.id, scheduledTime); return { scheduledTime, - estimatedTime: live?.estimatedAt ? moment(live.estimatedAt) : null, + estimatedTime: live?.estimatedAt ? DateTime.fromISO(live.estimatedAt) : null, isCancelled: live?.isCancelled ?? false, }; }); diff --git a/src/lib/data/route_planning.ts b/src/lib/data/route_planning.ts index 02888f67..358b00af 100644 --- a/src/lib/data/route_planning.ts +++ b/src/lib/data/route_planning.ts @@ -5,14 +5,14 @@ import { direction, route, stop } from '$lib/db/schema'; import { createDbQuery, createLoggingQuery } from '$lib/utils/queries'; import { toStopSuggestions } from '$lib/utils/route-planning'; import { and, asc, eq, sql } from 'drizzle-orm'; -import moment from 'moment'; +import { Duration } from 'luxon'; export enum QueryKey { STOP_SEARCH = 'stopSearch', TRIP_PLAN = 'MRTripPlan', } -const TRIP_PLAN_STALE_TIME = moment.duration(1, 'minute'); +const TRIP_PLAN_STALE_TIME = Duration.fromObject({ minutes: 1 }); const TIME_KINDS: Record = { [Deadline.LEAVE_BY]: 'departAt', diff --git a/src/lib/data/stop_estimates.test.ts b/src/lib/data/stop_estimates.test.ts index 533630ef..286f9dc6 100644 --- a/src/lib/data/stop_estimates.test.ts +++ b/src/lib/data/stop_estimates.test.ts @@ -1,10 +1,10 @@ import { EstimateSource, type TimeEstimate } from '$lib/data/types'; -import moment from 'moment'; +import { DateTime } from 'luxon'; import { describe, expect, it } from 'vitest'; import { resolveStopEstimates, type StopEstimateInputs } from './stop_estimates'; const estimate: TimeEstimate = { - scheduledTime: moment('2026-10-10T12:00:00'), + scheduledTime: DateTime.fromISO('2026-10-10T12:00:00'), estimatedTime: null, isRealTime: false, }; diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 7da4c477..628fcd0f 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -11,7 +11,7 @@ import { OctagonPauseIcon, } from '@lucide/svelte'; import type { TripLegType } from '$lib/api/client'; -import moment from 'moment'; +import type { DateTime } from 'luxon'; export enum Amenity { AIR_CONDITIONING = 'AIR_CONDITIONING', @@ -86,8 +86,8 @@ export interface Bus { } export interface TimeEstimate { - estimatedTime: moment.Moment | null; - scheduledTime: moment.Moment; + estimatedTime: DateTime | null; + scheduledTime: DateTime; isRealTime: boolean; } @@ -106,8 +106,8 @@ export interface StopEstimates { } export interface TimetableDeparture { - scheduledTime: moment.Moment; - estimatedTime: moment.Moment | null; + scheduledTime: DateTime; + estimatedTime: DateTime | null; isCancelled: boolean; } diff --git a/src/lib/managers/live-data.manager.svelte.ts b/src/lib/managers/live-data.manager.svelte.ts index e13cd296..ef089de1 100644 --- a/src/lib/managers/live-data.manager.svelte.ts +++ b/src/lib/managers/live-data.manager.svelte.ts @@ -10,7 +10,7 @@ import { LiveSocket } from '$lib/sync/live-socket'; import { OfflineTracker } from '$lib/sync/offline-tracker.svelte'; import { appLogger } from '$lib/utils/logger'; import { App } from '@capacitor/app'; -import moment from 'moment'; +import { DateTime, Duration } from 'luxon'; export enum LiveDataStatus { LOADING = 'loading', @@ -25,14 +25,14 @@ enum SocketState { } // A bus that left a minute ago is often still at the stop. -const DEPARTED_GRACE = moment.duration(1, 'minute'); +const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); function departsAt(departure: WebsocketDeparture) { - return moment(departure.estimatedAt ?? departure.scheduledAt); + return DateTime.fromISO(departure.estimatedAt ?? departure.scheduledAt); } -function slotKey(stopId: string, scheduledAt: moment.MomentInput) { - return `${stopId}|${moment(scheduledAt).valueOf()}`; +function slotKey(stopId: string, scheduledAt: DateTime) { + return `${stopId}|${scheduledAt.toMillis()}`; } /** Holds the subscribed route's vehicles and departures, straight from the websocket. */ @@ -50,7 +50,7 @@ class LiveDataManager { }); readonly #departuresBySlot = $derived( - new Map(this.departures.map((d) => [slotKey(d.stopId, d.scheduledAt), d])), + new Map(this.departures.map((d) => [slotKey(d.stopId, DateTime.fromISO(d.scheduledAt)), d])), ); #routeId: string | null = null; @@ -99,19 +99,19 @@ class LiveDataManager { /** Upcoming departures at a stop, soonest first. */ estimatesFor(stopId: string): TimeEstimate[] { - const earliest = moment().subtract(DEPARTED_GRACE); + const earliest = DateTime.now().minus(DEPARTED_GRACE); return this.departures - .filter((d) => d.stopId === stopId && !d.isCancelled && departsAt(d).isSameOrAfter(earliest)) - .sort((a, b) => departsAt(a).diff(departsAt(b))) + .filter((d) => d.stopId === stopId && !d.isCancelled && departsAt(d) >= earliest) + .sort((a, b) => departsAt(a).toMillis() - departsAt(b).toMillis()) .map((d) => ({ - scheduledTime: moment(d.scheduledAt), - estimatedTime: d.estimatedAt ? moment(d.estimatedAt) : null, + scheduledTime: DateTime.fromISO(d.scheduledAt), + estimatedTime: d.estimatedAt ? DateTime.fromISO(d.estimatedAt) : null, isRealTime: d.estimatedAt !== null, })); } /** The live departure for one scheduled time at a stop, if the server sent one. */ - departureAt(stopId: string, scheduledAt: moment.Moment) { + departureAt(stopId: string, scheduledAt: DateTime) { return this.#departuresBySlot.get(slotKey(stopId, scheduledAt)); } diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index d0764cb8..85c11c01 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -15,7 +15,7 @@ import { appLogger, describeError } from '$lib/utils/logger'; import { refreshDbQueries } from '$lib/utils/queries'; import { RetryScheduler } from '$lib/utils/retry-scheduler'; import { lt, sql } from 'drizzle-orm'; -import moment from 'moment'; +import { DateTime } from 'luxon'; const INITIAL_RETRY_DELAY_MS = 5_000; const MAX_RETRY_DELAY_MS = 5 * 60_000; @@ -45,7 +45,7 @@ class SyncManager { versionStatus = $state(VersionStatus.UNKNOWN); isSyncing = $state(false); - #lastSyncedAt = $state(null); + #lastSyncedAt = $state(null); #started = false; #syncQueued = false; readonly #retry = new RetryScheduler(() => this.sync(), { @@ -56,7 +56,7 @@ class SyncManager { #queue: Promise = Promise.resolve(); get hasSyncedToday(): boolean { - return this.#lastSyncedAt?.isSame(moment(), 'day') ?? false; + return this.#lastSyncedAt?.hasSame(DateTime.now(), 'day') ?? false; } async start() { @@ -85,7 +85,7 @@ class SyncManager { try { if (await this.#checkVersion()) { await this.#runSync(); - this.#lastSyncedAt = moment(); + this.#lastSyncedAt = DateTime.now(); } this.#retry.reset(); } catch (error) { diff --git a/src/lib/utils/queries.ts b/src/lib/utils/queries.ts index cd7255e0..42e986ff 100644 --- a/src/lib/utils/queries.ts +++ b/src/lib/utils/queries.ts @@ -1,19 +1,19 @@ import { describeError, queryLogger } from '$lib/utils/logger'; import { createQuery, QueryClient, type NetworkMode } from '@tanstack/svelte-query'; -import moment from 'moment'; +import { Duration } from 'luxon'; interface LoggingQueryParams { queryFn: () => Promise; queryKey: unknown[]; label?: string; - staleTime?: moment.Duration | number; - refetchInterval?: moment.Duration | number; + staleTime?: Duration | number; + refetchInterval?: Duration | number; enabled?: boolean; networkMode?: NetworkMode; } -function parseTime(time?: moment.Duration | number) { - return moment.isDuration(time) ? time.asMilliseconds() : time; +function parseTime(time?: Duration | number) { + return Duration.isDuration(time) ? time.toMillis() : time; } export function createLoggingQuery(params: () => LoggingQueryParams) { @@ -25,10 +25,10 @@ export function createLoggingQuery(params: () => LoggingQueryParams) { queryKey: p.queryKey, queryFn: async () => { try { - const start = moment.now(); + const start = Date.now(); const data = await p.queryFn(); - queryLogger.d(`Query ${label} succeeded in ${moment.now() - start} ms`); + queryLogger.d(`Query ${label} succeeded in ${Date.now() - start} ms`); return data; } catch (e) { queryLogger.e(`Query ${label} failed: ${describeError(e)}`); @@ -51,7 +51,7 @@ interface DbQueryParams { key: unknown[]; queryFn: () => Promise; enabled?: boolean; - refetchInterval?: moment.Duration | number; + refetchInterval?: Duration | number; } /** A query on the local database. It re-runs when the sync manager changes the data. */ diff --git a/src/lib/utils/timetable.test.ts b/src/lib/utils/timetable.test.ts new file mode 100644 index 00000000..9e5df4c7 --- /dev/null +++ b/src/lib/utils/timetable.test.ts @@ -0,0 +1,52 @@ +import type { TimetableDeparture } from '$lib/data/types'; +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; +import buildTimetable from './timetable'; + +const NOW = DateTime.fromISO('2026-10-10T12:00:30'); + +function departure(time: string, overrides: Partial = {}): TimetableDeparture { + return { + scheduledTime: DateTime.fromISO(`2026-10-10T${time}`), + estimatedTime: null, + isCancelled: false, + ...overrides, + }; +} + +const highlightedTimes = (departures: TimetableDeparture[]) => + buildTimetable(departures, NOW) + .flatMap((row) => row.items) + .filter((item) => item.highlighted) + .map((item) => item.time); + +describe('buildTimetable', () => { + it('does not point at a next bus without live data', () => { + expect(highlightedTimes([departure('11:50'), departure('12:10')])).toEqual([]); + }); + + it('points at the first live-confirmed bus that has not left or been cancelled', () => { + const departures = [ + departure('11:50'), + departure('12:05', { isCancelled: true }), + departure('12:10', { estimatedTime: DateTime.fromISO('2026-10-10T12:12') }), + ]; + + expect(highlightedTimes(departures)).toEqual(['12:12']); + }); + + it('keeps a bus due this minute as upcoming', () => { + const [row] = buildTimetable([departure('12:00')], NOW); + + expect(row!.items[0]!.expired).toBe(false); + }); + + it('marks the row holding the next bus', () => { + const departures = ['11:51', '11:52', '11:53', '11:54', '11:55', '12:06'].map((time) => + departure(time), + ); + departures[0] = { ...departures[0]!, estimatedTime: departures[0]!.scheduledTime }; + + expect(buildTimetable(departures, NOW).map((row) => row.highlighted)).toEqual([false, true]); + }); +}); diff --git a/src/lib/utils/timetable.ts b/src/lib/utils/timetable.ts index 5dc81382..b5c48dee 100644 --- a/src/lib/utils/timetable.ts +++ b/src/lib/utils/timetable.ts @@ -1,8 +1,9 @@ import type { TimetableDeparture } from '$lib/data/types'; import { chunk } from 'lodash-es'; -import moment from 'moment'; +import { DateTime } from 'luxon'; const ITEMS_PER_ROW = 5; +const DEPARTURE_TIME_FORMAT = 'h:mm'; interface TableItem { time: string; @@ -17,23 +18,26 @@ export interface TableItemRow { highlighted: boolean; } -export default function buildTimetable(departures: TimetableDeparture[]): TableItemRow[] { - const now = moment(); +export default function buildTimetable( + departures: TimetableDeparture[], + now: DateTime = DateTime.now(), +): TableItemRow[] { + const currentMinute = now.startOf('minute'); const hasLiveData = departures.some((d) => d.estimatedTime !== null); - const departsAt = (d: TimetableDeparture) => d.estimatedTime ?? d.scheduledTime; - // Only point at the next bus when live data can confirm it. - const next = hasLiveData - ? departures.find((d) => !d.isCancelled && !departsAt(d).isBefore(now, 'minute')) - : undefined; + const items = departures.map((d) => { + const departsAt = d.estimatedTime ?? d.scheduledTime; + return { + time: departsAt.toFormat(DEPARTURE_TIME_FORMAT), + highlighted: false, + live: d.estimatedTime !== null, + cancelled: d.isCancelled, + expired: departsAt < currentMinute, + }; + }); - const items = departures.map((d) => ({ - time: departsAt(d).format('h:mm'), - highlighted: d === next, - live: d.estimatedTime !== null, - cancelled: d.isCancelled, - expired: departsAt(d).isBefore(now, 'minute'), - })); + const next = hasLiveData ? items.find((item) => !item.cancelled && !item.expired) : undefined; + if (next) next.highlighted = true; return chunk(items, ITEMS_PER_ROW).map((row) => ({ items: row, diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[id]/StopRow.svelte index 7b0653c6..2f8ccda6 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[id]/StopRow.svelte @@ -59,7 +59,7 @@ } const deviationMinutes = Math.round( - firstEstimate.estimatedTime.diff(firstEstimate.scheduledTime, 'minutes'), + firstEstimate.estimatedTime.diff(firstEstimate.scheduledTime, 'minutes').minutes, ); if (deviationMinutes === 0) { diff --git a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte b/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte index 03d74a8d..22359544 100644 --- a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte +++ b/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte @@ -7,7 +7,7 @@ import DateStepper from '$lib/components/ui/date-stepper/date-stepper.svelte'; import Spinner from '$lib/components/ui/spinner/spinner.svelte'; import { useRoute, useTimetable } from '$lib/data/app'; - import moment from 'moment'; + import { DateTime } from 'luxon'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -17,7 +17,7 @@ { height: 80, id: 'l' }, ]; - let date = $state(moment()); + let date = $state(DateTime.now()); const routeQuery = useRoute(() => ({ routeId: data.routeId })); @@ -43,7 +43,7 @@ {/snippet}
- + {#if timetable.isLoading || routeQuery.isLoading} From cf11838474c70c77333b3adb013fd59105c1f63d Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:21:04 -0500 Subject: [PATCH 14/29] refactor: track live connection as one state --- src/lib/data/app.ts | 4 +- src/lib/managers/live-data.manager.svelte.ts | 76 +++++----------- src/lib/managers/sync.manager.svelte.ts | 5 - src/lib/sync/live-socket.test.ts | 96 ++++++++++++++++++++ src/lib/sync/live-socket.ts | 55 ++++++++--- src/lib/sync/offline-tracker.svelte.ts | 33 +++---- src/lib/sync/offline-tracker.test.ts | 25 ++--- src/routes/+layout.svelte | 11 ++- src/routes/route/[id]/StopRow.svelte | 6 +- 9 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 src/lib/sync/live-socket.test.ts diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 5d3674ea..593710a6 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -13,7 +13,7 @@ import { import { db } from '$lib/db/database'; import { alert, alertDirection, direction, route, stop, timetable } from '$lib/db/schema'; import { resolveStopEstimates } from '$lib/data/stop_estimates'; -import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; +import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { syncManager } from '$lib/managers/sync.manager.svelte'; import { findBoundingBox } from '$lib/utils/geo'; import { createDbQuery } from '$lib/utils/queries'; @@ -196,7 +196,7 @@ export function useStopEstimate(params: () => { stop: Stop }) { return { get data(): StopEstimates { const { stop } = params(); - const isLive = liveDataManager.status === LiveDataStatus.LIVE; + const isLive = liveDataManager.isLive; if (!upcoming.data) return { source: EstimateSource.LOADING, estimates: [] }; // The server skips days a stop has no service, so a missing timetable is only diff --git a/src/lib/managers/live-data.manager.svelte.ts b/src/lib/managers/live-data.manager.svelte.ts index ef089de1..e6d524f3 100644 --- a/src/lib/managers/live-data.manager.svelte.ts +++ b/src/lib/managers/live-data.manager.svelte.ts @@ -6,24 +6,12 @@ import { type WebsocketVehicle, } from '$lib/api/websocket'; import type { TimeEstimate } from '$lib/data/types'; -import { LiveSocket } from '$lib/sync/live-socket'; +import { ConnectionState, LiveSocket } from '$lib/sync/live-socket'; import { OfflineTracker } from '$lib/sync/offline-tracker.svelte'; import { appLogger } from '$lib/utils/logger'; import { App } from '@capacitor/app'; import { DateTime, Duration } from 'luxon'; -export enum LiveDataStatus { - LOADING = 'loading', - LIVE = 'live', - OFFLINE = 'offline', -} - -enum SocketState { - CONNECTING = 'connecting', - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', -} - // A bus that left a minute ago is often still at the stop. const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); @@ -38,39 +26,26 @@ function slotKey(stopId: string, scheduledAt: DateTime) { /** Holds the subscribed route's vehicles and departures, straight from the websocket. */ class LiveDataManager { vehicles = $state.raw([]); - departures = $state.raw([]); - #socketState = $state(SocketState.CONNECTING); - #hasDepartures = $state(false); + #connection = ConnectionState.IDLE; + #departures = $state.raw(null); - /** Whether the subscribed route's departures are live, still on their way, or unreachable. */ - readonly status = $derived.by(() => { - if (this.#socketState === SocketState.DISCONNECTED) return LiveDataStatus.OFFLINE; - return this.#hasDepartures ? LiveDataStatus.LIVE : LiveDataStatus.LOADING; - }); + /** Whether the subscribed route's departures have arrived over a connected socket. */ + readonly isLive = $derived(this.#departures !== null); readonly #departuresBySlot = $derived( - new Map(this.departures.map((d) => [slotKey(d.stopId, DateTime.fromISO(d.scheduledAt)), d])), + new Map( + (this.#departures ?? []).map((d) => [slotKey(d.stopId, DateTime.fromISO(d.scheduledAt)), d]), + ), ); #routeId: string | null = null; - #hasOpened = false; #onReconnected = () => {}; readonly #offline = new OfflineTracker(); readonly #socket = new LiveSocket(WEBSOCKET_URL, { - onOpen: () => { - this.#socketState = SocketState.CONNECTED; - this.#offline.connected(); - if (this.#hasOpened) this.#onReconnected(); - this.#hasOpened = true; - }, - onClose: (reconnecting) => { - this.#socketState = SocketState.DISCONNECTED; - if (reconnecting) this.#offline.connecting(); - this.#clear(); - }, + onStateChange: (state) => this.#onConnectionChange(state), onMessage: (message) => this.#onMessage(message), }); @@ -81,13 +56,11 @@ class LiveDataManager { /** Opens the socket and keeps it open while the app is in the foreground. */ connect(onReconnected: () => void) { + if (this.#connection !== ConnectionState.IDLE) return; this.#onReconnected = onReconnected; - App.addListener('pause', () => { - this.#offline.paused(); - this.#socket.disconnect(); - }); - App.addListener('resume', () => this.#connectSocket()); - this.#connectSocket(); + App.addListener('pause', () => this.#socket.disconnect()); + App.addListener('resume', () => this.#socket.connect()); + this.#socket.connect(); } subscribe(routeId: string | null) { @@ -100,7 +73,7 @@ class LiveDataManager { /** Upcoming departures at a stop, soonest first. */ estimatesFor(stopId: string): TimeEstimate[] { const earliest = DateTime.now().minus(DEPARTED_GRACE); - return this.departures + return (this.#departures ?? []) .filter((d) => d.stopId === stopId && !d.isCancelled && departsAt(d) >= earliest) .sort((a, b) => departsAt(a).toMillis() - departsAt(b).toMillis()) .map((d) => ({ @@ -115,19 +88,18 @@ class LiveDataManager { return this.#departuresBySlot.get(slotKey(stopId, scheduledAt)); } - // Retries after a drop stay disconnected, so the offline fallback does not flicker between attempts. - #connectSocket() { - if (this.#socketState !== SocketState.CONNECTED) { - this.#socketState = SocketState.CONNECTING; - this.#offline.connecting(); - } - this.#socket.connect(); + #onConnectionChange(state: ConnectionState) { + const wasReconnecting = this.#connection === ConnectionState.RECONNECTING; + this.#connection = state; + this.#offline.update(state); + + if (state !== ConnectionState.CONNECTED) this.#clear(); + else if (wasReconnecting) this.#onReconnected(); } #clear() { this.vehicles = []; - this.departures = []; - this.#hasDepartures = false; + this.#departures = null; } #onMessage(message: WebsocketServerMessage) { @@ -136,9 +108,7 @@ class LiveDataManager { if (message.routeId === this.#routeId) this.vehicles = message.vehicles ?? []; break; case WebsocketMessageType.DEPARTURES: - if (message.routeId !== this.#routeId) return; - this.departures = message.departures ?? []; - this.#hasDepartures = true; + if (message.routeId === this.#routeId) this.#departures = message.departures ?? []; break; case WebsocketMessageType.ERROR: appLogger.e(`Live socket error: ${message.message}`); diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index 85c11c01..2010a4fd 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -1,7 +1,6 @@ import { getSupportedVersion, reportSyncFailure, syncStream, SyncMode } from '$lib/api/client'; import { db } from '$lib/db/database'; import { syncAck, timetable } from '$lib/db/schema'; -import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { isResetStream, planFullSync, @@ -69,10 +68,6 @@ class SyncManager { await this.#checkVersion().catch((error) => appLogger.w(`Version check failed: ${describeError(error)}`), ); - if (this.versionStatus === VersionStatus.UNSUPPORTED) return; - - liveDataManager.connect(() => this.sync()); - await this.sync(); } sync(): Promise { diff --git a/src/lib/sync/live-socket.test.ts b/src/lib/sync/live-socket.test.ts new file mode 100644 index 00000000..1a3865b9 --- /dev/null +++ b/src/lib/sync/live-socket.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectionState, LiveSocket } from './live-socket'; + +class FakeWebSocket { + static readonly OPEN = 1; + static opened: FakeWebSocket[] = []; + + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + + constructor() { + FakeWebSocket.opened.push(this); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + send() {} + + close() { + queueMicrotask(() => this.onclose?.()); + } +} + +function createSocket() { + const states: ConnectionState[] = []; + const socket = new LiveSocket('ws://test', { + onStateChange: (state) => states.push(state), + onMessage: () => {}, + }); + return { socket, states }; +} + +const latest = () => FakeWebSocket.opened.at(-1)!; + +beforeEach(() => { + FakeWebSocket.opened = []; + vi.useFakeTimers(); + vi.stubGlobal('WebSocket', FakeWebSocket); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('LiveSocket', () => { + it('stays connecting across failed first attempts', async () => { + const { socket, states } = createSocket(); + socket.connect(); + latest().close(); + await vi.runAllTimersAsync(); + + expect(states).toEqual([ConnectionState.CONNECTING]); + expect(FakeWebSocket.opened.length).toBeGreaterThan(1); + }); + + it('reconnects after a dropped connection', async () => { + const { socket, states } = createSocket(); + socket.connect(); + latest().open(); + latest().close(); + await vi.advanceTimersToNextTimerAsync(); + latest().open(); + + expect(states).toEqual([ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.RECONNECTING, + ConnectionState.CONNECTED, + ]); + }); + + it('opens a new socket right away when resumed before the old one finishes closing', () => { + const { socket, states } = createSocket(); + socket.connect(); + latest().open(); + socket.disconnect(); + socket.connect(); + + expect(FakeWebSocket.opened).toHaveLength(2); + expect(states.at(-1)).toBe(ConnectionState.RECONNECTING); + }); + + it('ignores a second connect while already connecting', () => { + const { socket } = createSocket(); + socket.connect(); + socket.connect(); + + expect(FakeWebSocket.opened).toHaveLength(1); + }); +}); diff --git a/src/lib/sync/live-socket.ts b/src/lib/sync/live-socket.ts index 8831eb5d..fcd86448 100644 --- a/src/lib/sync/live-socket.ts +++ b/src/lib/sync/live-socket.ts @@ -11,9 +11,16 @@ const MAX_RETRY_DELAY_MS = 30_000; // The gateway drops a client that sends nothing for 60 seconds. const PING_INTERVAL_MS = 20_000; +export enum ConnectionState { + IDLE = 'idle', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + PAUSED = 'paused', +} + interface LiveSocketHandlers { - onOpen(): void; - onClose(reconnecting: boolean): void; + onStateChange(state: ConnectionState): void; onMessage(message: WebsocketServerMessage): void; } @@ -22,8 +29,8 @@ export class LiveSocket { readonly #url: string; readonly #handlers: LiveSocketHandlers; + #state = ConnectionState.IDLE; #socket: WebSocket | null = null; - #wanted = false; #routeId: string | null = null; readonly #retry = new RetryScheduler(() => this.#open(), { initialDelayMs: INITIAL_RETRY_DELAY_MS, @@ -36,15 +43,25 @@ export class LiveSocket { this.#handlers = handlers; } + get #wantsOpen() { + return this.#state !== ConnectionState.IDLE && this.#state !== ConnectionState.PAUSED; + } + connect() { - this.#wanted = true; - if (!this.#socket) this.#open(); + if (this.#wantsOpen) return; + this.#setState( + this.#state === ConnectionState.IDLE + ? ConnectionState.CONNECTING + : ConnectionState.RECONNECTING, + ); + this.#open(); } disconnect() { - this.#wanted = false; + if (!this.#wantsOpen) return; this.#retry.cancel(); - this.#socket?.close(); + this.#closeSocket(); + this.#setState(ConnectionState.PAUSED); } subscribe(routeId: string | null) { @@ -52,6 +69,12 @@ export class LiveSocket { this.#sendSubscription(); } + #setState(state: ConnectionState) { + if (state === this.#state) return; + this.#state = state; + this.#handlers.onStateChange(state); + } + #open() { this.#retry.cancel(); const socket = new WebSocket(this.#url); @@ -64,7 +87,7 @@ export class LiveSocket { PING_INTERVAL_MS, ); this.#sendSubscription(); - this.#handlers.onOpen(); + this.#setState(ConnectionState.CONNECTED); }; socket.onmessage = (event) => { @@ -75,16 +98,18 @@ export class LiveSocket { socket.onclose = () => { if (this.#socket !== socket) return; - clearInterval(this.#pingTimer); - this.#socket = null; - this.#handlers.onClose(this.#wanted); - if (this.#wanted) this.#scheduleReconnect(); + this.#closeSocket(); + if (this.#state === ConnectionState.CONNECTED) this.#setState(ConnectionState.RECONNECTING); + const delay = this.#retry.schedule(); + appLogger.i(`Live socket closed, reconnecting in ${Math.round(delay)} ms`); }; } - #scheduleReconnect() { - const delay = this.#retry.schedule(); - appLogger.i(`Live socket closed, reconnecting in ${Math.round(delay)} ms`); + #closeSocket() { + clearInterval(this.#pingTimer); + const socket = this.#socket; + this.#socket = null; + socket?.close(); } #sendSubscription() { diff --git a/src/lib/sync/offline-tracker.svelte.ts b/src/lib/sync/offline-tracker.svelte.ts index bc71c031..089a80b8 100644 --- a/src/lib/sync/offline-tracker.svelte.ts +++ b/src/lib/sync/offline-tracker.svelte.ts @@ -1,33 +1,24 @@ +import { ConnectionState } from '$lib/sync/live-socket'; + export const FIRST_CONNECT_GRACE_MS = 5_000; export const RECONNECT_GRACE_MS = 15_000; +const GRACE_MS: Partial> = { + [ConnectionState.CONNECTING]: FIRST_CONNECT_GRACE_MS, + [ConnectionState.RECONNECTING]: RECONNECT_GRACE_MS, +}; + /** Flags the app offline once the live socket has gone too long without connecting. */ export class OfflineTracker { isOffline = $state(false); - #hasConnected = false; #timer: ReturnType | undefined; - /** Called on every connect attempt. Retries keep the countdown already running. */ - connecting() { - if (this.#timer !== undefined) return; - const grace = this.#hasConnected ? RECONNECT_GRACE_MS : FIRST_CONNECT_GRACE_MS; - this.#timer = setTimeout(() => (this.isOffline = true), grace); - } - - connected() { - this.#hasConnected = true; - this.isOffline = false; - this.#stop(); - } - - /** The socket closes on purpose in the background, so that does not count. */ - paused() { - this.#stop(); - } - - #stop() { + update(state: ConnectionState) { clearTimeout(this.#timer); - this.#timer = undefined; + if (state === ConnectionState.CONNECTED) this.isOffline = false; + + const grace = GRACE_MS[state]; + if (grace !== undefined) this.#timer = setTimeout(() => (this.isOffline = true), grace); } } diff --git a/src/lib/sync/offline-tracker.test.ts b/src/lib/sync/offline-tracker.test.ts index a0c17658..951cd2a2 100644 --- a/src/lib/sync/offline-tracker.test.ts +++ b/src/lib/sync/offline-tracker.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectionState } from './live-socket'; import { FIRST_CONNECT_GRACE_MS, OfflineTracker, @@ -11,7 +12,7 @@ afterEach(() => vi.useRealTimers()); describe('OfflineTracker', () => { it('goes offline when the first connection takes too long', () => { const tracker = new OfflineTracker(); - tracker.connecting(); + tracker.update(ConnectionState.CONNECTING); vi.advanceTimersByTime(FIRST_CONNECT_GRACE_MS - 1); expect(tracker.isOffline).toBe(false); @@ -21,9 +22,7 @@ describe('OfflineTracker', () => { it('waits longer before going offline after a dropped connection', () => { const tracker = new OfflineTracker(); - tracker.connecting(); - tracker.connected(); - tracker.connecting(); + tracker.update(ConnectionState.RECONNECTING); vi.advanceTimersByTime(RECONNECT_GRACE_MS - 1); expect(tracker.isOffline).toBe(false); @@ -31,29 +30,19 @@ describe('OfflineTracker', () => { expect(tracker.isOffline).toBe(true); }); - it('keeps counting from the first attempt across retries', () => { - const tracker = new OfflineTracker(); - tracker.connecting(); - vi.advanceTimersByTime(FIRST_CONNECT_GRACE_MS - 1); - tracker.connecting(); - - vi.advanceTimersByTime(1); - expect(tracker.isOffline).toBe(true); - }); - it('comes back online when the socket connects', () => { const tracker = new OfflineTracker(); - tracker.connecting(); + tracker.update(ConnectionState.CONNECTING); vi.advanceTimersByTime(FIRST_CONNECT_GRACE_MS); - tracker.connected(); + tracker.update(ConnectionState.CONNECTED); expect(tracker.isOffline).toBe(false); }); it('does not go offline while paused', () => { const tracker = new OfflineTracker(); - tracker.connecting(); - tracker.paused(); + tracker.update(ConnectionState.RECONNECTING); + tracker.update(ConnectionState.PAUSED); vi.advanceTimersByTime(RECONNECT_GRACE_MS); expect(tracker.isOffline).toBe(false); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f37419f5..99e0d2f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,8 +21,11 @@ let { children } = $props(); - // Every page under /route/[id] shows live data for that route. - $effect(() => liveDataManager.subscribe(page.params.id ?? null)); + const ROUTE_PAGES = '/route/[id]'; + + $effect(() => + liveDataManager.subscribe(page.route.id?.startsWith(ROUTE_PAGES) ? page.params.id! : null), + ); onDestroy(() => { mapManager.unregisterMap(); @@ -33,6 +36,10 @@ await migratePrefs(); await frontPageManager.load(); await syncManager.start(); + if (syncManager.versionStatus === VersionStatus.UNSUPPORTED) return; + + liveDataManager.connect(() => syncManager.sync()); + await syncManager.sync(); }); installDynamicType(); diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[id]/StopRow.svelte index 2f8ccda6..e1ff08fc 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[id]/StopRow.svelte @@ -15,7 +15,7 @@ type Route, type Stop, } from '$lib/data/types'; - import { LiveDataStatus, liveDataManager } from '$lib/managers/live-data.manager.svelte'; + import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { CalendarIcon } from '@lucide/svelte'; import { getContext } from 'svelte'; @@ -48,9 +48,7 @@ return 'No upcoming departures'; } if (source === EstimateSource.SCHEDULE) { - return liveDataManager.status === LiveDataStatus.OFFLINE - ? 'Offline, showing scheduled times' - : 'Scheduled times'; + return liveDataManager.isOffline ? 'Offline, showing scheduled times' : 'Scheduled times'; } const firstEstimate = estimates.find((estimate) => estimate.estimatedTime); From 36eac377a63cb37dbd26844a4cc0058da9d2f995 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:22:12 -0500 Subject: [PATCH 15/29] refactor: simplify sync manager state and drop unused failure reports --- src/lib/api/client.ts | 21 +------ src/lib/data/app.ts | 8 +-- src/lib/db/sql.ts | 3 + src/lib/managers/sync.manager.svelte.ts | 84 ++++++++++++------------- 4 files changed, 49 insertions(+), 67 deletions(-) create mode 100644 src/lib/db/sql.ts diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 01406cf9..d3fcb2ba 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,13 +1,10 @@ -import { appLogger } from '$lib/utils/logger'; import createClient from 'openapi-fetch'; import type { components, paths } from './openapi'; const PRODUCTION_API_URL = 'https://api.maroonrides.app'; -const DEV_SERVER_API_URL = 'http://100.89.139.58:3000'; const LINE_SEPARATOR = '\n'; -export const API_URL: string = - import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? PRODUCTION_API_URL : PRODUCTION_API_URL); +export const API_URL: string = import.meta.env.VITE_API_URL ?? PRODUCTION_API_URL; export const WEBSOCKET_URL = `${API_URL.replace(/^http/, 'ws')}/api/ws`; type Schemas = components['schemas']; @@ -30,22 +27,6 @@ export async function getSupportedVersion(): Promise { return data.version; } -export enum SyncMode { - INCREMENTAL = 'incremental', - FULL = 'full', -} - -export interface SyncFailureReport { - mode: SyncMode; - appVersion: string; - error: string; -} - -// TODO: send to the gateway once it has an endpoint for sync failures. -export async function reportSyncFailure(report: SyncFailureReport): Promise { - appLogger.w(`Sync failure report: ${JSON.stringify(report)}`); -} - export async function syncStream(body: SyncRequest): Promise { const { data, error } = await client.POST('/api/sync/stream', { body, parseAs: 'text' }); if (error) throw new Error(`sync stream failed: ${error.detail}`); diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 593710a6..51782901 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -12,6 +12,7 @@ import { } from '$lib/data/types'; import { db } from '$lib/db/database'; import { alert, alertDirection, direction, route, stop, timetable } from '$lib/db/schema'; +import { TODAY_SQL } from '$lib/db/sql'; import { resolveStopEstimates } from '$lib/data/stop_estimates'; import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { syncManager } from '$lib/managers/sync.manager.svelte'; @@ -141,7 +142,6 @@ interface UpcomingTimes { const useUpcomingTimes = (params: () => { stop: Stop }) => createDbQuery(() => { const { stop } = params(); - const today = sql`date('now', 'localtime')`; return { key: ['upcomingTimes', stop.id], refetchInterval: SCHEDULE_REFRESH_INTERVAL, @@ -149,18 +149,18 @@ const useUpcomingTimes = (params: () => { stop: Stop }) => const [rows, timetables, timetablesFromToday] = await Promise.all([ scheduledTimes( stop, - today, + TODAY_SQL, gte(sql`unixepoch(${slotTime})`, sql`unixepoch('now', ${LATE_DEPARTURE_WINDOW})`), ).limit(UPCOMING_TIME_LIMIT), db .select({ id: timetable.id }) .from(timetable) - .where(and(eq(timetable.stopId, stop.id), eq(timetable.serviceDate, today))) + .where(and(eq(timetable.stopId, stop.id), eq(timetable.serviceDate, TODAY_SQL))) .limit(1), db .select({ id: timetable.id }) .from(timetable) - .where(gte(timetable.serviceDate, today)) + .where(gte(timetable.serviceDate, TODAY_SQL)) .limit(1), ]); diff --git a/src/lib/db/sql.ts b/src/lib/db/sql.ts new file mode 100644 index 00000000..b6a93946 --- /dev/null +++ b/src/lib/db/sql.ts @@ -0,0 +1,3 @@ +import { sql } from 'drizzle-orm'; + +export const TODAY_SQL = sql`date('now', 'localtime')`; diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index 2010a4fd..d95d8c01 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -1,6 +1,7 @@ -import { getSupportedVersion, reportSyncFailure, syncStream, SyncMode } from '$lib/api/client'; +import { getSupportedVersion, syncStream } from '$lib/api/client'; import { db } from '$lib/db/database'; import { syncAck, timetable } from '$lib/db/schema'; +import { TODAY_SQL } from '$lib/db/sql'; import { isResetStream, planFullSync, @@ -9,11 +10,11 @@ import { SYNC_REQUEST_TYPES, type Statement, } from '$lib/sync/sync-stream'; -import { APP_VERSION, isAppVersionSupported } from '$lib/utils/app-version'; +import { isAppVersionSupported } from '$lib/utils/app-version'; import { appLogger, describeError } from '$lib/utils/logger'; import { refreshDbQueries } from '$lib/utils/queries'; import { RetryScheduler } from '$lib/utils/retry-scheduler'; -import { lt, sql } from 'drizzle-orm'; +import { lt } from 'drizzle-orm'; import { DateTime } from 'luxon'; const INITIAL_RETRY_DELAY_MS = 5_000; @@ -35,18 +36,14 @@ function fetchLines(acks: string[]) { return syncStream({ protocol: SYNC_PROTOCOL_V1, types: SYNC_REQUEST_TYPES, acks }); } -function reportFailure(mode: SyncMode, error: unknown) { - return reportSyncFailure({ mode, appVersion: APP_VERSION, error: describeError(error) }); -} - /** Owns every write to the local database, which is the sync stream and its cleanup. */ class SyncManager { - versionStatus = $state(VersionStatus.UNKNOWN); - isSyncing = $state(false); - + #versionStatus = $state(VersionStatus.UNKNOWN); + #isSyncing = $state(false); #lastSyncedAt = $state(null); - #started = false; - #syncQueued = false; + + #startup: Promise | null = null; + #pendingSync: Promise | null = null; readonly #retry = new RetryScheduler(() => this.sync(), { initialDelayMs: INITIAL_RETRY_DELAY_MS, maxDelayMs: MAX_RETRY_DELAY_MS, @@ -54,29 +51,29 @@ class SyncManager { // Writes run one at a time, since web SQLite has no locking of its own. #queue: Promise = Promise.resolve(); + get versionStatus() { + return this.#versionStatus; + } + + get isSyncing() { + return this.#isSyncing; + } + get hasSyncedToday(): boolean { return this.#lastSyncedAt?.hasSame(DateTime.now(), 'day') ?? false; } - async start() { - if (this.#started) return; - this.#started = true; - - this.#enqueue(() => - db.delete(timetable).where(lt(timetable.serviceDate, sql`date('now', 'localtime')`)), - ); - await this.#checkVersion().catch((error) => - appLogger.w(`Version check failed: ${describeError(error)}`), - ); + /** Clears past timetables and checks whether the server still supports this app version. */ + start(): Promise { + this.#startup ??= this.#startUp(); + return this.#startup; } + /** Runs a sync after any already in progress. Calls made while one is waiting share it. */ sync(): Promise { - if (this.#syncQueued) return this.#queue; - this.#syncQueued = true; - - return this.#enqueue(async () => { - this.#syncQueued = false; - this.isSyncing = true; + this.#pendingSync ??= this.#enqueue(async () => { + this.#pendingSync = null; + this.#isSyncing = true; try { if (await this.#checkVersion()) { await this.#runSync(); @@ -85,15 +82,21 @@ class SyncManager { this.#retry.reset(); } catch (error) { const delay = this.#retry.schedule(); - appLogger.w(`Sync failed, retrying in ${Math.round(delay)} ms`); - throw error; + appLogger.w(`Sync failed, retrying in ${Math.round(delay)} ms: ${describeError(error)}`); } finally { - this.isSyncing = false; + this.#isSyncing = false; } }); + return this.#pendingSync; + } + + async #startUp() { + this.#enqueue(() => db.delete(timetable).where(lt(timetable.serviceDate, TODAY_SQL))); + await this.#checkVersion().catch((error) => + appLogger.w(`Version check failed: ${describeError(error)}`), + ); } - // Every task may have changed data on screen, so each one refreshes the database queries. #enqueue(task: () => PromiseLike): Promise { this.#queue = this.#queue .then(task) @@ -103,13 +106,13 @@ class SyncManager { } async #checkVersion(): Promise { - if (this.versionStatus === VersionStatus.UNKNOWN) { + if (this.#versionStatus === VersionStatus.UNKNOWN) { const minimumVersion = await getSupportedVersion(); - this.versionStatus = isAppVersionSupported(minimumVersion) + this.#versionStatus = isAppVersionSupported(minimumVersion) ? VersionStatus.SUPPORTED : VersionStatus.UNSUPPORTED; } - return this.versionStatus === VersionStatus.SUPPORTED; + return this.#versionStatus === VersionStatus.SUPPORTED; } async #runSync() { @@ -121,20 +124,15 @@ class SyncManager { await applyAll(planSync(lines)); appLogger.i(`Applied ${lines.length} sync lines`); } catch (error) { - await reportFailure(SyncMode.INCREMENTAL, error); + appLogger.w(`Incremental sync failed, running a full sync: ${describeError(error)}`); await this.#fullSync(); } } async #fullSync() { const lines = await fetchLines([]); - try { - await applyAll(planFullSync(lines)); - appLogger.i(`Replaced local data with ${lines.length} sync lines`); - } catch (error) { - await reportFailure(SyncMode.FULL, error); - throw error; - } + await applyAll(planFullSync(lines)); + appLogger.i(`Replaced local data with ${lines.length} sync lines`); } } From d738a09b644fbc9edc1f0a012a089e2ee8af0266 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:23:38 -0500 Subject: [PATCH 16/29] feat: validate gateway messages and sync lines with zod --- src/lib/api/client.ts | 3 +- src/lib/api/sync-lines.test.ts | 19 +++++++++ src/lib/api/sync-lines.ts | 77 ++++++++++++++++++++++++++++++++++ src/lib/api/websocket.ts | 69 +++++++++++++++++------------- src/lib/sync/live-socket.ts | 5 ++- 5 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 src/lib/api/sync-lines.test.ts create mode 100644 src/lib/api/sync-lines.ts diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index d3fcb2ba..6d96cd3a 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,5 +1,6 @@ import createClient from 'openapi-fetch'; import type { components, paths } from './openapi'; +import { syncStreamLineSchema } from './sync-lines'; const PRODUCTION_API_URL = 'https://api.maroonrides.app'; const LINE_SEPARATOR = '\n'; @@ -34,7 +35,7 @@ export async function syncStream(body: SyncRequest): Promise { return data .split(LINE_SEPARATOR) .filter(Boolean) - .map((line) => JSON.parse(line) as SyncStreamLine); + .map((line) => syncStreamLineSchema.parse(JSON.parse(line)) satisfies SyncStreamLine); } export async function planTrip(body: TripPlanRequest): Promise { diff --git a/src/lib/api/sync-lines.test.ts b/src/lib/api/sync-lines.test.ts new file mode 100644 index 00000000..71cd3908 --- /dev/null +++ b/src/lib/api/sync-lines.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { syncStreamLineSchema } from './sync-lines'; + +const timetableLine = { + type: 'TimetableV1', + ack: 'TimetablesV1|1', + data: { id: 't', stopId: 's', serviceDate: '2026-10-10', departures: null }, +}; + +describe('syncStreamLineSchema', () => { + it('accepts a line the server sends', () => { + expect(syncStreamLineSchema.parse(timetableLine)).toEqual(timetableLine); + }); + + it('rejects a line missing a column', () => { + const { stopId: _, ...data } = timetableLine.data; + expect(syncStreamLineSchema.safeParse({ ...timetableLine, data }).success).toBe(false); + }); +}); diff --git a/src/lib/api/sync-lines.ts b/src/lib/api/sync-lines.ts new file mode 100644 index 00000000..d5c0f006 --- /dev/null +++ b/src/lib/api/sync-lines.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +const line = (type: T, data: D) => + z.object({ type: z.literal(type), ack: z.string(), data }); + +const nullableStrings = z.array(z.string()).nullable(); + +export const syncStreamLineSchema = z.discriminatedUnion('type', [ + line( + 'RouteV1', + z.object({ + id: z.string(), + shortName: z.string(), + longName: z.string(), + lightColor: z.string(), + darkColor: z.string(), + active: z.boolean(), + }), + ), + line('RouteDeleteV1', z.object({ routeId: z.string() })), + line( + 'DirectionV1', + z.object({ + id: z.string(), + routeId: z.string(), + destination: z.string(), + path: z.string(), + sequence: z.number(), + }), + ), + line('DirectionDeleteV1', z.object({ directionId: z.string() })), + line( + 'StopV1', + z.object({ + id: z.string(), + directionId: z.string(), + name: z.string(), + lat: z.number(), + lon: z.number(), + sequence: z.number(), + isTimepoint: z.boolean(), + amenities: nullableStrings, + }), + ), + line('StopDeleteV1', z.object({ stopId: z.string() })), + line( + 'AlertV1', + z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + timeRangeText: z.string(), + startsAt: z.string(), + endsAt: z.string().nullable(), + dailyStartTime: z.string(), + dailyEndTime: z.string(), + }), + ), + line('AlertDeleteV1', z.object({ alertId: z.string() })), + line( + 'AlertDirectionV1', + z.object({ id: z.string(), alertId: z.string(), directionId: z.string() }), + ), + line('AlertDirectionDeleteV1', z.object({ alertDirectionId: z.string() })), + line( + 'TimetableV1', + z.object({ + id: z.string(), + stopId: z.string(), + serviceDate: z.string(), + departures: nullableStrings, + }), + ), + line('TimetableDeleteV1', z.object({ timetableId: z.string() })), + line('SyncResetV1', z.unknown()), + line('SyncCompleteV1', z.unknown()), +]); diff --git a/src/lib/api/websocket.ts b/src/lib/api/websocket.ts index 75eed45f..71806ba1 100644 --- a/src/lib/api/websocket.ts +++ b/src/lib/api/websocket.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + // The gateway does not publish these in openapi.json, they mirror apps/gateway/dtos/websocket.go. export enum WebsocketMessageType { @@ -15,34 +17,43 @@ export type WebsocketClientMessage = | { type: WebsocketMessageType.UNSUBSCRIBE } | { type: WebsocketMessageType.PING }; -export interface WebsocketVehicle { - id: string; - directionId: string; - name: string; - lat: number; - lon: number; - heading: number; - speed: number; - passengers: number; - capacity: number; - amenities: string[] | null; - seenAt: string; -} +const websocketVehicleSchema = z.object({ + id: z.string(), + directionId: z.string(), + name: z.string(), + lat: z.number(), + lon: z.number(), + heading: z.number(), + speed: z.number(), + passengers: z.number(), + capacity: z.number(), + amenities: z.array(z.string()).nullable(), + seenAt: z.string(), +}); -export interface WebsocketDeparture { - id: string; - stopId: string; - scheduledAt: string; - estimatedAt: string | null; - isCancelled: boolean; -} +const websocketDepartureSchema = z.object({ + id: z.string(), + stopId: z.string(), + scheduledAt: z.string(), + estimatedAt: z.string().nullable(), + isCancelled: z.boolean(), +}); + +export const websocketServerMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal(WebsocketMessageType.PONG) }), + z.object({ type: z.literal(WebsocketMessageType.ERROR), message: z.string() }), + z.object({ + type: z.literal(WebsocketMessageType.VEHICLES), + routeId: z.string(), + vehicles: z.array(websocketVehicleSchema).nullable(), + }), + z.object({ + type: z.literal(WebsocketMessageType.DEPARTURES), + routeId: z.string(), + departures: z.array(websocketDepartureSchema).nullable(), + }), +]); -export type WebsocketServerMessage = - | { type: WebsocketMessageType.PONG } - | { type: WebsocketMessageType.ERROR; message: string } - | { type: WebsocketMessageType.VEHICLES; routeId: string; vehicles: WebsocketVehicle[] | null } - | { - type: WebsocketMessageType.DEPARTURES; - routeId: string; - departures: WebsocketDeparture[] | null; - }; +export type WebsocketVehicle = z.infer; +export type WebsocketDeparture = z.infer; +export type WebsocketServerMessage = z.infer; diff --git a/src/lib/sync/live-socket.ts b/src/lib/sync/live-socket.ts index fcd86448..f34575d5 100644 --- a/src/lib/sync/live-socket.ts +++ b/src/lib/sync/live-socket.ts @@ -1,5 +1,6 @@ import { WebsocketMessageType, + websocketServerMessageSchema, type WebsocketClientMessage, type WebsocketServerMessage, } from '$lib/api/websocket'; @@ -91,7 +92,9 @@ export class LiveSocket { }; socket.onmessage = (event) => { - this.#handlers.onMessage(JSON.parse(event.data) as WebsocketServerMessage); + const message = websocketServerMessageSchema.safeParse(JSON.parse(event.data)); + if (message.success) this.#handlers.onMessage(message.data); + else appLogger.w(`Ignoring live socket message: ${message.error.message}`); }; socket.onerror = () => socket.close(); From 16c3164d5e618e38e944e3ff7d22103b1904bea1 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:25:01 -0500 Subject: [PATCH 17/29] refactor: share upcoming estimate logic between live and scheduled times --- src/lib/data/app.ts | 21 +++++---------- src/lib/data/stop_estimates.test.ts | 27 +++++++++++++++++++- src/lib/data/stop_estimates.ts | 25 ++++++++++++++++++ src/lib/managers/live-data.manager.svelte.ts | 24 +++++------------ 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 51782901..3405935d 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -13,7 +13,7 @@ import { import { db } from '$lib/db/database'; import { alert, alertDirection, direction, route, stop, timetable } from '$lib/db/schema'; import { TODAY_SQL } from '$lib/db/sql'; -import { resolveStopEstimates } from '$lib/data/stop_estimates'; +import { resolveStopEstimates, toTimeEstimate, upcomingEstimates } from '$lib/data/stop_estimates'; import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { syncManager } from '$lib/managers/sync.manager.svelte'; import { findBoundingBox } from '$lib/utils/geo'; @@ -28,8 +28,6 @@ const STOP_ESTIMATE_LIMIT = 3; // A late bus is still coming after its scheduled time, so recent times stay in play for live estimates. const LATE_DEPARTURE_WINDOW = '-30 minutes'; const UPCOMING_TIME_LIMIT = 10; -// A bus that left a minute ago is often still at the stop. -const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); const slotTime = sql`slot.value`; @@ -175,18 +173,11 @@ const useUpcomingTimes = (params: () => { stop: Stop }) => /** Marks each scheduled time with its live estimate, dropping cancelled and departed buses. */ function withLiveEstimates(times: DateTime[], stop: Stop): TimeEstimate[] { - const earliest = DateTime.now().minus(DEPARTED_GRACE); - return times - .flatMap((scheduledTime) => { - const live = liveDataManager.departureAt(stop.id, scheduledTime); - if (live?.isCancelled) return []; - - const estimatedTime = live?.estimatedAt ? DateTime.fromISO(live.estimatedAt) : null; - if ((estimatedTime ?? scheduledTime) < earliest) return []; - return [{ scheduledTime, estimatedTime, isRealTime: estimatedTime !== null }]; - }) - .sort((a, b) => +(a.estimatedTime ?? a.scheduledTime) - +(b.estimatedTime ?? b.scheduledTime)) - .slice(0, STOP_ESTIMATE_LIMIT); + const estimates = times.flatMap((scheduledTime) => { + const live = liveDataManager.departureAt(stop.id, scheduledTime); + return live?.isCancelled ? [] : [toTimeEstimate(scheduledTime, live?.estimatedAt)]; + }); + return upcomingEstimates(estimates).slice(0, STOP_ESTIMATE_LIMIT); } /** A stop's next departures from its timetable, updated with live estimates once they arrive. */ diff --git a/src/lib/data/stop_estimates.test.ts b/src/lib/data/stop_estimates.test.ts index 286f9dc6..ae66afeb 100644 --- a/src/lib/data/stop_estimates.test.ts +++ b/src/lib/data/stop_estimates.test.ts @@ -1,7 +1,12 @@ import { EstimateSource, type TimeEstimate } from '$lib/data/types'; import { DateTime } from 'luxon'; import { describe, expect, it } from 'vitest'; -import { resolveStopEstimates, type StopEstimateInputs } from './stop_estimates'; +import { + resolveStopEstimates, + toTimeEstimate, + upcomingEstimates, + type StopEstimateInputs, +} from './stop_estimates'; const estimate: TimeEstimate = { scheduledTime: DateTime.fromISO('2026-10-10T12:00:00'), @@ -46,3 +51,23 @@ describe('resolveStopEstimates', () => { ).toEqual({ source: EstimateSource.LIVE, estimates: [estimate] }); }); }); + +describe('upcomingEstimates', () => { + const now = DateTime.fromISO('2026-10-10T12:00:00'); + const at = (time: string, estimatedAt: string | null = null) => + toTimeEstimate( + DateTime.fromISO(`2026-10-10T${time}`), + estimatedAt && `2026-10-10T${estimatedAt}`, + ); + + it('keeps a bus that left under a minute ago', () => { + expect(upcomingEstimates([at('11:59:30')], now)).toHaveLength(1); + expect(upcomingEstimates([at('11:58:59')], now)).toHaveLength(0); + }); + + it('orders by live estimate when there is one', () => { + const late = at('12:01', '12:10'); + const onTime = at('12:05'); + expect(upcomingEstimates([late, onTime], now)).toEqual([onTime, late]); + }); +}); diff --git a/src/lib/data/stop_estimates.ts b/src/lib/data/stop_estimates.ts index 901f3c4a..ed1a9c52 100644 --- a/src/lib/data/stop_estimates.ts +++ b/src/lib/data/stop_estimates.ts @@ -1,4 +1,29 @@ import { EstimateSource, type StopEstimates, type TimeEstimate } from '$lib/data/types'; +import { DateTime, Duration } from 'luxon'; + +// A bus that left a minute ago is often still at the stop. +const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); + +const departsAt = (estimate: TimeEstimate) => estimate.estimatedTime ?? estimate.scheduledTime; + +export function toTimeEstimate( + scheduledTime: DateTime, + estimatedAt: string | null | undefined, +): TimeEstimate { + const estimatedTime = estimatedAt ? DateTime.fromISO(estimatedAt) : null; + return { scheduledTime, estimatedTime, isRealTime: estimatedTime !== null }; +} + +/** Drops buses that have already left and puts the rest soonest first. */ +export function upcomingEstimates( + estimates: TimeEstimate[], + now: DateTime = DateTime.now(), +): TimeEstimate[] { + const earliest = now.minus(DEPARTED_GRACE); + return estimates + .filter((estimate) => departsAt(estimate) >= earliest) + .sort((a, b) => departsAt(a).toMillis() - departsAt(b).toMillis()); +} export interface StopEstimateInputs { /** The stop has a timetable for today. */ diff --git a/src/lib/managers/live-data.manager.svelte.ts b/src/lib/managers/live-data.manager.svelte.ts index e6d524f3..778c3bbb 100644 --- a/src/lib/managers/live-data.manager.svelte.ts +++ b/src/lib/managers/live-data.manager.svelte.ts @@ -5,19 +5,13 @@ import { type WebsocketServerMessage, type WebsocketVehicle, } from '$lib/api/websocket'; +import { toTimeEstimate, upcomingEstimates } from '$lib/data/stop_estimates'; import type { TimeEstimate } from '$lib/data/types'; import { ConnectionState, LiveSocket } from '$lib/sync/live-socket'; import { OfflineTracker } from '$lib/sync/offline-tracker.svelte'; import { appLogger } from '$lib/utils/logger'; import { App } from '@capacitor/app'; -import { DateTime, Duration } from 'luxon'; - -// A bus that left a minute ago is often still at the stop. -const DEPARTED_GRACE = Duration.fromObject({ minutes: 1 }); - -function departsAt(departure: WebsocketDeparture) { - return DateTime.fromISO(departure.estimatedAt ?? departure.scheduledAt); -} +import { DateTime } from 'luxon'; function slotKey(stopId: string, scheduledAt: DateTime) { return `${stopId}|${scheduledAt.toMillis()}`; @@ -72,15 +66,11 @@ class LiveDataManager { /** Upcoming departures at a stop, soonest first. */ estimatesFor(stopId: string): TimeEstimate[] { - const earliest = DateTime.now().minus(DEPARTED_GRACE); - return (this.#departures ?? []) - .filter((d) => d.stopId === stopId && !d.isCancelled && departsAt(d) >= earliest) - .sort((a, b) => departsAt(a).toMillis() - departsAt(b).toMillis()) - .map((d) => ({ - scheduledTime: DateTime.fromISO(d.scheduledAt), - estimatedTime: d.estimatedAt ? DateTime.fromISO(d.estimatedAt) : null, - isRealTime: d.estimatedAt !== null, - })); + return upcomingEstimates( + (this.#departures ?? []) + .filter((d) => d.stopId === stopId && !d.isCancelled) + .map((d) => toTimeEstimate(DateTime.fromISO(d.scheduledAt), d.estimatedAt)), + ); } /** The live departure for one scheduled time at a stop, if the server sent one. */ From 93e2b0982f94e624455a640b43d44be4f92b0e80 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:25:01 -0500 Subject: [PATCH 18/29] chore: drop comments that restate code --- src/lib/sync/offline-tracker.svelte.ts | 1 - src/lib/sync/sync-stream.test.ts | 2 +- src/lib/sync/sync-stream.ts | 1 - src/lib/utils/retry-scheduler.ts | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/sync/offline-tracker.svelte.ts b/src/lib/sync/offline-tracker.svelte.ts index 089a80b8..83cb9176 100644 --- a/src/lib/sync/offline-tracker.svelte.ts +++ b/src/lib/sync/offline-tracker.svelte.ts @@ -8,7 +8,6 @@ const GRACE_MS: Partial> = { [ConnectionState.RECONNECTING]: RECONNECT_GRACE_MS, }; -/** Flags the app offline once the live socket has gone too long without connecting. */ export class OfflineTracker { isOffline = $state(false); diff --git a/src/lib/sync/sync-stream.test.ts b/src/lib/sync/sync-stream.test.ts index cbe37406..ff941c37 100644 --- a/src/lib/sync/sync-stream.test.ts +++ b/src/lib/sync/sync-stream.test.ts @@ -50,7 +50,7 @@ beforeEach(async () => { }); describe('planSync', () => { - it('stores rows as the server sends them', () => { + it('stores rows as the server sends them, with null lists as empty', () => { apply( planSync([ routeLine('Bonfire', ACK_ID), diff --git a/src/lib/sync/sync-stream.ts b/src/lib/sync/sync-stream.ts index cf08ea19..8d9b444e 100644 --- a/src/lib/sync/sync-stream.ts +++ b/src/lib/sync/sync-stream.ts @@ -70,7 +70,6 @@ function remove(table: T, toId: (data: D) => string): const controlLine: EntityHandler = () => []; -// Go encodes an empty slice as null, the local columns are not nullable. const HANDLERS: { [T in SyncEntityType]: EntityHandler> } = { RouteV1: upsert(route, (data: LineData<'RouteV1'>) => data), RouteDeleteV1: remove(route, (data: LineData<'RouteDeleteV1'>) => data.routeId), diff --git a/src/lib/utils/retry-scheduler.ts b/src/lib/utils/retry-scheduler.ts index e2e171ec..754b750a 100644 --- a/src/lib/utils/retry-scheduler.ts +++ b/src/lib/utils/retry-scheduler.ts @@ -5,7 +5,6 @@ interface RetrySchedulerOptions { maxDelayMs: number; } -/** Re-runs a task after each failure, doubling the wait up to a cap. */ export class RetryScheduler { readonly #task: () => void; readonly #initialDelayMs: number; From 30f12e1631cec8e234d1bf1cee3588f749a8c966 Mon Sep 17 00:00:00 2001 From: bwees Date: Wed, 21 Oct 2026 16:26:32 -0500 Subject: [PATCH 19/29] refactor: share stop estimate status messages and query --- src/lib/components/map/popup/StopPopup.svelte | 23 ++++----- src/lib/data/stop_estimates.test.ts | 30 +++++++++++ src/lib/data/stop_estimates.ts | 31 ++++++++++++ src/lib/data/types.ts | 2 - src/routes/route/[id]/EstimateRow.svelte | 14 ++---- src/routes/route/[id]/StopRow.svelte | 50 ++----------------- 6 files changed, 80 insertions(+), 70 deletions(-) diff --git a/src/lib/components/map/popup/StopPopup.svelte b/src/lib/components/map/popup/StopPopup.svelte index c0775b7b..24550675 100644 --- a/src/lib/components/map/popup/StopPopup.svelte +++ b/src/lib/components/map/popup/StopPopup.svelte @@ -4,13 +4,9 @@ import Spinner from '$lib/components/ui/spinner/spinner.svelte'; import TimeBubble from '$lib/components/TimeBubble.svelte'; import { useStopEstimate } from '$lib/data/app'; - import { - Amenity, - ESTIMATES_UNAVAILABLE_MESSAGE, - EstimateSource, - type Route, - type Stop, - } from '$lib/data/types'; + import { estimateStatusMessage } from '$lib/data/stop_estimates'; + import { Amenity, EstimateSource, type Route, type Stop } from '$lib/data/types'; + import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { themeManager } from '$lib/managers/theme.manager.svelte'; import { getRouteTint } from '$lib/utils/tints'; @@ -24,9 +20,8 @@ const tint = $derived(getRouteTint(route, themeManager.theme)); - const stopEstimates = $derived(useStopEstimate(() => ({ stop }))); - const source = $derived(stopEstimates.data?.source ?? EstimateSource.LOADING); - const estimates = $derived(stopEstimates.data?.estimates ?? []); + const stopEstimates = useStopEstimate(() => ({ stop })); + const estimates = $derived(stopEstimates.data.estimates);
- {#if source === EstimateSource.LOADING} + {#if stopEstimates.data.source === EstimateSource.LOADING} - {:else if source === EstimateSource.UNAVAILABLE} -

{ESTIMATES_UNAVAILABLE_MESSAGE}

{:else if estimates.length === 0} -

No upcoming departures

+

+ {estimateStatusMessage(stopEstimates.data, liveDataManager.isOffline)} +

{/if} {#each estimates as estimate, i} diff --git a/src/lib/data/stop_estimates.test.ts b/src/lib/data/stop_estimates.test.ts index ae66afeb..094b6d34 100644 --- a/src/lib/data/stop_estimates.test.ts +++ b/src/lib/data/stop_estimates.test.ts @@ -2,6 +2,8 @@ import { EstimateSource, type TimeEstimate } from '$lib/data/types'; import { DateTime } from 'luxon'; import { describe, expect, it } from 'vitest'; import { + estimateStatusMessage, + EstimateMessage, resolveStopEstimates, toTimeEstimate, upcomingEstimates, @@ -71,3 +73,31 @@ describe('upcomingEstimates', () => { expect(upcomingEstimates([late, onTime], now)).toEqual([onTime, late]); }); }); + +describe('estimateStatusMessage', () => { + const live = (estimatedAt: string | null) => ({ + source: EstimateSource.LIVE, + estimates: [toTimeEstimate(DateTime.fromISO('2026-10-10T12:00:00'), estimatedAt)], + }); + + it('explains an empty list', () => { + expect( + estimateStatusMessage({ source: EstimateSource.UNAVAILABLE, estimates: [] }, false), + ).toBe(EstimateMessage.UNAVAILABLE); + expect(estimateStatusMessage({ source: EstimateSource.LIVE, estimates: [] }, false)).toBe( + EstimateMessage.NO_DEPARTURES, + ); + }); + + it('says when scheduled times are shown because the app is offline', () => { + const scheduled = { source: EstimateSource.SCHEDULE, estimates: [estimate] }; + expect(estimateStatusMessage(scheduled, true)).toBe(EstimateMessage.OFFLINE_SCHEDULED); + expect(estimateStatusMessage(scheduled, false)).toBe(EstimateMessage.SCHEDULED); + }); + + it('reports how far the next live bus is off schedule', () => { + expect(estimateStatusMessage(live('2026-10-10T12:03:00'), false)).toBe('3 min late'); + expect(estimateStatusMessage(live('2026-10-10T11:58:00'), false)).toBe('2 min early'); + expect(estimateStatusMessage(live(null), false)).toBe(EstimateMessage.ON_TIME); + }); +}); diff --git a/src/lib/data/stop_estimates.ts b/src/lib/data/stop_estimates.ts index ed1a9c52..0a08c463 100644 --- a/src/lib/data/stop_estimates.ts +++ b/src/lib/data/stop_estimates.ts @@ -51,3 +51,34 @@ export function resolveStopEstimates({ if (timetablesCurrent) return { source, estimates: [] }; return { source: EstimateSource.UNAVAILABLE, estimates: [] }; } + +export enum EstimateMessage { + LOADING = 'Loading departures', + UNAVAILABLE = 'Stop estimates unavailable', + NO_DEPARTURES = 'No upcoming departures', + SCHEDULED = 'Scheduled times', + OFFLINE_SCHEDULED = 'Offline, showing scheduled times', + ON_TIME = 'On time', +} + +function deviationMessage(estimates: TimeEstimate[]): string { + const first = estimates.find((estimate) => estimate.estimatedTime); + if (!first?.estimatedTime) return EstimateMessage.ON_TIME; + + const minutes = Math.round(first.estimatedTime.diff(first.scheduledTime, 'minutes').minutes); + if (minutes > 0) return `${minutes} min late`; + if (minutes < 0) return `${-minutes} min early`; + return EstimateMessage.ON_TIME; +} + +export function estimateStatusMessage( + { source, estimates }: StopEstimates, + isOffline: boolean, +): string { + if (source === EstimateSource.LOADING) return EstimateMessage.LOADING; + if (source === EstimateSource.UNAVAILABLE) return EstimateMessage.UNAVAILABLE; + if (estimates.length === 0) return EstimateMessage.NO_DEPARTURES; + if (source === EstimateSource.SCHEDULE) + return isOffline ? EstimateMessage.OFFLINE_SCHEDULED : EstimateMessage.SCHEDULED; + return deviationMessage(estimates); +} diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 628fcd0f..6b3e6826 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -98,8 +98,6 @@ export enum EstimateSource { UNAVAILABLE = 'unavailable', } -export const ESTIMATES_UNAVAILABLE_MESSAGE = 'Stop estimates unavailable'; - export interface StopEstimates { source: EstimateSource; estimates: TimeEstimate[]; diff --git a/src/routes/route/[id]/EstimateRow.svelte b/src/routes/route/[id]/EstimateRow.svelte index 7ad0166f..f44215f9 100644 --- a/src/routes/route/[id]/EstimateRow.svelte +++ b/src/routes/route/[id]/EstimateRow.svelte @@ -1,31 +1,27 @@
- {#if isLoading} + {#if estimates.source === EstimateSource.LOADING} {/if} - {#each estimates.data?.estimates ?? [] as estimate, i} + {#each estimates.estimates as estimate, i} {/each}
diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[id]/StopRow.svelte index e1ff08fc..4f324e22 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[id]/StopRow.svelte @@ -7,14 +7,8 @@ import Button from '$lib/components/ui/button/button.svelte'; import { mapManager } from '$lib/managers/map.manager.svelte'; import { useStopEstimate } from '$lib/data/app'; - import { - Amenity, - ESTIMATES_UNAVAILABLE_MESSAGE, - EstimateSource, - type Direction, - type Route, - type Stop, - } from '$lib/data/types'; + import { estimateStatusMessage } from '$lib/data/stop_estimates'; + import { Amenity, type Direction, type Route, type Stop } from '$lib/data/types'; import { liveDataManager } from '$lib/managers/live-data.manager.svelte'; import { CalendarIcon } from '@lucide/svelte'; import { getContext } from 'svelte'; @@ -32,42 +26,8 @@ const effectiveStop = $derived(estimateStop ?? stop); - const stopEstimates = $derived(useStopEstimate(() => ({ stop: effectiveStop }))); - - let subtitle = $derived.by(() => { - const source = stopEstimates.data?.source ?? EstimateSource.LOADING; - const estimates = stopEstimates.data?.estimates ?? []; - - if (source === EstimateSource.LOADING) { - return 'Loading departures'; - } - if (source === EstimateSource.UNAVAILABLE) { - return ESTIMATES_UNAVAILABLE_MESSAGE; - } - if (estimates.length === 0) { - return 'No upcoming departures'; - } - if (source === EstimateSource.SCHEDULE) { - return liveDataManager.isOffline ? 'Offline, showing scheduled times' : 'Scheduled times'; - } - - const firstEstimate = estimates.find((estimate) => estimate.estimatedTime); - if (!firstEstimate?.estimatedTime) { - return 'On time'; - } - - const deviationMinutes = Math.round( - firstEstimate.estimatedTime.diff(firstEstimate.scheduledTime, 'minutes').minutes, - ); - - if (deviationMinutes === 0) { - return 'On time'; - } else if (deviationMinutes > 0) { - return `${deviationMinutes} min late`; - } else { - return `${Math.abs(deviationMinutes)} min early`; - } - }); + const stopEstimates = useStopEstimate(() => ({ stop: effectiveStop })); + const subtitle = $derived(estimateStatusMessage(stopEstimates.data, liveDataManager.isOffline));
@@ -100,7 +60,7 @@
{#if route && direction} - + {/if}