From a83b369cb73e90ae5eab6d938b808d247620d4c0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 22:43:10 +0200 Subject: [PATCH] fix: stale-while-revalidate for offline reads + axios timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigator.onLine is unreliable on Android — returns true whenever any network interface is up, regardless of actual reachability. This caused all repo reads to take the API branch and either wait 5 s for the SW NetworkFirst timeout (cache hit) or hang indefinitely (cache miss). - All read repos (list/get) now return cached IndexedDB data instantly and carry a background refresh promise that resolves to fresh data or null on failure. Callers that opted in (loadTrip, loadTrips) apply fresh data silently when it arrives. - tripStore.loadTrip: Promise.all now reads all 7 resources from IndexedDB (instant), fires network refreshes in background, sets isLoading: false immediately, then applies fresh data via a second Promise.all when ready. Tags/categories use upsertTags/upsertCategories. - DashboardPage.loadTrips: same pattern — renders from cache instantly, silently updates trip list on refresh. - axios timeout set to 8 s so requests can never hang indefinitely. - SW networkTimeoutSeconds lowered from 5 to 2 as defence in depth. --- client/src/api/client.ts | 1 + client/src/pages/DashboardPage.tsx | 9 +++- client/src/repo/accommodationRepo.ts | 28 ++++++++---- client/src/repo/budgetRepo.ts | 32 +++++++++----- client/src/repo/dayRepo.ts | 32 +++++++++----- client/src/repo/fileRepo.ts | 32 +++++++++----- client/src/repo/packingRepo.ts | 32 +++++++++----- client/src/repo/placeRepo.ts | 32 +++++++++----- client/src/repo/reservationRepo.ts | 32 +++++++++----- client/src/repo/todoRepo.ts | 32 +++++++++----- client/src/repo/tripRepo.ts | 63 ++++++++++++++++++-------- client/src/store/tripStore.ts | 66 +++++++++++++++++++++------- client/src/sw.ts | 2 +- 13 files changed, 270 insertions(+), 123 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e39c6a47..c8801411 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -33,6 +33,7 @@ function translateRateLimit(): string { export const apiClient: AxiosInstance = axios.create({ baseURL: '/api', withCredentials: true, + timeout: 8000, headers: { 'Content-Type': 'application/json', }, diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index df440723..dfffdcc0 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -744,12 +744,17 @@ export default function DashboardPage(): React.ReactElement { const loadTrips = async () => { setIsLoading(true) try { - const { trips, archivedTrips } = await tripRepo.list() + const { trips, archivedTrips, refresh } = await tripRepo.list() setTrips(sortTrips(trips)) setArchivedTrips(sortTrips(archivedTrips)) + setIsLoading(false) + refresh.then(fresh => { + if (!fresh) return + setTrips(sortTrips(fresh.trips)) + setArchivedTrips(sortTrips(fresh.archivedTrips)) + }).catch(() => {}) } catch { toast.error(t('dashboard.toast.loadError')) - } finally { setIsLoading(false) } } diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 06cd2890..207a824d 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -4,15 +4,25 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Accommodation } from '../types' export const accommodationRepo = { - async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> { - if (!navigator.onLine) { - const accommodations = await offlineDb.accommodations - .where('trip_id').equals(Number(tripId)).toArray() - return { accommodations } - } - const result = await accommodationsApi.list(tripId) - upsertAccommodations(result.accommodations || []).catch(() => {}) - return result + async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> { + const cached = await offlineDb.accommodations + .where('trip_id').equals(Number(tripId)).toArray() + + const refresh = (async () => { + try { + const result = await accommodationsApi.list(tripId) + upsertAccommodations(result.accommodations || []).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { accommodations: cached, refresh } + + const fresh = await refresh + if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) } + return { accommodations: fresh.accommodations, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ accommodation: Accommodation }> { diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index c26c97b8..eef1bfe2 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { BudgetItem } from '../types' export const budgetRepo = { - async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.budgetItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await budgetApi.list(tripId) - upsertBudgetItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> { + const cached = await offlineDb.budgetItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await budgetApi.list(tripId) + upsertBudgetItems(result.items) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ item: BudgetItem }> { diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index 65ccbbf5..9725ab07 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Day } from '../types' export const dayRepo = { - async list(tripId: number | string): Promise<{ days: Day[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.days - .where('trip_id') - .equals(Number(tripId)) - .sortBy('day_number' as keyof Day) - return { days: cached as Day[] } - } - const result = await daysApi.list(tripId) - upsertDays(result.days) - return result + async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> { + const cached = (await offlineDb.days + .where('trip_id') + .equals(Number(tripId)) + .sortBy('day_number' as keyof Day)) as Day[] + + const refresh = (async () => { + try { + const result = await daysApi.list(tripId) + upsertDays(result.days) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { days: cached, refresh } + + const fresh = await refresh + if (!fresh) return { days: [], refresh: Promise.resolve(null) } + return { days: fresh.days, refresh: Promise.resolve(fresh) } }, async update(tripId: number | string, dayId: number | string, data: Record): Promise<{ day: Day }> { diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 95448010..92502ce0 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { TripFile } from '../types' export const fileRepo = { - async list(tripId: number | string): Promise<{ files: TripFile[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.tripFiles - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { files: cached } - } - const result = await filesApi.list(tripId) - upsertTripFiles(result.files) - return result + async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> { + const cached = await offlineDb.tripFiles + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await filesApi.list(tripId) + upsertTripFiles(result.files) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { files: cached, refresh } + + const fresh = await refresh + if (!fresh) return { files: [], refresh: Promise.resolve(null) } + return { files: fresh.files, refresh: Promise.resolve(fresh) } }, async update(tripId: number | string, id: number, data: Record): Promise { diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 30859fc6..5343209b 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { PackingItem } from '../types' export const packingRepo = { - async list(tripId: number | string): Promise<{ items: PackingItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.packingItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await packingApi.list(tripId) - upsertPackingItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> { + const cached = await offlineDb.packingItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await packingApi.list(tripId) + upsertPackingItems(result.items) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ item: PackingItem }> { diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 36b1acc2..df486cd1 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Place } from '../types' export const placeRepo = { - async list(tripId: number | string, params?: Record): Promise<{ places: Place[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.places - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { places: cached } - } - const result = await placesApi.list(tripId, params) - upsertPlaces(result.places) - return result + async list(tripId: number | string, params?: Record): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> { + const cached = await offlineDb.places + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await placesApi.list(tripId, params) + upsertPlaces(result.places) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { places: cached, refresh } + + const fresh = await refresh + if (!fresh) return { places: [], refresh: Promise.resolve(null) } + return { places: fresh.places, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ place: Place }> { diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index e9598314..ee07f285 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Reservation } from '../types' export const reservationRepo = { - async list(tripId: number | string): Promise<{ reservations: Reservation[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.reservations - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { reservations: cached } - } - const result = await reservationsApi.list(tripId) - upsertReservations(result.reservations) - return result + async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> { + const cached = await offlineDb.reservations + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await reservationsApi.list(tripId) + upsertReservations(result.reservations) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { reservations: cached, refresh } + + const fresh = await refresh + if (!fresh) return { reservations: [], refresh: Promise.resolve(null) } + return { reservations: fresh.reservations, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ reservation: Reservation }> { diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index c93ba17e..d1748b98 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -4,17 +4,27 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { TodoItem } from '../types' export const todoRepo = { - async list(tripId: number | string): Promise<{ items: TodoItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.todoItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await todoApi.list(tripId) - upsertTodoItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> { + const cached = await offlineDb.todoItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await todoApi.list(tripId) + upsertTodoItems(result.items) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(fresh) } }, async create(tripId: number | string, data: Record): Promise<{ item: TodoItem }> { diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index cc98d665..652bcaf6 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -3,33 +3,58 @@ import { offlineDb, upsertTrip } from '../db/offlineDb' import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Trip } from '../types' +type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null> +type TripRefresh = Promise<{ trip: Trip } | null> + export const tripRepo = { - async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> { - if (!navigator.onLine) { - const all = await offlineDb.trips.toArray() + async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> { + const all = await offlineDb.trips.toArray() + + const refresh: TripsRefresh = (async () => { + try { + const [active, archived] = await Promise.all([ + tripsApi.list(), + tripsApi.list({ archived: 1 }), + ]) + active.trips.forEach(t => upsertTrip(t)) + archived.trips.forEach(t => upsertTrip(t)) + return { trips: active.trips, archivedTrips: archived.trips } + } catch { + return null + } + })() + + if (all.length > 0) { return { trips: all.filter(t => !t.is_archived), archivedTrips: all.filter(t => t.is_archived), + refresh, } } - const [active, archived] = await Promise.all([ - tripsApi.list(), - tripsApi.list({ archived: 1 }), - ]) - active.trips.forEach(t => upsertTrip(t)) - archived.trips.forEach(t => upsertTrip(t)) - return { trips: active.trips, archivedTrips: archived.trips } + + const fresh = await refresh + if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) } + return { ...fresh, refresh: Promise.resolve(fresh) } }, - async get(tripId: number | string): Promise<{ trip: Trip }> { - if (!navigator.onLine) { - const cached = await offlineDb.trips.get(Number(tripId)) - if (cached) return { trip: cached } - throw new Error('No cached trip data available offline') - } - const result = await tripsApi.get(tripId) - upsertTrip(result.trip) - return result + async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> { + const cached = await offlineDb.trips.get(Number(tripId)) + + const refresh: TripRefresh = (async () => { + try { + const result = await tripsApi.get(tripId) + upsertTrip(result.trip) + return result + } catch { + return null + } + })() + + if (cached) return { trip: cached, refresh } + + const fresh = await refresh + if (!fresh) throw new Error('No cached trip data available offline') + return { trip: fresh.trip, refresh: Promise.resolve(fresh) } }, async update(tripId: number | string, data: Partial): Promise<{ trip: Trip }> { diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 1c9cb6f8..518f6b3d 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' import { tagsApi, categoriesApi } from '../api/client' -import { offlineDb } from '../db/offlineDb' +import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb' import { tripRepo } from '../repo/tripRepo' import { dayRepo } from '../repo/dayRepo' import { placeRepo } from '../repo/placeRepo' @@ -89,27 +89,37 @@ export const useTripStore = create((set, get) => ({ loadTrip: async (tripId: number | string) => { set({ isLoading: true, error: null }) try { - const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([ + // All reads from IndexedDB — instant, no network wait + const [tripData, daysData, placesData, packingData, todoData, cachedTags, cachedCategories] = await Promise.all([ tripRepo.get(tripId), dayRepo.list(tripId), placeRepo.list(tripId), packingRepo.list(tripId), todoRepo.list(tripId), - navigator.onLine - ? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags }))) - : offlineDb.tags.toArray().then(tags => ({ tags })), - navigator.onLine - ? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories }))) - : offlineDb.categories.toArray().then(categories => ({ categories })), + offlineDb.tags.toArray(), + offlineDb.categories.toArray(), ]) - const assignmentsMap: AssignmentsMap = {} - const dayNotesMap: DayNotesMap = {} - for (const day of daysData.days) { - assignmentsMap[String(day.id)] = day.assignments || [] - dayNotesMap[String(day.id)] = day.notes_items || [] + // Tags/categories background refresh (network-only, applied when ready) + const tagsRefresh = tagsApi.list() + .then(fresh => { upsertTags(fresh.tags).catch(() => {}); return fresh }) + .catch(() => null) + const categoriesRefresh = categoriesApi.list() + .then(fresh => { upsertCategories(fresh.categories).catch(() => {}); return fresh }) + .catch(() => null) + + const buildMaps = (days: Day[]) => { + const assignmentsMap: AssignmentsMap = {} + const dayNotesMap: DayNotesMap = {} + for (const day of days) { + assignmentsMap[String(day.id)] = day.assignments || [] + dayNotesMap[String(day.id)] = day.notes_items || [] + } + return { assignmentsMap, dayNotesMap } } + const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days) + set({ trip: tripData.trip, days: daysData.days, @@ -118,10 +128,36 @@ export const useTripStore = create((set, get) => ({ dayNotes: dayNotesMap, packingItems: packingData.items, todoItems: todoData.items, - tags: tagsData.tags, - categories: categoriesData.categories, + tags: cachedTags, + categories: cachedCategories, isLoading: false, }) + + // Apply background refreshes — update state when fresh data arrives + Promise.all([ + tripData.refresh, + daysData.refresh, + placesData.refresh, + packingData.refresh, + todoData.refresh, + tagsRefresh, + categoriesRefresh, + ]).then(([freshTrip, freshDays, freshPlaces, freshPacking, freshTodo, freshTags, freshCategories]) => { + const updates: Partial = {} + if (freshTrip) updates.trip = freshTrip.trip + if (freshDays) { + const { assignmentsMap: am, dayNotesMap: dm } = buildMaps(freshDays.days) + updates.days = freshDays.days + updates.assignments = am + updates.dayNotes = dm + } + if (freshPlaces) updates.places = freshPlaces.places + if (freshPacking) updates.packingItems = freshPacking.items + if (freshTodo) updates.todoItems = freshTodo.items + if (freshTags) updates.tags = freshTags.tags + if (freshCategories) updates.categories = freshCategories.categories + if (Object.keys(updates).length > 0) set(updates) + }).catch(() => {}) } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Unknown error' set({ isLoading: false, error: message }) diff --git a/client/src/sw.ts b/client/src/sw.ts index ae8d742f..d1ea15a4 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -100,7 +100,7 @@ const authRedirectPlugin = { function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst { return new NetworkFirst({ cacheName: 'api-data', - networkTimeoutSeconds: 5, + networkTimeoutSeconds: 2, plugins: [ authRedirectPlugin, new ExpirationPlugin({