diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx
new file mode 100644
index 0000000..03b5626
--- /dev/null
+++ b/client/src/components/PhotoCapture.tsx
@@ -0,0 +1,266 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { db } from '../services/db.js'
+import { getActiveMasterKey } from '../services/auth.js'
+import { encryptJson, decryptJson } from '../services/crypto.js'
+import { syncLogbook } from '../services/sync.js'
+import { useLiveQuery } from 'dexie-react-hooks'
+import { Camera, Trash2 } from 'lucide-react'
+
+interface PhotoCaptureProps {
+ entryId: string
+ logbookId: string
+}
+
+interface DecryptedPhoto {
+ payloadId: string
+ image: string
+ caption: string
+ updatedAt: string
+}
+
+export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) {
+ const { t } = useTranslation()
+ const [caption, setCaption] = useState('')
+ const [uploading, setUploading] = useState(false)
+ const [error, setError] = useState
(null)
+ const [decryptedPhotos, setDecryptedPhotos] = useState([])
+
+ const fileInputRef = useRef(null)
+
+ // Reactively query local photos database
+ const localPhotos = useLiveQuery(
+ () => db.photos.where({ entryId }).toArray(),
+ [entryId]
+ )
+
+ // Decrypt photos on query updates
+ useEffect(() => {
+ async function decryptPhotosList() {
+ if (!localPhotos) return
+
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) return
+
+ const list: DecryptedPhoto[] = []
+ for (const p of localPhotos) {
+ try {
+ const decrypted = await decryptJson(p.encryptedData, p.iv, p.tag, masterKey)
+ if (decrypted) {
+ list.push({
+ payloadId: p.payloadId,
+ image: decrypted.image,
+ caption: decrypted.caption || '',
+ updatedAt: p.updatedAt
+ })
+ }
+ } catch (e) {
+ console.error('Failed to decrypt photo attachment:', e)
+ }
+ }
+ setDecryptedPhotos(list)
+ }
+
+ decryptPhotosList()
+ }, [localPhotos])
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ setUploading(true)
+ setError(null)
+
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ const img = new Image()
+ img.onload = async () => {
+ try {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ if (!ctx) throw new Error('Could not get canvas context')
+
+ let width = img.width
+ let height = img.height
+ const MAX_WIDTH = 1280
+ const MAX_HEIGHT = 720
+
+ // Calculate resizing conserving aspect ratio
+ if (width > MAX_WIDTH || height > MAX_HEIGHT) {
+ const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
+ width = Math.round(width * ratio)
+ height = Math.round(height * ratio)
+ }
+
+ canvas.width = width
+ canvas.height = height
+ ctx.drawImage(img, 0, 0, width, height)
+
+ // Compress to JPEG, 70% quality
+ const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
+
+ // Encrypt
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) throw new Error('Master key not found. Please log in.')
+
+ const photoId = window.crypto.randomUUID()
+ const photoPayload = {
+ image: compressedBase64,
+ caption: caption.trim()
+ }
+
+ const encrypted = await encryptJson(photoPayload, masterKey)
+ const now = new Date().toISOString()
+
+ // Store locally
+ await db.photos.put({
+ payloadId: photoId,
+ entryId,
+ logbookId,
+ encryptedData: encrypted.ciphertext,
+ iv: encrypted.iv,
+ tag: encrypted.tag,
+ caption: '', // stored encrypted inside payload
+ updatedAt: now
+ })
+
+ // Queue for background sync
+ await db.syncQueue.put({
+ action: 'create',
+ type: 'photo',
+ payloadId: photoId,
+ logbookId,
+ data: JSON.stringify({
+ encryptedData: encrypted.ciphertext,
+ iv: encrypted.iv,
+ tag: encrypted.tag,
+ entryId
+ }),
+ updatedAt: now
+ })
+
+ setCaption('')
+ if (fileInputRef.current) fileInputRef.current.value = ''
+
+ syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
+ } catch (err: any) {
+ console.error('Failed to process image:', err)
+ setError(err.message || 'Failed to process image')
+ } finally {
+ setUploading(false)
+ }
+ }
+ img.src = event.target?.result as string
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const handleDelete = async (photoId: string) => {
+ if (window.confirm(t('logs.photo_delete_confirm'))) {
+ try {
+ const now = new Date().toISOString()
+
+ await db.photos.delete(photoId)
+
+ await db.syncQueue.put({
+ action: 'delete',
+ type: 'photo',
+ payloadId: photoId,
+ logbookId,
+ data: '',
+ updatedAt: now
+ })
+
+ syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
+ } catch (err: any) {
+ console.error('Failed to delete photo:', err)
+ }
+ }
+ }
+
+ const triggerSelect = () => {
+ if (fileInputRef.current) {
+ fileInputRef.current.click()
+ }
+ }
+
+ return (
+
+
+
+
{t('logs.photos_title')}
+
+
+ {error &&
{error}
}
+
+ {/* Upload area */}
+
+
+ {/* Photo Grid */}
+ {decryptedPhotos.length === 0 ? (
+
{t('logs.no_photos')}
+ ) : (
+
+ {decryptedPhotos.map((photo) => (
+
+
+

+
+
+ {photo.caption && (
+
+ {photo.caption}
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 2286843..f7de214 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -88,6 +88,24 @@
"event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen",
"share_csv": "CSV teilen",
+ "export_pdf": "PDF herunterladen",
+ "exporting_pdf": "PDF wird generiert...",
+ "photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
+ "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
+ "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
+ "photo_btn": "Foto aufnehmen / Hochladen",
+ "photo_processing": "Wird verarbeitet...",
+ "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
+ "photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
+ "gps_tracking_title": "GPS-Routenaufzeichnung (E2E-verschlüsselt)",
+ "gps_tracking_status_active": "Aufzeichnung läuft",
+ "gps_tracking_status_inactive": "Aufzeichnung inaktiv",
+ "gps_tracking_btn_start": "Aufzeichnung starten",
+ "gps_tracking_btn_stop": "Aufzeichnung stoppen",
+ "gps_tracking_btn_gpx": "GPX herunterladen",
+ "gps_tracking_stat_duration": "Dauer",
+ "gps_tracking_stat_distance": "Distanz",
+ "gps_tracking_stat_waypoints": "Wegpunkte",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen."
},
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 5e72946..d9975ac 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -88,6 +88,24 @@
"event_distance": "Distance (nm)",
"export_csv": "Download CSV",
"share_csv": "Share CSV",
+ "export_pdf": "Download PDF",
+ "exporting_pdf": "Generating PDF...",
+ "photos_title": "Photo Attachments (E2E Encrypted)",
+ "photo_caption_label": "Photo Caption / Label (Optional)",
+ "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
+ "photo_btn": "Take Photo / Upload",
+ "photo_processing": "Processing...",
+ "no_photos": "No photos attached to this journal entry yet.",
+ "photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
+ "gps_tracking_title": "GPS Route Tracking (E2E Encrypted)",
+ "gps_tracking_status_active": "Tracking Active",
+ "gps_tracking_status_inactive": "Tracking Inactive",
+ "gps_tracking_btn_start": "Start Tracking",
+ "gps_tracking_btn_stop": "Stop Tracking",
+ "gps_tracking_btn_gpx": "Download GPX",
+ "gps_tracking_stat_duration": "Duration",
+ "gps_tracking_stat_distance": "Distance",
+ "gps_tracking_stat_waypoints": "Waypoints",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead."
},
diff --git a/client/src/services/db.ts b/client/src/services/db.ts
index 8a83014..447c7af 100644
--- a/client/src/services/db.ts
+++ b/client/src/services/db.ts
@@ -41,10 +41,30 @@ export interface LocalEntry {
updatedAt: string
}
+export interface LocalPhoto {
+ payloadId: string
+ entryId: string
+ logbookId: string
+ encryptedData: string // encrypted base64 image data
+ iv: string
+ tag: string
+ caption: string // encrypted caption
+ updatedAt: string
+}
+
+export interface LocalGpsTrack {
+ entryId: string // one track per daily journal entry
+ logbookId: string
+ encryptedData: string // encrypted waypoints JSON string
+ iv: string
+ tag: string
+ updatedAt: string
+}
+
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
- type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook'
+ type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
@@ -57,6 +77,8 @@ class DaagboxDatabase extends Dexie {
crews!: Table
deviations!: Table
entries!: Table
+ photos!: Table
+ gpsTracks!: Table
syncQueue!: Table
constructor() {
@@ -69,6 +91,16 @@ class DaagboxDatabase extends Dexie {
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId'
})
+ this.version(2).stores({
+ logbooks: 'id, encryptedTitle, updatedAt, isSynced',
+ 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'
+ })
}
}
diff --git a/client/src/services/gpsTracker.ts b/client/src/services/gpsTracker.ts
new file mode 100644
index 0000000..228b115
--- /dev/null
+++ b/client/src/services/gpsTracker.ts
@@ -0,0 +1,271 @@
+import { db } from './db.js'
+import { getActiveMasterKey } from './auth.js'
+import { encryptJson, decryptJson } from './crypto.js'
+import { syncLogbook } from './sync.js'
+
+export interface GpsWaypoint {
+ timestamp: number
+ lat: number
+ lng: number
+ speedKnots?: number
+ heading?: number
+}
+
+let watchId: number | null = null
+let wakeLock: any = null
+let activeEntryId: string | null = null
+let lastWaypoint: GpsWaypoint | null = null
+let onWaypointAddedCallback: ((waypoint: GpsWaypoint) => void) | null = null
+
+// Haversine formula to compute distance in meters
+export function getDistanceMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const R = 6371e3 // Earth's radius in meters
+ const phi1 = (lat1 * Math.PI) / 180
+ const phi2 = (lat2 * Math.PI) / 180
+ const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
+ const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
+
+ const a =
+ Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return R * c
+}
+
+// Request Screen Wake Lock
+async function requestWakeLock() {
+ try {
+ if ('wakeLock' in navigator) {
+ wakeLock = await (navigator as any).wakeLock.request('screen')
+ console.log('GPS Tracker: Screen Wake Lock acquired')
+ }
+ } catch (err) {
+ console.warn('GPS Tracker: Wake Lock request failed:', err)
+ }
+}
+
+// Release Screen Wake Lock
+function releaseWakeLock() {
+ if (wakeLock) {
+ wakeLock.release().then(() => {
+ wakeLock = null;
+ console.log('GPS Tracker: Screen Wake Lock released')
+ })
+ }
+}
+
+// Handle visibility changes to re-acquire wake lock if tab is minimized/restored
+if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', async () => {
+ if (watchId !== null && document.visibilityState === 'visible') {
+ await requestWakeLock()
+ }
+ })
+}
+
+// Start GPS Tracking Run
+export async function startGpsTracking(
+ logbookId: string,
+ entryId: string,
+ onWaypointAdded?: (waypoint: GpsWaypoint) => void
+): Promise {
+ if (watchId !== null) {
+ throw new Error('Tracking is already active')
+ }
+
+ if (!navigator.geolocation) {
+ throw new Error('Geolocation is not supported by your device')
+ }
+
+ activeEntryId = entryId
+ onWaypointAddedCallback = onWaypointAdded || null
+ lastWaypoint = null
+
+ // Acquire Screen Wake Lock to prevent standby/sleep
+ await requestWakeLock()
+
+ // Load last waypoint from existing track to resume or filter correctly
+ try {
+ const existingTrack = await getDecryptedGpsTrack(entryId)
+ if (existingTrack && existingTrack.length > 0) {
+ lastWaypoint = existingTrack[existingTrack.length - 1]
+ }
+ } catch (e) {
+ console.warn('Could not read existing waypoints for filtering:', e)
+ }
+
+ watchId = navigator.geolocation.watchPosition(
+ async (position) => {
+ const { latitude, longitude, speed, heading } = position.coords
+ const now = Date.now()
+
+ // Convert speed from m/s to knots (1 m/s = 1.94384 knots)
+ const speedKnots = speed !== null && speed !== undefined && speed >= 0 ? speed * 1.94384 : undefined
+ const headingDeg = heading !== null && heading !== undefined && heading >= 0 ? heading : undefined
+
+ const newWaypoint: GpsWaypoint = {
+ timestamp: now,
+ lat: Number(latitude.toFixed(6)),
+ lng: Number(longitude.toFixed(6)),
+ speedKnots: speedKnots !== undefined ? Number(speedKnots.toFixed(1)) : undefined,
+ heading: headingDeg !== undefined ? Number(headingDeg.toFixed(0)) : undefined
+ }
+
+ // Filter: Only add if distance to last waypoint > 15 meters OR if 30 seconds elapsed
+ if (lastWaypoint) {
+ const distance = getDistanceMeters(lastWaypoint.lat, lastWaypoint.lng, newWaypoint.lat, newWaypoint.lng)
+ const timeElapsed = now - lastWaypoint.timestamp
+
+ // Throttle check
+ if (distance < 15 && timeElapsed < 30000) {
+ // Skip insignificant waypoint
+ return
+ }
+ }
+
+ // Save waypoint
+ try {
+ await saveWaypoint(logbookId, entryId, newWaypoint)
+ lastWaypoint = newWaypoint
+ if (onWaypointAddedCallback) {
+ onWaypointAddedCallback(newWaypoint)
+ }
+ } catch (err) {
+ console.error('GPS Tracker: Failed to save waypoint:', err)
+ }
+ },
+ (error) => {
+ console.error('GPS Geolocation tracking error:', error)
+ },
+ {
+ enableHighAccuracy: true,
+ maximumAge: 0
+ }
+ )
+}
+
+// Stop GPS Tracking Run
+export function stopGpsTracking(): void {
+ if (watchId !== null) {
+ navigator.geolocation.clearWatch(watchId)
+ watchId = null
+ }
+ releaseWakeLock()
+ activeEntryId = null
+ onWaypointAddedCallback = null
+ lastWaypoint = null
+ console.log('GPS Tracker: Stopped tracking')
+}
+
+// Is Tracking currently running for this entry?
+export function isGpsTrackingActive(entryId?: string): boolean {
+ if (entryId) {
+ return watchId !== null && activeEntryId === entryId
+ }
+ return watchId !== null
+}
+
+// Get the decrypted waypoints array for a journal entry
+export async function getDecryptedGpsTrack(entryId: string): Promise {
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) {
+ throw new Error('Master key not found. Please log in.')
+ }
+
+ const record = await db.gpsTracks.get(entryId)
+ if (!record) return []
+
+ try {
+ const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
+ return Array.isArray(decrypted) ? decrypted : []
+ } catch (err) {
+ console.error('Failed to decrypt GPS track:', err)
+ return []
+ }
+}
+
+// Helper: append waypoint, encrypt, and save/queue sync
+async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWaypoint): Promise {
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) throw new Error('Master key not found. Please log in.')
+
+ // Fetch current waypoints
+ const waypoints = await getDecryptedGpsTrack(entryId)
+ waypoints.push(waypoint)
+
+ // Encrypt array
+ const encrypted = await encryptJson(waypoints, masterKey)
+ const now = new Date().toISOString()
+
+ // Save to Dexie
+ await db.gpsTracks.put({
+ entryId,
+ logbookId,
+ encryptedData: encrypted.ciphertext,
+ iv: encrypted.iv,
+ tag: encrypted.tag,
+ updatedAt: now
+ })
+
+ // Add to Sync queue (payloadId is entryId here)
+ await db.syncQueue.put({
+ action: 'create', // upsert mapping is used on server
+ type: 'gpsTrack',
+ payloadId: entryId,
+ logbookId,
+ data: JSON.stringify(encrypted),
+ updatedAt: now
+ })
+
+ // Trigger sync
+ syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
+}
+
+// Generate GPX file contents from Waypoints
+export function generateGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
+ const trkpts = waypoints
+ .map((wp) => {
+ const timeISO = new Date(wp.timestamp).toISOString()
+ const courseTag = wp.heading !== undefined ? `${wp.heading}` : ''
+ const speedTag = wp.speedKnots !== undefined ? `${(wp.speedKnots / 1.94384).toFixed(2)}` : '' // speed back in m/s for GPX spec
+ return `
+
+ ${courseTag}
+ ${speedTag}
+ `
+ })
+ .join('\n')
+
+ return `
+
+
+
+
+
+ Track Log ${dateStr}
+
+${trkpts}
+
+
+`
+}
+
+// Download GPX file client-side
+export function downloadGpxFile(waypoints: GpsWaypoint[], dateStr: string): void {
+ if (waypoints.length === 0) {
+ alert('No waypoints recorded to export.')
+ return
+ }
+ const gpxContent = generateGpxString(waypoints, dateStr)
+ const blob = new Blob([gpxContent], { type: 'application/gpx+xml;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `track_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.gpx`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts
new file mode 100644
index 0000000..3d155e9
--- /dev/null
+++ b/client/src/services/pdfExport.ts
@@ -0,0 +1,223 @@
+import { jsPDF } from 'jspdf'
+import { db } from './db.js'
+import { getActiveMasterKey } from './auth.js'
+import { decryptJson } from './crypto.js'
+
+export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise {
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) {
+ throw new Error('Master key not found. Please log in.')
+ }
+
+ // 1. Fetch Yacht details
+ let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
+ const yachtRecord = await db.yachts.get(logbookId);
+ if (yachtRecord) {
+ try {
+ const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
+ yachtName = yacht.name || '';
+ homePort = yacht.port || '';
+ // owner not needed in PDF layout
+ registration = yacht.registrationNumber || yacht.registration || '';
+ callsign = yacht.callSign || '';
+ atis = yacht.atis || '';
+ mmsi = yacht.mmsi || '';
+ } catch (e) {
+ console.error('Failed to decrypt yacht details for PDF:', e);
+ }
+ }
+
+ // 2. Fetch active Entry
+ const entryRecord = await db.entries.get(entryId);
+ if (!entryRecord) {
+ throw new Error('Entry not found');
+ }
+
+ const entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey);
+ if (!entry) {
+ throw new Error('Failed to decrypt entry');
+ }
+
+ // Create PDF landscape A4
+ const doc = new jsPDF({
+ orientation: 'landscape',
+ unit: 'mm',
+ format: 'a4'
+ });
+
+ // Setup Styles
+ doc.setFont('Helvetica', 'normal');
+
+ // --- DRAW HEADER SECTION ---
+ doc.setFontSize(14);
+ doc.setFont('Helvetica', 'bold');
+ doc.text('OFFIZIELLES SCHIFFSLOGBUCH (AC NAUTIK STANDARD)', 10, 15);
+
+ doc.setFontSize(8.5);
+ doc.setFont('Helvetica', 'normal');
+ doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
+ doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
+ doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
+ doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
+ doc.text(`ATIS: ${atis || '—'}`, 210, 21);
+ doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
+
+ doc.text(`Datum: ${entry.date || '—'}`, 10, 26);
+ doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26);
+ doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26);
+ doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26);
+
+ // Divider line
+ doc.setLineWidth(0.3);
+ doc.line(10, 29, 287, 29);
+
+ // --- DRAW EVENTS TABLE ---
+ doc.setFont('Helvetica', 'bold');
+ doc.setFontSize(9);
+ doc.text('CHRONOLOGISCHES EREIGNISPROTOKOLL / EVENT JOURNAL', 10, 34);
+
+ // Table Headers
+ const colWidths = [12, 10, 10, 12, 12, 13, 10, 12, 10, 15, 12, 45, 94]; // Total = 277mm
+ const colHeaders = [
+ 'Zeit', 'MgK', 'rwK', 'Wind Dir', 'Wind Str', 'Druck', 'See',
+ 'Strom', 'Lage', 'Segel/Motor', 'Log', 'GPS Position', 'Bemerkungen / Vorkommnisse'
+ ];
+
+ let startY = 37;
+ let rowHeight = 6;
+ doc.setFontSize(7.5);
+
+ // Draw Header Row
+ let currentX = 10;
+ doc.setFillColor(240, 240, 240);
+ doc.rect(10, startY, 277, rowHeight, 'F');
+ doc.rect(10, startY, 277, rowHeight, 'S');
+
+ for (let i = 0; i < colHeaders.length; i++) {
+ doc.text(colHeaders[i], currentX + 1, startY + 4.2);
+ currentX += colWidths[i];
+ }
+
+ // Draw Data Rows
+ const events = entry.events || [];
+ const maxRows = 16;
+ const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''));
+
+ doc.setFont('Helvetica', 'normal');
+
+ for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) {
+ const y = startY + rowHeight + (rowIndex * rowHeight);
+
+ // Draw row outline
+ doc.rect(10, y, 277, rowHeight, 'S');
+
+ // Draw vertical column cell dividers
+ let cellX = 10;
+ for (let colIdx = 0; colIdx < colWidths.length - 1; colIdx++) {
+ cellX += colWidths[colIdx];
+ doc.line(cellX, y, cellX, y + rowHeight);
+ }
+
+ const ev = sortedEvents[rowIndex];
+ if (ev) {
+ let writeX = 10;
+ doc.text(ev.time || '', writeX + 1, y + 4.2);
+ writeX += colWidths[0];
+ doc.text(ev.mgk ? `${ev.mgk}°` : '—', writeX + 1, y + 4.2);
+ writeX += colWidths[1];
+ doc.text(ev.rwk ? `${ev.rwk}°` : '—', writeX + 1, y + 4.2);
+ writeX += colWidths[2];
+ doc.text(ev.windDirection || '—', writeX + 1, y + 4.2);
+ writeX += colWidths[3];
+ doc.text(ev.windStrength || '—', writeX + 1, y + 4.2);
+ writeX += colWidths[4];
+ doc.text(ev.windPressure ? `${ev.windPressure} hPa` : '—', writeX + 1, y + 4.2);
+ writeX += colWidths[5];
+ doc.text(ev.seaState || '—', writeX + 1, y + 4.2);
+ writeX += colWidths[6];
+ doc.text(ev.current || '—', writeX + 1, y + 4.2);
+ writeX += colWidths[7];
+ doc.text(ev.heel ? `${ev.heel}°` : '—', writeX + 1, y + 4.2);
+ writeX += colWidths[8];
+ doc.text(ev.sailsOrMotor || '—', writeX + 1, y + 4.2);
+ writeX += colWidths[9];
+ doc.text(ev.logReading ? `${ev.logReading} sm` : '—', writeX + 1, y + 4.2);
+ writeX += colWidths[10];
+
+ const gps = ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—';
+ doc.text(gps, writeX + 1, y + 4.2);
+ writeX += colWidths[11];
+
+ // Clip remarks to fit within the 94mm bounds
+ const remarks = ev.remarks || '';
+ const maxChars = 65;
+ const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
+ doc.text(clippedRemarks, writeX + 1, y + 4.2);
+ }
+ }
+
+ // --- DRAW FOOTER SECTION ---
+ const footerY = startY + rowHeight + (maxRows * rowHeight) + 4;
+
+ // Consumables (Water & Diesel)
+ doc.setFont('Helvetica', 'bold');
+ doc.setFontSize(8.5);
+ doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
+
+ let fwY = footerY + 5;
+ doc.rect(10, fwY, 110, rowHeight * 3, 'S');
+ doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
+ doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
+ doc.line(40, fwY, 40, fwY + rowHeight * 3);
+ doc.line(60, fwY, 60, fwY + rowHeight * 3);
+ doc.line(80, fwY, 80, fwY + rowHeight * 3);
+ doc.line(100, fwY, 100, fwY + rowHeight * 3);
+
+ doc.setFont('Helvetica', 'bold');
+ doc.setFontSize(7.5);
+ doc.text('Betriebsmittel', 11, fwY + 4.2);
+ doc.text('Morgen (L)', 41, fwY + 4.2);
+ doc.text('Nachgefüllt', 61, fwY + 4.2);
+ doc.text('Abend (L)', 81, fwY + 4.2);
+ doc.text('Verbrauch', 101, fwY + 4.2);
+
+ doc.setFont('Helvetica', 'normal');
+ doc.text('Frischwasser', 11, fwY + rowHeight + 4.2);
+ doc.text(String(entry.freshwater?.morning ?? '0'), 41, fwY + rowHeight + 4.2);
+ doc.text(String(entry.freshwater?.refilled ?? '0'), 61, fwY + rowHeight + 4.2);
+ doc.text(String(entry.freshwater?.evening ?? '0'), 81, fwY + rowHeight + 4.2);
+ doc.text(String(entry.freshwater?.consumption ?? '0'), 101, fwY + rowHeight + 4.2);
+
+ doc.text('Treibstoff (Fuel)', 11, fwY + rowHeight * 2 + 4.2);
+ doc.text(String(entry.fuel?.morning ?? '0'), 41, fwY + rowHeight * 2 + 4.2);
+ doc.text(String(entry.fuel?.refilled ?? '0'), 61, fwY + rowHeight * 2 + 4.2);
+ doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
+ doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
+
+ // Signatures Box
+ let sigX = 130;
+ let sigY = footerY + 5;
+ doc.setFont('Helvetica', 'bold');
+ doc.text('FREIGABE & UNTERSCHRIFTEN / SIGNATURES', sigX, footerY + 3);
+
+ doc.rect(sigX, sigY, 157, rowHeight * 3, 'S');
+ doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
+ doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
+
+ doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2);
+ doc.setFont('Helvetica', 'normal');
+ doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
+
+ doc.setFont('Helvetica', 'bold');
+ doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2);
+ doc.setFont('Helvetica', 'normal');
+ doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+
+ return doc;
+}
+
+export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string): Promise {
+ const doc = await generateLogbookPagePdf(logbookId, entryId);
+ const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
+ doc.save(filename);
+}
diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts
index cd3395c..aea952b 100644
--- a/client/src/services/sync.ts
+++ b/client/src/services/sync.ts
@@ -91,7 +91,7 @@ async function pullChanges(logbookId: string): Promise {
return false
}
- const { yacht, deviation, crews, entries } = await response.json()
+ const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
// 1. Sync Yacht Payload
if (yacht) {
@@ -186,6 +186,72 @@ async function pullChanges(logbookId: string): Promise {
}
}
+ // 5. Sync Photos
+ const serverPhotoMap = new Map()
+ if (photos && Array.isArray(photos)) {
+ for (const p of photos) {
+ serverPhotoMap.set(p.payloadId, p)
+ const local = await db.photos.get(p.payloadId)
+ if (!local || isNewer(p.updatedAt, local.updatedAt)) {
+ await db.photos.put({
+ payloadId: p.payloadId,
+ entryId: p.entryId,
+ logbookId,
+ encryptedData: p.encryptedData,
+ iv: p.iv,
+ tag: p.tag,
+ caption: '', // caption is stored inside encryptedData JSON
+ updatedAt: p.updatedAt
+ })
+ }
+ }
+ }
+
+ // Deletions for Photos
+ const localPhotos = await db.photos.where({ logbookId }).toArray()
+ for (const lp of localPhotos) {
+ if (!serverPhotoMap.has(lp.payloadId)) {
+ const pendingCreate = await db.syncQueue
+ .where({ payloadId: lp.payloadId, action: 'create' })
+ .first()
+ if (!pendingCreate) {
+ await db.photos.delete(lp.payloadId)
+ }
+ }
+ }
+
+ // 6. Sync GPS Tracks
+ const serverGpsTrackMap = new Map()
+ if (gpsTracks && Array.isArray(gpsTracks)) {
+ for (const gt of gpsTracks) {
+ serverGpsTrackMap.set(gt.entryId, gt)
+ const local = await db.gpsTracks.get(gt.entryId)
+ if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
+ await db.gpsTracks.put({
+ entryId: gt.entryId,
+ logbookId,
+ encryptedData: gt.encryptedData,
+ iv: gt.iv,
+ tag: gt.tag,
+ updatedAt: gt.updatedAt
+ })
+ }
+ }
+ }
+
+ // Deletions for GPS Tracks
+ const localGpsTracks = await db.gpsTracks.where({ logbookId }).toArray()
+ for (const lgt of localGpsTracks) {
+ if (!serverGpsTrackMap.has(lgt.entryId)) {
+ const pendingCreate = await db.syncQueue
+ .where({ payloadId: lgt.entryId, action: 'create' })
+ .first()
+ if (!pendingCreate) {
+ await db.gpsTracks.delete(lgt.entryId)
+ }
+ }
+ }
+
return true
} catch (error) {
console.error('Error during sync pull:', error)
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 5d26869..b480be3 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -44,6 +44,8 @@ model Logbook {
crews CrewPayload[]
deviations DeviationPayload[]
entries EntryPayload[]
+ photos PhotoPayload[]
+ gpsTracks GpsTrackPayload[]
@@index([userId])
}
@@ -94,3 +96,32 @@ model EntryPayload {
@@unique([logbookId, payloadId])
@@index([logbookId])
}
+
+model PhotoPayload {
+ id String @id @default(uuid())
+ logbookId String
+ payloadId String
+ entryId String
+ encryptedData String
+ iv String
+ tag String
+ updatedAt DateTime @updatedAt
+ logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
+
+ @@unique([logbookId, payloadId])
+ @@index([logbookId])
+ @@index([entryId])
+}
+
+model GpsTrackPayload {
+ id String @id @default(uuid())
+ logbookId String
+ entryId String @unique
+ encryptedData String
+ iv String
+ tag String
+ updatedAt DateTime @updatedAt
+ logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
+
+ @@index([logbookId])
+}
diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts
index e299831..0bbef9f 100644
--- a/server/src/routes/sync.ts
+++ b/server/src/routes/sync.ts
@@ -152,6 +152,41 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
+ } else if (type === 'photo') {
+ if (action === 'delete') {
+ await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
+ } else {
+ const existing = await prisma.photoPayload.findUnique({
+ where: { logbookId_payloadId: { logbookId, payloadId } }
+ })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ const entryId = parsed.entryId || ''
+ await prisma.photoPayload.upsert({
+ where: { logbookId_payloadId: { logbookId, payloadId } },
+ create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
+ } else if (type === 'gpsTrack') {
+ if (action === 'delete') {
+ await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
+ } else {
+ const existing = await prisma.gpsTrackPayload.findUnique({
+ where: { entryId: payloadId }
+ })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ await prisma.gpsTrackPayload.upsert({
+ where: { entryId: payloadId },
+ create: { logbookId, entryId: payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
}
results.push({ payloadId, status: 'success' })
@@ -193,12 +228,16 @@ router.get('/pull', async (req: any, res) => {
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
+ const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
+ const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
return res.json({
yacht,
deviation,
crews,
- entries
+ entries,
+ photos,
+ gpsTracks
})
} catch (error: any) {
console.error('Error during sync pull:', error)