Compare commits

...

11 Commits

Author SHA1 Message Date
Julien G. 32a4c217fd Merge c64101b12a into 640e5616e9 2026-05-05 20:48:10 +00:00
jubnl c64101b12a fix: prevent IDB write-stall from blocking trip page and sync loop
clearAll() now clears all tables in a transaction instead of calling
offlineDb.delete(), which triggered our versionchange handler and put
Dexie into a broken write state for the rest of the session.

tripRepo.get() gets the same 2 s timeout guard as list() so a stalled
IDB read no longer freezes the trip splash screen.

_doSync wraps each syncTrip() in a 30 s per-trip timeout so a single
stalled write transaction cannot prevent the loop from advancing to
subsequent trips.
2026-05-05 22:47:37 +02:00
jubnl 935d91196b fix: add versionchange handler to close stale Dexie connection on external IDB delete
All repo mutations (places, budget, packing, todo, accommodation, reservations,
files) and tripSyncManager's syncTrip() use awaited Dexie writes that would
stall indefinitely under the same root cause as the dashboard cold-path hang:
Dexie keeping a stale connection after DevTools "Clear site data" fires a
versionchange event while the tab is open.

Registering an explicit versionchange handler that calls close() lets Dexie
cleanly discard the stale connection. The next operation triggers auto-reopen
with a fresh IDB connection where writes succeed. This is the standard Dexie
pattern and prevents the stall from affecting any part of the app.

Also tighten the toArray() guard in tripRepo.list() to catch() a rejection
(from a potential close() race) in addition to timing out.
2026-05-05 21:57:17 +02:00
jubnl b71ce3dd5e fix: prevent cold-path hang when Dexie write transactions stall after external IDB clear
When DevTools "Clear site data" deletes the IDB while the tab is open, Dexie
receives a versionchange event and closes its connection. On reopen, read
transactions work (toArray completes after ~400ms), but write transactions can
stall indefinitely, causing the cold-path 'await refresh' to never resolve.

Two changes:
- Make upsertTrip calls fire-and-forget in the IIFE so network data is returned
  immediately without blocking on potentially-stuck IDB writes.
- Add a 2-second timeout to the initial offlineDb.trips.toArray() call so that
  if the read also stalls, the cold path falls through to the network fetch.
- Reduce the outer dashboard timeout from 12s to 5s now that the inner path
  cannot stall for more than ~2s + network RTT.
2026-05-05 21:49:07 +02:00
jubnl 37d9a321ab feat: add sync progress and result feedback to offline settings tab 2026-05-05 21:02:25 +02:00
jubnl 544d5641d0 fix: resolve splash hang, dashboard skeleton, and sync-stuck regressions
- TripPlannerPage: change splash effect dep from `trip` (object ref) to
  `trip?.id` (primitive) — background refreshes no longer reset the 1500 ms
  timer on every new object reference, fixing the forever-splash on SPA nav
- tripRepo.list: await upserts on the cold-IDB path so the next mount reads
  from Dexie instead of hitting the network again, fixing the remount skeleton
- tripSyncManager: add stale-flag detection (>2 min resets _syncing), 90 s
  hard timeout via Promise.race, parallel post-sync prefetch via
  Promise.allSettled, and updated header comment to reflect manual-only policy
- OfflineTab: guard handleResync with a 120 s client-side timeout that
  interrupts and clears the spinner if syncAll stalls
2026-05-05 20:52:00 +02:00
jubnl 6b90c7b255 refactor: make syncAll manual-only via offline settings tab
Remove automatic syncAll() calls on login, MFA login, register, and the
window 'online' event. Background bundle sync was the primary cause of
request storms that slowed down initial page loads. Mutation flushing on
reconnect is preserved — only the expensive trip-bundle sync is removed
from auto-triggers.
2026-05-05 20:08:02 +02:00
jubnl 443ae7cb19 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
2026-05-05 20:05:10 +02:00
jubnl 83cba5a9ef fix: handle QuotaExceededError in tripSyncManager with progressive eviction
When IDB storage is full, syncTrip() throws an AbortError wrapping
QuotaExceededError. Now: clear that trip's existing data and retry once;
if quota is still exceeded, wipe all IDB data so the next sync starts
fresh rather than silently failing on every subsequent call.
2026-05-05 19:21:52 +02:00
jubnl 2ae4a18466 fix: decouple IDB writes from network-data return path
QuotaExceededError from a full IndexedDB was being caught by the IIFE's
try/catch (after the earlier await-upsert change), causing repos to return
null/empty even when the network fetch succeeded. Fire-and-forget upserts
with .catch(()=>{}) ensure write failures never suppress fetched data.
2026-05-05 19:19:48 +02:00
jubnl f8fdb14627 fix: remove navigator.onLine guards and fix upsert races in all repos
navigator.onLine returns false transiently during service worker activation
(skipWaiting + clientsClaim), causing all repo refresh IIFEs to return null
immediately on first page load — leaving the UI with empty data until F5.

