diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 1586af8..3028e6b 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -321,7 +321,41 @@
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
- "tour_restart": "Tour erneut starten"
+ "tour_restart": "Tour erneut starten",
+ "backup_title": "Backup & Wiederherstellung",
+ "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
+ "backup_export_title": "Backup erstellen",
+ "backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
+ "backup_restore_title": "Backup wiederherstellen",
+ "backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
+ "backup_passphrase": "Backup-Passphrase",
+ "backup_passphrase_placeholder": "Mindestens 8 Zeichen",
+ "backup_passphrase_confirm": "Passphrase bestätigen",
+ "backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.",
+ "backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.",
+ "backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.",
+ "backup_export_btn": "Backup herunterladen",
+ "backup_exporting": "Backup wird erstellt…",
+ "backup_export_success": "Backup erstellt ({{count}} Reisetage).",
+ "backup_file_label": "Backup-Datei (.daagbok.json)",
+ "backup_preview_btn": "Inhalt prüfen",
+ "backup_previewing": "Prüfe…",
+ "backup_restore_btn": "Wiederherstellen",
+ "backup_restoring": "Wird wiederhergestellt…",
+ "backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.",
+ "backup_restore_cancelled": "Wiederherstellung abgebrochen.",
+ "backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
+ "backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
+ "backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
+ "backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
+ "backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
+ "backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
+ "backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
+ "backup_stat_entries": "{{count}} Reisetage",
+ "backup_stat_photos": "{{count}} Fotos",
+ "backup_stat_crew": "{{count}} Crew-Einträge",
+ "backup_stat_tracks": "{{count}} GPS-Tracks",
+ "backup_exported_at": "Exportiert: {{date}}"
},
"disclaimer": {
"title": "Wichtige Hinweise",
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 06f5b18..9176934 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -321,7 +321,41 @@
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
- "tour_restart": "Restart tour"
+ "tour_restart": "Restart tour",
+ "backup_title": "Backup & restore",
+ "backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
+ "backup_export_title": "Create backup",
+ "backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
+ "backup_restore_title": "Restore backup",
+ "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
+ "backup_passphrase": "Backup passphrase",
+ "backup_passphrase_placeholder": "At least 8 characters",
+ "backup_passphrase_confirm": "Confirm passphrase",
+ "backup_passphrase_short": "The backup passphrase must be at least 8 characters.",
+ "backup_passphrase_mismatch": "Passphrases do not match.",
+ "backup_wrong_passphrase": "Wrong passphrase or corrupted backup.",
+ "backup_export_btn": "Download backup",
+ "backup_exporting": "Creating backup…",
+ "backup_export_success": "Backup created ({{count}} travel days).",
+ "backup_file_label": "Backup file (.daagbok.json)",
+ "backup_preview_btn": "Verify contents",
+ "backup_previewing": "Verifying…",
+ "backup_restore_btn": "Restore",
+ "backup_restoring": "Restoring…",
+ "backup_restore_success": "Logbook “{{title}}” has been restored.",
+ "backup_restore_cancelled": "Restore cancelled.",
+ "backup_invalid_json": "The file is not valid JSON.",
+ "backup_invalid_format": "Unknown or outdated backup format.",
+ "backup_not_owner": "Only the logbook owner can create backups.",
+ "backup_not_authenticated": "Please sign in to restore a backup.",
+ "backup_id_conflict": "A logbook with this ID already exists.",
+ "backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?",
+ "backup_new_id_confirm": "Import the backup as a new logbook with a new ID?",
+ "backup_stat_entries": "{{count}} travel days",
+ "backup_stat_photos": "{{count}} photos",
+ "backup_stat_crew": "{{count}} crew records",
+ "backup_stat_tracks": "{{count}} GPS tracks",
+ "backup_exported_at": "Exported: {{date}}"
},
"disclaimer": {
"title": "Important notice",
diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts
index 8b2e703..0c29c52 100644
--- a/client/src/services/analytics.ts
+++ b/client/src/services/analytics.ts
@@ -17,7 +17,9 @@ export const PlausibleEvents = {
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
- PHOTO_UPLOADED: 'Photo Uploaded'
+ PHOTO_UPLOADED: 'Photo Uploaded',
+ BACKUP_EXPORTED: 'Backup Exported',
+ BACKUP_RESTORED: 'Backup Restored'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
diff --git a/client/src/services/logbookBackup.ts b/client/src/services/logbookBackup.ts
new file mode 100644
index 0000000..6ed8fa3
--- /dev/null
+++ b/client/src/services/logbookBackup.ts
@@ -0,0 +1,601 @@
+import { db } from './db.js'
+import { getActiveMasterKey } from './auth.js'
+import {
+ decryptJson,
+ encryptBuffer,
+ decryptBuffer
+} from './crypto.js'
+import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
+import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
+import { syncLogbook } from './sync.js'
+import type { SyncQueueItem } from './db.js'
+
+export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
+export const BACKUP_VERSION = 1 as const
+
+export interface LogbookBackupFile {
+ format: typeof BACKUP_FORMAT
+ version: typeof BACKUP_VERSION
+ exportedAt: string
+ logbook: {
+ id: string
+ encryptedTitle: string
+ updatedAt: string
+ isDemo?: boolean
+ }
+ logbookKey: {
+ ciphertext: string
+ iv: string
+ tag: string
+ }
+ payloads: {
+ yacht: {
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ } | null
+ deviation: {
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ } | null
+ crews: Array<{
+ payloadId: string
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ }>
+ entries: Array<{
+ payloadId: string
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ }>
+ photos: Array<{
+ payloadId: string
+ entryId: string
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ }>
+ gpsTracks: Array<{
+ entryId: string
+ encryptedData: string
+ iv: string
+ tag: string
+ updatedAt: string
+ }>
+ }
+ counts: {
+ entries: number
+ photos: number
+ crews: number
+ gpsTracks: number
+ hasYacht: boolean
+ hasDeviation: boolean
+ }
+}
+
+export interface LogbookBackupPreview {
+ title: string
+ exportedAt: string
+ sourceLogbookId: string
+ counts: LogbookBackupFile['counts']
+}
+
+async function deriveBackupPassphraseKey(passphrase: string): Promise
{
+ const encoder = new TextEncoder()
+ const passphraseBytes = encoder.encode(passphrase.trim())
+ const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
+
+ const baseKey = await window.crypto.subtle.importKey(
+ 'raw',
+ passphraseBytes,
+ { name: 'PBKDF2' },
+ false,
+ ['deriveKey']
+ )
+
+ return window.crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt: saltBytes,
+ iterations: 100_000,
+ hash: 'SHA-256'
+ },
+ baseKey,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ )
+}
+
+async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
+ const key = await deriveBackupPassphraseKey(passphrase)
+ return encryptBuffer(logbookKey, key)
+}
+
+async function unwrapLogbookKey(
+ wrapped: LogbookBackupFile['logbookKey'],
+ passphrase: string
+): Promise {
+ const key = await deriveBackupPassphraseKey(passphrase)
+ return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
+}
+
+function isBackupFile(value: unknown): value is LogbookBackupFile {
+ if (!value || typeof value !== 'object') return false
+ const obj = value as Partial
+ return (
+ obj.format === BACKUP_FORMAT &&
+ obj.version === BACKUP_VERSION &&
+ typeof obj.exportedAt === 'string' &&
+ !!obj.logbook?.id &&
+ !!obj.logbook?.encryptedTitle &&
+ !!obj.logbookKey?.ciphertext &&
+ !!obj.payloads
+ )
+}
+
+function encryptedPayloadData(
+ encryptedData: string,
+ iv: string,
+ tag: string,
+ extra?: Record
+): string {
+ return JSON.stringify({
+ ciphertext: encryptedData,
+ iv,
+ tag,
+ ...extra
+ })
+}
+
+async function collectLogbookPayloads(logbookId: string): Promise {
+ const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
+ db.yachts.get(logbookId),
+ db.deviations.get(logbookId),
+ db.crews.where({ logbookId }).toArray(),
+ db.entries.where({ logbookId }).toArray(),
+ db.photos.where({ logbookId }).toArray(),
+ db.gpsTracks.where({ logbookId }).toArray()
+ ])
+
+ return {
+ yacht: yacht
+ ? {
+ encryptedData: yacht.encryptedData,
+ iv: yacht.iv,
+ tag: yacht.tag,
+ updatedAt: yacht.updatedAt
+ }
+ : null,
+ deviation: deviation
+ ? {
+ encryptedData: deviation.encryptedData,
+ iv: deviation.iv,
+ tag: deviation.tag,
+ updatedAt: deviation.updatedAt
+ }
+ : null,
+ crews: crews.map((c) => ({
+ payloadId: c.payloadId,
+ encryptedData: c.encryptedData,
+ iv: c.iv,
+ tag: c.tag,
+ updatedAt: c.updatedAt
+ })),
+ entries: entries.map((e) => ({
+ payloadId: e.payloadId,
+ encryptedData: e.encryptedData,
+ iv: e.iv,
+ tag: e.tag,
+ updatedAt: e.updatedAt
+ })),
+ photos: photos.map((p) => ({
+ payloadId: p.payloadId,
+ entryId: p.entryId,
+ encryptedData: p.encryptedData,
+ iv: p.iv,
+ tag: p.tag,
+ updatedAt: p.updatedAt
+ })),
+ gpsTracks: gpsTracks.map((t) => ({
+ entryId: t.entryId,
+ encryptedData: t.encryptedData,
+ iv: t.iv,
+ tag: t.tag,
+ updatedAt: t.updatedAt
+ }))
+ }
+}
+
+function remapBackup(
+ backup: LogbookBackupFile,
+ newLogbookId: string
+): LogbookBackupFile {
+ return {
+ ...backup,
+ logbook: {
+ ...backup.logbook,
+ id: newLogbookId
+ },
+ payloads: {
+ ...backup.payloads,
+ yacht: backup.payloads.yacht
+ ? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
+ : null,
+ deviation: backup.payloads.deviation
+ ? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
+ : null,
+ crews: backup.payloads.crews.map((c) => ({ ...c })),
+ entries: backup.payloads.entries.map((e) => ({ ...e })),
+ photos: backup.payloads.photos.map((p) => ({ ...p })),
+ gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
+ }
+ }
+}
+
+async function queueRestoredLogbookForSync(
+ logbookId: string,
+ encryptedTitle: string,
+ logbookKey: ArrayBuffer,
+ payloads: LogbookBackupFile['payloads']
+): Promise {
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) throw new Error('Master key not found')
+
+ const aesMasterKey = await window.crypto.subtle.importKey(
+ 'raw',
+ masterKey,
+ { name: 'AES-GCM' },
+ false,
+ ['encrypt']
+ )
+ const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
+ const now = new Date().toISOString()
+
+ const items: Omit[] = [
+ {
+ action: 'create',
+ type: 'logbook',
+ payloadId: logbookId,
+ logbookId,
+ data: JSON.stringify({
+ encryptedTitle,
+ encryptedKey: encryptedKey.ciphertext,
+ iv: encryptedKey.iv,
+ tag: encryptedKey.tag
+ }),
+ updatedAt: now
+ }
+ ]
+
+ if (payloads.yacht) {
+ items.push({
+ action: 'update',
+ type: 'yacht',
+ payloadId: logbookId,
+ logbookId,
+ data: encryptedPayloadData(
+ payloads.yacht.encryptedData,
+ payloads.yacht.iv,
+ payloads.yacht.tag
+ ),
+ updatedAt: payloads.yacht.updatedAt
+ })
+ }
+
+ if (payloads.deviation) {
+ items.push({
+ action: 'update',
+ type: 'deviation',
+ payloadId: logbookId,
+ logbookId,
+ data: encryptedPayloadData(
+ payloads.deviation.encryptedData,
+ payloads.deviation.iv,
+ payloads.deviation.tag
+ ),
+ updatedAt: payloads.deviation.updatedAt
+ })
+ }
+
+ for (const crew of payloads.crews) {
+ items.push({
+ action: 'create',
+ type: 'crew',
+ payloadId: crew.payloadId,
+ logbookId,
+ data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
+ updatedAt: crew.updatedAt
+ })
+ }
+
+ for (const entry of payloads.entries) {
+ items.push({
+ action: 'create',
+ type: 'entry',
+ payloadId: entry.payloadId,
+ logbookId,
+ data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
+ updatedAt: entry.updatedAt
+ })
+ }
+
+ for (const photo of payloads.photos) {
+ items.push({
+ action: 'create',
+ type: 'photo',
+ payloadId: photo.payloadId,
+ logbookId,
+ data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
+ entryId: photo.entryId
+ }),
+ updatedAt: photo.updatedAt
+ })
+ }
+
+ for (const track of payloads.gpsTracks) {
+ items.push({
+ action: 'create',
+ type: 'gpsTrack',
+ payloadId: track.entryId,
+ logbookId,
+ data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
+ updatedAt: track.updatedAt
+ })
+ }
+
+ await db.syncQueue.bulkPut(items)
+}
+
+async function writeBackupToDexie(
+ logbookId: string,
+ backup: LogbookBackupFile,
+ logbookKey: ArrayBuffer
+): Promise {
+ const { logbook, payloads } = backup
+
+ await db.logbooks.put({
+ id: logbookId,
+ encryptedTitle: logbook.encryptedTitle,
+ updatedAt: logbook.updatedAt,
+ isSynced: 0,
+ isShared: 0,
+ isDemo: logbook.isDemo ? 1 : 0
+ })
+
+ await saveLogbookKey(logbookId, logbookKey)
+
+ if (payloads.yacht) {
+ await db.yachts.put({
+ logbookId,
+ encryptedData: payloads.yacht.encryptedData,
+ iv: payloads.yacht.iv,
+ tag: payloads.yacht.tag,
+ updatedAt: payloads.yacht.updatedAt
+ })
+ }
+
+ if (payloads.deviation) {
+ await db.deviations.put({
+ logbookId,
+ encryptedData: payloads.deviation.encryptedData,
+ iv: payloads.deviation.iv,
+ tag: payloads.deviation.tag,
+ updatedAt: payloads.deviation.updatedAt
+ })
+ }
+
+ if (payloads.crews.length > 0) {
+ await db.crews.bulkPut(
+ payloads.crews.map((c) => ({
+ payloadId: c.payloadId,
+ logbookId,
+ encryptedData: c.encryptedData,
+ iv: c.iv,
+ tag: c.tag,
+ updatedAt: c.updatedAt
+ }))
+ )
+ }
+
+ if (payloads.entries.length > 0) {
+ await db.entries.bulkPut(
+ payloads.entries.map((e) => ({
+ payloadId: e.payloadId,
+ logbookId,
+ encryptedData: e.encryptedData,
+ iv: e.iv,
+ tag: e.tag,
+ updatedAt: e.updatedAt
+ }))
+ )
+ }
+
+ if (payloads.photos.length > 0) {
+ await db.photos.bulkPut(
+ payloads.photos.map((p) => ({
+ payloadId: p.payloadId,
+ entryId: p.entryId,
+ logbookId,
+ encryptedData: p.encryptedData,
+ iv: p.iv,
+ tag: p.tag,
+ caption: '',
+ updatedAt: p.updatedAt
+ }))
+ )
+ }
+
+ if (payloads.gpsTracks.length > 0) {
+ await db.gpsTracks.bulkPut(
+ payloads.gpsTracks.map((t) => ({
+ entryId: t.entryId,
+ logbookId,
+ encryptedData: t.encryptedData,
+ iv: t.iv,
+ tag: t.tag,
+ updatedAt: t.updatedAt
+ }))
+ )
+ }
+}
+
+export async function exportLogbookBackup(
+ logbookId: string,
+ passphrase: string
+): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
+ if (!passphrase.trim() || passphrase.length < 8) {
+ throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
+ }
+
+ const logbook = await db.logbooks.get(logbookId)
+ if (!logbook || logbook.isShared === 1) {
+ throw new Error('BACKUP_NOT_OWNER')
+ }
+
+ if (navigator.onLine) {
+ await syncLogbook(logbookId).catch((err) => {
+ console.warn('Pre-backup sync failed, exporting local data:', err)
+ })
+ }
+
+ const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
+ const payloads = await collectLogbookPayloads(logbookId)
+ const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
+
+ const backup: LogbookBackupFile = {
+ format: BACKUP_FORMAT,
+ version: BACKUP_VERSION,
+ exportedAt: new Date().toISOString(),
+ logbook: {
+ id: logbook.id,
+ encryptedTitle: logbook.encryptedTitle,
+ updatedAt: logbook.updatedAt,
+ isDemo: logbook.isDemo === 1
+ },
+ logbookKey: wrappedKey,
+ payloads,
+ counts: {
+ entries: payloads.entries.length,
+ photos: payloads.photos.length,
+ crews: payloads.crews.length,
+ gpsTracks: payloads.gpsTracks.length,
+ hasYacht: !!payloads.yacht,
+ hasDeviation: !!payloads.deviation
+ }
+ }
+
+ const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
+ const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
+ const datePart = new Date().toISOString().slice(0, 10)
+ const filename = `${safeTitle}-${datePart}.daagbok.json`
+ const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
+
+ return { blob, filename, backup }
+}
+
+export async function parseLogbookBackupFile(file: File): Promise {
+ const text = await file.text()
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ throw new Error('BACKUP_INVALID_JSON')
+ }
+
+ if (!isBackupFile(parsed)) {
+ throw new Error('BACKUP_INVALID_FORMAT')
+ }
+
+ return parsed
+}
+
+export async function previewLogbookBackup(
+ backup: LogbookBackupFile,
+ passphrase: string
+): Promise {
+ const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
+ const parsed = JSON.parse(backup.logbook.encryptedTitle)
+ const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
+
+ return {
+ title,
+ exportedAt: backup.exportedAt,
+ sourceLogbookId: backup.logbook.id,
+ counts: backup.counts
+ }
+}
+
+export interface RestoreLogbookOptions {
+ overwrite?: boolean
+ assignNewId?: boolean
+}
+
+export async function restoreLogbookBackup(
+ backup: LogbookBackupFile,
+ passphrase: string,
+ options: RestoreLogbookOptions = {}
+): Promise<{ logbookId: string; title: string }> {
+ if (!getActiveMasterKey()) {
+ throw new Error('BACKUP_NOT_AUTHENTICATED')
+ }
+
+ const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
+ const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
+ const title = await decryptJson(
+ parsedTitle.ciphertext,
+ parsedTitle.iv,
+ parsedTitle.tag,
+ logbookKey
+ )
+
+ let targetId = backup.logbook.id
+ const existing = await db.logbooks.get(targetId)
+
+ if (existing && !options.overwrite && !options.assignNewId) {
+ throw new Error('BACKUP_ID_CONFLICT')
+ }
+
+ if (existing && options.overwrite) {
+ await deleteLocalLogbookCache(targetId)
+ }
+
+ if (options.assignNewId || (existing && !options.overwrite)) {
+ targetId = crypto.randomUUID()
+ }
+
+ const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
+
+ await writeBackupToDexie(targetId, prepared, logbookKey)
+ await queueRestoredLogbookForSync(
+ targetId,
+ prepared.logbook.encryptedTitle,
+ logbookKey,
+ prepared.payloads
+ )
+
+ if (navigator.onLine) {
+ await syncLogbook(targetId).catch((err) => {
+ console.warn('Post-restore sync failed, data saved locally:', err)
+ })
+ }
+
+ return { logbookId: targetId, title }
+}
+
+export function downloadBackupBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = filename
+ anchor.click()
+ URL.revokeObjectURL(url)
+}
diff --git a/docs/plausible-events.md b/docs/plausible-events.md
index bb71708..850661a 100644
--- a/docs/plausible-events.md
+++ b/docs/plausible-events.md
@@ -32,6 +32,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
+| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
+| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
## Bewusst nicht getrackt
@@ -47,6 +49,7 @@ Empfohlene Goal-Ketten für Auswertung:
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
+5. **Datensicherung:** Backup Exported → Backup Restored
## Entwicklung