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 @@
+