diff --git a/client/src/App.css b/client/src/App.css
index 68a251b..ca73dc8 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -1799,6 +1799,22 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 24px;
}
+.logbook-card-select {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
.logbook-card {
background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop);
@@ -1809,18 +1825,61 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex;
align-items: flex-start;
gap: 16px;
- cursor: pointer;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
-.logbook-card:hover {
+.logbook-card:hover,
+.logbook-card:focus-within {
transform: translateY(-2px);
border-color: var(--app-border);
box-shadow: var(--app-card-shadow);
background: var(--app-surface-hover);
}
+.sync-conflict-banner {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ margin: 0 0 16px;
+ padding: 16px;
+ border-radius: var(--app-radius-card);
+ border: 1px solid var(--app-warning-border, #f59e0b);
+ background: var(--app-warning-bg, rgba(245, 158, 11, 0.12));
+ color: var(--app-text);
+}
+
+.sync-conflict-banner__body p {
+ margin: 4px 0 12px;
+ font-size: 14px;
+ color: var(--app-text-muted);
+}
+
+.sync-conflict-banner__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.storage-persist-hint {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0 0 16px;
+ padding: 12px 16px;
+ border-radius: var(--app-radius-card);
+}
+
+.storage-persist-hint p {
+ margin: 0;
+ flex: 1;
+ min-width: 200px;
+ font-size: 14px;
+ color: var(--app-text-muted);
+}
+
.logbook-card--shared {
border-left: 3px solid #38bdf8;
}
@@ -2130,9 +2189,65 @@ html.scheme-dark .themed-select-option.is-selected {
align-items: start;
}
+.app-bottom-nav {
+ display: none;
+}
+
@media (max-width: 768px) {
.app-body {
grid-template-columns: minmax(0, 1fr);
+ padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
+ }
+
+ .app-sidebar {
+ display: none;
+ }
+
+ .app-bottom-nav {
+ display: flex;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ justify-content: space-around;
+ align-items: stretch;
+ gap: 4px;
+ padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
+ background: var(--app-surface-alt);
+ backdrop-filter: var(--app-backdrop);
+ border-top: 1px solid var(--app-border-subtle);
+ box-sizing: border-box;
+ }
+
+ .bottom-nav-btn {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 6px 4px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--app-text-muted);
+ font-size: 10px;
+ font-weight: 500;
+ cursor: pointer;
+ }
+
+ .bottom-nav-btn span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ }
+
+ .bottom-nav-btn.active {
+ background: var(--app-sidebar-active-bg);
+ color: var(--app-sidebar-active-text);
}
}
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 070fda1..76f2666 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -53,6 +53,8 @@ import {
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
+import SyncConflictBanner from './components/SyncConflictBanner.tsx'
+import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
@@ -71,6 +73,7 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
+ const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
@@ -428,10 +431,19 @@ function App() {
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
+ useEffect(() => {
+ if (!isAuthenticated) return
+ if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
+ void requestPersistentStorage().then(({ persisted, supported }) => {
+ if (supported && !persisted) setStoragePersistHint(true)
+ })
+ }, [isAuthenticated])
+
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
+ void requestPersistentStorage()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -606,7 +618,7 @@ function App() {
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
- : `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
+ : t('app.tagline')}
@@ -646,10 +658,28 @@ function App() {
+
+
+ {storagePersistHint && (
+
+
{t('pwa.storage_persist_hint')}
+
+
+ )}
+
{/* Active Workspace */}
{/* Navigation Sidebar */}
-
diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx
index d0b2870..afb1354 100644
--- a/client/src/components/LogEntriesList.tsx
+++ b/client/src/components/LogEntriesList.tsx
@@ -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)}
>
+
+
@@ -492,8 +500,6 @@ export default function LogEntriesList({
)}
-
-
))}
diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx
index 639bf24..d588a8c 100644
--- a/client/src/components/LogEntryEditor.tsx
+++ b/client/src/components/LogEntryEditor.tsx
@@ -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)
}
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx
index 69dae7c..3a2bb73 100644
--- a/client/src/components/LogbookDashboard.tsx
+++ b/client/src/components/LogbookDashboard.tsx
@@ -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
onSelectLogbook(lb.id, lb.title)}
>
+
+
{!lb.isShared && (
diff --git a/client/src/components/ModalDialog.tsx b/client/src/components/ModalDialog.tsx
index 8594a23..3e9e2f1 100644
--- a/client/src/components/ModalDialog.tsx
+++ b/client/src/components/ModalDialog.tsx
@@ -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
@@ -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(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 => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
- setConfirmLabel(btnText || 'OK')
+ setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise((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((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 }) {
{children}
{isOpen && (
-
-
e.stopPropagation()}>
- {title &&
{title}
}
-
{message}
+
+
e.stopPropagation()}
+ >
+ {title && (
+
+ {title}
+
+ )}
+
+ {message}
+
{type === 'confirm' && (
-
diff --git a/client/src/components/SyncConflictBanner.tsx b/client/src/components/SyncConflictBanner.tsx
new file mode 100644
index 0000000..719ba91
--- /dev/null
+++ b/client/src/components/SyncConflictBanner.tsx
@@ -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
([])
+
+ useEffect(() => {
+ const refresh = () => {
+ setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
+ }
+ refresh()
+ return subscribeSyncConflicts(refresh)
+ }, [logbookId])
+
+ if (items.length === 0) return null
+
+ const first = items[0]
+
+ return (
+
+
+
+
{t('sync.conflict_title')}
+
+ {t('sync.conflict_message', {
+ count: items.length,
+ id: first.payloadId.slice(0, 8)
+ })}
+
+
+ void resolveSyncConflictUseServer(first)}
+ >
+ {t('sync.conflict_use_server')}
+
+ void resolveSyncConflictKeepLocal(first)}
+ >
+ {t('sync.conflict_keep_local')}
+
+
+
+
+ )
+}
diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json
index f9d3c4b..91e028c 100644
--- a/client/src/i18n/locales/da.json
+++ b/client/src/i18n/locales/da.json
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
+ "dialog": {
+ "ok": "OK",
+ "yes": "Ja",
+ "no": "Nej"
+ },
+ "errors": {
+ "load_failed": "Data kunne ikke indlæses.",
+ "save_failed": "Ændringer kunne ikke gemmes.",
+ "delete_failed": "Sletning mislykkedes.",
+ "export_failed": "Eksport mislykkedes."
+ },
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
@@ -92,13 +103,18 @@
"update_title": "Opdatering tilgængelig",
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
"update_now": "Opdater nu",
- "update_reloading": "Indlæser..."
+ "update_reloading": "Indlæser...",
+ "storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
},
"sync": {
"status_synced": "Synkroniseret",
"status_syncing": "Synkroniser...",
"status_offline": "Offline-cache",
- "status_unsynced": "Usynkroniserede ændringer"
+ "status_unsynced": "Usynkroniserede ændringer",
+ "conflict_title": "Synkroniseringskonflikt",
+ "conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
+ "conflict_use_server": "Brug serverversion",
+ "conflict_keep_local": "Behold min version"
},
"vessel": {
"title": "Skibets stamdata",
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 4c0bec2..404d958 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
+ "dialog": {
+ "ok": "OK",
+ "yes": "Ja",
+ "no": "Nein"
+ },
+ "errors": {
+ "load_failed": "Daten konnten nicht geladen werden.",
+ "save_failed": "Änderungen konnten nicht gespeichert werden.",
+ "delete_failed": "Löschen fehlgeschlagen.",
+ "export_failed": "Export fehlgeschlagen."
+ },
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
@@ -92,13 +103,18 @@
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
- "update_reloading": "Wird geladen…"
+ "update_reloading": "Wird geladen…",
+ "storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
- "status_unsynced": "Unsynchronisierte Änderungen"
+ "status_unsynced": "Unsynchronisierte Änderungen",
+ "conflict_title": "Synchronisationskonflikt",
+ "conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
+ "conflict_use_server": "Server-Version übernehmen",
+ "conflict_keep_local": "Meine Version behalten"
},
"vessel": {
"title": "Schiffs-Stammdaten",
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 89bc4c0..644fb25 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
+ "dialog": {
+ "ok": "OK",
+ "yes": "Yes",
+ "no": "No"
+ },
+ "errors": {
+ "load_failed": "Could not load data.",
+ "save_failed": "Could not save changes.",
+ "delete_failed": "Could not delete.",
+ "export_failed": "Export failed."
+ },
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
@@ -92,13 +103,18 @@
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
- "update_reloading": "Reloading…"
+ "update_reloading": "Reloading…",
+ "storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
- "status_unsynced": "Unsynced changes"
+ "status_unsynced": "Unsynced changes",
+ "conflict_title": "Sync conflict",
+ "conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
+ "conflict_use_server": "Use server version",
+ "conflict_keep_local": "Keep my version"
},
"vessel": {
"title": "Vessel Master Data",
diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json
index 65a717f..ddf2a23 100644
--- a/client/src/i18n/locales/nb.json
+++ b/client/src/i18n/locales/nb.json
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
+ "dialog": {
+ "ok": "OK",
+ "yes": "Ja",
+ "no": "Nei"
+ },
+ "errors": {
+ "load_failed": "Data kunne ikke lastes.",
+ "save_failed": "Endringer kunne ikke lagres.",
+ "delete_failed": "Sletting mislyktes.",
+ "export_failed": "Eksport mislyktes."
+ },
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
@@ -92,13 +103,18 @@
"update_title": "Oppdatering tilgjengelig",
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
"update_now": "Oppdater nå",
- "update_reloading": "Laster..."
+ "update_reloading": "Laster...",
+ "storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
},
"sync": {
"status_synced": "Synkronisert",
"status_syncing": "Synkroniser...",
"status_offline": "Frakoblet hurtigbuffer",
- "status_unsynced": "Usynkroniserte endringer"
+ "status_unsynced": "Usynkroniserte endringer",
+ "conflict_title": "Synkroniseringskonflikt",
+ "conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
+ "conflict_use_server": "Bruk serverversjon",
+ "conflict_keep_local": "Behold min versjon"
},
"vessel": {
"title": "Stamdata for skip",
diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json
index 19cfdad..cd251d4 100644
--- a/client/src/i18n/locales/sv.json
+++ b/client/src/i18n/locales/sv.json
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
+ "dialog": {
+ "ok": "OK",
+ "yes": "Ja",
+ "no": "Nej"
+ },
+ "errors": {
+ "load_failed": "Data kunde inte laddas.",
+ "save_failed": "Ändringar kunde inte sparas.",
+ "delete_failed": "Radering misslyckades.",
+ "export_failed": "Export misslyckades."
+ },
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
@@ -92,13 +103,18 @@
"update_title": "Uppdatering tillgänglig",
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
"update_now": "Uppdatering nu",
- "update_reloading": "Laddar..."
+ "update_reloading": "Laddar...",
+ "storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
},
"sync": {
"status_synced": "Synkroniserad",
"status_syncing": "Synkronisera...",
"status_offline": "Offline-cache",
- "status_unsynced": "Osynkroniserade förändringar"
+ "status_unsynced": "Osynkroniserade förändringar",
+ "conflict_title": "Synkroniseringskonflikt",
+ "conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
+ "conflict_use_server": "Använd serverversion",
+ "conflict_keep_local": "Behåll min version"
},
"vessel": {
"title": "Masterdata för fartyg",
diff --git a/client/src/services/db.ts b/client/src/services/db.ts
index 8ef4dcc..d6f9fab 100644
--- a/client/src/services/db.ts
+++ b/client/src/services/db.ts
@@ -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
yachts!: Table
@@ -101,6 +110,7 @@ class DaagboxDatabase extends Dexie {
nmeaArchives!: Table
logbookKeys!: Table
syncQueue!: Table
+ entryDrafts!: Table
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'
+ })
}
}
diff --git a/client/src/services/entryDraft.ts b/client/src/services/entryDraft.ts
new file mode 100644
index 0000000..24e34bc
--- /dev/null
+++ b/client/src/services/entryDraft.ts
@@ -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 {
+ 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(
+ logbookId: string,
+ entryId: string
+): Promise {
+ 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 {
+ await db.entryDrafts.delete([logbookId, entryId])
+}
diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts
index 8449aa7..cabe948 100644
--- a/client/src/services/sync.ts
+++ b/client/src/services/sync.ts
@@ -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()
@@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise {
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 {
+ 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 {
+ 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)
+}
diff --git a/client/src/services/syncConflicts.ts b/client/src/services/syncConflicts.ts
new file mode 100644
index 0000000..7f042cc
--- /dev/null
+++ b/client/src/services/syncConflicts.ts
@@ -0,0 +1,48 @@
+export interface SyncConflict {
+ logbookId: string
+ payloadId: string
+ type: string
+ reason: string
+ queueItemId?: number
+ detectedAt: string
+}
+
+const conflicts = new Map()
+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): 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)
+}
diff --git a/client/src/utils/errors.ts b/client/src/utils/errors.ts
new file mode 100644
index 0000000..3017f78
--- /dev/null
+++ b/client/src/utils/errors.ts
@@ -0,0 +1,10 @@
+/** Map unknown errors to a user-facing message (i18n key or fallback). */
+export function getErrorMessage(err: unknown, fallback: string): string {
+ if (err instanceof Error && err.message.trim()) {
+ return err.message
+ }
+ if (typeof err === 'string' && err.trim()) {
+ return err
+ }
+ return fallback
+}
diff --git a/client/src/utils/storagePersist.ts b/client/src/utils/storagePersist.ts
new file mode 100644
index 0000000..0bc3b6d
--- /dev/null
+++ b/client/src/utils/storagePersist.ts
@@ -0,0 +1,17 @@
+/** Request durable IndexedDB storage (important on iOS Safari). */
+export async function requestPersistentStorage(): Promise<{
+ persisted: boolean
+ supported: boolean
+}> {
+ if (!('storage' in navigator) || !navigator.storage.persist) {
+ return { persisted: false, supported: false }
+ }
+ try {
+ const persisted = await navigator.storage.persisted()
+ if (persisted) return { persisted: true, supported: true }
+ const granted = await navigator.storage.persist()
+ return { persisted: granted, supported: true }
+ } catch {
+ return { persisted: false, supported: true }
+ }
+}