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': '加载中…',