Implement AC Nautik PDF Export, E2E Encrypted Photos, and Background GPS Route Tracking
This commit is contained in:
@@ -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<LocalCrew>
|
||||
deviations!: Table<LocalDeviation>
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<GpsWaypoint[]> {
|
||||
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<void> {
|
||||
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 ? `<course>${wp.heading}</course>` : ''
|
||||
const speedTag = wp.speedKnots !== undefined ? `<speed>${(wp.speedKnots / 1.94384).toFixed(2)}</speed>` : '' // speed back in m/s for GPX spec
|
||||
return ` <trkpt lat="${wp.lat}" lon="${wp.lng}">
|
||||
<time>${timeISO}</time>
|
||||
${courseTag}
|
||||
${speedTag}
|
||||
</trkpt>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbox" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<time>${new Date().toISOString()}</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>Track Log ${dateStr}</name>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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<jsPDF> {
|
||||
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<void> {
|
||||
const doc = await generateLogbookPagePdf(logbookId, entryId);
|
||||
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
doc.save(filename);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sync Photos
|
||||
const serverPhotoMap = new Map<string, any>()
|
||||
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<string, any>()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user