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:
2026-06-01 15:30:08 +02:00
parent f8dc6ace3c
commit 9089d017b6
18 changed files with 678 additions and 54 deletions
+15 -9
View File
@@ -8,6 +8,7 @@ import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -142,7 +143,7 @@ export default function LogEntriesList({
setEntries(list)
} catch (err: any) {
console.error('Failed to load log entries:', err)
setError(err.message || 'Decryption failed. Could not load journal list.')
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
}
@@ -176,7 +177,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -204,7 +205,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported'))
} else {
console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
}
} finally {
setExporting(false)
@@ -225,7 +226,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -317,7 +318,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -347,7 +348,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.')
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
@@ -460,8 +461,12 @@ export default function LogEntriesList({
key={item.id}
className="logbook-card glass"
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<FileText size={24} />
</div>
@@ -483,6 +488,9 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
</button>
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
@@ -492,8 +500,6 @@ export default function LogEntriesList({
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
+14 -2
View File
@@ -5,6 +5,8 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
@@ -288,6 +290,14 @@ export default function LogEntryEditor({
events
])
useEffect(() => {
if (readOnly || loading || !date) return
const timer = window.setTimeout(() => {
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
}, 4000)
return () => window.clearTimeout(timer)
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
@@ -1208,15 +1218,17 @@ export default function LogEntryEditor({
...signaturesForSave
})
await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => {
setSuccess(false)
onBack()
}, 1500)
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setSaving(false)
}
+13 -7
View File
@@ -6,6 +6,7 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
try {
const data = await fetchLogbooks()
setLogbooks(data)
} catch (err: any) {
setError(err.message || 'Failed to load logbooks')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
setRefreshing(false)
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) {
setError(err.message || 'Failed to delete logbook')
setError(getErrorMessage(err, t('errors.delete_failed')))
} finally {
setLoading(false)
}
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)
)
} catch (err: any) {
setError(err.message || 'Failed to update logbook title')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -226,8 +227,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
>
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
>
<div className="card-icon">
<BookOpen size={24} />
</div>
@@ -282,6 +287,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
</span>
</div>
</div>
</button>
{!lb.isShared && (
<div className="logbook-card-actions">
+80 -21
View File
@@ -1,4 +1,14 @@
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
import React, {
createContext,
useContext,
useState,
useRef,
useCallback,
useMemo,
useEffect,
useId
} from 'react'
import { useTranslation } from 'react-i18next'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -16,6 +26,11 @@ export function useDialog() {
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const titleId = useId()
const messageId = useId()
const confirmRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const resolveRef = useRef<((val: any) => void) | null>(null)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
alertResolveRef.current = resolve
})
}, [])
}, [t])
const showConfirm = useCallback((
msg: string,
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
confirmResolveRef.current = resolve
})
}, [])
}, [t])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
if (type === 'confirm' && confirmResolveRef.current) {
confirmResolveRef.current(true)
confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
}
}, [type])
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm') handleCancel()
else handleConfirm()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 id={titleId} className="custom-dialog-title">
{title}
</h3>
)}
<p id={messageId} className="custom-dialog-message">
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</div>
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import {
getSyncConflicts,
subscribeSyncConflicts,
type SyncConflict
} from '../services/syncConflicts.js'
import {
resolveSyncConflictKeepLocal,
resolveSyncConflictUseServer
} from '../services/sync.js'
interface SyncConflictBannerProps {
logbookId: string | null
}
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
const { t } = useTranslation()
const [items, setItems] = useState<SyncConflict[]>([])
useEffect(() => {
const refresh = () => {
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
}
refresh()
return subscribeSyncConflicts(refresh)
}, [logbookId])
if (items.length === 0) return null
const first = items[0]
return (
<div className="sync-conflict-banner" role="alert">
<AlertTriangle size={20} aria-hidden />
<div className="sync-conflict-banner__body">
<strong>{t('sync.conflict_title')}</strong>
<p>
{t('sync.conflict_message', {
count: items.length,
id: first.payloadId.slice(0, 8)
})}
</p>
<div className="sync-conflict-banner__actions">
<button
type="button"
className="btn secondary"
onClick={() => void resolveSyncConflictUseServer(first)}
>
{t('sync.conflict_use_server')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void resolveSyncConflictKeepLocal(first)}
>
{t('sync.conflict_keep_local')}
</button>
</div>
</div>
</div>
)
}