From f092e7a95f5dc8c52a3af560ba67051cdc880b10 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:30:07 +0200 Subject: [PATCH] fix(offline): a write the server keeps failing on no longer blocks every other write A queued change the server answered with a 5xx was put back at the head of the line and the whole flush stopped, every 30 seconds, forever: one bad payload held every later change of every trip. Now only its own trip waits, with gaps doubling from 30 s, and after eight failures it is parked as failed. A DELETE answered 404 counts as done instead of showing as a failed sync. Settings > Offline offers to try parked changes again or discard them. --- .../components/Settings/OfflineTab.test.tsx | 19 ++++ client/src/components/Settings/OfflineTab.tsx | 18 +++- .../components/Settings/useOfflineSettings.ts | 13 ++- client/src/db/offlineDb.ts | 6 ++ .../screens/settings/MSettingsOffline.tsx | 18 +++- client/src/sync/mutationQueue.ts | 74 +++++++++++++++- client/tests/unit/sync/mutationQueue.test.ts | 86 +++++++++++++++++++ shared/src/i18n/ar/settings.ts | 3 + shared/src/i18n/az/settings.ts | 4 + shared/src/i18n/br/settings.ts | 4 + shared/src/i18n/ca/settings.ts | 4 + shared/src/i18n/cs/settings.ts | 4 + shared/src/i18n/de/settings.ts | 4 + shared/src/i18n/en/settings.ts | 4 + shared/src/i18n/es/settings.ts | 4 + shared/src/i18n/et/settings.ts | 4 + shared/src/i18n/fr/settings.ts | 4 + shared/src/i18n/gr/settings.ts | 4 + shared/src/i18n/hu/settings.ts | 4 + shared/src/i18n/id/settings.ts | 4 + shared/src/i18n/it/settings.ts | 4 + shared/src/i18n/ja/settings.ts | 3 + shared/src/i18n/ko/settings.ts | 3 + shared/src/i18n/nl/settings.ts | 4 + shared/src/i18n/pl/settings.ts | 4 + shared/src/i18n/ru/settings.ts | 4 + shared/src/i18n/sk/settings.ts | 4 + shared/src/i18n/sv/settings.ts | 4 + shared/src/i18n/th/settings.ts | 4 + shared/src/i18n/tr/settings.ts | 4 + shared/src/i18n/uk/settings.ts | 4 + shared/src/i18n/vi/settings.ts | 4 + shared/src/i18n/zh-TW/settings.ts | 3 + shared/src/i18n/zh/settings.ts | 3 + 34 files changed, 331 insertions(+), 6 deletions(-) diff --git a/client/src/components/Settings/OfflineTab.test.tsx b/client/src/components/Settings/OfflineTab.test.tsx index 58f131e3f..2f4467f16 100644 --- a/client/src/components/Settings/OfflineTab.test.tsx +++ b/client/src/components/Settings/OfflineTab.test.tsx @@ -44,6 +44,8 @@ const h = vi.hoisted(() => { conflicts: vi.fn(), resolveKeepMine: vi.fn(), resolveKeepServer: vi.fn(), + retryFailed: vi.fn(), + discardFailed: vi.fn(), clearTileCache: vi.fn(), }; }); @@ -68,6 +70,8 @@ vi.mock('../../sync/mutationQueue', async (importOriginal) => { conflicts: h.conflicts, resolveKeepMine: h.resolveKeepMine, resolveKeepServer: h.resolveKeepServer, + retryFailed: h.retryFailed, + discardFailed: h.discardFailed, }, }; }); @@ -236,6 +240,21 @@ describe('OfflineTab', () => { expect(within(stat('Conflicts')).getByText('1')).toBeInTheDocument(); }); + it('FE-COMP-OFFLINETAB-030: failed changes offer to try them again or discard them', async () => { + h.failedCount.mockResolvedValue(2); + h.retryFailed.mockResolvedValue(undefined); + h.discardFailed.mockResolvedValue(undefined); + h.syncAll.mockResolvedValue([]); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'Try again' })); + expect(h.retryFailed).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Discard' })); + expect(h.discardFailed).toHaveBeenCalledTimes(1); + expect(screen.getByText(/never reached the server/)).toBeInTheDocument(); + }); + it('FE-COMP-OFFLINETAB-008: conflicts are named from the queued body, the server copy or the entity id', async () => { h.conflicts.mockResolvedValue([ conflict({ id: 'a', body: { name: 'Louvre' } }), diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx index db484503c..3d20336b1 100644 --- a/client/src/components/Settings/OfflineTab.tsx +++ b/client/src/components/Settings/OfflineTab.tsx @@ -41,7 +41,7 @@ export default function OfflineTab(): React.ReactElement { syncing, clearing, loading, preparing, progress, notice, prefs, canClear, runPrepare, handleToggleForce, handleResync, handleClear, handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict, - handleConflictStrategy, + handleConflictStrategy, retryFailed, discardFailed, } = useOfflineSettings() const [confirmClear, setConfirmClear] = useState(false) @@ -226,6 +226,22 @@ export default function OfflineTab(): React.ReactElement { {failedCount > 0 && } + {failedCount > 0 && ( +
+ {t('settings.offline.failed.hint')} +
+ + +
+
+ )} + {loading ? (
diff --git a/client/src/components/Settings/useOfflineSettings.ts b/client/src/components/Settings/useOfflineSettings.ts index cd0a0ae48..e76223782 100644 --- a/client/src/components/Settings/useOfflineSettings.ts +++ b/client/src/components/Settings/useOfflineSettings.ts @@ -256,6 +256,17 @@ export function useOfflineSettings() { await load() }, [load, tripStorageState]) + const retryFailed = useCallback(async () => { + await mutationQueue.retryFailed() + await load() + }, [load]) + + const discardFailed = useCallback(async () => { + await mutationQueue.discardFailed() + if (!isEffectivelyOffline()) await tripSyncManager.syncAll().catch(() => null) + await load() + }, [load]) + const resolveConflict = useCallback(async (id: string, keepMine: boolean) => { if (keepMine) await mutationQueue.resolveKeepMine(id) else await mutationQueue.resolveKeepServer(id) @@ -277,6 +288,6 @@ export function useOfflineSettings() { canClear: storedTripCount > 0 || pendingCount > 0, load, runPrepare, handleToggleForce, handleResync, handleClear, handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict, - handleConflictStrategy, + handleConflictStrategy, retryFailed, discardFailed, } } diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 97f641278..5c2f16063 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -59,6 +59,12 @@ export interface QueuedMutation { * next one (see mutationQueue's STUCK_SYNCING_MS). */ syncingSince?: number; + /** + * Not before this time (ms): set after the server answered with a 5xx, so a + * write the server keeps failing on is retried with growing gaps rather than + * on every trigger. + */ + retryAfter?: number; } export interface SyncMeta { diff --git a/client/src/mobile/screens/settings/MSettingsOffline.tsx b/client/src/mobile/screens/settings/MSettingsOffline.tsx index 6e67d8ac1..46603e2ec 100644 --- a/client/src/mobile/screens/settings/MSettingsOffline.tsx +++ b/client/src/mobile/screens/settings/MSettingsOffline.tsx @@ -34,7 +34,7 @@ export default function MSettingsOffline() { syncing, clearing, loading, preparing, progress, notice, prefs, canClear, runPrepare, handleToggleForce, handleResync, handleClear, handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict, - handleConflictStrategy, + handleConflictStrategy, retryFailed, discardFailed, } = useOfflineSettings() async function doClear() { @@ -196,6 +196,22 @@ export default function MSettingsOffline() { {failedCount > 0 && }
+ {failedCount > 0 && ( +
+

{t('settings.offline.failed.hint')}

+
+ void retryFailed()}> + + {t('settings.offline.failed.retry')} + + void discardFailed()}> + + {t('settings.offline.failed.discard')} + +
+
+ )} +
setShowClearConfirm(true)}> diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 6eb4a0784..1d013b0d4 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -66,6 +66,14 @@ export function nextTempId(): number { return -_lastTempId } +/** + * A write the server answers with a 5xx this many times is parked as failed. + * The gaps double from 30 s, so the last try comes about two hours after the + * first: long enough for a restart or an outage, short of blocking forever. + */ +const MAX_SERVER_ERROR_ATTEMPTS = 8 +const SERVER_ERROR_BACKOFF_MS = 30_000 + /** HTTP statuses that should be retried later rather than treated as terminal. */ function isRetryableStatus(status: number | undefined): boolean { // 401: token expired mid-flush (offline window) — retry after re-auth. @@ -112,8 +120,10 @@ export const mutationQueue = { /** * Drain the queue: replay each pending mutation against the server in FIFO order. - * Stops on first network error (will retry on next trigger). - * 4xx responses are marked failed and skipped. + * Stops on the first network error (retried on the next trigger). 4xx answers + * are marked failed and skipped; a DELETE answered 404 counts as done. A 5xx + * holds back only its own trip, retried with growing gaps, and is parked as + * failed once it has failed MAX_SERVER_ERROR_ATTEMPTS times. */ async flush(): Promise { if (_flushing || isEffectivelyOffline() || !isAuthed()) return @@ -145,12 +155,21 @@ export const mutationQueue = { .equals('pending') .sortBy('createdAt') + // Trips whose queue waits behind a write the server failed on. Their later + // writes must keep their order, but every other trip goes on syncing. + const blockedTrips = new Set() + for (const mutation of pending) { // Re-checked every pass, not just on entry: this loop writes server // responses straight into Dexie, and after a logout the proxy points at // the shared anonymous database. A flush that started before logout // would otherwise seed it with the previous account's rows. if (!isAuthed()) break + if (blockedTrips.has(mutation.tripId)) continue + if (mutation.retryAfter !== undefined && mutation.retryAfter > Date.now()) { + blockedTrips.add(mutation.tripId) + continue + } // Mark as syncing so UI can show progress. The stamp is what lets the // next flush tell an in-flight row from one a killed tab abandoned. @@ -263,6 +282,17 @@ export const mutationQueue = { } catch (err: unknown) { const httpStatus = (err as { response?: { status: number } })?.response?.status + // A DELETE of something the server no longer has did what it set out + // to do. Reporting it as a failed sync would flag a change that worked. + if (httpStatus === 404 && mutation.method === 'DELETE') { + if (mutation.resource && reqEntityId !== undefined) { + const table = getTable(mutation.resource) + if (table) await table.delete(reqEntityId) + } + await offlineDb.mutationQueue.delete(mutation.id) + continue + } + // 409 = the entity changed on the server since this offline edit was // made. This is NOT a dropped change like other 4xx — resolve it per // the user's strategy instead of failing it. Deliberately scoped to @@ -307,8 +337,29 @@ export const mutationQueue = { attempts: mutation.attempts + 1, lastError: String(err), }) + } else if (httpStatus !== undefined && httpStatus >= 500) { + // The server answered and failed. Retried with growing gaps, and + // only this trip waits for it. One that never goes through is + // parked as failed instead of holding every later write forever. + const attempts = mutation.attempts + 1 + if (attempts >= MAX_SERVER_ERROR_ATTEMPTS) { + if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) { + const table = getTable(mutation.resource) + if (table) await table.delete(mutation.tempId) + } + await offlineDb.mutationQueue.update(mutation.id, { status: 'failed', attempts, lastError: String(err), retryAfter: undefined }) + } else { + await offlineDb.mutationQueue.update(mutation.id, { + status: 'pending', + attempts, + lastError: String(err), + retryAfter: Date.now() + SERVER_ERROR_BACKOFF_MS * 2 ** (attempts - 1), + }) + blockedTrips.add(mutation.tripId) + } } else { - // Network / transient error — reset to pending, abort flush (retry next trigger) + // No answer at all (network) or a retryable status — reset to + // pending and stop the flush; the next trigger retries. await offlineDb.mutationQueue.update(mutation.id, { status: 'pending', attempts: mutation.attempts + 1, @@ -364,6 +415,23 @@ export const mutationQueue = { .count() }, + /** Put every parked change back in line, from a fresh start, and flush. */ + async retryFailed(): Promise { + await offlineDb.mutationQueue + .where('status') + .equals('failed') + .modify(m => { m.status = 'pending'; m.attempts = 0; m.retryAfter = undefined; m.lastError = null }) + await this.flush() + }, + + /** + * Drop every parked change. The local copy of an edit that never reached the + * server goes with the next trip sync, which reads the server's version. + */ + async discardFailed(): Promise { + await offlineDb.mutationQueue.where('status').equals('failed').delete() + }, + /** Count unresolved sync conflicts (offline edits the server rejected as stale). */ async conflictCount(): Promise { return offlineDb.mutationQueue diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts index 19cfc6a5d..5cd3a7973 100644 --- a/client/tests/unit/sync/mutationQueue.test.ts +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -503,3 +503,89 @@ describe('mutationQueue.flush — failure handling (B3)', () => { expect(m!.status).toBe('pending'); }); }); + +describe('mutationQueue.flush — a write the server keeps failing on', () => { + it('holds back only its own trip, with a growing gap, while other trips sync', async () => { + const stuck = generateUUID(); + const sameTrip = generateUUID(); + const otherTrip = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id: stuck })); + await mutationQueue.enqueue(makeMutation({ id: sameTrip, body: { name: 'Louvre' } })); + await mutationQueue.enqueue(makeMutation({ id: otherTrip, tripId: 2, url: '/trips/2/places' })); + + server.use( + http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + http.post('/api/trips/2/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 2, id: 77 }) })), + ); + + const before = Date.now(); + await mutationQueue.flush(); + + const first = await offlineDb.mutationQueue.get(stuck); + expect(first).toMatchObject({ status: 'pending', attempts: 1 }); + expect(first!.retryAfter).toBeGreaterThanOrEqual(before + 30_000); + // The trip's later write keeps its place in line… + expect(await offlineDb.mutationQueue.get(sameTrip)).toMatchObject({ status: 'pending', attempts: 0 }); + // …and the other trip is not held up at all. + expect(await offlineDb.mutationQueue.get(otherTrip)).toBeUndefined(); + + // Inside the gap the next trigger does not knock again. + await mutationQueue.flush(); + expect((await offlineDb.mutationQueue.get(stuck))!.attempts).toBe(1); + }); + + it('parks it as failed once it has failed often enough, so the trip moves on', async () => { + const stuck = generateUUID(); + const tempId = nextTempId(); + await offlineDb.places.put(buildPlace({ trip_id: 1, id: tempId })); + await mutationQueue.enqueue(makeMutation({ id: stuck, tempId })); + await offlineDb.mutationQueue.update(stuck, { attempts: 7 }); + + server.use(http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.get(stuck)).toMatchObject({ status: 'failed', attempts: 8 }); + expect(await offlineDb.places.get(tempId)).toBeUndefined(); + expect(await mutationQueue.failedCount()).toBe(1); + }); + + it('counts a DELETE the server answers 404 as done', async () => { + const id = generateUUID(); + await offlineDb.places.put(buildPlace({ trip_id: 1, id: 5 })); + await mutationQueue.enqueue(makeMutation({ id, method: 'DELETE', url: '/trips/1/places/5', body: undefined, entityId: 5 })); + + server.use(http.delete('/api/trips/1/places/5', () => HttpResponse.json({ error: 'Place not found' }, { status: 404 }))); + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + expect(await offlineDb.places.get(5)).toBeUndefined(); + expect(await mutationQueue.failedCount()).toBe(0); + }); +}); + +describe('mutationQueue — parked changes', () => { + it('retryFailed puts them back in line from a fresh start and sends them', async () => { + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + await offlineDb.mutationQueue.update(id, { status: 'failed', attempts: 8, lastError: 'boom' }); + server.use(http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 91 }) }))); + + await mutationQueue.retryFailed(); + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + expect(await offlineDb.places.get(91)).toBeDefined(); + }); + + it('discardFailed drops only the parked ones', async () => { + const parked = generateUUID(); + const waiting = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id: parked })); + await mutationQueue.enqueue(makeMutation({ id: waiting })); + await offlineDb.mutationQueue.update(parked, { status: 'failed' }); + + await mutationQueue.discardFailed(); + + expect(await offlineDb.mutationQueue.get(parked)).toBeUndefined(); + expect(await offlineDb.mutationQueue.get(waiting)).toBeDefined(); + }); +}); diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 0fde1e045..359297694 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -535,6 +535,9 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'الرحلات المخزّنة مؤقتًا', 'settings.offline.stats.pending': 'تغييرات معلّقة', 'settings.offline.stats.failed': 'تغييرات فاشلة', + 'settings.offline.failed.hint': 'لم تصل هذه التغييرات إلى الخادم. أعد المحاولة أو تجاهلها للاحتفاظ بنسخة الخادم.', + 'settings.offline.failed.retry': 'إعادة المحاولة', + 'settings.offline.failed.discard': 'تجاهل', 'settings.offline.stats.conflicts': 'تعارضات', 'settings.offline.empty': 'لا توجد رحلات مخزّنة بعد. اتصل بالإنترنت للمزامنة.', 'settings.offline.loading': 'جارٍ التحميل…', diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index 60aff0a23..586b79e8c 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -678,6 +678,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Keşlənmiş səyahətlər', 'settings.offline.stats.pending': 'Gözləyən dəyişikliklər', 'settings.offline.stats.failed': 'Uğursuz dəyişikliklər', + 'settings.offline.failed.hint': + 'Bu dəyişikliklər serverə çatmayıb. Yenidən cəhd edin və ya serverdəki versiyanı saxlamaq üçün onları ləğv edin.', + 'settings.offline.failed.retry': 'Yenidən cəhd et', + 'settings.offline.failed.discard': 'Ləğv et', 'settings.offline.stats.conflicts': 'Ziddiyyətlər', 'settings.offline.empty': 'Hələ heç bir səyahət keşlənməyib. Sinxronlaşdırmaq üçün internetə qoşulun.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 19164fee2..c8d2a2e19 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -555,6 +555,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Viagens em cache', 'settings.offline.stats.pending': 'Alterações pendentes', 'settings.offline.stats.failed': 'Alterações com falha', + 'settings.offline.failed.hint': + 'Essas alterações nunca chegaram ao servidor. Tente novamente ou descarte-as para manter a versão do servidor.', + 'settings.offline.failed.retry': 'Tentar novamente', + 'settings.offline.failed.discard': 'Descartar', 'settings.offline.stats.conflicts': 'Conflitos', 'settings.offline.empty': 'Nenhuma viagem em cache ainda. Conecte-se à internet para sincronizar.', 'settings.offline.loading': 'Carregando…', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index a8647cedb..9c15ad4f8 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -530,6 +530,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Viatges a la memòria cau', 'settings.offline.stats.pending': 'Canvis pendents', 'settings.offline.stats.failed': 'Canvis fallits', + 'settings.offline.failed.hint': + 'Aquests canvis no han arribat mai al servidor. Torna-ho a provar o descarta’ls per conservar la versió del servidor.', + 'settings.offline.failed.retry': 'Torna-ho a provar', + 'settings.offline.failed.discard': 'Descarta', 'settings.offline.stats.conflicts': 'Conflictes', 'settings.offline.empty': "Encara no hi ha cap viatge a la memòria cau. Connecta't a internet per sincronitzar.", 'settings.offline.loading': 'Carregant…', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 8f2f6e0d4..65cca6539 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -543,6 +543,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Cesty v mezipaměti', 'settings.offline.stats.pending': 'Čekající změny', 'settings.offline.stats.failed': 'Neúspěšné změny', + 'settings.offline.failed.hint': + 'Tyto změny se na server nikdy nedostaly. Zkuste to znovu, nebo je zahoďte a ponechte verzi ze serveru.', + 'settings.offline.failed.retry': 'Zkusit znovu', + 'settings.offline.failed.discard': 'Zahodit', 'settings.offline.stats.conflicts': 'Konflikty', 'settings.offline.empty': 'Zatím žádné cesty v mezipaměti. Pro synchronizaci se připojte k internetu.', 'settings.offline.loading': 'Načítání…', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index ff7d30738..dfb2c1264 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -557,6 +557,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Zwischengespeicherte Reisen', 'settings.offline.stats.pending': 'Ausstehende Änderungen', 'settings.offline.stats.failed': 'Fehlgeschlagene Änderungen', + 'settings.offline.failed.hint': + 'Diese Änderungen haben den Server nie erreicht. Versuche es erneut oder verwirf sie, um die Version vom Server zu behalten.', + 'settings.offline.failed.retry': 'Erneut versuchen', + 'settings.offline.failed.discard': 'Verwerfen', 'settings.offline.stats.conflicts': 'Konflikte', 'settings.offline.empty': 'Noch keine Reisen zwischengespeichert. Verbinde dich mit dem Internet zum Synchronisieren.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index ec4ede372..af771f68c 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -552,6 +552,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Cached trips', 'settings.offline.stats.pending': 'Pending changes', 'settings.offline.stats.failed': 'Failed changes', + 'settings.offline.failed.hint': + "These changes never reached the server. Try them again, or discard them to keep the server's version.", + 'settings.offline.failed.retry': 'Try again', + 'settings.offline.failed.discard': 'Discard', 'settings.offline.stats.conflicts': 'Conflicts', 'settings.offline.empty': 'No trips cached yet. Connect to the internet to sync.', 'settings.offline.loading': 'Loading…', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 24dec908c..691b4957a 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -555,6 +555,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Viajes en caché', 'settings.offline.stats.pending': 'Cambios pendientes', 'settings.offline.stats.failed': 'Cambios fallidos', + 'settings.offline.failed.hint': + 'Estos cambios nunca llegaron al servidor. Vuelve a intentarlo o descártalos para conservar la versión del servidor.', + 'settings.offline.failed.retry': 'Reintentar', + 'settings.offline.failed.discard': 'Descartar', 'settings.offline.stats.conflicts': 'Conflictos', 'settings.offline.empty': 'Aún no hay viajes en caché. Conéctate a internet para sincronizar.', 'settings.offline.loading': 'Cargando…', diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index f1997f13a..7f174cc1f 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -561,6 +561,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Vahemälus reisid', 'settings.offline.stats.pending': 'Ootel muudatused', 'settings.offline.stats.failed': 'Ebaõnnestunud muudatused', + 'settings.offline.failed.hint': + 'Need muudatused ei jõudnud kunagi serverisse. Proovi uuesti või loobu neist, et säilitada serveri versioon.', + 'settings.offline.failed.retry': 'Proovi uuesti', + 'settings.offline.failed.discard': 'Loobu', 'settings.offline.stats.conflicts': 'Konfliktid', 'settings.offline.empty': 'Reise pole veel vahemällu salvestatud. Sünkroonimiseks loo internetiühendus.', 'settings.offline.loading': 'Laadimine…', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 2575d05fc..9ee8a64f9 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -563,6 +563,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Voyages en cache', 'settings.offline.stats.pending': 'Modifications en attente', 'settings.offline.stats.failed': 'Modifications échouées', + 'settings.offline.failed.hint': + 'Ces modifications ne sont jamais parvenues au serveur. Réessayez, ou abandonnez-les pour garder la version du serveur.', + 'settings.offline.failed.retry': 'Réessayer', + 'settings.offline.failed.discard': 'Abandonner', 'settings.offline.stats.conflicts': 'Conflits', 'settings.offline.empty': "Aucun voyage en cache pour l'instant. Connectez-vous à Internet pour synchroniser.", 'settings.offline.loading': 'Chargement…', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index f8ba8a78d..51864a952 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -563,6 +563,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Ταξίδια σε προσωρινή μνήμη', 'settings.offline.stats.pending': 'Εκκρεμείς αλλαγές', 'settings.offline.stats.failed': 'Αποτυχημένες αλλαγές', + 'settings.offline.failed.hint': + 'Αυτές οι αλλαγές δεν έφτασαν ποτέ στον διακομιστή. Δοκιμάστε ξανά ή απορρίψτε τες για να κρατήσετε την έκδοση του διακομιστή.', + 'settings.offline.failed.retry': 'Δοκιμή ξανά', + 'settings.offline.failed.discard': 'Απόρριψη', 'settings.offline.stats.conflicts': 'Διενέξεις', 'settings.offline.empty': 'Δεν υπάρχουν ταξίδια σε προσωρινή μνήμη ακόμη. Συνδεθείτε στο διαδίκτυο για συγχρονισμό.', 'settings.offline.loading': 'Φόρτωση…', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index be0daee8f..a93a60973 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -552,6 +552,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Gyorsítótárazott utazások', 'settings.offline.stats.pending': 'Függőben lévő módosítások', 'settings.offline.stats.failed': 'Sikertelen módosítások', + 'settings.offline.failed.hint': + 'Ezek a módosítások nem jutottak el a szerverre. Próbáld újra, vagy vesd el őket, hogy a szerveren lévő változat maradjon.', + 'settings.offline.failed.retry': 'Újrapróbálás', + 'settings.offline.failed.discard': 'Elvetés', 'settings.offline.stats.conflicts': 'Ütközések', 'settings.offline.empty': 'Még nincsenek gyorsítótárazott utazások. Csatlakozz az internethez a szinkronizáláshoz.', 'settings.offline.loading': 'Betöltés…', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 0bb32c73a..d3fd586b6 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -550,6 +550,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Perjalanan di cache', 'settings.offline.stats.pending': 'Perubahan tertunda', 'settings.offline.stats.failed': 'Perubahan gagal', + 'settings.offline.failed.hint': + 'Perubahan ini tidak pernah sampai ke server. Coba lagi, atau buang untuk mempertahankan versi di server.', + 'settings.offline.failed.retry': 'Coba lagi', + 'settings.offline.failed.discard': 'Buang', 'settings.offline.stats.conflicts': 'Konflik', 'settings.offline.empty': 'Belum ada perjalanan di cache. Sambungkan ke internet untuk menyinkronkan.', 'settings.offline.loading': 'Memuat…', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 305ae4878..30abb6b04 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -556,6 +556,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Viaggi in cache', 'settings.offline.stats.pending': 'Modifiche in sospeso', 'settings.offline.stats.failed': 'Modifiche non riuscite', + 'settings.offline.failed.hint': + 'Queste modifiche non hanno mai raggiunto il server. Riprova oppure scartale per mantenere la versione del server.', + 'settings.offline.failed.retry': 'Riprova', + 'settings.offline.failed.discard': 'Scarta', 'settings.offline.stats.conflicts': 'Conflitti', 'settings.offline.empty': 'Nessun viaggio ancora in cache. Connettiti a internet per sincronizzare.', 'settings.offline.loading': 'Caricamento…', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 58e02e479..f491e7218 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -522,6 +522,9 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'キャッシュ済みの旅行', 'settings.offline.stats.pending': '保留中の変更', 'settings.offline.stats.failed': '失敗した変更', + 'settings.offline.failed.hint': 'これらの変更はサーバーに届いていません。もう一度試すか、破棄してサーバー上のバージョンを残してください。', + 'settings.offline.failed.retry': '再試行', + 'settings.offline.failed.discard': '破棄', 'settings.offline.stats.conflicts': '競合', 'settings.offline.empty': 'キャッシュされた旅行はまだありません。インターネットに接続して同期してください。', 'settings.offline.loading': '読み込み中…', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index e18b07733..38d36d0b7 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -539,6 +539,9 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': '캐시된 여행', 'settings.offline.stats.pending': '대기 중인 변경 사항', 'settings.offline.stats.failed': '실패한 변경 사항', + 'settings.offline.failed.hint': '이 변경 사항은 서버에 전달되지 않았습니다. 다시 시도하거나 삭제하여 서버의 버전을 유지하세요.', + 'settings.offline.failed.retry': '다시 시도', + 'settings.offline.failed.discard': '삭제', 'settings.offline.stats.conflicts': '충돌', 'settings.offline.empty': '아직 캐시된 여행이 없습니다. 인터넷에 연결하여 동기화하세요.', 'settings.offline.loading': '불러오는 중…', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index ca0f4a90b..dd3ba55d1 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -555,6 +555,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Gecachte reizen', 'settings.offline.stats.pending': 'Wijzigingen in afwachting', 'settings.offline.stats.failed': 'Mislukte wijzigingen', + 'settings.offline.failed.hint': + 'Deze wijzigingen hebben de server nooit bereikt. Probeer het opnieuw of verwijder ze om de versie van de server te houden.', + 'settings.offline.failed.retry': 'Opnieuw proberen', + 'settings.offline.failed.discard': 'Verwerpen', 'settings.offline.stats.conflicts': 'Conflicten', 'settings.offline.empty': 'Nog geen reizen gecacht. Maak verbinding met internet om te synchroniseren.', 'settings.offline.loading': 'Laden…', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index c591e6506..356372df5 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -551,6 +551,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Podróże w pamięci podręcznej', 'settings.offline.stats.pending': 'Oczekujące zmiany', 'settings.offline.stats.failed': 'Nieudane zmiany', + 'settings.offline.failed.hint': + 'Te zmiany nigdy nie dotarły do serwera. Spróbuj ponownie lub odrzuć je, aby zachować wersję z serwera.', + 'settings.offline.failed.retry': 'Spróbuj ponownie', + 'settings.offline.failed.discard': 'Odrzuć', 'settings.offline.stats.conflicts': 'Konflikty', 'settings.offline.empty': 'Brak podróży w pamięci podręcznej. Połącz się z internetem, aby zsynchronizować.', 'settings.offline.loading': 'Ładowanie…', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 7b4913719..dc9019ab8 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -556,6 +556,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Кэшированные поездки', 'settings.offline.stats.pending': 'Изменения в очереди', 'settings.offline.stats.failed': 'Неудавшиеся изменения', + 'settings.offline.failed.hint': + 'Эти изменения так и не попали на сервер. Повторите попытку или отмените их, чтобы оставить версию с сервера.', + 'settings.offline.failed.retry': 'Повторить', + 'settings.offline.failed.discard': 'Отменить', 'settings.offline.stats.conflicts': 'Конфликты', 'settings.offline.empty': 'Поездки ещё не кэшированы. Подключитесь к интернету для синхронизации.', 'settings.offline.loading': 'Загрузка…', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index f6f9ea5ac..060fb2a6b 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -561,6 +561,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Cesty vo vyrovnávacej pamäti', 'settings.offline.stats.pending': 'Čakajúce zmeny', 'settings.offline.stats.failed': 'Neúspešné zmeny', + 'settings.offline.failed.hint': + 'Tieto zmeny sa nikdy nedostali na server. Skúste to znova alebo ich zahoďte a ponechajte verziu zo servera.', + 'settings.offline.failed.retry': 'Skúsiť znova', + 'settings.offline.failed.discard': 'Zahodiť', 'settings.offline.stats.conflicts': 'Konflikty', 'settings.offline.empty': 'Zatiaľ žiadne cesty vo vyrovnávacej pamäti. Pre synchronizáciu sa pripojte k internetu.', 'settings.offline.loading': 'Načítava sa…', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 4287b637e..3bed8082d 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -548,6 +548,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Cachelagrade resor', 'settings.offline.stats.pending': 'Väntande ändringar', 'settings.offline.stats.failed': 'Misslyckade ändringar', + 'settings.offline.failed.hint': + 'De här ändringarna nådde aldrig servern. Försök igen, eller släng dem för att behålla serverns version.', + 'settings.offline.failed.retry': 'Försök igen', + 'settings.offline.failed.discard': 'Släng', 'settings.offline.stats.conflicts': 'Konflikter', 'settings.offline.empty': 'Inga resor har cachelagrats ännu. Anslut till internet för att synkronisera.', 'settings.offline.loading': 'Laddar…', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index 4daaa4308..1bd65378b 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -542,6 +542,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': "ทริปที่แคชไว้", 'settings.offline.stats.pending': "รอดำเนินการเปลี่ยนแปลง", 'settings.offline.stats.failed': "การเปลี่ยนแปลงล้มเหลว", + 'settings.offline.failed.hint': + 'การเปลี่ยนแปลงเหล่านี้ไม่เคยไปถึงเซิร์ฟเวอร์ ลองอีกครั้ง หรือทิ้งไปเพื่อคงเวอร์ชันบนเซิร์ฟเวอร์ไว้', + 'settings.offline.failed.retry': 'ลองอีกครั้ง', + 'settings.offline.failed.discard': 'ทิ้ง', 'settings.offline.stats.conflicts': "ข้อขัดแย้ง", 'settings.offline.empty': "ยังไม่มีแคชการเดินทาง เชื่อมต่ออินเทอร์เน็ตเพื่อซิงค์", 'settings.offline.loading': "กำลังโหลด...", diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index a6e1a52f5..6a3138968 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -551,6 +551,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Önbelleğe alınan geziler', 'settings.offline.stats.pending': 'Bekleyen değişiklikler', 'settings.offline.stats.failed': 'Başarısız değişiklikler', + 'settings.offline.failed.hint': + 'Bu değişiklikler sunucuya hiç ulaşmadı. Yeniden deneyin ya da sunucudaki sürümü korumak için onları silin.', + 'settings.offline.failed.retry': 'Yeniden dene', + 'settings.offline.failed.discard': 'Sil', 'settings.offline.stats.conflicts': 'Çakışmalar', 'settings.offline.empty': 'Henüz önbelleğe alınmış gezi yok. Senkronize etmek için internete bağlanın.', 'settings.offline.loading': 'Yükleniyor…', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 7828a19dc..6e207abce 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -552,6 +552,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Кешовані поїздки', 'settings.offline.stats.pending': 'Зміни в очікуванні', 'settings.offline.stats.failed': 'Невдалі зміни', + 'settings.offline.failed.hint': + 'Ці зміни так і не потрапили на сервер. Спробуйте ще раз або відкиньте їх, щоб залишити версію із сервера.', + 'settings.offline.failed.retry': 'Спробувати ще раз', + 'settings.offline.failed.discard': 'Відкинути', 'settings.offline.stats.conflicts': 'Конфлікти', 'settings.offline.empty': 'Поки що немає кешованих поїздок. Підключіться до інтернету, щоб синхронізувати.', 'settings.offline.loading': 'Завантаження…', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index daeec9af8..d3df7e004 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -553,6 +553,10 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': 'Chuyến đi trong bộ nhớ đệm', 'settings.offline.stats.pending': 'Thay đổi đang chờ', 'settings.offline.stats.failed': 'Thay đổi không thành công', + 'settings.offline.failed.hint': + 'Những thay đổi này chưa bao giờ đến được máy chủ. Hãy thử lại, hoặc bỏ chúng để giữ phiên bản trên máy chủ.', + 'settings.offline.failed.retry': 'Thử lại', + 'settings.offline.failed.discard': 'Bỏ', 'settings.offline.stats.conflicts': 'Xung đột', 'settings.offline.empty': 'Chưa có chuyến đi nào trong bộ nhớ đệm. Kết nối internet để đồng bộ hóa.', 'settings.offline.loading': 'Đang tải…', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 3f084ac8c..940f2b291 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -516,6 +516,9 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': '已快取的行程', 'settings.offline.stats.pending': '待處理的變更', 'settings.offline.stats.failed': '失敗的變更', + 'settings.offline.failed.hint': '這些變更從未送達伺服器。請重試,或捨棄它們以保留伺服器上的版本。', + 'settings.offline.failed.retry': '重試', + 'settings.offline.failed.discard': '捨棄', 'settings.offline.stats.conflicts': '衝突', 'settings.offline.empty': '尚未快取任何行程。請連線網際網路以同步。', 'settings.offline.loading': '載入中…', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index cb27facaf..93cd476cd 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -512,6 +512,9 @@ const settings: TranslationStrings = { 'settings.offline.stats.trips': '已缓存行程', 'settings.offline.stats.pending': '待处理更改', 'settings.offline.stats.failed': '失败的更改', + 'settings.offline.failed.hint': '这些更改从未到达服务器。请重试,或放弃它们以保留服务器上的版本。', + 'settings.offline.failed.retry': '重试', + 'settings.offline.failed.discard': '放弃', 'settings.offline.stats.conflicts': '冲突', 'settings.offline.empty': '尚未缓存任何行程。请连接互联网以同步。', 'settings.offline.loading': '加载中…',