mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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.
This commit is contained in:
@@ -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(<OfflineTab />);
|
||||
|
||||
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' } }),
|
||||
|
||||
@@ -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 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{failedCount > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SettingsHint>{t('settings.offline.failed.hint')}</SettingsHint>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<RefreshCw size={14} />
|
||||
{t('settings.offline.failed.retry')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER} style={fs(12.5, 'body')}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.failed.discard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
||||
<RefreshCw size={13} className="animate-spin" />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && <MStat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{failedCount > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<p className="m-0 font-geist text-[0.6875rem] leading-relaxed text-m-muted">{t('settings.offline.failed.hint')}</p>
|
||||
<div className="flex gap-2">
|
||||
<MSetButton disabled={offline} onClick={() => void retryFailed()}>
|
||||
<RefreshCw size={14} />
|
||||
{t('settings.offline.failed.retry')}
|
||||
</MSetButton>
|
||||
<MSetButton variant="danger" onClick={() => void discardFailed()}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.failed.discard')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<MSetButton variant="danger" disabled={clearing || !canClear} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -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<void> {
|
||||
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<number>()
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
await offlineDb.mutationQueue.where('status').equals('failed').delete()
|
||||
},
|
||||
|
||||
/** Count unresolved sync conflicts (offline edits the server rejected as stale). */
|
||||
async conflictCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': 'جارٍ التحميل…',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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í…',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Φόρτωση…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '読み込み中…',
|
||||
|
||||
@@ -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': '불러오는 중…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Загрузка…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': "กำลังโหลด...",
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Завантаження…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '載入中…',
|
||||
|
||||
@@ -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': '加载中…',
|
||||
|
||||
Reference in New Issue
Block a user