From 443ae7cb1915f6a27b5bc187e5c4ceda3ad8fffe Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 20:05:10 +0200 Subject: [PATCH] fix: prevent splash-forever on slow first-load after clearing storage Three changes: - tripSyncManager: add interrupt() so trip page load can stop competing background bundle sync requests; also try clearing blobCache before falling back to full clearAll() on QuotaExceededError - TripPlannerPage: call tripSyncManager.interrupt() when mounting so loadTrip gets network priority over background syncAll - TripPlannerPage: show a 'go back to dashboard' link after 12 seconds on the splash screen so users are never stuck with no escape --- client/src/db/offlineDb.ts | 5 ++++ client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/id.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + client/src/pages/TripPlannerPage.tsx | 21 +++++++++++++++++ client/src/sync/tripSyncManager.ts | 34 ++++++++++++++++++++++++---- 18 files changed, 70 insertions(+), 5 deletions(-) diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 224794c6..0726eb27 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -185,6 +185,11 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.trips.delete(tripId); } +/** Clear cached file blobs only — frees significant quota without losing trip data. */ +export async function clearBlobCache(): Promise { + await offlineDb.blobCache.clear(); +} + /** Wipe the entire offline database (called on logout). */ export async function clearAll(): Promise { await offlineDb.delete(); diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 99295939..3ec5ac24 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -925,6 +925,7 @@ const ar: Record = { 'trip.tabs.budget': 'الميزانية', 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...', 'trip.mobilePlan': 'الخطة', 'trip.mobilePlaces': 'الأماكن', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index e23ca5a1..445aff81 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -909,6 +909,7 @@ const br: Record = { 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', 'trip.confirm.deletePlaces': 'Excluir {count} lugares?', 'trip.toast.placesDeleted': '{count} lugares excluídos', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Carregando fotos dos lugares...', // Day Plan Sidebar diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index cc951bce..0646f906 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -923,6 +923,7 @@ const cs: Record = { 'trip.tabs.budget': 'Rozpočet', 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Načítání fotek míst...', 'trip.mobilePlan': 'Plán', 'trip.mobilePlaces': 'Místa', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1aab8743..90075129 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -928,6 +928,7 @@ const de: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Dateien', 'trip.loading': 'Reise wird geladen...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Fotos der Orte werden geladen...', 'trip.mobilePlan': 'Planung', 'trip.mobilePlaces': 'Orte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 145c6c5b..05f2ed93 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1000,6 +1000,7 @@ const en: Record = { 'trip.tabs.files': 'Files', 'trip.loading': 'Loading trip...', 'trip.loadingPhotos': 'Loading place photos...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Places', 'trip.toast.placeUpdated': 'Place updated', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a4562409..7aca6599 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -898,6 +898,7 @@ const es: Record = { 'trip.tabs.budget': 'Presupuesto', 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Cargando fotos de los lugares...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lugares', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index ef26c1df..5294a8d4 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -922,6 +922,7 @@ const fr: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Chargement des photos des lieux...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lieux', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index a07397d5..4a0463c2 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -937,6 +937,7 @@ const hu: Record = { 'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?', 'trip.confirm.deletePlaces': '{count} helyet töröl?', 'trip.toast.placesDeleted': '{count} hely törölve', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Helyek fotóinak betöltése...', // Napi terv oldalsáv diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index f9e51bb3..e8c8743e 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -983,6 +983,7 @@ const id: Record = { 'trip.tabs.budget': 'Anggaran', 'trip.tabs.files': 'File', 'trip.loading': 'Memuat perjalanan...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Memuat foto tempat...', 'trip.mobilePlan': 'Rencana', 'trip.mobilePlaces': 'Tempat', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 44bdcfe8..97db52ef 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -937,6 +937,7 @@ const it: Record = { 'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?', 'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?', 'trip.toast.placesDeleted': '{count} luoghi eliminati', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Caricamento foto dei luoghi...', // Day Plan Sidebar diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index c16c68cb..2cef9019 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -922,6 +922,7 @@ const nl: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Plaatsfoto laden...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Plaatsen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 005541ae..f017f6b3 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1764,6 +1764,7 @@ const pl: Record = { 'login.setNewPassword': 'Ustaw nowe hasło', 'login.setNewPasswordHint': 'Musisz zmienić hasło.', 'atlas.searchCountry': 'Szukaj kraju...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Ładowanie zdjęć...', 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 0f82d22a..c7157095 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -922,6 +922,7 @@ const ru: Record = { 'trip.tabs.budget': 'Бюджет', 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Загрузка фото мест...', 'trip.mobilePlan': 'План', 'trip.mobilePlaces': 'Места', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 72e9e9d2..069ce565 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -922,6 +922,7 @@ const zh: Record = { 'trip.tabs.budget': '预算', 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': '正在加载地点照片...', 'trip.mobilePlan': '计划', 'trip.mobilePlaces': '地点', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 276a76f6..7b96af65 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -982,6 +982,7 @@ const zhTw: Record = { 'trip.tabs.budget': '預算', 'trip.tabs.files': '檔案', 'trip.loading': '載入旅行中...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': '正在載入地點照片...', 'trip.mobilePlan': '計劃', 'trip.mobilePlaces': '地點', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index bb9a1eab..adba3ad7 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -31,6 +31,7 @@ import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { offlineDb } from '../db/offlineDb' +import { tripSyncManager } from '../sync/tripSyncManager' import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' @@ -328,6 +329,8 @@ export default function TripPlannerPage(): React.ReactElement | null { // Load trip + files (needed for place inspector file section) useEffect(() => { if (tripId) { + // Stop background sync so its bundle requests don't compete with loadTrip + tripSyncManager.interrupt() tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripActions.loadFiles(tripId) loadAccommodations() @@ -726,12 +729,18 @@ export default function TripPlannerPage(): React.ReactElement | null { // Splash screen — show for initial load + a brief moment for photos to start loading const [splashDone, setSplashDone] = useState(false) + const [slowLoad, setSlowLoad] = useState(false) useEffect(() => { if (!isLoading && trip) { const timer = setTimeout(() => setSplashDone(true), 1500) return () => clearTimeout(timer) } }, [isLoading, trip]) + // Show escape hatch after 12 seconds on splash (covers slow first-load scenarios) + useEffect(() => { + const timer = setTimeout(() => setSlowLoad(true), 12000) + return () => clearTimeout(timer) + }, []) if (isLoading || !splashDone) { return ( @@ -771,6 +780,18 @@ export default function TripPlannerPage(): React.ReactElement | null { }} /> ))} + {slowLoad && ( + + )} ) } diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 865e9803..e773396b 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -27,6 +27,7 @@ import { upsertCategories, upsertSyncMeta, clearTripData, + clearBlobCache, clearAll, } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' @@ -135,6 +136,7 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { // ── Public API ──────────────────────────────────────────────────────────────── let _syncing = false +let _interrupted = false export const tripSyncManager = { /** @@ -145,6 +147,7 @@ export const tripSyncManager = { async syncAll(): Promise { if (_syncing || !navigator.onLine) return _syncing = true + _interrupted = false try { const { trips } = await tripsApi.list() as { trips: Trip[] } @@ -152,9 +155,10 @@ export const tripSyncManager = { const stale = trips.filter(isStale) await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) - // Sync eligible trips + // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) const toSync = trips.filter(shouldCache) for (const trip of toSync) { + if (_interrupted) break try { await syncTrip(trip.id) } catch (err) { @@ -165,11 +169,19 @@ export const tripSyncManager = { await syncTrip(trip.id) } catch (retryErr) { if (isQuotaError(retryErr)) { - console.warn('[tripSync] quota still exceeded after eviction — clearing all IDB data') - await clearAll() - return + // Trip data + blob cache — free largest storage first before nuking everything + console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') + await clearBlobCache() + try { + await syncTrip(trip.id) + } catch { + console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') + await clearAll() + return + } + } else { + console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) } - console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) } } else { console.error(`[tripSync] failed for trip ${trip.id}:`, err) @@ -177,6 +189,8 @@ export const tripSyncManager = { } } + if (_interrupted) return + // Cache global user data (tags + categories) — fire-and-forget tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) @@ -184,6 +198,7 @@ export const tripSyncManager = { // Cache file blobs + map tiles in background (don't block syncAll) const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined for (const trip of toSync) { + if (_interrupted) break const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() cacheFilesForTrip(files).catch(console.error) @@ -195,8 +210,17 @@ export const tripSyncManager = { } }, + /** + * Signal syncAll to stop after the current in-flight bundle request. + * Call when the user navigates to a trip page so loadTrip gets priority. + */ + interrupt(): void { + _interrupted = true + }, + /** Reset syncing flag — useful in tests. */ _resetSyncing(): void { _syncing = false + _interrupted = false }, }