mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
11 Commits
0629b375dd
...
32a4c217fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 32a4c217fd | |||
| c64101b12a | |||
| 935d91196b | |||
| b71ce3dd5e | |||
| 37d9a321ab | |||
| 544d5641d0 | |||
| 6b90c7b255 | |||
| 443ae7cb19 | |||
| 83cba5a9ef | |||
| 2ae4a18466 | |||
| f8fdb14627 |
@@ -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 }}>
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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': '地點',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user