Fixes applied across all list repos (trip, day, place, packing, todo, budget,
reservation, accommodation, file):
- Drop navigator.onLine guard; let fetch fail naturally when truly offline
- Await all upsert calls (some were fire-and-forget, risking race conditions
  against subsequent reads and silent swallowed failures)
- Return Promise.resolve(null) instead of Promise.resolve(fresh) in the
  IDB-empty network path, so loadTrip's background refresh Promise.all
  resolves null and skips set({trip}), preventing a spurious reference change
  that was resetting the 1500ms splash timer

Tests updated: placeRepo and packingRepo "empty cache" tests now simulate
genuine network failure (HttpResponse.error) instead of relying on the
navigator.onLine guard that no longer exists; DashboardPage tests clear IDB
before each test and use a query-safe assertion after background refresh.
2026-05-05 18:04:15 +02:00
34 changed files with 326 additions and 91 deletions
+46 -2
View File
@@ -7,6 +7,7 @@ import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle }
import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import type { SyncProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import {
DEFAULT_SW_CONFIG,
@@ -30,6 +31,9 @@ export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<{ current: number; total: number } | null>(null)
const [syncResult, setSyncResult] = useState<{ ok: number; failed: number } | null>(null)
const syncResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
@@ -89,6 +93,10 @@ export default function OfflineTab(): React.ReactElement {
}
}, [])
useEffect(() => {
return () => { if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) }
}, [])
async function handleSaveConfig() {
const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated)
@@ -122,8 +130,30 @@ export default function OfflineTab(): React.ReactElement {
async function handleResync() {
setSyncing(true)
setSyncProgress(null)
setSyncResult(null)
if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current)
function handleProgress(p: SyncProgress) {
if (p.phase === 'trip') {
setSyncProgress({ current: p.index + 1, total: p.total })
} else if (p.phase === 'done') {
setSyncProgress(null)
setSyncResult({ ok: p.ok, failed: p.failed })
syncResultTimerRef.current = setTimeout(() => setSyncResult(null), 5000)
}
}
try {
await tripSyncManager.syncAll()
const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000))
const result = await Promise.race([
tripSyncManager.syncAll({ onProgress: handleProgress }).then(() => 'done' as const),
timeout,
])
if (result === 'timeout') {
tripSyncManager.interrupt()
console.warn('[OfflineTab] sync timed out after 120 s')
}
await load()
} finally {
setSyncing(false)
@@ -168,7 +198,11 @@ export default function OfflineTab(): React.ReactElement {
}}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'}
{syncing
? syncProgress
? `Syncing ${syncProgress.current}/${syncProgress.total}`
: 'Syncing…'
: 'Re-sync now'}
</button>
<button
@@ -187,6 +221,16 @@ export default function OfflineTab(): React.ReactElement {
</button>
</div>
{/* Sync result */}
{syncResult && (
<span style={{ fontSize: 12, color: syncResult.failed > 0 ? '#ef4444' : '#22c55e', display: 'flex', alignItems: 'center', gap: 4 }}>
<CheckCircle size={12} />
{syncResult.failed > 0
? `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''} · ${syncResult.failed} failed`
: `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''}`}
</span>
)}
{/* Cache configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+54 -3
View File
@@ -68,6 +68,13 @@ class TrekOfflineDb extends Dexie {
constructor() {
super('trek-offline');
// When the database is deleted externally (e.g. DevTools "Clear site data"
// while the tab is open), IDB fires versionchange on the open connection.
// Without an explicit close() here, Dexie keeps the stale connection alive
// and subsequent write transactions queue behind it indefinitely. Closing
// forces Dexie to auto-reopen on the next operation with a fresh connection.
this.on('versionchange', () => { this.close() })
this.version(1).stores({
trips: 'id',
days: 'id, trip_id',
@@ -185,9 +192,53 @@ 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();
// Re-open so subsequent operations don't fail
await offlineDb.open();
// Use table.clear() instead of offlineDb.delete() to avoid triggering the
// versionchange handler (which calls close()), which would put Dexie into a
// broken write state for the remainder of the session.
await offlineDb.transaction(
'rw',
[
offlineDb.trips,
offlineDb.days,
offlineDb.places,
offlineDb.packingItems,
offlineDb.todoItems,
offlineDb.budgetItems,
offlineDb.reservations,
offlineDb.tripFiles,
offlineDb.accommodations,
offlineDb.tripMembers,
offlineDb.tags,
offlineDb.categories,
offlineDb.mutationQueue,
offlineDb.syncMeta,
offlineDb.blobCache,
],
async () => {
await Promise.all([
offlineDb.trips.clear(),
offlineDb.days.clear(),
offlineDb.places.clear(),
offlineDb.packingItems.clear(),
offlineDb.todoItems.clear(),
offlineDb.budgetItems.clear(),
offlineDb.reservations.clear(),
offlineDb.tripFiles.clear(),
offlineDb.accommodations.clear(),
offlineDb.tripMembers.clear(),
offlineDb.tags.clear(),
offlineDb.categories.clear(),
offlineDb.mutationQueue.clear(),
offlineDb.syncMeta.clear(),
offlineDb.blobCache.clear(),
])
},
)
}
+1
View File
@@ -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': 'الأماكن',
+1
View File
@@ -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
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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
+1
View File
@@ -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',
+1
View File
@@ -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
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Места',
+1
View File
@@ -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': '地点',
+1
View File
@@ -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': '地點',
+5 -2
View File
@@ -7,10 +7,12 @@ import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { offlineDb } from '../db/offlineDb';
import DashboardPage from './DashboardPage';
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Seed auth with authenticated user
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
@@ -329,7 +331,8 @@ describe('DashboardPage', () => {
const tokyoTrip = screen.getAllByText('Tokyo Trip')[0];
await user.click(tokyoTrip);
expect(tokyoTrip).toBeInTheDocument();
// Re-query after click — background refresh may re-render the list
expect(screen.getAllByText('Tokyo Trip').length).toBeGreaterThan(0);
});
});
+5 -1
View File
@@ -744,7 +744,11 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips, refresh } = await tripRepo.list()
const listOrTimeout = Promise.race([
tripRepo.list(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('trips-load-timeout')), 5_000)),
])
const { trips, archivedTrips, refresh } = await listOrTimeout
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setIsLoading(false)
+22 -1
View File
@@ -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])
}, [isLoading, trip?.id])
// 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>
)
}
+1 -2
View File
@@ -9,7 +9,6 @@ export const accommodationRepo = {
.where('trip_id').equals(Number(tripId)).toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
@@ -23,7 +22,7 @@ export const accommodationRepo = {
const fresh = await refresh
if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) }
return { accommodations: fresh.accommodations, refresh: Promise.resolve(fresh) }
return { accommodations: fresh.accommodations, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const budgetRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
upsertBudgetItems(result.items).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const budgetRepo = {
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const dayRepo = {
.sortBy('day_number' as keyof Day)) as Day[]
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await daysApi.list(tripId)
upsertDays(result.days)
upsertDays(result.days).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const dayRepo = {
const fresh = await refresh
if (!fresh) return { days: [], refresh: Promise.resolve(null) }
return { days: fresh.days, refresh: Promise.resolve(fresh) }
return { days: fresh.days, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, dayId: number | string, data: Record<string, unknown>): Promise<{ day: Day }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const fileRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
upsertTripFiles(result.files).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const fileRepo = {
const fresh = await refresh
if (!fresh) return { files: [], refresh: Promise.resolve(null) }
return { files: fresh.files, refresh: Promise.resolve(fresh) }
return { files: fresh.files, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ file: TripFile }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const packingRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
upsertPackingItems(result.items).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const packingRepo = {
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const placeRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
upsertPlaces(result.places).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const placeRepo = {
const fresh = await refresh
if (!fresh) return { places: [], refresh: Promise.resolve(null) }
return { places: fresh.places, refresh: Promise.resolve(fresh) }
return { places: fresh.places, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const reservationRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
upsertReservations(result.reservations).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const reservationRepo = {
const fresh = await refresh
if (!fresh) return { reservations: [], refresh: Promise.resolve(null) }
return { reservations: fresh.reservations, refresh: Promise.resolve(fresh) }
return { reservations: fresh.reservations, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
+2 -3
View File
@@ -11,10 +11,9 @@ export const todoRepo = {
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
upsertTodoItems(result.items).catch(() => {})
return result
} catch {
return null
@@ -25,7 +24,7 @@ export const todoRepo = {
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
+20 -9
View File
@@ -8,17 +8,26 @@ type TripRefresh = Promise<{ trip: Trip } | null>
export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
const all = await offlineDb.trips.toArray()
// Guard: if Dexie is in a bad state (e.g. externally deleted while tab was
// open and the versionchange close() races with this read), fall back to the
// cold/network path rather than throwing or hanging.
const all = await Promise.race([
offlineDb.trips.toArray().catch(() => [] as Trip[]),
new Promise<Trip[]>(resolve => setTimeout(() => resolve([]), 2000)),
])
const refresh: TripsRefresh = (async () => {
if (!navigator.onLine) return null
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))
// Fire-and-forget IDB writes: returning data immediately unblocks the cold
// path even when Dexie write transactions stall after an external DB clear.
Promise.all([
...active.trips.map(t => upsertTrip(t)),
...archived.trips.map(t => upsertTrip(t)),
]).catch(() => {})
return { trips: active.trips, archivedTrips: archived.trips }
} catch {
return null
@@ -35,17 +44,19 @@ export const tripRepo = {
const fresh = await refresh
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
return { ...fresh, refresh: Promise.resolve(fresh) }
return { ...fresh, refresh: Promise.resolve(null) }
},
async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
const cached = await offlineDb.trips.get(Number(tripId))
const cached = await Promise.race([
offlineDb.trips.get(Number(tripId)).catch(() => undefined),
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 2000)),
])
const refresh: TripRefresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
upsertTrip(result.trip).catch(() => {})
return result
} catch {
return null
@@ -56,7 +67,7 @@ export const tripRepo = {
const fresh = await refresh
if (!fresh) throw new Error('No cached trip data available offline')
return { trip: fresh.trip, refresh: Promise.resolve(fresh) }
return { trip: fresh.trip, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, data: Partial<Trip>): Promise<{ trip: Trip }> {
-4
View File
@@ -4,7 +4,6 @@ import { authApi } from '../api/client'
import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { clearAll } from '../db/offlineDb'
import { useSystemNoticeStore } from './systemNoticeStore.js'
@@ -100,7 +99,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
@@ -124,7 +122,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
@@ -148,7 +145,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
+6 -7
View File
@@ -1,19 +1,19 @@
/**
* Sync triggers register event listeners that flush the mutation queue
* and/or run a full trip sync based on the connectivity trigger source.
* based on the connectivity trigger source.
*
* Trigger matrix:
* window 'online' flush mutations + full syncAll (network truly back)
* window 'online' flush mutations (network truly back)
* visibilitychange visible flush mutations only (avoid hammering server on tab switch)
* periodic 30s flush mutations only
* WS reconnect flush mutations only (no syncAll avoids rate-limiter
* on server restart / socket timeout while already online)
* WS reconnect flush mutations only
*
* Full trip sync (syncAll) is manual-only via the Offline settings tab.
*
* Call `registerSyncTriggers()` once on app mount.
* Call `unregisterSyncTriggers()` on unmount / logout.
*/
import { mutationQueue } from './mutationQueue'
import { tripSyncManager } from './tripSyncManager'
import { setPreReconnectHook } from '../api/websocket'
const PERIODIC_MS = 30_000
@@ -21,10 +21,9 @@ const PERIODIC_MS = 30_000
let _intervalId: ReturnType<typeof setInterval> | null = null
let _registered = false
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
/** Network came back — flush any pending mutations. */
function onOnline() {
mutationQueue.flush().catch(console.error)
tripSyncManager.syncAll().catch(console.error)
}
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
+130 -35
View File
@@ -5,10 +5,8 @@
* Eviction: trips where end_date < today - 7 days.
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
*
* Call syncAll() on:
* - login success
* - trip list refresh (DashboardPage)
* - WS reconnect (phase 7)
* syncAll() is manual-only triggered via Settings Offline tab.
* No automatic sync on login, dashboard load, or WS reconnect.
*/
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import {
@@ -27,6 +25,8 @@ import {
upsertCategories,
upsertSyncMeta,
clearTripData,
clearBlobCache,
clearAll,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { useSettingsStore } from '../store/settingsStore'
@@ -34,6 +34,11 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation,
// ── Types ─────────────────────────────────────────────────────────────────────
export type SyncProgress =
| { phase: 'start'; total: number }
| { phase: 'trip'; tripId: number; index: number; total: number }
| { phase: 'done'; ok: number; failed: number }
interface TripBundle {
trip: Trip
days: Day[]
@@ -69,6 +74,14 @@ function isPhoto(file: TripFile): boolean {
return file.mime_type.startsWith('image/')
}
function isQuotaError(err: unknown): boolean {
if (!(err instanceof Error)) return false
if (err.name === 'QuotaExceededError') return true
// Dexie wraps IDB errors: AbortError with inner QuotaExceededError
const inner = (err as { inner?: unknown }).inner
return inner instanceof Error && inner.name === 'QuotaExceededError'
}
// ── Core logic ────────────────────────────────────────────────────────────────
/** Fetch bundle + write all entities for one trip into Dexie. */
@@ -125,54 +138,136 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
// ── Public API ────────────────────────────────────────────────────────────────
const SYNC_TIMEOUT_MS = 90_000
const SYNC_STALE_MS = 120_000
let _syncing = false
let _interrupted = false
let _syncStartedAt = 0
export const tripSyncManager = {
/**
* Sync all cache-eligible trips.
* Evicts stale trips. Caches file blobs in the background.
* No-ops when offline.
* No-ops when offline or already syncing (unless stale flag).
*/
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine) return
async syncAll(opts?: { onProgress?: (p: SyncProgress) => void }): Promise<void> {
// Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync)
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
if (!navigator.onLine) return
_syncing = true
_syncStartedAt = Date.now()
_interrupted = false
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS)
)
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
// Evict stale trips first
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips
const toSync = trips.filter(shouldCache)
for (const trip of toSync) {
try {
await syncTrip(trip.id)
} catch (err) {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
// 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(() => {})
// 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) {
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
cacheFilesForTrip(files).catch(console.error)
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
await Promise.race([this._doSync(opts?.onProgress), timeout])
} catch (err) {
if (err instanceof Error && err.message === 'syncAll timeout') {
console.warn('[tripSync] syncAll timed out after 90 s — interrupting')
_interrupted = true
}
} finally {
_syncing = false
}
},
async _doSync(onProgress?: (p: SyncProgress) => void): Promise<void> {
const { trips } = await tripsApi.list() as { trips: Trip[] }
// Evict stale trips first
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page)
const toSync = trips.filter(shouldCache)
onProgress?.({ phase: 'start', total: toSync.length })
let ok = 0
let failed = 0
for (let i = 0; i < toSync.length; i++) {
const trip = toSync[i]
if (_interrupted) return
onProgress?.({ phase: 'trip', tripId: trip.id, index: i, total: toSync.length })
let tripOk = false
try {
await Promise.race([
syncTrip(trip.id),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('syncTrip timeout')), 30_000)
),
])
tripOk = true
} catch (err) {
if (isQuotaError(err)) {
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
try {
await clearTripData(trip.id)
await syncTrip(trip.id)
tripOk = true
} catch (retryErr) {
if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
await clearBlobCache()
try {
await syncTrip(trip.id)
tripOk = true
} catch {
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
await clearAll()
onProgress?.({ phase: 'done', ok, failed: failed + 1 })
return
}
} else {
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
}
}
} else {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
if (tripOk) ok++; else failed++
}
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(() => {})
// Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget)
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const prefetchWork = toSync
.filter(() => !_interrupted)
.map(async trip => {
const [files, places] = await Promise.all([
offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(),
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
])
cacheFilesForTrip(files).catch(console.error)
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
})
await Promise.allSettled(prefetchWork)
onProgress?.({ phase: 'done', ok, failed })
},
/**
* 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
_syncStartedAt = 0
},
}
+4 -2
View File
@@ -58,8 +58,10 @@ describe('packingRepo.list', () => {
expect(restCalled).toBe(false);
});
it('offline — returns empty array when nothing cached', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
it('offline — returns empty array when nothing cached and network fails', async () => {
server.use(
http.get('/api/trips/99/packing', () => HttpResponse.error()),
);
const result = await packingRepo.list(99);
expect(result.items).toHaveLength(0);
});
+4 -2
View File
@@ -59,8 +59,10 @@ describe('placeRepo.list', () => {
expect(restCalled).toBe(false);
});
it('offline — returns empty array when nothing cached', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
it('offline — returns empty array when nothing cached and network fails', async () => {
server.use(
http.get('/api/trips/99/places', () => HttpResponse.error()),
);
const result = await placeRepo.list(99);
expect(result.places).toHaveLength(0);
});