feat: implement Phase 4 (CSV export, share, sync indicators, OS themes) and add dev starter script

This commit is contained in:
2026-05-28 10:35:53 +02:00
parent 54011294ad
commit 72d6bceee6
11 changed files with 741 additions and 51 deletions
+166
View File
@@ -0,0 +1,166 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson } from './crypto.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. User must log in.')
}
// 1. Fetch Yacht details
let yachtName = '', homePort = '', owner = '', charter = '', 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 = yacht.owner || '';
charter = yacht.charter || '';
registration = yacht.registration || '';
callsign = yacht.callsign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for CSV:', e);
}
}
// 2. Fetch logbook entries
const localEntries = await db.entries.where({ logbookId }).toArray();
const decryptedEntries = [];
for (const entry of localEntries) {
try {
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
if (dec) {
decryptedEntries.push(dec);
}
} catch (e) {
console.error('Failed to decrypt entry for CSV:', e);
}
}
// Sort chronological ascending (by date, and then dayOfTravel numerical)
decryptedEntries.sort((a, b) => {
const timeA = new Date(a.date || '').getTime() || 0;
const timeB = new Date(b.date || '').getTime() || 0;
if (timeA !== timeB) return timeA - timeB;
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0);
});
// Headers matching the requested event fields & metadata
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
];
const rows: string[][] = [headers];
for (const entry of decryptedEntries) {
const dateVal = entry.date || '';
const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || '';
const dest = entry.destination || '';
const signS = entry.signSkipper || '';
const signC = entry.signCrew || '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
const fwCons = entry.freshwater?.consumption ?? '';
const fuelM = entry.fuel?.morning ?? '';
const fuelR = entry.fuel?.refilled ?? '';
const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? '';
const eventsList = entry.events || [];
if (eventsList.length === 0) {
// Create one row even if there are no events for the day
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
} else {
// Sort events chronologically by time
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
for (const ev of sortedEvents) {
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
}
}
}
// Convert array of arrays to CSV string
return rows.map(r => r.join(',')).join('\n');
}
export async function downloadCsv(logbookId: string, title: string): Promise<void> {
const csvContent = await exportLogbookToCsv(logbookId);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Sanitize filename
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export async function shareCsv(logbookId: string, title: string): Promise<void> {
const csvContent = await exportLogbookToCsv(logbookId);
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
const file = new File([csvContent], filename, { type: 'text/csv' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: `Kapteins Daagbox - ${title}`,
text: `Logbook export for yacht ${title}`
});
} catch (e: any) {
if (e.name !== 'AbortError') {
console.error('Sharing failed, falling back to download:', e);
await downloadCsv(logbookId, title);
}
}
} else {
throw new Error('share_unsupported');
}
}