feat(ux): Sprint 3 mobile nav, sync conflicts, and resilience
Improve mobile bottom navigation, accessible dialogs and cards, explicit sync conflict resolution, i18n error messages, encrypted draft autosave, and persistent storage hints for offline data safety. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,6 +90,15 @@ export interface SyncQueueItem {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryDraftRecord {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
class DaagboxDatabase extends Dexie {
|
||||
logbooks!: Table<LocalLogbook>
|
||||
yachts!: Table<LocalYacht>
|
||||
@@ -101,6 +110,7 @@ class DaagboxDatabase extends Dexie {
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||
|
||||
constructor() {
|
||||
super('DaagboxDatabase')
|
||||
@@ -167,6 +177,19 @@ class DaagboxDatabase extends Dexie {
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(7).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { db } from './db.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
|
||||
export interface EntryDraftRecord {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export async function saveEntryDraft(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
payload: unknown
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
|
||||
await db.entryDrafts.put({
|
||||
logbookId,
|
||||
entryId,
|
||||
encryptedData: ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadEntryDraft<T = unknown>(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<T | null> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return null
|
||||
|
||||
const row = await db.entryDrafts.get([logbookId, entryId])
|
||||
if (!row) return null
|
||||
|
||||
try {
|
||||
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
|
||||
} catch {
|
||||
await db.entryDrafts.delete([logbookId, entryId])
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
|
||||
await db.entryDrafts.delete([logbookId, entryId])
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
import { getLogbookAccess } from './logbookAccess.js'
|
||||
import {
|
||||
clearSyncConflict,
|
||||
reportSyncConflict,
|
||||
type SyncConflict
|
||||
} from './syncConflicts.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
@@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
const queueItem = pending[i]
|
||||
if (!queueItem) continue
|
||||
|
||||
if (res.status === 'success' || res.status === 'conflict') {
|
||||
if (res.status === 'success') {
|
||||
if (queueItem.id !== undefined) {
|
||||
await db.syncQueue.delete(queueItem.id)
|
||||
}
|
||||
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
|
||||
} else if (res.status === 'conflict') {
|
||||
reportSyncConflict({
|
||||
logbookId,
|
||||
payloadId: res.payloadId ?? queueItem.payloadId,
|
||||
type: queueItem.type,
|
||||
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
|
||||
queueItemId: queueItem.id
|
||||
})
|
||||
} else {
|
||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||
}
|
||||
@@ -525,3 +539,43 @@ export function stopBackgroundSync() {
|
||||
syncIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept server version: pull latest and drop the conflicting queue item. */
|
||||
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
|
||||
if (conflict.queueItemId !== undefined) {
|
||||
await db.syncQueue.delete(conflict.queueItemId)
|
||||
} else {
|
||||
const pending = await db.syncQueue
|
||||
.where({ logbookId: conflict.logbookId })
|
||||
.filter(
|
||||
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||
)
|
||||
.toArray()
|
||||
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
|
||||
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
|
||||
}
|
||||
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||
await pullChanges(conflict.logbookId)
|
||||
}
|
||||
|
||||
/** Keep local version: bump queue timestamp and retry push. */
|
||||
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
|
||||
const bump = new Date(Date.now() + 1000).toISOString()
|
||||
if (conflict.queueItemId !== undefined) {
|
||||
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
|
||||
} else {
|
||||
const pending = await db.syncQueue
|
||||
.where({ logbookId: conflict.logbookId })
|
||||
.filter(
|
||||
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||
)
|
||||
.toArray()
|
||||
for (const item of pending) {
|
||||
if (item.id !== undefined) {
|
||||
await db.syncQueue.update(item.id, { updatedAt: bump })
|
||||
}
|
||||
}
|
||||
}
|
||||
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||
await flushPushQueue(conflict.logbookId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface SyncConflict {
|
||||
logbookId: string
|
||||
payloadId: string
|
||||
type: string
|
||||
reason: string
|
||||
queueItemId?: number
|
||||
detectedAt: string
|
||||
}
|
||||
|
||||
const conflicts = new Map<string, SyncConflict>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function conflictKey(logbookId: string, payloadId: string, type: string): string {
|
||||
return `${logbookId}:${type}:${payloadId}`
|
||||
}
|
||||
|
||||
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
|
||||
const all = Array.from(conflicts.values())
|
||||
if (!logbookId) return all
|
||||
return all.filter((c) => c.logbookId === logbookId)
|
||||
}
|
||||
|
||||
export function hasSyncConflicts(logbookId?: string): boolean {
|
||||
return getSyncConflicts(logbookId).length > 0
|
||||
}
|
||||
|
||||
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
|
||||
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
|
||||
listeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
|
||||
conflicts.delete(conflictKey(logbookId, payloadId, type))
|
||||
listeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
export function clearSyncConflictsForLogbook(logbookId: string): void {
|
||||
for (const key of conflicts.keys()) {
|
||||
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
|
||||
}
|
||||
listeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
export function subscribeSyncConflicts(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
Reference in New Issue
Block a user