mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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
This commit is contained in:
@@ -185,6 +185,11 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.trips.delete(tripId);
|
||||
}
|
||||
|
||||
/** Clear cached file blobs only — frees significant quota without losing trip data. */
|
||||
export async function clearBlobCache(): Promise<void> {
|
||||
await offlineDb.blobCache.clear();
|
||||
}
|
||||
|
||||
/** Wipe the entire offline database (called on logout). */
|
||||
export async function clearAll(): Promise<void> {
|
||||
await offlineDb.delete();
|
||||
|
||||
@@ -925,6 +925,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.budget': 'الميزانية',
|
||||
'trip.tabs.files': 'الملفات',
|
||||
'trip.loading': 'جارٍ تحميل الرحلة...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
|
||||
'trip.mobilePlan': 'الخطة',
|
||||
'trip.mobilePlaces': 'الأماكن',
|
||||
|
||||
@@ -909,6 +909,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -923,6 +923,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -928,6 +928,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1000,6 +1000,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -898,6 +898,7 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -922,6 +922,7 @@ const fr: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -937,6 +937,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -983,6 +983,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -937,6 +937,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -922,6 +922,7 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1764,6 +1764,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -922,6 +922,7 @@ const ru: Record<string, string> = {
|
||||
'trip.tabs.budget': 'Бюджет',
|
||||
'trip.tabs.files': 'Файлы',
|
||||
'trip.loading': 'Загрузка поездки...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': 'Загрузка фото мест...',
|
||||
'trip.mobilePlan': 'План',
|
||||
'trip.mobilePlaces': 'Места',
|
||||
|
||||
@@ -922,6 +922,7 @@ const zh: Record<string, string> = {
|
||||
'trip.tabs.budget': '预算',
|
||||
'trip.tabs.files': '文件',
|
||||
'trip.loading': '加载旅行中...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': '正在加载地点照片...',
|
||||
'trip.mobilePlan': '计划',
|
||||
'trip.mobilePlaces': '地点',
|
||||
|
||||
@@ -982,6 +982,7 @@ const zhTw: Record<string, string> = {
|
||||
'trip.tabs.budget': '預算',
|
||||
'trip.tabs.files': '檔案',
|
||||
'trip.loading': '載入旅行中...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': '正在載入地點照片...',
|
||||
'trip.mobilePlan': '計劃',
|
||||
'trip.mobilePlaces': '地點',
|
||||
|
||||
@@ -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 {
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{slowLoad && (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
style={{
|
||||
marginTop: 24, appearance: 'none', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'transparent',
|
||||
color: 'var(--text-faint)', fontSize: 13, textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{t('trip.splash.goBack')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _syncing = false
|
||||
let _interrupted = false
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
@@ -145,6 +147,7 @@ export const tripSyncManager = {
|
||||
async syncAll(): Promise<void> {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user