diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 95bad48..c1af675 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,6 +9,8 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-dialog') 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 8f2abec..7c2c6fd 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,6 +2,12 @@ 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-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-dialog' +project(':capacitor-dialog').projectDir = new File('../node_modules/.pnpm/@capacitor+dialog@8.0.1_@capacitor+core@8.3.0/node_modules/@capacitor/dialog/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/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 47eafb8..9579212 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -12,6 +12,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.0"), + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.3.0/node_modules/@capacitor/app"), + .package(name: "CapacitorDialog", path: "../../../node_modules/.pnpm/@capacitor+dialog@8.0.1_@capacitor+core@8.3.0/node_modules/@capacitor/dialog"), .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 +24,8 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorDialog", package: "CapacitorDialog"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), .product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"), .product(name: "CapacitorPreferences", package: "CapacitorPreferences") diff --git a/package.json b/package.json index 4f3168c..659453f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ }, "dependencies": { "@capacitor/android": "8.3.0", + "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.3.0", + "@capacitor/dialog": "^8.0.1", "@capacitor/filesystem": "^8.1.2", "@capacitor/geolocation": "^8.0.0", "@capacitor/ios": "^8.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe974d5..125f627 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@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/core': specifier: ^8.3.0 version: 8.3.0 + '@capacitor/dialog': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.3.0) '@capacitor/filesystem': specifier: ^8.1.2 version: 8.1.2(@capacitor/core@8.3.0) @@ -131,6 +137,11 @@ packages: peerDependencies: '@capacitor/core': ^8.3.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'} @@ -139,6 +150,11 @@ packages: '@capacitor/core@8.3.0': resolution: {integrity: sha512-S4ajn4G/fS3VJj8salxqH/3LO5PPWv1VxGKQ27OCajnDcLJjEg9VXwgMPnlypgkIOqCJ2fmQLtk8GT+BlI9/rw==} + '@capacitor/dialog@8.0.1': + resolution: {integrity: sha512-Dq0mZBsd++7CvPvpD2gfbUB44k/zCoe5K9IgYmMImQ4Yphu4/MxLVpFDFAqSya011UBBTGPrUQGVacIW3jDNkw==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/filesystem@8.1.2': resolution: {integrity: sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==} peerDependencies: @@ -1550,6 +1566,10 @@ snapshots: 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': dependencies: '@ionic/cli-framework-output': 2.2.8 @@ -1576,6 +1596,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@capacitor/dialog@8.0.1(@capacitor/core@8.3.0)': + dependencies: + '@capacitor/core': 8.3.0 + '@capacitor/filesystem@8.1.2(@capacitor/core@8.3.0)': dependencies: '@capacitor/core': 8.3.0 diff --git a/src/lib/components/ConnectionPill.svelte b/src/lib/components/ConnectionPill.svelte new file mode 100644 index 0000000..238ed92 --- /dev/null +++ b/src/lib/components/ConnectionPill.svelte @@ -0,0 +1,64 @@ + + +{#if mayShow && isBadStatus} + {@const Icon = status === ConnectionStatus.OFFLINE ? CloudOff : Spinner} +
+
+ +
+ + + + + {ConnectionStatus.asMessage(status)} + +
+
+
+
+{/if} diff --git a/src/lib/components/map/MapElements.svelte b/src/lib/components/map/MapElements.svelte index 26a7566..50bb4a0 100644 --- a/src/lib/components/map/MapElements.svelte +++ b/src/lib/components/map/MapElements.svelte @@ -16,7 +16,7 @@ import { goto } from '$app/navigation'; const routes = useRoutes(); - const drawnRouteIds = $derived(mapManager.drawnRoutes.map((route) => route.id)); + const drawnRouteIds = $derived(mapManager.drawnRoutes.map((route) => route.routeCode)); const busLocations = $derived(useVehicles(() => ({ route: mapManager.selectedRoute }))); let userLocation: Location | null = $state(null); @@ -58,21 +58,21 @@ flushScheduled = false; const topmost = requestedRoutes.reduce((a, b) => (b.index > a.index ? b : a)).route; requestedRoutes.length = 0; //cursed - goto(`/route/${topmost.id}`); + goto(`/route/${topmost.routeCode}`); } {#snippet routeLine(route: Route, index: number)} - {#each route.directions as direction (`${route.id}-${direction.id}`)} + {#each route.directions as direction (`${route.routeCode}-${direction.id}`)} {@const isSelected = direction.id === mapManager.selectedDirectionId || mapManager.selectedDirectionId == ''} - {@const isShown = drawnRouteIds.includes(route.id)} + {@const isShown = drawnRouteIds.includes(route.routeCode)} {@const opacity = isShown ? (isSelected ? 1 : 0.5) : 0} [point.longitude, point.latitude])} color={getRouteTint(route, themeManager.theme)} - id={`${route.id}-${direction.id}`} + id={`${route.routeCode}-${direction.id}`} onclick={() => { if (!isSelected) return; //opacity check instead of isShown for ghost directional routes requestRoute(route, index); @@ -103,7 +103,7 @@ {/snippet} -{#each routes?.data as route, i (route.id)} +{#each routes?.data as route, i (route.routeCode)} {@render routeLine(route, i)} {/each} diff --git a/src/lib/components/ui/map/MapControls.svelte b/src/lib/components/ui/map/MapControls.svelte index d6bb21e..9b06316 100644 --- a/src/lib/components/ui/map/MapControls.svelte +++ b/src/lib/components/ui/map/MapControls.svelte @@ -2,11 +2,15 @@ import { mapManager } from '$lib/managers/map.manager.svelte'; import { cn } from '$lib/utils.js'; import { Geolocation } from '@capacitor/geolocation'; - import { Locate, LocateFixed } from '@lucide/svelte'; + import { Braces, Locate, LocateFixed } from '@lucide/svelte'; import { getContext } from 'svelte'; import Button from '../button/button.svelte'; import Spinner from '../spinner/spinner.svelte'; import type { MapContext } from './Map.svelte'; + import { connectivityManager } from '$lib/managers/connectivity.manager.svelte'; + import { Dialog } from '@capacitor/dialog'; + import { Capacitor, CapacitorException } from '@capacitor/core'; + import { App } from '@capacitor/app'; interface Props { position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; showZoom?: boolean; @@ -67,6 +71,25 @@ waitingForLocation = false; } } + async function handleCacheScramble() { + const { message, status } = await connectivityManager.scrambleCacheUUIDS(); + await Dialog.alert({ + title: 'Cache Scrambler', + message: message, + }); + if (!status) return; + // true = exit + try { + await App.exitApp(); + } catch (error) { + if (!(error instanceof CapacitorException)) return; + await Dialog.alert({ + title: 'Cache Scrambler', + message: 'assuming webpage... reloading', + }); + window.location.reload(); + } + } {#if loaded} @@ -86,5 +109,17 @@ {/if} + + {#if connectivityManager.isDevMode} + + {/if} {/if} diff --git a/src/lib/data/api/aggie_spirit.ts b/src/lib/data/api/aggie_spirit.ts index 563f569..fc25308 100644 --- a/src/lib/data/api/aggie_spirit.ts +++ b/src/lib/data/api/aggie_spirit.ts @@ -45,6 +45,7 @@ export const useAuthCodeAPI = () => { const query = createLoggingQuery(() => ({ label: ASAPIQueryKey.AUTH_CODE, queryKey: [ASAPIQueryKey.AUTH_CODE], + meta: { network: true, auth: true }, queryFn: async () => { const authCodeB64 = await (await fetch('https://auth.maroonrides.app')).text(); return atob(authCodeB64); @@ -60,6 +61,7 @@ export const useAuthTokenAPI = () => { const query = createDependencyQuery(() => ({ queryKey: [ASAPIQueryKey.AUTH_TOKEN], + meta: { network: true }, queryFn: async () => { var res = await fetch('https://aggiespirit.ts.tamu.edu/', { credentials: 'omit' }); @@ -84,6 +86,7 @@ export const useRoutePlanAuthTokenAPI = (params: () => { queryString: string }) const { queryString } = params(); return { queryKey: [ASAPIQueryKey.ROUTE_PLAN_AUTH_TOKEN], + meta: { network: true }, queryFn: async () => { var res = await fetch(`https://aggiespirit.ts.tamu.edu/TripPlanner/${queryString}`, { credentials: 'omit', @@ -111,6 +114,7 @@ export const useBaseDataAPI = () => { const query = createDependencyQuery(() => ({ queryKey: [ASAPIQueryKey.BASE_DATA], + meta: { network: true }, queryFn: async () => { const baseData = await getBaseData(authTokenQuery.data!); GetBaseDataResponseSchema.parse(baseData); @@ -130,6 +134,7 @@ export const usePatternPathsAPI = () => { const query = createDependencyQuery(() => ({ queryKey: [ASAPIQueryKey.PATTERN_PATHS], + meta: { network: true }, queryFn: async () => { const baseData = baseDataQuery.data as IGetBaseDataResponse; @@ -174,6 +179,7 @@ export const useStopEstimateAPI = ( const { routeKey, directionKey, stopCode } = params(); return { queryKey: [ASAPIQueryKey.STOP_ESTIMATE, routeKey, directionKey, stopCode], + meta: { network: true }, queryFn: async () => { const response = await getNextDepartureTimes( routeKey, @@ -200,6 +206,7 @@ export const useTimetableEstimateAPI = (params: () => { stopCode: string; date: const { stopCode, date } = params(); return { queryKey: [ASAPIQueryKey.TIMETABLE_ESTIMATE, stopCode, moment(date).format('YYYY-MM-DD')], + meta: { network: true }, queryFn: async () => { const response = await getStopEstimates(stopCode, date, authTokenQuery.data!); GetStopEstimatesResponseSchema.parse(response); @@ -221,6 +228,7 @@ export const useStopScheduleAPI = (params: () => { stopCode: string; date: Date const { stopCode, date } = params(); return { queryKey: [ASAPIQueryKey.STOP_SCHEDULE, stopCode, moment(date).format('YYYY-MM-DD')], + meta: { network: true }, queryFn: async () => { const response = await getStopSchedules(stopCode, date, authTokenQuery.data!); GetStopSchedulesResponseSchema.parse(response); @@ -242,6 +250,7 @@ export const useVehiclesAPI = (params: () => { routeKey: string }) => { const { routeKey } = params(); return { queryKey: [ASAPIQueryKey.VEHICLES, routeKey], + meta: { network: true }, queryFn: async () => { let busesResponse = (await getVehicles( [routeKey], diff --git a/src/lib/data/api/route_planning.ts b/src/lib/data/api/route_planning.ts index 53b166e..4b82bba 100644 --- a/src/lib/data/api/route_planning.ts +++ b/src/lib/data/api/route_planning.ts @@ -24,6 +24,7 @@ export const useSearchSuggestionAPI = (params: () => { query: string }) => { const { query } = params(); return { queryKey: [ASRoutePlanQueryKey.SEARCH_SUGGESTION, query], + meta: { network: true }, 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 @@ -93,6 +94,7 @@ export const useTripPlanAPI = ( const { origin, destination, date, deadline } = params(); return { queryKey: [ASRoutePlanQueryKey.TRIP_PLAN, origin, destination, date, deadline], + meta: { network: true }, queryFn: async () => { let response = await getTripPlan( routePlanAuthToken.data!, diff --git a/src/lib/data/app.ts b/src/lib/data/app.ts index 52e07a8..99eaaf9 100644 --- a/src/lib/data/app.ts +++ b/src/lib/data/app.ts @@ -9,7 +9,9 @@ import { type StopSchedule, type TimeEstimate, } from '$lib/data/types'; +import { connectivityManager } from '$lib/managers/connectivity.manager.svelte'; import { queryLogger } from '$lib/utils/logger'; +import type { CreateQueryResult } from '@tanstack/svelte-query'; import moment from 'moment'; import { createDependencyQuery, createSelectableQuery } from '../utils/queries'; import { @@ -38,6 +40,8 @@ export const useRoutes = () => { const query = createDependencyQuery(() => ({ queryKey: [QueryKey.ROUTE_LIST], + meta: { isRoutes: true }, + placeholderData: connectivityManager.cachedRoutes, queryFn: async () => { queryLogger.i(`Loaded ${asRouteList.data?.length} routes from Aggie Spirit`); return asRouteList.data!; diff --git a/src/lib/data/structure/aggie_spirit.ts b/src/lib/data/structure/aggie_spirit.ts index 5d05273..74c34c5 100644 --- a/src/lib/data/structure/aggie_spirit.ts +++ b/src/lib/data/structure/aggie_spirit.ts @@ -179,7 +179,7 @@ export const useASStopEstimate = ( queryFn: async () => { const stopEstimates = apiStopEstimateQuery.data!; - return stopEstimates.routeDirectionTimes[0].nextDeparts.map((e) => { + return (stopEstimates.routeDirectionTimes[0]?.nextDeparts ?? []).map((e) => { return { dataSource: DataSource.AGGIE_SPIRIT, estimatedTime: e.estimatedDepartTimeUtc diff --git a/src/lib/managers/connectivity.manager.svelte.ts b/src/lib/managers/connectivity.manager.svelte.ts new file mode 100644 index 0000000..302e81a --- /dev/null +++ b/src/lib/managers/connectivity.manager.svelte.ts @@ -0,0 +1,226 @@ +import type { Route } from '$lib/data/types'; +import { Directory, Encoding, Filesystem, type WriteFileResult } from '@capacitor/filesystem'; +import type { Query, QueryClient } from '@tanstack/svelte-query'; + +type UnknownQuery = Query; + +// TODO inline if this doesnt expand to anything else +const CACHE_ROUTES = 'cache/routes.json'; +const CACHE_EXPIRY = 7 * (24 * 60 * 60) * 1e3; + +const RETRY_MIN = 5e3; +const RETRY_MAX = 300e3; + +export enum ConnectionStatus { + OFFLINE, + CONNECTING, + RECONNECTING, + ONLINE, +} +export namespace ConnectionStatus { + export function asMessage(s: ConnectionStatus): string { + switch (s) { + case ConnectionStatus.OFFLINE: + return 'Offline'; + case ConnectionStatus.CONNECTING: + return 'Connecting...'; + case ConnectionStatus.RECONNECTING: + return 'Reconnecting...'; + case ConnectionStatus.ONLINE: + return 'Online'; + default: + return ''; + } + } +} + +type CacheContent = { + expiresAtUTC: number; + content: any; +}; +class ConnectivityManager { + // for debug + isDevMode = $state(false); + + private client = $state(undefined); + + private retryAfter = RETRY_MIN; + private mayRetryAt = $state(0); + private isRetrying = $state(false); + + cachedRoutes = $state([]); + cacheWasStale = $state(undefined); //flicks true when ids change, requires connection obviously + cacheIsSynced = $state(false); + + apiError = $state(undefined); // tamu error + authError = $state(undefined); // auth.maroonrides.app + isError = $derived(this.apiError || this.authError); + + //whether or not to pick CONNECTING/RECONNECTING + private hasFullyConnectedBefore = $state(false); + private isFullyConnected = $derived(this.apiError === false && this.authError === false); + + constructor() { + this.tryUncache(); + } + + // for testing dont expose to user durrr + async scrambleCacheUUIDS(): Promise<{ status: boolean; message: string }> { + if (!this.isDevMode) return { message: 'Dev mode is not enabled.', status: false }; + const numRoutes = this.cachedRoutes.length; + if (!numRoutes) return { message: 'There is no cached copy to modify yet.', status: false }; + const mod = this.cachedRoutes.map((r) => { + return { + ...r, + id: crypto.randomUUID(), + }; + }); + const result = await this.tryCache(mod, true); + if (!result) return { message: 'Failed Route[] comparison', status: false }; + return { + message: `Regenerated ${numRoutes} cached UUIDS. The app will now exit.`, + status: true, + }; + } + shareClient(q: QueryClient) { + this.client = q; + } + private backoff() { + this.retryAfter = Math.min(RETRY_MAX, this.retryAfter * 2); + } + private resetBackoff() { + this.retryAfter = RETRY_MIN; + } + + // if you ever use parallel js (hahaha) use CAS + private tryEnqueueReconnect(failureTime: number) { + if (!this.client || failureTime <= this.mayRetryAt) return; + if (this.isRetrying) return; //arbitrary time for refetch to finish + // + const delay = this.retryAfter; + this.mayRetryAt = failureTime + delay; + this.backoff(); + // + setTimeout(async () => { + if (!this.client) return; + this.isRetrying = true; + try { + await this.client.refetchQueries({ + predicate: (q) => q.state.status === 'error' && !!q.meta?.network, + }); + } finally { + this.isRetrying = false; + if (this.isError) this.tryEnqueueReconnect(Date.now()); + } + }, delay); + } + reportError(error: Error, query: UnknownQuery) { + const tags = query.meta; + if (this.isRetrying || !(tags && tags.network)) return; //filter tagged reqs + // + if (tags.auth) this.authError = true; + else this.apiError = true; + // + this.tryEnqueueReconnect(Date.now()); + } + reportSuccess(data: unknown, query: UnknownQuery) { + const tags = query.meta; + if (!tags) return; + if (tags.isRoutes) this.tryCache(data as Route[]); + if (!tags.network) return; //filter tagged reqs + // + if (tags.auth) this.authError = false; + else this.apiError = false; + // + if (!this.isFullyConnected) return; + this.resetBackoff(); + this.hasFullyConnectedBefore = true; + } + + //arrow function so svelte can use reactively + getConnectionStatus = () => { + if (this.isError && this.isRetrying) { + return this.hasFullyConnectedBefore + ? ConnectionStatus.RECONNECTING + : ConnectionStatus.CONNECTING; + } + // only give connecting message when cache is loaded + if (this.authError === undefined) return ConnectionStatus.CONNECTING; + if (this.authError === true) return ConnectionStatus.OFFLINE; + + if (this.apiError === undefined) return ConnectionStatus.CONNECTING; + if (this.apiError === true) return ConnectionStatus.OFFLINE; + return ConnectionStatus.ONLINE; + }; + // only true when cache is synced and route id exists + validate(route: Route): boolean { + return this.cacheIsSynced && this.cachedRoutes.some((r) => r.id === route.id); + } + + private checkForUUIDDesync(cache: Route[], cmp: Route[]): boolean { + if (cache.length !== cmp.length) return true; + + const ids = new Map(cache.map((r) => [r.routeCode, r.id])); + return cmp.some((r) => ids.has(r.routeCode) && ids.get(r.routeCode) !== r.id); + } + + private newCacheExpiry(data: any): CacheContent { + return { expiresAtUTC: Date.now() + CACHE_EXPIRY, content: data }; + } + + private checkCacheExpiry(data: any): data is CacheContent { + try { + return Date.now() < data.expiresAtUTC; + } catch {} + return false; + } + private tryCache( + data: Route[], + noUpdateMemory: boolean = false, + ): Promise | null { + if (this.cachedRoutes === data) return null; + if (!noUpdateMemory) { + const wasStale = this.checkForUUIDDesync(this.cachedRoutes, data); + this.cachedRoutes = data; + this.cacheWasStale = wasStale; + this.cacheIsSynced = true; + } + return Filesystem.writeFile({ + directory: Directory.Data, + path: CACHE_ROUTES, + data: JSON.stringify(this.newCacheExpiry(data)), + encoding: Encoding.UTF8, + recursive: true, // make parents + }); + } + private async tryUncache() { + try { + const { data: raw } = await Filesystem.readFile({ + directory: Directory.Data, + path: CACHE_ROUTES, + encoding: Encoding.UTF8, + }); + // no validation bc the cache will just overwrite + // 5 seconds after the app starts if the user messes + // w their own data + const data = JSON.parse(raw as string); + if (!this.checkCacheExpiry(data)) return; + + this.cachedRoutes = data.content as Route[]; + } catch {} + } +} + +// Persist connectivityManager across Svelte HMRs +let connectivityManager: ConnectivityManager; + +if (import.meta.hot && import.meta.hot.data) { + if (!import.meta.hot.data.connectivityManager) { + import.meta.hot.data.connectivityManager = new ConnectivityManager(); + } + connectivityManager = import.meta.hot.data.connectivityManager; +} else { + connectivityManager = new ConnectivityManager(); +} + +export { connectivityManager }; diff --git a/src/lib/managers/map.manager.svelte.ts b/src/lib/managers/map.manager.svelte.ts index 1a3543d..e3a2c90 100644 --- a/src/lib/managers/map.manager.svelte.ts +++ b/src/lib/managers/map.manager.svelte.ts @@ -37,7 +37,6 @@ class MapManager { setDrawnRoutes(routes: Route[], animateTo: boolean = true) { this.drawnRoutes = routes; - if (animateTo) { const allPoints = routes.flatMap((route) => route.directions.flatMap((direction) => diff --git a/src/lib/utils/queries.ts b/src/lib/utils/queries.ts index 19d116e..26767d3 100644 --- a/src/lib/utils/queries.ts +++ b/src/lib/utils/queries.ts @@ -6,8 +6,10 @@ interface Params { queryFn: () => Promise; queryKey: any[]; staleTime?: moment.Duration | number; + placeholderData?: any; refetchInterval?: moment.Duration | number; enabled?: boolean; + meta?: Record; } interface DependencyQueryParams extends Params { @@ -27,9 +29,10 @@ export function createDependencyQuery(params: () => DependencyQueryParams) 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)], + meta: p.meta, + placeholderData: p.placeholderData, queryFn: async () => { try { const start = moment.now(); @@ -95,6 +98,7 @@ export function createLoggingQuery(params: () => LoggingQueryParams) { return { queryKey: p.queryKey, + meta: p.meta, queryFn: async () => { try { const start = moment.now(); @@ -125,6 +129,7 @@ interface SelectableQueryParams { staleTime?: moment.Duration | number; refetchInterval?: moment.Duration | number; enabled?: boolean; + placeholderData?: any; queries: Partial>>; unsupportedValue?: T; selector?: S; @@ -152,6 +157,7 @@ export function createSelectableQuery( throw e; } }, + placeholderData: p.placeholderData, enabled: (p.enabled ?? true) && (selectedQuery?.isSuccess ?? false), staleTime: parseTime(p.staleTime), refetchInterval: parseTime(p.refetchInterval), diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 672a071..31f5854 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,19 +8,30 @@ import { mapManager } from '$lib/managers/map.manager.svelte'; import { installInterceptor } from '$lib/utils/interceptor'; import { migratePrefs } from '$lib/utils/prefs'; - import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; + import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; import { onDestroy, onMount } from 'svelte'; import './layout.css'; + import { connectivityManager } from '$lib/managers/connectivity.manager.svelte'; + import ConnectionPill from '$lib/components/ConnectionPill.svelte'; let { children } = $props(); - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { networkMode: 'offlineFirst' } }, + queryCache: new QueryCache({ + onError: (error, query) => connectivityManager.reportError(error, query), + onSuccess: (data, query) => connectivityManager.reportSuccess(data, query), + }), + }); onDestroy(() => { mapManager.unregisterMap(); }); onMount(async () => { + connectivityManager.shareClient(queryClient); + connectivityManager.isDevMode = true; //enables scrambling button + migratePrefs(); }); @@ -33,6 +44,7 @@ +
frontPageManager.favorites.includes(route.routeCode)) ?? [], + routesData.filter((route) => frontPageManager.favorites.includes(route.routeCode)), ); $effect(() => { - mapManager.setDrawnRoutes( - frontPageManager.selectedTab == 'all' ? (routes.data ?? []) : favRoutes, - ); + mapManager.setDrawnRoutes(frontPageManager.selectedTab == 'all' ? routesData : favRoutes); }); onMount(async () => frontPageManager.loadFavorites()); @@ -67,14 +67,14 @@ {#if frontPageManager.selectedTab === 'all'} - {#if routes.isLoading} + {#if routes.isLoading && !routesData.length} - {:else if routes.isError} -

Error loading routes: {routes.error.message}

+ {/if} - {#each routes.data ?? [] as route} - goto(`/route/${route.id}`)} /> + {#each routesData as route} + goto(`/route/${route.routeCode}`)} /> {/each}
{:else if frontPageManager.selectedTab === 'favorites'} @@ -88,7 +88,7 @@ {/if} {#each favRoutes as route} - goto(`/route/${route.id}`)} /> + goto(`/route/${route.routeCode}`)} /> {/each} {/if} diff --git a/src/routes/route/[id]/+page.svelte b/src/routes/route/[routeCode]/+page.svelte similarity index 86% rename from src/routes/route/[id]/+page.svelte rename to src/routes/route/[routeCode]/+page.svelte index 9f21025..f3f3e8e 100644 --- a/src/routes/route/[id]/+page.svelte +++ b/src/routes/route/[routeCode]/+page.svelte @@ -1,7 +1,7 @@ + +
+
+
+ + +

{subtitle}

+
+
+
+ +
+ {#if isLoading}{/if} +
+ +
+
diff --git a/src/routes/route/[id]/StopRow.svelte b/src/routes/route/[routeCode]/StopRow.svelte similarity index 93% rename from src/routes/route/[id]/StopRow.svelte rename to src/routes/route/[routeCode]/StopRow.svelte index 8e86294..db37ec1 100644 --- a/src/routes/route/[id]/StopRow.svelte +++ b/src/routes/route/[routeCode]/StopRow.svelte @@ -29,7 +29,7 @@ })), ); - const { data: stopAmenities } = useStopAmenities(() => ({ route, direction, stop })); + const { data: stopAmenities } = $derived(useStopAmenities(() => ({ route, direction, stop }))); let subtitle = $derived.by(() => { if (!estimates || estimates.length === 0) { @@ -84,7 +84,7 @@ size="sm" class="rounded-full" onclick={() => { - goto(`/route/${route?.id}/timetable/${stop.id}/${direction?.id}`); + goto(`/route/${route?.routeCode}/timetable/${stop.id}/${direction?.id}`); }} > diff --git a/src/routes/route/[id]/alerts/+page.svelte b/src/routes/route/[routeCode]/alerts/+page.svelte similarity index 93% rename from src/routes/route/[id]/alerts/+page.svelte rename to src/routes/route/[routeCode]/alerts/+page.svelte index 694960e..bbd2c10 100644 --- a/src/routes/route/[id]/alerts/+page.svelte +++ b/src/routes/route/[routeCode]/alerts/+page.svelte @@ -12,11 +12,11 @@ let { data }: { data: PageData } = $props(); const routes = useRoutes(); - const route = $derived(routes.data?.find((r) => r.id === data.routeId) ?? null); + const route = $derived(routes.data?.find((r) => r.routeCode === data.routeCode) ?? null); const alerts = useAlerts(() => ({ route })); function onClose() { - goto(`/route/${route?.id}`); + goto(`/route/${route?.routeCode}`); } const descriptionClasses = diff --git a/src/routes/route/[id]/alerts/+page.ts b/src/routes/route/[routeCode]/alerts/+page.ts similarity index 77% rename from src/routes/route/[id]/alerts/+page.ts rename to src/routes/route/[routeCode]/alerts/+page.ts index 4c13d23..e3b6c90 100644 --- a/src/routes/route/[id]/alerts/+page.ts +++ b/src/routes/route/[routeCode]/alerts/+page.ts @@ -2,6 +2,6 @@ import type { PageLoad } from './$types'; export const load: PageLoad = async ({ params }) => { return { - routeId: params.id, + routeCode: params.routeCode, }; }; diff --git a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte b/src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.svelte similarity index 88% rename from src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte rename to src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.svelte index 25580f4..a6a2d4f 100644 --- a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.svelte +++ b/src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.svelte @@ -21,14 +21,14 @@ const routes = useRoutes(); - const route = $derived(routes.data?.find((r) => r.id === data.routeId) ?? null); + const route = $derived(routes.data?.find((r) => r.routeCode === data.routeCode) ?? 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 })); function onClose() { - goto(`/route/${route?.id}`); + goto(`/route/${route?.routeCode}`); } @@ -56,12 +56,12 @@ {/if} {#each timetable.data as table} - {#if table.route.id == route?.id && table.direction.id == direction?.id} + {#if table.route.routeCode == route?.routeCode && table.direction.id == direction?.id}
goto(`/route/${table.route.id}`)} + onclick={() => goto(`/route/${table.route.routeCode}`)} /> {#if table.timetable.length === 0}

No timetable for selected day

diff --git a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.ts b/src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.ts similarity index 84% rename from src/routes/route/[id]/timetable/[stop]/[direction]/+page.ts rename to src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.ts index 5badee7..2cbdd07 100644 --- a/src/routes/route/[id]/timetable/[stop]/[direction]/+page.ts +++ b/src/routes/route/[routeCode]/timetable/[stop]/[direction]/+page.ts @@ -2,7 +2,7 @@ import type { PageLoad } from './$types'; export const load: PageLoad = async ({ params }) => { return { - routeId: params.id, + routeCode: params.routeCode, stopId: params.stop, directionId: params.direction, };