mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken". The app shell. On the first online launch after a version bump, App.tsx deleted EVERY Cache Storage bucket and unregistered EVERY service worker, then reloaded. That is exactly wrong for a Workbox autoUpdate build, which swaps its precache atomically on install and never leaves an empty one. Between the purge and the completion of a fresh 22 MB precache the origin had no shell at all; anyone who closed the app or lost signal inside that window could no longer start the PWA offline. It also threw away the map tiles and documents the user had downloaded on purpose. It now asks the worker to fetch the new build and reloads once it has taken over. The version marker also moves inside the guarded path, because it was written after the purge and outside its try: a throwing setItem left the caches gone, the marker unwritten and the reload unreached, repeating on every launch. The reported version is validated before it reaches this device's storage and before it is compared against the stored marker, so a value that is not a release tag can neither be persisted nor start an update on every launch. The session. loadUser's offline branch kept the persisted session but never called onAuthSuccess, the only caller of setAuthed(true), and the mutation queue's flush, syncAll and prepareForOffline are all gated on it. A session that launched without network could therefore never sync again for the rest of its life, even after the signal returned: the offline settings buttons spun and returned instantly having done nothing, and queued edits never uploaded. It also left reads pointed at the anonymous database instead of the user's own. The date rule. A trip is only cached when it has not ended, and one that ended over a week ago is actively evicted. The per-trip switch was an opt-out list, so ticking a finished trip changed nothing at all. For anyone whose trips are all in the past, "Download for offline use" stored none of them, reported success, and the cache stayed at zero. Switching a trip on now pins it, which overrides both the skip and the eviction, and the switch shows the honest position rather than promising storage that never happens. What the screen says. A sync that never started (already running, offline, signed out) was indistinguishable from one that stored everything: the progress bar painted full and a green "Ready for offline use" appeared over a run of nothing. Both syncs now report why they stopped and the screen says so. A rejected Dexie read no longer renders as "No trips cached yet", and the clear button stays reachable when the database holds data that no bookkeeping row points at. A run that rejects outright says so too: the trip list it starts from is a plain fetch, so a network blip took the whole thing down and left the last progress frame on screen looking like a finished download. The 401-with-HTML branch in the API client now confirms a reachable proxy before tearing the worker down, since several of TREK's own routes answer 401 with a text/html body. Both settings shells move onto one shared hook rather than growing the same logic twice, and the tile prefetch caps its depth when the browser refused persistent storage. Opaque tiles are billed at about 7 MB each, and an origin that is not exempt from eviction loses its whole bucket, precached shell included, which Workbox then never refills because sw.js has not changed. Closes #2228
This commit is contained in:
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
|
||||
// The old handler wiped every Cache Storage bucket and every registration
|
||||
// here, which left the device with no app shell until a fresh ~22 MB
|
||||
// precache finished: anyone who closed the app or lost signal inside that
|
||||
// window could no longer start the PWA offline at all, and it also threw
|
||||
// away the map tiles and documents the user had downloaded on purpose.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const cachesDelete = vi.fn().mockResolvedValue(true)
|
||||
const unregister = vi.fn().mockResolvedValue(true)
|
||||
const update = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
// It asks the worker to fetch the new build; Workbox swaps the precache
|
||||
// atomically once that install completes.
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
expect(cachesDelete).not.toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
|
||||
// The value arrives over the wire and is written to this device's storage,
|
||||
// so it is validated rather than trusted. An unusable one must also not be
|
||||
// compared against the stored marker, or every launch would see a mismatch
|
||||
// and try to update.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
|
||||
// The marker used to be written after the purge and outside its try, so a
|
||||
// throwing step left it unwritten and the whole destructive path repeated on
|
||||
// every launch, for good.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: a device that has never mirrored the preference must not treat
|
||||
|
||||
+34
-12
@@ -316,24 +316,46 @@ export default function App() {
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
||||
// from being compared against the stored marker and starting an update on
|
||||
// every launch.
|
||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
// mode, blocked site data, quota) left the caches deleted, the marker
|
||||
// unwritten and the reload unreached, and the purge then repeated on
|
||||
// every single launch, permanently (#2228).
|
||||
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
|
||||
if (storedVersion && storedVersion !== version) {
|
||||
// A newer build is deployed. Ask the service worker to fetch it and
|
||||
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
|
||||
// installs the new precache and only then drops the outdated one, so
|
||||
// there is never a moment without an app shell.
|
||||
//
|
||||
// This used to delete EVERY Cache Storage bucket and unregister EVERY
|
||||
// worker instead. That left the device with no shell and no worker
|
||||
// until a fresh ~22 MB precache finished (minutes on mobile data),
|
||||
// and anyone who closed the app or lost signal in that window was
|
||||
// left with a PWA that could no longer start offline at all. It also
|
||||
// threw away the map tiles and file blobs the user had deliberately
|
||||
// downloaded for offline use (#2228).
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
|
||||
await reg.update()
|
||||
return
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
} catch { /* fall through to a plain reload */ }
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
|
||||
return getRegistration
|
||||
}
|
||||
|
||||
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
|
||||
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
|
||||
probeNow.mockResolvedValue('proxy-wall')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
|
||||
// text/html is not proof of a proxy: several of TREK's own routes answer
|
||||
// res.status(401).send('Authentication required'), which Express labels
|
||||
// text/html. Tearing the worker down for one of those costs the user
|
||||
// offline mode for a wall that is not there, so confirm reachability first.
|
||||
probeNow.mockResolvedValue('online')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
|
||||
expect(probeNow).toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
|
||||
installServiceWorker(vi.fn(async () => true))
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
|
||||
@@ -214,16 +214,24 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
// HTML body (a JS redirect page) instead of a 302.
|
||||
//
|
||||
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
|
||||
// routes answer with res.status(401).send('Authentication required'),
|
||||
// which Express labels text/html. Tearing the service worker down for one
|
||||
// of those would cost the user offline mode for a proxy wall that isn't
|
||||
// there, so confirm a reachable proxy first, exactly like the no-response
|
||||
// branch above (#2228).
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
if (await probeNow() === 'proxy-wall') {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-024
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-028
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -21,16 +21,17 @@ const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const tripsCount = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
@@ -104,6 +105,7 @@ const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsCount.mockResolvedValue(rows.length);
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
@@ -130,6 +132,7 @@ beforeEach(() => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
@@ -138,8 +141,8 @@ beforeEach(() => {
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.syncAll.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
@@ -284,6 +287,7 @@ describe('OfflineTab', () => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -294,7 +298,7 @@ describe('OfflineTab', () => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -304,6 +308,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 1 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -322,6 +327,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'trips', current: 1, total: 4 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -380,7 +386,7 @@ describe('OfflineTab', () => {
|
||||
it('FE-COMP-OFFLINETAB-017: Re-sync shows the syncing label while it runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
h.syncAll.mockImplementation(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
h.syncAll.mockImplementation(() => new Promise(resolve => { release = () => resolve({ status: 'done', trips: 1 }); }));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
@@ -408,21 +414,69 @@ describe('OfflineTab', () => {
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-019: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
it('FE-COMP-OFFLINETAB-019: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OfflineTab />);
|
||||
|
||||
// Both fixture trips ended in 2025, so the date rule would skip them: the
|
||||
// switch has to read "off" rather than promising storage that never happens.
|
||||
const toggle = await screen.findByRole('button', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([2]);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Tokyo' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tokyo' }));
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
// Turning it off releases the pin, so "off then on" cannot leave it pinned.
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-025: an ongoing trip is stored without needing a pin', async () => {
|
||||
const soon = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
const ongoing = buildTrip({ id: 7, title: 'Lisbon', start_date: '2025-01-01', end_date: soon });
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [ongoing] })));
|
||||
render(<OfflineTab />);
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: 'Lisbon' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Stored offline')).toBeInTheDocument();
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-026: a sync that never ran says so instead of reporting success', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'busy' });
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('A sync is already running. Try again in a moment.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-028: a download that throws is reported instead of leaving the last frame up', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockRejectedValue(new Error('network went away'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('The download could not finish. Check your connection and try again.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-027: an unreadable offline database is reported, not shown as empty', async () => {
|
||||
h.syncMetaToArray.mockRejectedValue(new Error('DatabaseClosedError'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
expect(await screen.findByText(/Could not read this device’s offline storage/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No trips cached yet. Connect to the internet to sync.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-020: the per-trip section disappears when there are no trips at all', async () => {
|
||||
@@ -478,6 +532,7 @@ describe('OfflineTab', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
expect(h.clearAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -6,33 +6,18 @@
|
||||
* - What to store: a map-tiles toggle plus a per-trip on/off.
|
||||
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
|
||||
* - Cache stats + clear.
|
||||
*
|
||||
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
|
||||
* `MSettingsOffline`; this file is the desktop markup over it.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../db/offlineDb'
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
@@ -44,132 +29,14 @@ function conflictName(m: QueuedMutation): string {
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm(t('settings.offline.clearConfirm'))) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}
|
||||
const {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
||||
runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
@@ -237,9 +104,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: isOfflineNoticeWarning(notice) ? 'var(--warning)' : 'var(--success)' }}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,7 +141,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
control={
|
||||
<select
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
onChange={e => handleConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
@@ -302,7 +170,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -311,9 +179,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -335,10 +204,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button"
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
disabled={clearing || !canClear}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || rows.length === 0)}
|
||||
style={btnStyle(clearing || !canClear)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
@@ -349,7 +218,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('settings.offline.empty')}
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Shared state for the offline settings screen (#1135), used by both the desktop
|
||||
* `OfflineTab` and its phone twin `MSettingsOffline`. The two shells render very
|
||||
* different markup over identical logic, so the logic lives here once, the same
|
||||
* arrangement as `useInstanceSettings` / `useRangeBypass`.
|
||||
*
|
||||
* It also carries the honesty fixes from #2228: a sync that never started is
|
||||
* reported as such instead of silently painting a finished progress bar, a
|
||||
* failed read of the offline database surfaces instead of rendering as "nothing
|
||||
* cached", and a trip the user switches on is pinned so the date rule cannot
|
||||
* quietly refuse to store it.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, isDateEligible, type PrepareProgress, type SyncOutcome } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
setTripOfflineEnabled, setTripPinned, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
export interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/** How a trip's storage switch should read, and why. */
|
||||
export interface TripStorageState {
|
||||
/** The switch position: is this trip kept on the device? */
|
||||
on: boolean
|
||||
/** The user asked for it explicitly, overriding the date rule. */
|
||||
pinned: boolean
|
||||
/** The date rule would keep it on its own (ongoing, future, or open-ended). */
|
||||
dateEligible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The last thing a download / re-sync run did. `null` while nothing has been
|
||||
* run yet. The screen shows no banner in that state.
|
||||
*/
|
||||
export type OfflineNotice =
|
||||
| { kind: 'stored'; trips: number }
|
||||
| { kind: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
||||
| { kind: 'sync-failed' }
|
||||
| { kind: 'load-failed' }
|
||||
|
||||
/**
|
||||
* The message a notice reads as. Shared so both shells say the same thing and
|
||||
* only the chrome around it differs.
|
||||
*/
|
||||
export function offlineNoticeKey(notice: OfflineNotice): string {
|
||||
if (notice.kind === 'load-failed') return 'settings.offline.notice.loadFailed'
|
||||
if (notice.kind === 'sync-failed') return 'settings.offline.notice.failed'
|
||||
if (notice.kind === 'skipped') {
|
||||
if (notice.reason === 'busy') return 'settings.offline.notice.busy'
|
||||
if (notice.reason === 'offline') return 'settings.offline.notice.offline'
|
||||
return 'settings.offline.notice.signedOut'
|
||||
}
|
||||
return notice.trips > 0 ? 'settings.offline.notice.stored' : 'settings.offline.notice.nothing'
|
||||
}
|
||||
|
||||
/** A notice that reports a problem rather than a result. */
|
||||
export function isOfflineNoticeWarning(notice: OfflineNotice): boolean {
|
||||
return notice.kind !== 'stored' || notice.trips === 0
|
||||
}
|
||||
|
||||
function noticeFor(outcome: SyncOutcome): OfflineNotice {
|
||||
return outcome.status === 'done'
|
||||
? { kind: 'stored', trips: outcome.trips }
|
||||
: { kind: 'skipped', reason: outcome.reason }
|
||||
}
|
||||
|
||||
export function useOfflineSettings() {
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [storedTripCount, setStoredTripCount] = useState(0)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [notice, setNotice] = useState<OfflineNotice | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, storedTrips, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
offlineDb.trips.count(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setStoredTripCount(storedTrips)
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} catch (err) {
|
||||
// A Dexie read that rejects (a closed handle, a failed upgrade) used to
|
||||
// leave every list at its initial empty value, so a broken offline
|
||||
// database was indistinguishable from an empty one: the screen read
|
||||
// "No trips cached yet" and gave the user nothing to act on (#2228).
|
||||
console.error('[offline] could not read the offline database:', err)
|
||||
setNotice({ kind: 'load-failed' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const outcome = await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
setNotice(noticeFor(outcome))
|
||||
// A run that never started never reported progress either; clearing it
|
||||
// keeps the bar from painting "finished" over a download that did not
|
||||
// happen.
|
||||
if (outcome.status !== 'done') setProgress(null)
|
||||
} catch (err) {
|
||||
// The trip list this starts from is a plain fetch, so a network blip
|
||||
// rejects the whole run. Reporting that beats leaving the last progress
|
||||
// frame on screen as if the download had completed.
|
||||
console.error('[offline] the download could not finish:', err)
|
||||
setProgress(null)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleToggleForce = useCallback(async () => {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (!isEffectivelyOffline()) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}, [forced, runPrepare, setForced])
|
||||
|
||||
const handleResync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
/** Wipes the offline database. Each shell asks for confirmation its own way. */
|
||||
const handleClear = useCallback(async () => {
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
setNotice(null)
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const handleToggleTiles = useCallback(async () => {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}, [prefs.cacheTiles])
|
||||
|
||||
/**
|
||||
* How a trip's switch should read. A finished trip is only stored when the
|
||||
* user pinned it, so reporting the raw opt-out flag would show "on" over a
|
||||
* trip the sync deliberately skips, which is what made the whole feature
|
||||
* look broken for anyone whose trips are all in the past (#2228).
|
||||
*/
|
||||
const tripStorageState = useCallback((trip: Trip): TripStorageState => {
|
||||
const dateEligible = isDateEligible(trip)
|
||||
const pinned = prefs.pinnedTripIds.includes(trip.id)
|
||||
const allowed = !prefs.disabledTripIds.includes(trip.id)
|
||||
return { on: allowed && (pinned || dateEligible), pinned, dateEligible }
|
||||
}, [prefs.pinnedTripIds, prefs.disabledTripIds])
|
||||
|
||||
const handleToggleTrip = useCallback(async (trip: Trip) => {
|
||||
const next = !tripStorageState(trip).on
|
||||
setTripOfflineEnabled(trip.id, next)
|
||||
// Switching a finished trip on has to pin it, or the date rule would skip
|
||||
// it on the next sync and evict it a week after it ended.
|
||||
setTripPinned(trip.id, next && !isDateEligible(trip))
|
||||
if (!next) {
|
||||
await clearTripData(trip.id)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
if (isEffectivelyOffline()) {
|
||||
setNotice({ kind: 'skipped', reason: 'offline' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
}
|
||||
await load()
|
||||
}, [load, tripStorageState])
|
||||
|
||||
const resolveConflict = useCallback(async (id: string, keepMine: boolean) => {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleConflictStrategy = useCallback((strategy: ConflictStrategy) => {
|
||||
setConflictStrategy(strategy)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, storedTripCount,
|
||||
// Clearable once the database actually holds something, not only once a
|
||||
// sync wrote its bookkeeping row: a download that died between writing the
|
||||
// trip and writing its syncMeta left `rows` empty over a non-empty Dexie,
|
||||
// and the clear button was disabled on exactly the state that needed it.
|
||||
canClear: storedTripCount > 0 || pendingCount > 0,
|
||||
load, runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
}
|
||||
}
|
||||
@@ -3,35 +3,20 @@
|
||||
* components/Settings/OfflineTab. Same logic (force-offline switch, prepare /
|
||||
* resync, conflict resolver, per-trip storage, cache stats + clear), rebuilt on
|
||||
* the MSet* card system with MToggle switches and an MConfirmSheet for clear.
|
||||
*
|
||||
* The logic itself lives in the shared `useOfflineSettings` hook; only the
|
||||
* markup differs between the two shells.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../../db/offlineDb'
|
||||
import { tripsApi } from '../../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode'
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from '../../../components/Settings/useOfflineSettings'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../../db/offlineDb'
|
||||
import type { Trip } from '../../../types'
|
||||
import type { ConflictStrategy } from '../../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../../db/offlineDb'
|
||||
import { MSetCard, MSetEyebrow, MSetRow, MSetSegments, MSetButton } from './MSettingsUi'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
const server = (m.conflictServer ?? {}) as { name?: unknown }
|
||||
@@ -42,132 +27,19 @@ function conflictName(m: QueuedMutation): string {
|
||||
|
||||
export default function MSettingsOffline() {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// Per-trip storage toggles are driven by the FULL trip list, not just the
|
||||
// cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs.
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
||||
runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
|
||||
async function doClear() {
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
setShowClearConfirm(false)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
await handleClear()
|
||||
setShowClearConfirm(false)
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
@@ -229,9 +101,10 @@ export default function MSettingsOffline() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="mt-[10px] flex items-center gap-[6px] font-geist text-[0.6875rem] text-[color:var(--m-st-confirmed)]">
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div className={`mt-[10px] flex items-center gap-[6px] font-geist text-[0.6875rem] ${isOfflineNoticeWarning(notice) ? 'text-[color:var(--m-st-pending)]' : 'text-[color:var(--m-st-confirmed)]'}`}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,7 +148,7 @@ export default function MSettingsOffline() {
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.offline.conflicts.strategyTitle')}</MSetEyebrow>
|
||||
<MSetSegments<ConflictStrategy>
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={setConflictStrategy}
|
||||
onChange={handleConflictStrategy}
|
||||
options={strategyOptions}
|
||||
/>
|
||||
</div>
|
||||
@@ -297,14 +170,15 @@ export default function MSettingsOffline() {
|
||||
<div className="mb-1 text-[0.78125rem] font-bold text-m-ink">{t('settings.offline.storage.tripsTitle')}</div>
|
||||
<div>
|
||||
{allTrips.map((trip, i) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
const sub = on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')
|
||||
return (
|
||||
<MSetRow
|
||||
key={trip.id}
|
||||
first={i === 0}
|
||||
label={<span className="block truncate">{trip.title}</span>}
|
||||
sub={on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
trailing={<MToggle checked={on} onChange={() => handleToggleTrip(trip.id)} ariaLabel={trip.title} />}
|
||||
sub={dateEligible ? sub : `${sub} · ${t('settings.offline.storage.tripFinished')}`}
|
||||
trailing={<MToggle checked={on} onChange={() => handleToggleTrip(trip)} ariaLabel={trip.title} />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -323,7 +197,7 @@ export default function MSettingsOffline() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<MSetButton variant="danger" disabled={clearing || rows.length === 0} onClick={() => setShowClearConfirm(true)}>
|
||||
<MSetButton variant="danger" disabled={clearing || !canClear} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</MSetButton>
|
||||
@@ -332,7 +206,9 @@ export default function MSettingsOffline() {
|
||||
{loading ? (
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">{t('settings.offline.empty')}</p>
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
|
||||
@@ -294,12 +294,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Genuinely offline — keep the persisted session so the PWA serves cached
|
||||
// data without a scary error. This is the offline-first happy path.
|
||||
set({ isLoading: false })
|
||||
// ...but the session still has to be marked live. onAuthSuccess is the
|
||||
// only caller of setAuthed(true), and every repair path (the mutation
|
||||
// queue's flush, syncAll, prepareForOffline) is gated on it. Skipping
|
||||
// it here left a session that launched without network unable to sync
|
||||
// for the rest of its life, even after the signal came back: the offline
|
||||
// settings buttons spun and returned instantly having done nothing, and
|
||||
// queued edits never uploaded (#2228). It also points the offline DB at
|
||||
// this user's scoped database, so cached data is read from the right
|
||||
// one rather than the anonymous fallback.
|
||||
const cachedUser = get().user
|
||||
if (cachedUser && get().isAuthenticated) await onAuthSuccess(cachedUser.id)
|
||||
} else {
|
||||
// Server erroring (5xx) or unreachable while we're online: keep the session
|
||||
// (don't eject the user over a transient outage), but flag it so the UI can
|
||||
// say "couldn't reach the server" instead of showing a blank, error-free
|
||||
// page that looks like the user's trips were lost. #1283
|
||||
set({ isLoading: false, authCheckFailed: true })
|
||||
// Same reasoning as the offline branch: the session is kept, so it must
|
||||
// be marked live or the sync layer stays dead until the next full login.
|
||||
const cachedUser = get().user
|
||||
if (cachedUser && get().isAuthenticated) await onAuthSuccess(cachedUser.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
* world map"). See tripSyncManager / clearTileCache.
|
||||
* disabledTripIds — trips the user explicitly excluded from offline storage.
|
||||
* Everything else that is date-eligible is cached.
|
||||
* pinnedTripIds : trips the user explicitly asked for that the date rule
|
||||
* would otherwise skip or evict (a finished trip). Without
|
||||
* this there was no way to say yes: the list was opt-out
|
||||
* only, so ticking a past trip changed nothing and the
|
||||
* download quietly stored none of them (#2228).
|
||||
* conflictStrategy — what to do when an offline edit collides with a newer
|
||||
* server change: 'ask' surfaces a per-conflict picker,
|
||||
* 'mine'/'server' resolve automatically.
|
||||
@@ -22,6 +27,7 @@ export type ConflictStrategy = 'ask' | 'mine' | 'server'
|
||||
export interface OfflinePrefs {
|
||||
cacheTiles: boolean
|
||||
disabledTripIds: number[]
|
||||
pinnedTripIds: number[]
|
||||
conflictStrategy: ConflictStrategy
|
||||
}
|
||||
|
||||
@@ -30,6 +36,7 @@ const STORAGE_KEY = 'trek_offline_prefs'
|
||||
const DEFAULTS: OfflinePrefs = {
|
||||
cacheTiles: true,
|
||||
disabledTripIds: [],
|
||||
pinnedTripIds: [],
|
||||
conflictStrategy: 'ask',
|
||||
}
|
||||
|
||||
@@ -44,6 +51,7 @@ function read(): OfflinePrefs {
|
||||
return {
|
||||
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
|
||||
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
|
||||
pinnedTripIds: Array.isArray(parsed.pinnedTripIds) ? parsed.pinnedTripIds.filter(n => typeof n === 'number') : [],
|
||||
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
|
||||
}
|
||||
} catch {
|
||||
@@ -59,7 +67,7 @@ function write(next: OfflinePrefs): void {
|
||||
|
||||
/** Current snapshot (a copy — callers must not mutate it in place). */
|
||||
export function getOfflinePrefs(): OfflinePrefs {
|
||||
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] }
|
||||
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds], pinnedTripIds: [..._prefs.pinnedTripIds] }
|
||||
}
|
||||
|
||||
export function setCacheTiles(on: boolean): void {
|
||||
@@ -77,6 +85,14 @@ export function isTripOfflineEnabled(tripId: number): boolean {
|
||||
return !_prefs.disabledTripIds.includes(tripId)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user explicitly asked to keep this trip, overriding the date
|
||||
* rule that otherwise skips and evicts finished trips (tripSyncManager).
|
||||
*/
|
||||
export function isTripPinned(tripId: number): boolean {
|
||||
return _prefs.pinnedTripIds.includes(tripId)
|
||||
}
|
||||
|
||||
/** Turn offline storage for a single trip on or off. */
|
||||
export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
||||
const has = _prefs.disabledTripIds.includes(tripId)
|
||||
@@ -88,6 +104,20 @@ export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
||||
write({ ..._prefs, disabledTripIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a trip the date rule would not keep on its own, or release it again.
|
||||
* Turning a trip off releases the pin too, so "off then on" cannot leave a
|
||||
* finished trip silently pinned.
|
||||
*/
|
||||
export function setTripPinned(tripId: number, on: boolean): void {
|
||||
const has = _prefs.pinnedTripIds.includes(tripId)
|
||||
if (on === has) return
|
||||
const pinnedTripIds = on
|
||||
? [..._prefs.pinnedTripIds, tripId]
|
||||
: _prefs.pinnedTripIds.filter(id => id !== tripId)
|
||||
write({ ..._prefs, pinnedTripIds })
|
||||
}
|
||||
|
||||
/** Subscribe to preference changes. Returns an unsubscribe function. */
|
||||
export function onOfflinePrefsChange(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
|
||||
@@ -6,13 +6,48 @@
|
||||
*
|
||||
* Best-effort and idempotent: returns whether persistence is (now) granted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Last known answer, so callers that have to size a download against it do not
|
||||
* each re-ask. `null` until the request at app init has resolved.
|
||||
*
|
||||
* The answer matters: a browser that refuses persistence evicts this origin's
|
||||
* WHOLE bucket under pressure (the Workbox precache with it), and since sw.js
|
||||
* is byte-identical between releases, Workbox never re-installs and never
|
||||
* refills what was evicted. The PWA then cannot start offline at all, and
|
||||
* reinstalling does not help because it does not clear or restore site data
|
||||
* (#2228).
|
||||
*/
|
||||
let _persisted: boolean | null = null
|
||||
|
||||
export async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
|
||||
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
|
||||
_persisted = false
|
||||
return false
|
||||
}
|
||||
// Already persisted? Avoid re-prompting where the API distinguishes.
|
||||
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
|
||||
return await navigator.storage.persist()
|
||||
if (navigator.storage.persisted && (await navigator.storage.persisted())) {
|
||||
_persisted = true
|
||||
return true
|
||||
}
|
||||
_persisted = await navigator.storage.persist()
|
||||
return _persisted
|
||||
} catch {
|
||||
_persisted = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this origin's storage is exempt from eviction. Treats "not asked yet"
|
||||
* as not granted, so a caller sizing a download errs on the small side.
|
||||
*/
|
||||
export function isStoragePersisted(): boolean {
|
||||
return _persisted === true
|
||||
}
|
||||
|
||||
/** Test helper: forget the cached answer. */
|
||||
export function _resetPersistentStorage(): void {
|
||||
_persisted = null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import type { Place } from '../types'
|
||||
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
import { isAuthed } from './authGate'
|
||||
import { isStoragePersisted } from './persistentStorage'
|
||||
import { isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
|
||||
import { OFM_POSITRON } from '../constants/mapDefaults'
|
||||
import { clearVectorCache, prefetchVectorForPlaces } from './glPrefetcher'
|
||||
@@ -54,6 +55,13 @@ export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
|
||||
*/
|
||||
export const TILE_CONCURRENCY = 6
|
||||
|
||||
/**
|
||||
* Deepest zoom prefetched when the browser refused persistent storage. Keeps the
|
||||
* padded quota cost of one trip in the low hundreds of megabytes rather than the
|
||||
* tens of gigabytes a full z16 run bills (#2228).
|
||||
*/
|
||||
export const UNPERSISTED_MAX_ZOOM = 12
|
||||
|
||||
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
|
||||
const TILE_CACHE = 'map-tiles'
|
||||
|
||||
@@ -430,7 +438,16 @@ export async function prefetchTilesForTrip(
|
||||
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||
// these tiles under the inflated quota, we request persistent storage at app
|
||||
// init instead (sync/persistentStorage.ts).
|
||||
const fetched = await prefetchTiles(bbox, template, 0, 16, cartoKey)
|
||||
//
|
||||
// When that request was refused, the padding stops being an accounting detail:
|
||||
// a full run bills this origin tens of gigabytes it is not exempt from, and
|
||||
// the browser answers by evicting the whole bucket, including the precached
|
||||
// app shell, which then never comes back on its own. So cap the depth instead.
|
||||
// The shallow zooms are the ones the trip map actually opens at and cost a few
|
||||
// dozen tiles (#2180); the deep ones are the volume, and they are what the
|
||||
// user loses rather than the ability to start the app at all (#2228).
|
||||
const maxZoom = isStoragePersisted() ? 16 : UNPERSISTED_MAX_ZOOM
|
||||
const fetched = await prefetchTiles(bbox, template, 0, maxZoom, cartoKey)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
|
||||
@@ -77,18 +77,18 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('tripSyncManager.prepareForOffline — guards', () => {
|
||||
it('FE-SYNC-PREP-001: returns 0 and hits no endpoint when logged out', async () => {
|
||||
it('FE-SYNC-PREP-001: reports why it stopped and hits no endpoint when logged out', async () => {
|
||||
setAuthed(false)
|
||||
let called = false
|
||||
server.use(http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }) }))
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(0)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'signed-out' })
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-SYNC-PREP-002: returns 0 when the browser is offline', async () => {
|
||||
it('FE-SYNC-PREP-002: reports that it stopped because the browser is offline', async () => {
|
||||
setOnline(false)
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(0)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'offline' })
|
||||
})
|
||||
|
||||
it('FE-SYNC-PREP-003: a second concurrent run is refused by the syncing flag', async () => {
|
||||
@@ -99,7 +99,11 @@ describe('tripSyncManager.prepareForOffline — guards', () => {
|
||||
tripSyncManager.prepareForOffline(),
|
||||
tripSyncManager.prepareForOffline(),
|
||||
])
|
||||
expect([first, second].sort()).toEqual([0, 1])
|
||||
// The loser says so rather than reporting a successful run of nothing: the
|
||||
// settings screen paints that answer instead of a finished progress bar
|
||||
// (#2228).
|
||||
expect(first).toEqual({ status: 'done', trips: 1 })
|
||||
expect(second).toEqual({ status: 'skipped', reason: 'busy' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +116,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
const progress: PrepareProgress[] = []
|
||||
const count = await tripSyncManager.prepareForOffline(p => progress.push(p))
|
||||
|
||||
expect(count).toBe(1)
|
||||
expect(count).toEqual({ status: 'done', trips: 1 })
|
||||
expect(progress.map(p => p.phase)).toEqual(['trips', 'files', 'tiles', 'done'])
|
||||
expect(progress[0]).toMatchObject({ current: 1, total: 1, label: 'Munich' })
|
||||
expect(progress[3]).toMatchObject({ phase: 'done', current: 1, total: 1 })
|
||||
@@ -174,7 +178,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
http.get('/api/categories', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(1)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 })
|
||||
expect(await offlineDb.tags.count()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -183,7 +187,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
const broken = buildTrip({ id: 608, end_date: dateOffset(4) })
|
||||
serveTrips([broken, good], { 607: bundleFor(good) })
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(2)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 2 })
|
||||
expect(await offlineDb.trips.get(607)).toBeDefined()
|
||||
expect(await offlineDb.trips.get(608)).toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
@@ -196,7 +200,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
setTripOfflineEnabled(610, false)
|
||||
serveTrips([kept, excluded], { 609: bundleFor(kept), 610: bundleFor(excluded) })
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(1)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 })
|
||||
expect(await offlineDb.trips.get(609)).toBeDefined()
|
||||
expect(await offlineDb.trips.get(610)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs'
|
||||
import { getOfflinePrefs, isTripOfflineEnabled, isTripPinned } from './offlinePrefs'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -74,13 +74,24 @@ function whenIdle(fn: () => void | Promise<void>): void {
|
||||
else setTimeout(run, 2_000)
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
/**
|
||||
* Would the date rule keep this trip on its own? Exported so the offline
|
||||
* settings screen can show the honest state of a trip's switch: a finished trip
|
||||
* is not stored unless the user pins it, and a switch that reads "on" while
|
||||
* nothing is stored is what made "Download for offline use" look broken (#2228).
|
||||
*/
|
||||
export function isDateEligible(trip: Trip): boolean {
|
||||
if (!trip.end_date) return true // no end date → cache forever
|
||||
return trip.end_date >= todayStr() // ongoing or future
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
return isTripPinned(trip.id) || isDateEligible(trip)
|
||||
}
|
||||
|
||||
function isStale(trip: Trip): boolean {
|
||||
if (!trip.end_date) return false
|
||||
if (isTripPinned(trip.id)) return false // the user asked for it, keep it
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 7)
|
||||
return trip.end_date < ymd(cutoff)
|
||||
@@ -185,14 +196,32 @@ async function reconcileTrips(trips: Trip[]): Promise<Trip[]> {
|
||||
return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a sync did nothing. The settings screen shows these: a run that never
|
||||
* started used to be indistinguishable from a run that stored everything, so
|
||||
* "Download for offline use" reported success while the cache stayed at zero
|
||||
* and the user had no way to tell (#2228).
|
||||
*/
|
||||
export type SyncOutcome =
|
||||
| { status: 'done'; trips: number }
|
||||
| { status: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
||||
|
||||
function skipReason(): SyncOutcome | null {
|
||||
if (_syncing) return { status: 'skipped', reason: 'busy' }
|
||||
if (!navigator.onLine) return { status: 'skipped', reason: 'offline' }
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
return null
|
||||
}
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale and user-disabled trips. Caches file blobs + map tiles in the
|
||||
* background. No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
async syncAll(): Promise<SyncOutcome> {
|
||||
const skipped = skipReason()
|
||||
if (skipped) return skipped
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
@@ -201,7 +230,7 @@ export const tripSyncManager = {
|
||||
for (const trip of toSync) {
|
||||
// The gate is re-read per trip: a logout halfway through must not keep
|
||||
// writing the old account's rows into the (now anonymous) offline DB.
|
||||
if (!isAuthed()) return
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
@@ -220,7 +249,7 @@ export const tripSyncManager = {
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(trip.id, files).catch(console.error)
|
||||
}
|
||||
@@ -238,6 +267,7 @@ export const tripSyncManager = {
|
||||
}
|
||||
})
|
||||
}
|
||||
return { status: 'done', trips: toSync.length }
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
@@ -250,10 +280,11 @@ export const tripSyncManager = {
|
||||
* front instead of deferring them to idle time, reports progress, and forces
|
||||
* the tile prefetch to run even for a bbox we believe is already cached.
|
||||
*
|
||||
* Returns the number of trips prepared.
|
||||
* Returns how many trips were prepared, or why the run never started.
|
||||
*/
|
||||
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<number> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return 0
|
||||
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<SyncOutcome> {
|
||||
const skipped = skipReason()
|
||||
if (skipped) return skipped
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
@@ -263,7 +294,7 @@ export const tripSyncManager = {
|
||||
// 1) Trip bundles (structured data).
|
||||
let i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title })
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
@@ -281,7 +312,7 @@ export const tripSyncManager = {
|
||||
// 2) File blobs — awaited so "prepared" really means downloaded.
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'files', current: ++i, total, label: trip.title })
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
await cacheFilesForTrip(trip.id, files).catch(console.error)
|
||||
@@ -293,7 +324,7 @@ export const tripSyncManager = {
|
||||
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
|
||||
@@ -301,7 +332,7 @@ export const tripSyncManager = {
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'done', current: total, total })
|
||||
return total
|
||||
return { status: 'done', trips: total }
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
|
||||
@@ -21,16 +21,17 @@ const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const tripsCount = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
@@ -104,6 +105,7 @@ const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsCount.mockResolvedValue(rows.length);
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
@@ -126,6 +128,7 @@ beforeEach(() => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
@@ -134,8 +137,8 @@ beforeEach(() => {
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.syncAll.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
@@ -256,6 +259,7 @@ describe('MSettingsOffline', () => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
@@ -266,7 +270,7 @@ describe('MSettingsOffline', () => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -276,6 +280,7 @@ describe('MSettingsOffline', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 1 };
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
@@ -349,21 +354,36 @@ describe('MSettingsOffline', () => {
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-015: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
it('FE-MOB-OFFLINE-015: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
// Both fixture trips ended in 2025, so the date rule would skip them and the
|
||||
// switch has to say so rather than promising storage that never happens.
|
||||
const toggle = await screen.findByRole('switch', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([2]);
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'Tokyo' })).toHaveAttribute('aria-checked', 'true'));
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Tokyo' }));
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-022: a sync that never ran says so instead of reporting success', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'offline' });
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('No connection. Connect to store trips for offline use.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-016: the per-trip section disappears when there are no trips at all', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSystemNoticeStore } from '../../../src/store/systemNoticeStore';
|
||||
import { authApi } from '../../../src/api/client';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildUser } from '../../helpers/factories';
|
||||
import { isAuthed, setAuthed } from '../../../src/sync/authGate';
|
||||
|
||||
// The websocket module is already mocked globally in tests/setup.ts
|
||||
import { connect, disconnect } from '../../../src/api/websocket';
|
||||
@@ -21,12 +22,13 @@ vi.mock('../../../src/sync/syncTriggers', () => syncTriggers);
|
||||
|
||||
// The user-scoped offline DB is real (fake-indexeddb); only the reopen step is
|
||||
// made failable so the "auth still succeeds when the DB won't open" path runs.
|
||||
const dbControl = vi.hoisted(() => ({ reopenFails: false }));
|
||||
const dbControl = vi.hoisted(() => ({ reopenFails: false, reopenedFor: null as number | string | null }));
|
||||
vi.mock('../../../src/db/offlineDb', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/db/offlineDb')>();
|
||||
return {
|
||||
...actual,
|
||||
reopenForUser: async (userId: number | string) => {
|
||||
dbControl.reopenedFor = userId;
|
||||
if (dbControl.reopenFails) throw new Error('offline db locked');
|
||||
return actual.reopenForUser(userId);
|
||||
},
|
||||
@@ -37,6 +39,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
dbControl.reopenFails = false;
|
||||
dbControl.reopenedFor = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -318,6 +321,54 @@ describe('authStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-021: a session kept without a reachable server is still live (#2228)', () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
it('marks a session that launched offline as authenticated for the sync layer', async () => {
|
||||
// onAuthSuccess is the only caller of setAuthed(true), and the mutation
|
||||
// queue's flush, syncAll and prepareForOffline are all gated on it. The
|
||||
// offline branch used to keep the session but skip it, so a PWA that
|
||||
// launched without network could never sync again for the rest of its
|
||||
// life: the settings buttons spun and returned having done nothing.
|
||||
setAuthed(false);
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.error()));
|
||||
useAuthStore.setState({ user: buildUser({ id: 42 }), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(isAuthed()).toBe(true);
|
||||
// …and it points the offline DB at this user's scoped database, so reads
|
||||
// come from the right one rather than the anonymous fallback.
|
||||
expect(dbControl.reopenedFor).toBe(42);
|
||||
});
|
||||
|
||||
it('does the same when the server is erroring rather than absent', async () => {
|
||||
setAuthed(false);
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
useAuthStore.setState({ user: buildUser({ id: 7 }), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().authCheckFailed).toBe(true);
|
||||
expect(isAuthed()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the gate closed when the session was rejected', async () => {
|
||||
setAuthed(false);
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ error: 'nope' }, { status: 401 })));
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(isAuthed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-015: updateMapsKey', () => {
|
||||
it('updates user maps_api_key', async () => {
|
||||
server.use(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* offlinePrefs unit tests — device-local "what to store offline" + conflict strategy.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, isTripPinned, setTripPinned,
|
||||
onOfflinePrefsChange, _resetOfflinePrefs,
|
||||
} from '../../../src/sync/offlinePrefs'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -43,6 +44,34 @@ describe('offlinePrefs', () => {
|
||||
expect(getOfflinePrefs().disabledTripIds.filter(id => id === 3)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('#2228: a trip can be pinned so the date rule cannot skip or evict it', () => {
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
setTripPinned(5, true)
|
||||
expect(isTripPinned(5)).toBe(true)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([5])
|
||||
})
|
||||
|
||||
it('#2228: pinning is idempotent and releasing removes the pin', () => {
|
||||
setTripPinned(5, true)
|
||||
setTripPinned(5, true)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([5])
|
||||
setTripPinned(5, false)
|
||||
expect(isTripPinned(5)).toBe(false)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
})
|
||||
|
||||
it('#2228: prefs written before pinning existed still read back cleanly', async () => {
|
||||
// An installed device carries the old shape in localStorage. The module reads
|
||||
// it once at import, so re-import it to exercise that path: a missing
|
||||
// pinnedTripIds must default to "nothing pinned", never undefined.
|
||||
localStorage.setItem('trek_offline_prefs', JSON.stringify({ cacheTiles: false, disabledTripIds: [3], conflictStrategy: 'mine' }))
|
||||
vi.resetModules()
|
||||
const fresh = await import('../../../src/sync/offlinePrefs')
|
||||
expect(fresh.getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
expect(fresh.getOfflinePrefs().disabledTripIds).toEqual([3])
|
||||
expect(fresh.isTripPinned(3)).toBe(false)
|
||||
})
|
||||
|
||||
it('sets the conflict strategy', () => {
|
||||
setConflictStrategy('mine')
|
||||
expect(getOfflinePrefs().conflictStrategy).toBe('mine')
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
countTiles,
|
||||
prefetchTiles,
|
||||
prefetchTilesForTrip,
|
||||
UNPERSISTED_MAX_ZOOM,
|
||||
clearTileCache,
|
||||
MAX_TILES,
|
||||
TILE_CONCURRENCY,
|
||||
type TileBbox,
|
||||
} from '../../../src/sync/tilePrefetcher';
|
||||
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
|
||||
import { requestPersistentStorage, _resetPersistentStorage } from '../../../src/sync/persistentStorage';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
@@ -348,6 +350,30 @@ describe('prefetchTilesForTrip', () => {
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('#2228: caps the depth when the browser refused persistent storage', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
const places = [buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 })];
|
||||
|
||||
// Opaque tiles are billed at ~7 MB each. Without an eviction exemption a
|
||||
// full-depth run bills this origin tens of gigabytes, and the browser
|
||||
// answers by evicting the whole bucket, the precached app shell included,
|
||||
// which Workbox then never refills because sw.js has not changed.
|
||||
_resetPersistentStorage();
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
const zooms = vi.mocked(fetch).mock.calls.map(c => Number(new URL(String(c[0])).pathname.split('/')[1]));
|
||||
expect(Math.max(...zooms)).toBe(UNPERSISTED_MAX_ZOOM);
|
||||
|
||||
vi.mocked(fetch).mockClear();
|
||||
Object.defineProperty(navigator, 'storage', {
|
||||
configurable: true,
|
||||
value: { persist: async () => true, persisted: async () => true },
|
||||
});
|
||||
await requestPersistentStorage();
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png', true);
|
||||
const deepZooms = vi.mocked(fetch).mock.calls.map(c => Number(new URL(String(c[0])).pathname.split('/')[1]));
|
||||
expect(Math.max(...deepZooms)).toBe(16);
|
||||
});
|
||||
|
||||
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { setTripPinned, _resetOfflinePrefs } from '../../../src/sync/offlinePrefs';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
@@ -47,6 +48,7 @@ function makeBundle(tripId: number) {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
_resetOfflinePrefs();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
@@ -77,6 +79,28 @@ describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — offline guard', () => {
|
||||
it('#2228: reports why a run did not start instead of looking like a success', async () => {
|
||||
setAuthed(false);
|
||||
expect(await tripSyncManager.syncAll()).toEqual({ status: 'skipped', reason: 'signed-out' });
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'signed-out' });
|
||||
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
|
||||
expect(await tripSyncManager.syncAll()).toEqual({ status: 'skipped', reason: 'offline' });
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'offline' });
|
||||
});
|
||||
|
||||
it('#2228: reports how many trips a completed run stored', async () => {
|
||||
const tripId = 104;
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(3) });
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...makeBundle(tripId), trip })),
|
||||
);
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 });
|
||||
});
|
||||
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
@@ -147,6 +171,27 @@ describe('tripSyncManager.syncAll — trip filtering', () => {
|
||||
expect(bundleCalled).toBe(false);
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#2228: caches a past trip once the user pins it', async () => {
|
||||
const tripId = 103;
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(-40) });
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
// Without the pin the date rule refuses it, which is what made the per-trip
|
||||
// switch look like it did nothing for anyone whose trips are all finished.
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
|
||||
setTripPinned(tripId, true);
|
||||
tripSyncManager._resetSyncing();
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── stale eviction ─────────────────────────────────────────────────────────────
|
||||
@@ -167,6 +212,21 @@ describe('tripSyncManager.syncAll — stale eviction', () => {
|
||||
expect(await offlineDb.trips.get(staleId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#2228: does not evict a pinned trip, however long ago it ended', async () => {
|
||||
const pinnedId = 202;
|
||||
const trip = buildTrip({ id: pinnedId, end_date: dateOffset(-400) });
|
||||
await upsertTrip(trip);
|
||||
setTripPinned(pinnedId, true);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${pinnedId}/bundle`, () => HttpResponse.json({ ...makeBundle(pinnedId), trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(pinnedId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT evict trips that ended exactly 6 days ago', async () => {
|
||||
const recentId = 201;
|
||||
const bundle = makeBundle(recentId);
|
||||
|
||||
@@ -479,6 +479,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'الرحلات',
|
||||
'settings.offline.storage.tripOn': 'مخزّن دون اتصال',
|
||||
'settings.offline.storage.tripOff': 'غير مخزّن',
|
||||
'settings.offline.storage.tripFinished': 'منتهية. لن تُحفظ إلا إذا فعّلتها.',
|
||||
'settings.offline.notice.stored': 'تم حفظ {count} رحلة على هذا الجهاز',
|
||||
'settings.offline.notice.nothing': 'لا شيء للحفظ. فعّل الرحلات التي تريد الاحتفاظ بها.',
|
||||
'settings.offline.notice.busy': 'تتم مزامنة بالفعل. حاول مجددًا بعد قليل.',
|
||||
'settings.offline.notice.offline': 'لا يوجد اتصال. اتصل بالإنترنت لحفظ الرحلات للاستخدام دون اتصال.',
|
||||
'settings.offline.notice.signedOut': 'انتهت جلستك. سجّل الدخول مرة أخرى للمزامنة.',
|
||||
'settings.offline.notice.failed': 'تعذّر إكمال التنزيل. تحقّق من اتصالك ثم حاول مرة أخرى.',
|
||||
'settings.offline.notice.loadFailed': 'تعذّرت قراءة التخزين غير المتصل على هذا الجهاز. عادةً ما يُصلِح مسح الذاكرة المؤقتة المشكلة.',
|
||||
'settings.offline.clear': 'مسح ذاكرة التخزين المؤقت',
|
||||
'settings.offline.clearConfirm':
|
||||
'هل تريد مسح جميع بيانات الرحلة المخزّنة دون اتصال؟ يمكنك إعادة المزامنة في أي وقت أثناء الاتصال.',
|
||||
|
||||
@@ -494,6 +494,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Viagens',
|
||||
'settings.offline.storage.tripOn': 'Armazenada offline',
|
||||
'settings.offline.storage.tripOff': 'Não armazenada',
|
||||
'settings.offline.storage.tripFinished': 'Concluída. Só fica salva se você ativar.',
|
||||
'settings.offline.notice.stored': '{count} viagem(ns) salva(s) neste dispositivo',
|
||||
'settings.offline.notice.nothing': 'Nada para salvar. Ative as viagens que você quer manter.',
|
||||
'settings.offline.notice.busy': 'Já há uma sincronização em andamento. Tente de novo em instantes.',
|
||||
'settings.offline.notice.offline': 'Sem conexão. Conecte-se para salvar viagens off-line.',
|
||||
'settings.offline.notice.signedOut': 'Sua sessão expirou. Entre novamente para sincronizar.',
|
||||
'settings.offline.notice.failed': 'O download não pôde ser concluído. Verifique sua conexão e tente de novo.',
|
||||
'settings.offline.notice.loadFailed': 'Não foi possível ler o armazenamento off-line deste dispositivo. Limpar o cache costuma resolver.',
|
||||
'settings.offline.clear': 'Limpar cache',
|
||||
'settings.offline.clearConfirm':
|
||||
'Limpar todos os dados de viagem offline? Você pode sincronizar novamente a qualquer momento quando estiver online.',
|
||||
|
||||
@@ -475,6 +475,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Viatges',
|
||||
'settings.offline.storage.tripOn': 'Desat fora de línia',
|
||||
'settings.offline.storage.tripOff': 'No desat',
|
||||
'settings.offline.storage.tripFinished': 'Finalitzat. Només es desa si l’actives.',
|
||||
'settings.offline.notice.stored': '{count} viatge(s) desat(s) en aquest dispositiu',
|
||||
'settings.offline.notice.nothing': 'No hi ha res a desar. Activa els viatges que vulguis conservar.',
|
||||
'settings.offline.notice.busy': 'Ja hi ha una sincronització en curs. Torna-ho a provar d’aquí a un moment.',
|
||||
'settings.offline.notice.offline': 'Sense connexió. Connecta’t per desar viatges fora de línia.',
|
||||
'settings.offline.notice.signedOut': 'La teva sessió ha caducat. Torna a iniciar la sessió per sincronitzar.',
|
||||
'settings.offline.notice.failed': 'La descàrrega no s’ha pogut completar. Comprova la connexió i torna-ho a provar.',
|
||||
'settings.offline.notice.loadFailed': 'No s’ha pogut llegir l’emmagatzematge fora de línia d’aquest dispositiu. Normalment es resol buidant la memòria cau.',
|
||||
'settings.offline.clear': 'Netejar memòria cau',
|
||||
'settings.offline.clearConfirm':
|
||||
'Vols netejar totes les dades de viatge fora de línia? Pots tornar a sincronitzar en qualsevol moment mentre estiguis connectat.',
|
||||
|
||||
@@ -487,6 +487,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Cesty',
|
||||
'settings.offline.storage.tripOn': 'Uloženo offline',
|
||||
'settings.offline.storage.tripOff': 'Neuloženo',
|
||||
'settings.offline.storage.tripFinished': 'Ukončeno. Uloží se, jen když to zapnete.',
|
||||
'settings.offline.notice.stored': 'Uloženo {count} cest v tomto zařízení',
|
||||
'settings.offline.notice.nothing': 'Není co ukládat. Zapněte cesty, které si chcete ponechat.',
|
||||
'settings.offline.notice.busy': 'Synchronizace už probíhá. Zkuste to za chvíli znovu.',
|
||||
'settings.offline.notice.offline': 'Bez připojení. Připojte se, abyste mohli ukládat cesty offline.',
|
||||
'settings.offline.notice.signedOut': 'Vaše relace vypršela. Pro synchronizaci se znovu přihlaste.',
|
||||
'settings.offline.notice.failed': 'Stahování se nepodařilo dokončit. Zkontrolujte připojení a zkuste to znovu.',
|
||||
'settings.offline.notice.loadFailed': 'Offline úložiště tohoto zařízení se nepodařilo načíst. Obvykle pomůže vymazání mezipaměti.',
|
||||
'settings.offline.clear': 'Vymazat mezipaměť',
|
||||
'settings.offline.clearConfirm': 'Vymazat všechna offline data cest? Kdykoli online je můžete znovu synchronizovat.',
|
||||
'settings.offline.stats.trips': 'Cesty v mezipaměti',
|
||||
|
||||
@@ -496,6 +496,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Reisen',
|
||||
'settings.offline.storage.tripOn': 'Offline gespeichert',
|
||||
'settings.offline.storage.tripOff': 'Nicht gespeichert',
|
||||
'settings.offline.storage.tripFinished': 'Abgeschlossen. Wird nur gespeichert, wenn du es einschaltest.',
|
||||
'settings.offline.notice.stored': '{count} Reise(n) auf diesem Gerät gespeichert',
|
||||
'settings.offline.notice.nothing': 'Nichts zu speichern. Schalte die Reisen ein, die du behalten willst.',
|
||||
'settings.offline.notice.busy': 'Eine Synchronisierung läuft bereits. Versuche es gleich noch einmal.',
|
||||
'settings.offline.notice.offline': 'Keine Verbindung. Verbinde dich, um Reisen offline zu speichern.',
|
||||
'settings.offline.notice.signedOut': 'Deine Sitzung ist abgelaufen. Melde dich erneut an, um zu synchronisieren.',
|
||||
'settings.offline.notice.failed': 'Der Download konnte nicht abgeschlossen werden. Prüfe deine Verbindung und versuche es erneut.',
|
||||
'settings.offline.notice.loadFailed': 'Der Offline-Speicher dieses Geräts konnte nicht gelesen werden. Meist hilft es, den Cache zu leeren.',
|
||||
'settings.offline.clear': 'Cache leeren',
|
||||
'settings.offline.clearConfirm':
|
||||
'Alle offline gespeicherten Reisedaten löschen? Du kannst jederzeit online neu synchronisieren.',
|
||||
|
||||
@@ -495,6 +495,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Trips',
|
||||
'settings.offline.storage.tripOn': 'Stored offline',
|
||||
'settings.offline.storage.tripOff': 'Not stored',
|
||||
'settings.offline.storage.tripFinished': 'Finished. Only stored if you switch it on.',
|
||||
'settings.offline.notice.stored': 'Stored {count} trip(s) on this device',
|
||||
'settings.offline.notice.nothing': 'Nothing to store. Switch on the trips you want to keep.',
|
||||
'settings.offline.notice.busy': 'A sync is already running. Try again in a moment.',
|
||||
'settings.offline.notice.offline': 'No connection. Connect to store trips for offline use.',
|
||||
'settings.offline.notice.signedOut': 'Your session has ended. Sign in again to sync.',
|
||||
'settings.offline.notice.failed': 'The download could not finish. Check your connection and try again.',
|
||||
'settings.offline.notice.loadFailed': 'Could not read this device’s offline storage. Clearing the cache usually fixes it.',
|
||||
'settings.offline.clear': 'Clear cache',
|
||||
'settings.offline.clearConfirm': 'Clear all offline trip data? You can re-sync anytime while online.',
|
||||
'settings.offline.stats.trips': 'Cached trips',
|
||||
|
||||
@@ -495,6 +495,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Viajes',
|
||||
'settings.offline.storage.tripOn': 'Almacenado offline',
|
||||
'settings.offline.storage.tripOff': 'No almacenado',
|
||||
'settings.offline.storage.tripFinished': 'Finalizado. Solo se guarda si lo activas.',
|
||||
'settings.offline.notice.stored': '{count} viaje(s) guardado(s) en este dispositivo',
|
||||
'settings.offline.notice.nothing': 'Nada que guardar. Activa los viajes que quieras conservar.',
|
||||
'settings.offline.notice.busy': 'Ya hay una sincronización en curso. Inténtalo de nuevo en un momento.',
|
||||
'settings.offline.notice.offline': 'Sin conexión. Conéctate para guardar viajes sin conexión.',
|
||||
'settings.offline.notice.signedOut': 'Tu sesión ha caducado. Vuelve a iniciar sesión para sincronizar.',
|
||||
'settings.offline.notice.failed': 'La descarga no se pudo completar. Comprueba tu conexión e inténtalo de nuevo.',
|
||||
'settings.offline.notice.loadFailed': 'No se pudo leer el almacenamiento sin conexión de este dispositivo. Normalmente se soluciona vaciando la caché.',
|
||||
'settings.offline.clear': 'Vaciar caché',
|
||||
'settings.offline.clearConfirm':
|
||||
'¿Vaciar todos los datos de viaje offline? Puedes volver a sincronizar en cualquier momento con conexión.',
|
||||
|
||||
@@ -502,6 +502,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Voyages',
|
||||
'settings.offline.storage.tripOn': 'Stocké hors ligne',
|
||||
'settings.offline.storage.tripOff': 'Non stocké',
|
||||
'settings.offline.storage.tripFinished': 'Terminé. Conservé uniquement si vous l’activez.',
|
||||
'settings.offline.notice.stored': '{count} voyage(s) enregistré(s) sur cet appareil',
|
||||
'settings.offline.notice.nothing': 'Rien à enregistrer. Activez les voyages que vous souhaitez conserver.',
|
||||
'settings.offline.notice.busy': 'Une synchronisation est déjà en cours. Réessayez dans un instant.',
|
||||
'settings.offline.notice.offline': 'Aucune connexion. Connectez-vous pour enregistrer des voyages hors ligne.',
|
||||
'settings.offline.notice.signedOut': 'Votre session a expiré. Reconnectez-vous pour synchroniser.',
|
||||
'settings.offline.notice.failed': 'Le téléchargement n’a pas pu se terminer. Vérifiez votre connexion et réessayez.',
|
||||
'settings.offline.notice.loadFailed': 'Impossible de lire le stockage hors ligne de cet appareil. Vider le cache résout généralement le problème.',
|
||||
'settings.offline.clear': 'Vider le cache',
|
||||
'settings.offline.clearConfirm':
|
||||
'Vider toutes les données de voyage hors ligne ? Vous pourrez resynchroniser à tout moment lorsque vous serez en ligne.',
|
||||
|
||||
@@ -505,6 +505,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Ταξίδια',
|
||||
'settings.offline.storage.tripOn': 'Αποθηκευμένο εκτός σύνδεσης',
|
||||
'settings.offline.storage.tripOff': 'Μη αποθηκευμένο',
|
||||
'settings.offline.storage.tripFinished': 'Ολοκληρώθηκε. Αποθηκεύεται μόνο αν το ενεργοποιήσετε.',
|
||||
'settings.offline.notice.stored': 'Αποθηκεύτηκαν {count} ταξίδια σε αυτή τη συσκευή',
|
||||
'settings.offline.notice.nothing': 'Δεν υπάρχει τίποτα για αποθήκευση. Ενεργοποιήστε τα ταξίδια που θέλετε να κρατήσετε.',
|
||||
'settings.offline.notice.busy': 'Γίνεται ήδη συγχρονισμός. Δοκιμάστε ξανά σε λίγο.',
|
||||
'settings.offline.notice.offline': 'Δεν υπάρχει σύνδεση. Συνδεθείτε για να αποθηκεύσετε ταξίδια για χρήση χωρίς σύνδεση.',
|
||||
'settings.offline.notice.signedOut': 'Η συνεδρία σας έληξε. Συνδεθείτε ξανά για συγχρονισμό.',
|
||||
'settings.offline.notice.failed': 'Η λήψη δεν ολοκληρώθηκε. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.',
|
||||
'settings.offline.notice.loadFailed': 'Δεν ήταν δυνατή η ανάγνωση του τοπικού χώρου αποθήκευσης. Συνήθως βοηθάει η εκκαθάριση της προσωρινής μνήμης.',
|
||||
'settings.offline.clear': 'Εκκαθάριση προσωρινής μνήμης',
|
||||
'settings.offline.clearConfirm':
|
||||
'Εκκαθάριση όλων των δεδομένων ταξιδιού εκτός σύνδεσης; Μπορείτε να επανασυγχρονίσετε ανά πάσα στιγμή όσο είστε συνδεδεμένοι.',
|
||||
|
||||
@@ -495,6 +495,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Utazások',
|
||||
'settings.offline.storage.tripOn': 'Offline tárolva',
|
||||
'settings.offline.storage.tripOff': 'Nincs tárolva',
|
||||
'settings.offline.storage.tripFinished': 'Befejezve. Csak akkor mentjük, ha bekapcsolod.',
|
||||
'settings.offline.notice.stored': '{count} utazás mentve ezen az eszközön',
|
||||
'settings.offline.notice.nothing': 'Nincs mit menteni. Kapcsold be azokat az utazásokat, amelyeket meg szeretnél tartani.',
|
||||
'settings.offline.notice.busy': 'Már fut egy szinkronizálás. Próbáld újra egy pillanat múlva.',
|
||||
'settings.offline.notice.offline': 'Nincs kapcsolat. Csatlakozz, hogy offline menthesd az utazásokat.',
|
||||
'settings.offline.notice.signedOut': 'A munkamenet lejárt. Jelentkezz be újra a szinkronizáláshoz.',
|
||||
'settings.offline.notice.failed': 'A letöltés nem fejeződött be. Ellenőrizd a kapcsolatot, és próbáld újra.',
|
||||
'settings.offline.notice.loadFailed': 'Nem sikerült olvasni az eszköz offline tárhelyét. Általában segít a gyorsítótár törlése.',
|
||||
'settings.offline.clear': 'Gyorsítótár törlése',
|
||||
'settings.offline.clearConfirm':
|
||||
'Törlöd az összes offline utazási adatot? Online állapotban bármikor újraszinkronizálhatsz.',
|
||||
|
||||
@@ -492,6 +492,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Perjalanan',
|
||||
'settings.offline.storage.tripOn': 'Disimpan offline',
|
||||
'settings.offline.storage.tripOff': 'Tidak disimpan',
|
||||
'settings.offline.storage.tripFinished': 'Selesai. Hanya disimpan jika kamu mengaktifkannya.',
|
||||
'settings.offline.notice.stored': '{count} perjalanan tersimpan di perangkat ini',
|
||||
'settings.offline.notice.nothing': 'Tidak ada yang perlu disimpan. Aktifkan perjalanan yang ingin kamu simpan.',
|
||||
'settings.offline.notice.busy': 'Sinkronisasi sedang berjalan. Coba lagi sebentar.',
|
||||
'settings.offline.notice.offline': 'Tidak ada koneksi. Hubungkan untuk menyimpan perjalanan secara offline.',
|
||||
'settings.offline.notice.signedOut': 'Sesimu telah berakhir. Masuk lagi untuk menyinkronkan.',
|
||||
'settings.offline.notice.failed': 'Unduhan tidak dapat diselesaikan. Periksa koneksimu dan coba lagi.',
|
||||
'settings.offline.notice.loadFailed': 'Tidak dapat membaca penyimpanan offline perangkat ini. Membersihkan cache biasanya memperbaikinya.',
|
||||
'settings.offline.clear': 'Hapus cache',
|
||||
'settings.offline.clearConfirm':
|
||||
'Hapus semua data perjalanan offline? Kamu bisa menyinkronkan ulang kapan saja saat online.',
|
||||
|
||||
@@ -494,6 +494,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Viaggi',
|
||||
'settings.offline.storage.tripOn': 'Archiviato offline',
|
||||
'settings.offline.storage.tripOff': 'Non archiviato',
|
||||
'settings.offline.storage.tripFinished': 'Terminato. Salvato solo se lo attivi.',
|
||||
'settings.offline.notice.stored': '{count} viaggio/i salvato/i su questo dispositivo',
|
||||
'settings.offline.notice.nothing': 'Niente da salvare. Attiva i viaggi che vuoi conservare.',
|
||||
'settings.offline.notice.busy': 'Una sincronizzazione è già in corso. Riprova tra un momento.',
|
||||
'settings.offline.notice.offline': 'Nessuna connessione. Connettiti per salvare i viaggi offline.',
|
||||
'settings.offline.notice.signedOut': 'La tua sessione è scaduta. Accedi di nuovo per sincronizzare.',
|
||||
'settings.offline.notice.failed': 'Il download non è stato completato. Controlla la connessione e riprova.',
|
||||
'settings.offline.notice.loadFailed': 'Impossibile leggere l’archivio offline di questo dispositivo. Di solito basta svuotare la cache.',
|
||||
'settings.offline.clear': 'Svuota la cache',
|
||||
'settings.offline.clearConfirm':
|
||||
'Cancellare tutti i dati di viaggio offline? Puoi risincronizzare in qualsiasi momento quando sei online.',
|
||||
|
||||
@@ -466,6 +466,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': '旅行',
|
||||
'settings.offline.storage.tripOn': 'オフラインで保存済み',
|
||||
'settings.offline.storage.tripOff': '未保存',
|
||||
'settings.offline.storage.tripFinished': '終了済み。オンにした場合のみ保存されます。',
|
||||
'settings.offline.notice.stored': '{count} 件の旅行をこの端末に保存しました',
|
||||
'settings.offline.notice.nothing': '保存するものがありません。残しておきたい旅行をオンにしてください。',
|
||||
'settings.offline.notice.busy': 'すでに同期中です。しばらくしてからもう一度お試しください。',
|
||||
'settings.offline.notice.offline': '接続されていません。オフライン用に保存するには接続してください。',
|
||||
'settings.offline.notice.signedOut': 'セッションが終了しました。同期するには再度ログインしてください。',
|
||||
'settings.offline.notice.failed': 'ダウンロードを完了できませんでした。接続を確認してもう一度お試しください。',
|
||||
'settings.offline.notice.loadFailed': 'この端末のオフラインストレージを読み取れませんでした。キャッシュを削除すると解決することが多いです。',
|
||||
'settings.offline.clear': 'キャッシュを消去',
|
||||
'settings.offline.clearConfirm': 'すべてのオフライン旅行データを消去しますか?オンライン時にいつでも再同期できます。',
|
||||
'settings.offline.stats.trips': 'キャッシュ済みの旅行',
|
||||
|
||||
@@ -482,6 +482,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': '여행',
|
||||
'settings.offline.storage.tripOn': '오프라인에 저장됨',
|
||||
'settings.offline.storage.tripOff': '저장 안 됨',
|
||||
'settings.offline.storage.tripFinished': '종료됨. 켜야만 저장됩니다.',
|
||||
'settings.offline.notice.stored': '이 기기에 여행 {count}개를 저장했습니다',
|
||||
'settings.offline.notice.nothing': '저장할 항목이 없습니다. 보관할 여행을 켜세요.',
|
||||
'settings.offline.notice.busy': '이미 동기화가 진행 중입니다. 잠시 후 다시 시도하세요.',
|
||||
'settings.offline.notice.offline': '연결되지 않았습니다. 오프라인으로 저장하려면 연결하세요.',
|
||||
'settings.offline.notice.signedOut': '세션이 종료되었습니다. 동기화하려면 다시 로그인하세요.',
|
||||
'settings.offline.notice.failed': '다운로드를 끝낼 수 없었습니다. 연결을 확인하고 다시 시도하세요.',
|
||||
'settings.offline.notice.loadFailed': '이 기기의 오프라인 저장소를 읽지 못했습니다. 보통 캐시를 지우면 해결됩니다.',
|
||||
'settings.offline.clear': '캐시 지우기',
|
||||
'settings.offline.clearConfirm':
|
||||
'모든 오프라인 여행 데이터를 지울까요? 온라인일 때 언제든지 다시 동기화할 수 있습니다.',
|
||||
|
||||
@@ -495,6 +495,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Reizen',
|
||||
'settings.offline.storage.tripOn': 'Offline opgeslagen',
|
||||
'settings.offline.storage.tripOff': 'Niet opgeslagen',
|
||||
'settings.offline.storage.tripFinished': 'Afgelopen. Alleen opgeslagen als je het inschakelt.',
|
||||
'settings.offline.notice.stored': '{count} reis/reizen op dit apparaat opgeslagen',
|
||||
'settings.offline.notice.nothing': 'Niets op te slaan. Schakel de reizen in die je wilt bewaren.',
|
||||
'settings.offline.notice.busy': 'Er loopt al een synchronisatie. Probeer het zo opnieuw.',
|
||||
'settings.offline.notice.offline': 'Geen verbinding. Maak verbinding om reizen offline op te slaan.',
|
||||
'settings.offline.notice.signedOut': 'Je sessie is verlopen. Meld je opnieuw aan om te synchroniseren.',
|
||||
'settings.offline.notice.failed': 'De download kon niet worden voltooid. Controleer je verbinding en probeer het opnieuw.',
|
||||
'settings.offline.notice.loadFailed': 'Kan de offlineopslag van dit apparaat niet lezen. De cache wissen helpt meestal.',
|
||||
'settings.offline.clear': 'Cache wissen',
|
||||
'settings.offline.clearConfirm':
|
||||
'Alle offline reisgegevens wissen? Je kunt altijd opnieuw synchroniseren wanneer je online bent.',
|
||||
|
||||
@@ -494,6 +494,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Podróże',
|
||||
'settings.offline.storage.tripOn': 'Przechowywane offline',
|
||||
'settings.offline.storage.tripOff': 'Nieprzechowywane',
|
||||
'settings.offline.storage.tripFinished': 'Zakończona. Zapisywana tylko wtedy, gdy ją włączysz.',
|
||||
'settings.offline.notice.stored': 'Zapisano {count} podróży na tym urządzeniu',
|
||||
'settings.offline.notice.nothing': 'Nie ma czego zapisać. Włącz podróże, które chcesz zachować.',
|
||||
'settings.offline.notice.busy': 'Synchronizacja już trwa. Spróbuj ponownie za chwilę.',
|
||||
'settings.offline.notice.offline': 'Brak połączenia. Połącz się, aby zapisać podróże offline.',
|
||||
'settings.offline.notice.signedOut': 'Twoja sesja wygasła. Zaloguj się ponownie, aby zsynchronizować.',
|
||||
'settings.offline.notice.failed': 'Nie udało się ukończyć pobierania. Sprawdź połączenie i spróbuj ponownie.',
|
||||
'settings.offline.notice.loadFailed': 'Nie udało się odczytać pamięci offline tego urządzenia. Zwykle pomaga wyczyszczenie pamięci podręcznej.',
|
||||
'settings.offline.clear': 'Wyczyść pamięć podręczną',
|
||||
'settings.offline.clearConfirm':
|
||||
'Wyczyścić wszystkie dane podróży zapisane offline? Możesz je w każdej chwili zsynchronizować ponownie, będąc online.',
|
||||
|
||||
@@ -496,6 +496,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Поездки',
|
||||
'settings.offline.storage.tripOn': 'Хранится офлайн',
|
||||
'settings.offline.storage.tripOff': 'Не хранится',
|
||||
'settings.offline.storage.tripFinished': 'Завершёна. Сохраняется, только если вы её включите.',
|
||||
'settings.offline.notice.stored': 'На этом устройстве сохранено поездок: {count}',
|
||||
'settings.offline.notice.nothing': 'Сохранять нечего. Включите поездки, которые хотите оставить.',
|
||||
'settings.offline.notice.busy': 'Синхронизация уже идёт. Повторите попытку через мгновение.',
|
||||
'settings.offline.notice.offline': 'Нет соединения. Подключитесь, чтобы сохранить поездки офлайн.',
|
||||
'settings.offline.notice.signedOut': 'Сеанс завершён. Войдите снова, чтобы синхронизировать.',
|
||||
'settings.offline.notice.failed': 'Загрузку не удалось завершить. Проверьте соединение и повторите попытку.',
|
||||
'settings.offline.notice.loadFailed': 'Не удалось прочитать офлайн-хранилище этого устройства. Обычно помогает очистка кэша.',
|
||||
'settings.offline.clear': 'Очистить кэш',
|
||||
'settings.offline.clearConfirm':
|
||||
'Очистить все офлайн-данные поездок? Вы сможете синхронизировать их снова в любой момент при наличии подключения.',
|
||||
|
||||
@@ -492,6 +492,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Resor',
|
||||
'settings.offline.storage.tripOn': 'Lagrad offline',
|
||||
'settings.offline.storage.tripOff': 'Inte lagrad',
|
||||
'settings.offline.storage.tripFinished': 'Avslutad. Sparas bara om du slår på den.',
|
||||
'settings.offline.notice.stored': '{count} resa/resor sparade på den här enheten',
|
||||
'settings.offline.notice.nothing': 'Inget att spara. Slå på de resor du vill behålla.',
|
||||
'settings.offline.notice.busy': 'En synkronisering pågår redan. Försök igen om en stund.',
|
||||
'settings.offline.notice.offline': 'Ingen anslutning. Anslut för att spara resor offline.',
|
||||
'settings.offline.notice.signedOut': 'Din session har upphört. Logga in igen för att synkronisera.',
|
||||
'settings.offline.notice.failed': 'Nedladdningen kunde inte slutföras. Kontrollera anslutningen och försök igen.',
|
||||
'settings.offline.notice.loadFailed': 'Kunde inte läsa enhetens offlinelagring. Att rensa cachen brukar hjälpa.',
|
||||
'settings.offline.clear': 'Rensa cache',
|
||||
'settings.offline.clearConfirm': 'Rensa alla offline-resdata? Du kan synkronisera om när som helst när du är online.',
|
||||
'settings.offline.stats.trips': 'Cachelagrade resor',
|
||||
|
||||
@@ -492,6 +492,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Geziler',
|
||||
'settings.offline.storage.tripOn': 'Çevrimdışı saklandı',
|
||||
'settings.offline.storage.tripOff': 'Saklanmadı',
|
||||
'settings.offline.storage.tripFinished': 'Tamamlandı. Yalnızca açarsanız saklanır.',
|
||||
'settings.offline.notice.stored': 'Bu cihaza {count} gezi kaydedildi',
|
||||
'settings.offline.notice.nothing': 'Kaydedilecek bir şey yok. Saklamak istediğiniz gezileri açın.',
|
||||
'settings.offline.notice.busy': 'Zaten bir eşitleme sürüyor. Birazdan yeniden deneyin.',
|
||||
'settings.offline.notice.offline': 'Bağlantı yok. Gezileri çevrimdışı kaydetmek için bağlanın.',
|
||||
'settings.offline.notice.signedOut': 'Oturumunuz sona erdi. Eşitlemek için tekrar giriş yapın.',
|
||||
'settings.offline.notice.failed': 'İndirme tamamlanamadı. Bağlantınızı kontrol edip yeniden deneyin.',
|
||||
'settings.offline.notice.loadFailed': 'Bu cihazın çevrimdışı deposu okunamadı. Genellikle önbelleği temizlemek sorunu çözer.',
|
||||
'settings.offline.clear': 'Önbelleği temizle',
|
||||
'settings.offline.clearConfirm':
|
||||
'Tüm çevrimdışı gezi verileri silinsin mi? Çevrimiçiyken istediğiniz zaman yeniden senkronize edebilirsiniz.',
|
||||
|
||||
@@ -495,6 +495,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Поїздки',
|
||||
'settings.offline.storage.tripOn': 'Збережено офлайн',
|
||||
'settings.offline.storage.tripOff': 'Не збережено',
|
||||
'settings.offline.storage.tripFinished': 'Завершено. Зберігається, лише якщо ви це увімкнете.',
|
||||
'settings.offline.notice.stored': 'На цьому пристрої збережено подорожей: {count}',
|
||||
'settings.offline.notice.nothing': 'Нічого зберігати. Увімкніть подорожі, які хочете залишити.',
|
||||
'settings.offline.notice.busy': 'Синхронізація вже триває. Спробуйте ще раз за мить.',
|
||||
'settings.offline.notice.offline': 'Немає з’єднання. Під’єднайтеся, щоб зберегти подорожі офлайн.',
|
||||
'settings.offline.notice.signedOut': 'Сеанс завершився. Увійдіть знову, щоб синхронізувати.',
|
||||
'settings.offline.notice.failed': 'Не вдалося завершити завантаження. Перевірте з’єднання і спробуйте ще раз.',
|
||||
'settings.offline.notice.loadFailed': 'Не вдалося прочитати офлайн-сховище цього пристрою. Зазвичай допомагає очищення кешу.',
|
||||
'settings.offline.clear': 'Очистити кеш',
|
||||
'settings.offline.clearConfirm':
|
||||
'Очистити всі офлайн-дані поїздок? Ви можете синхронізувати їх будь-коли в режимі онлайн.',
|
||||
|
||||
@@ -496,6 +496,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': 'Chuyến đi',
|
||||
'settings.offline.storage.tripOn': 'Đã lưu ngoại tuyến',
|
||||
'settings.offline.storage.tripOff': 'Chưa lưu',
|
||||
'settings.offline.storage.tripFinished': 'Đã kết thúc. Chỉ lưu nếu bạn bật lên.',
|
||||
'settings.offline.notice.stored': 'Đã lưu {count} chuyến đi trên thiết bị này',
|
||||
'settings.offline.notice.nothing': 'Không có gì để lưu. Hãy bật những chuyến đi bạn muốn giữ.',
|
||||
'settings.offline.notice.busy': 'Đang đồng bộ. Vui lòng thử lại sau giây lát.',
|
||||
'settings.offline.notice.offline': 'Không có kết nối. Hãy kết nối để lưu chuyến đi ngoại tuyến.',
|
||||
'settings.offline.notice.signedOut': 'Phiên của bạn đã kết thúc. Hãy đăng nhập lại để đồng bộ.',
|
||||
'settings.offline.notice.failed': 'Không thể hoàn tất việc tải xuống. Hãy kiểm tra kết nối và thử lại.',
|
||||
'settings.offline.notice.loadFailed': 'Không đọc được bộ nhớ ngoại tuyến của thiết bị này. Xóa bộ nhớ đệm thường khắc phục được.',
|
||||
'settings.offline.clear': 'Xóa bộ nhớ đệm',
|
||||
'settings.offline.clearConfirm':
|
||||
'Xóa tất cả dữ liệu chuyến đi ngoại tuyến? Bạn có thể đồng bộ lại bất cứ lúc nào khi trực tuyến.',
|
||||
|
||||
@@ -462,6 +462,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': '行程',
|
||||
'settings.offline.storage.tripOn': '已離線儲存',
|
||||
'settings.offline.storage.tripOff': '未儲存',
|
||||
'settings.offline.storage.tripFinished': '已結束。僅在開啟後儲存。',
|
||||
'settings.offline.notice.stored': '已在此裝置儲存 {count} 個行程',
|
||||
'settings.offline.notice.nothing': '沒有可儲存的內容。請開啟你想保留的行程。',
|
||||
'settings.offline.notice.busy': '同步正在進行中,請稍後再試。',
|
||||
'settings.offline.notice.offline': '沒有連線。請連線以儲存行程供離線使用。',
|
||||
'settings.offline.notice.signedOut': '導覽階段已結束。請重新登入以同步。',
|
||||
'settings.offline.notice.failed': '下載未能完成。請檢查網路連線後重試。',
|
||||
'settings.offline.notice.loadFailed': '無法讀取此裝置的離線儲存。清除快取通常可以解決。',
|
||||
'settings.offline.clear': '清除快取',
|
||||
'settings.offline.clearConfirm': '要清除所有離線行程資料嗎?你可以在連線時隨時重新同步。',
|
||||
'settings.offline.stats.trips': '已快取的行程',
|
||||
|
||||
@@ -458,6 +458,14 @@ const settings: TranslationStrings = {
|
||||
'settings.offline.storage.tripsTitle': '行程',
|
||||
'settings.offline.storage.tripOn': '已离线存储',
|
||||
'settings.offline.storage.tripOff': '未存储',
|
||||
'settings.offline.storage.tripFinished': '已结束。仅在开启后保存。',
|
||||
'settings.offline.notice.stored': '已在此设备保存 {count} 个行程',
|
||||
'settings.offline.notice.nothing': '没有可保存的内容。请开启你想保留的行程。',
|
||||
'settings.offline.notice.busy': '同步正在进行中,请稍后重试。',
|
||||
'settings.offline.notice.offline': '没有连接。请连接网络以保存行程供离线使用。',
|
||||
'settings.offline.notice.signedOut': '会话已结束。请重新登录以同步。',
|
||||
'settings.offline.notice.failed': '下载未能完成。请检查网络连接后重试。',
|
||||
'settings.offline.notice.loadFailed': '无法读取此设备的离线存储。清除缓存通常可以解决。',
|
||||
'settings.offline.clear': '清除缓存',
|
||||
'settings.offline.clearConfirm': '清除所有离线行程数据?联网时你可以随时重新同步。',
|
||||
'settings.offline.stats.trips': '已缓存行程',
|
||||
|
||||
Reference in New Issue
Block a user