From 72d6bceee6d6cbcca63fd339d27f9225870a30cc Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 10:35:53 +0200 Subject: [PATCH] feat: implement Phase 4 (CSV export, share, sync indicators, OS themes) and add dev starter script --- .planning/ROADMAP.md | 10 +- .planning/STATE.md | 16 +- client/src/App.css | 280 +++++++++++++++++++++++ client/src/App.tsx | 113 ++++++--- client/src/components/LogEntriesList.tsx | 58 ++++- client/src/components/SettingsForm.tsx | 32 +++ client/src/i18n/locales/de.json | 14 +- client/src/i18n/locales/en.json | 14 +- client/src/services/csvExport.ts | 166 ++++++++++++++ client/src/services/sync.ts | 23 ++ scripts/start-dev.sh | 66 ++++++ 11 files changed, 741 insertions(+), 51 deletions(-) create mode 100644 client/src/services/csvExport.ts create mode 100755 scripts/start-dev.sh diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ad24928..46acc3f 100755 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -72,8 +72,8 @@ Plans: **Plans**: 2 plans Plans: -- [ ] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API. -- [ ] 04-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes. +- [x] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API. +- [x] 04-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes. ## Progress @@ -83,6 +83,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 | -| 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - | -| 3. Master Data & Log entries | 0/3 | Not started | - | -| 4. CSV Export & UI Polish | 0/2 | Not started | - | +| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | 2026-05-27 | +| 3. Master Data & Log entries | 3/3 | Completed | 2026-05-27 | +| 4. CSV Export & UI Polish | 2/2 | Completed | 2026-05-28 | diff --git a/.planning/STATE.md b/.planning/STATE.md index e2a360a..c6950df 100755 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -9,19 +9,19 @@ See: .planning/PROJECT.md (updated 2026-05-26) ## Current Position -Phase: 3 of 4 (Master Data & Log entries) -Plan: 3 of 3 in current phase +Phase: 4 of 4 (CSV Export & UI Polish) +Plan: 2 of 2 in current phase Status: Completed -Last activity: 2026-05-27 — Plan 03-03 completed (Logbook event records, browser Geolocation tracker, and OpenWeatherMap weather API integration complete) +Last activity: 2026-05-28 — Phase 4 completed (CSV Export, Web Share API integration, connection/sync indicators, and OS-adaptive UI themes) -Progress: [████████░░] 80% +Progress: [██████████] 100% ## Performance Metrics **Velocity:** -- Total plans completed: 8 +- Total plans completed: 10 - Average duration: 15 min -- Total execution time: 2.0 hours +- Total execution time: 2.5 hours **By Phase:** @@ -30,10 +30,10 @@ Progress: [████████░░] 80% | 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - | | 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - | | 3. Master Data & Log entries | 3/3 | Completed | - | -| 4. CSV Export & UI Polish | 0/2 | - | - | +| 4. CSV Export & UI Polish | 2/2 | Completed | - | **Recent Trend:** -- Last 5 plans: [02-01, 02-02, 03-01, 03-02, 03-03] +- Last 5 plans: [03-02, 03-03, 04-01, 04-02] - Trend: Stable *Updated after each plan completion* diff --git a/client/src/App.css b/client/src/App.css index 5d8ac48..71283b3 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1059,3 +1059,283 @@ body { .text-sm { font-size: 12px; } + +/* ========================================== */ +/* PHASE 4: CONNECTION & SYNC INDICATORS */ +/* ========================================== */ + +.sync-progress-bar { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 3px; + background: linear-gradient(90deg, #fbbf24, #d97706, #fbbf24); + background-size: 200% 100%; + animation: sync-slide 1.5s infinite linear; + z-index: 9999; +} + +@keyframes sync-slide { + 0% { background-position: 200% 0; } + 100% { background-position: 0 0; } +} + +.conn-status.warning { + background: rgba(251, 191, 36, 0.1); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.25); +} + +.pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #fbbf24; + display: inline-block; + margin-right: 6px; + animation: pulse-animation 1.5s infinite ease-in-out; +} + +@keyframes pulse-animation { + 0% { transform: scale(0.9); opacity: 0.6; } + 50% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(0.9); opacity: 0.6; } +} + +@media (max-width: 640px) { + .hide-mobile { + display: none !important; + } +} + +/* ========================================== */ +/* PHASE 4: OS-ADAPTIVE UI THEMES */ +/* ========================================== */ + +/* Body Overrides via :has() */ +body:has(.theme-material) { + background: #121212 !important; +} +body:has(.theme-cupertino) { + background: #000000 !important; +} + +/* --- MATERIAL THEME (ANDROID/LINUX) --- */ +.theme-material { + font-family: 'Roboto', 'Noto Sans', system-ui, -apple-system, sans-serif !important; +} + +/* Color & Text overrides */ +.theme-material h1, +.theme-material h2, +.theme-material h3, +.theme-material h4, +.theme-material .form-header h2, +.theme-material .form-icon, +.theme-material .header-logo, +.theme-material .card-icon, +.theme-material .btn-back, +.theme-material .cell-label { + color: #00adb5 !important; +} + +/* Card Overrides */ +.theme-material .auth-card, +.theme-material .form-card, +.theme-material .create-section, +.theme-material .app-sidebar, +.theme-material .app-content, +.theme-material .logbook-card, +.theme-material .crew-member-card, +.theme-material .member-editor-card { + background: #1e1e1e !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + border: 1px solid #2d2d2d !important; + border-radius: 4px !important; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important; +} + +/* Input Overrides */ +.theme-material .input-text, +.theme-material .input-textarea, +.theme-material select.input-text { + background: #2a2a2a !important; + border: 1px solid #3d3d3d !important; + border-radius: 4px !important; + color: #f1f5f9 !important; +} +.theme-material .input-text:focus, +.theme-material .input-textarea:focus { + border-color: #00adb5 !important; + box-shadow: 0 0 0 2px rgba(0, 173, 181, 0.2) !important; +} + +/* Button Overrides */ +.theme-material .btn.primary { + background: #00adb5 !important; + color: #ffffff !important; + border-radius: 4px !important; + box-shadow: none !important; +} +.theme-material .btn.primary:hover { + background: #008f95 !important; + transform: none !important; +} +.theme-material .btn.secondary, +.theme-material .btn-back { + background: #2a2a2a !important; + border: 1px solid #3d3d3d !important; + color: #f1f5f9 !important; + border-radius: 4px !important; +} +.theme-material .btn.secondary:hover, +.theme-material .btn-back:hover { + background: #333333 !important; +} + +/* Sidebar Overrides */ +.theme-material .sidebar-btn { + border-radius: 0 !important; + border-left: 4px solid transparent !important; +} +.theme-material .sidebar-btn.active { + background: rgba(0, 173, 181, 0.08) !important; + border-left: 4px solid #00adb5 !important; + color: #00adb5 !important; + border-top: none !important; + border-right: none !important; + border-bottom: none !important; +} + +/* Header Overrides */ +.theme-material .app-header { + border-bottom: 1px solid #2d2d2d !important; +} + +/* Tables and Grids */ +.theme-material .events-table th { + color: #00adb5 !important; + border-bottom: 2px solid #2d2d2d !important; +} +.theme-material .events-table td { + border-bottom: 1px solid #2d2d2d !important; +} +.theme-material .events-scroll-container { + border: 1px solid #2d2d2d !important; +} + +/* Progress bar override for Material */ +.theme-material ~ .sync-progress-bar { + background: linear-gradient(90deg, #00adb5, #008f95, #00adb5) !important; +} + + +/* --- CUPERTINO THEME (iOS/macOS) --- */ +.theme-cupertino { + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif !important; +} + +/* Color & Text overrides */ +.theme-cupertino h1, +.theme-cupertino h2, +.theme-cupertino h3, +.theme-cupertino h4, +.theme-cupertino .form-header h2, +.theme-cupertino .form-icon, +.theme-cupertino .header-logo, +.theme-cupertino .card-icon, +.theme-cupertino .btn-back, +.theme-cupertino .cell-label { + color: #0a84ff !important; +} + +/* Card Overrides */ +.theme-cupertino .auth-card, +.theme-cupertino .form-card, +.theme-cupertino .create-section, +.theme-cupertino .app-sidebar, +.theme-cupertino .app-content, +.theme-cupertino .logbook-card, +.theme-cupertino .crew-member-card, +.theme-cupertino .member-editor-card { + background: rgba(28, 28, 30, 0.7) !important; + backdrop-filter: blur(25px) !important; + -webkit-backdrop-filter: blur(25px) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + border-radius: 12px !important; + box-shadow: none !important; +} + +/* Input Overrides */ +.theme-cupertino .input-text, +.theme-cupertino .input-textarea, +.theme-cupertino select.input-text { + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 8px !important; + color: #ffffff !important; +} +.theme-cupertino .input-text:focus, +.theme-cupertino .input-textarea:focus { + border-color: #0a84ff !important; + box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.25) !important; +} + +/* Button Overrides */ +.theme-cupertino .btn.primary { + background: #0a84ff !important; + color: #ffffff !important; + border-radius: 9999px !important; + box-shadow: none !important; +} +.theme-cupertino .btn.primary:hover { + background: #007aff !important; + transform: none !important; +} +.theme-cupertino .btn.secondary, +.theme-cupertino .btn-back { + background: rgba(255, 255, 255, 0.08) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + color: #ffffff !important; + border-radius: 9999px !important; +} +.theme-cupertino .btn.secondary:hover, +.theme-cupertino .btn-back:hover { + background: rgba(255, 255, 255, 0.12) !important; +} + +/* Sidebar Overrides */ +.theme-cupertino .sidebar-btn { + border-radius: 8px !important; +} +.theme-cupertino .sidebar-btn.active { + background: rgba(10, 132, 255, 0.12) !important; + color: #0a84ff !important; + border: 1px solid rgba(10, 132, 255, 0.2) !important; +} + +/* Header Overrides */ +.theme-cupertino .app-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important; +} + +/* Tables and Grids */ +.theme-cupertino .events-table th { + color: #0a84ff !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.15) !important; +} +.theme-cupertino .events-table td { + border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; +} +.theme-cupertino .events-scroll-container { + border: 1px solid rgba(255, 255, 255, 0.1) !important; + background: rgba(28, 28, 30, 0.5) !important; +} + +/* Progress bar override for Cupertino */ +.theme-cupertino ~ .sync-progress-bar { + background: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff) !important; +} + diff --git a/client/src/App.tsx b/client/src/App.tsx index a3dd1eb..f5bdfd4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,7 +8,9 @@ import DeviationForm from './components/DeviationForm.tsx' import LogEntriesList from './components/LogEntriesList.tsx' import SettingsForm from './components/SettingsForm.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' -import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js' +import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' +import { db } from './services/db.js' +import { useLiveQuery } from 'dexie-react-hooks' import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -19,6 +21,43 @@ function App() { const [activeLogbookTitle, setActiveLogbookTitle] = useState(null) const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs') const [online, setOnline] = useState(navigator.onLine) + const [isSyncing, setIsSyncing] = useState(false) + const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean') + + const syncQueueCount = useLiveQuery( + () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), + [activeLogbookId] + ) + + const updateAppliedTheme = () => { + const configTheme = localStorage.getItem('active_theme') || 'auto' + if (configTheme === 'auto') { + const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera + if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) { + setAppliedTheme('cupertino') + } else if (/Android|Linux/.test(userAgent)) { + setAppliedTheme('material') + } else { + setAppliedTheme('ocean') + } + } else { + setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino') + } + } + + useEffect(() => { + updateAppliedTheme() + window.addEventListener('theme-changed', updateAppliedTheme) + return () => { + window.removeEventListener('theme-changed', updateAppliedTheme) + } + }, []) + + useEffect(() => { + return subscribeToSyncState((syncing) => { + setIsSyncing(syncing) + }) + }, []) useEffect(() => { const handleOnline = () => { @@ -93,44 +132,59 @@ function App() { } if (!isAuthenticated) { - return + return ( +
+ +
+ ) } if (!activeLogbookId) { return ( - +
+ +
) } return ( -
- {/* Active Logbook Header */} -
-
- -
-

{activeLogbookTitle}

-

{t('app.name')} / {activeLogbookId.substring(0, 8)}...

-
-
- -
-
- {online ? : } - {online ? 'Online' : t('sync.status_offline')} +
+ {isSyncing &&
} +
+ {/* Active Logbook Header */} +
+
+ +
+

{activeLogbookTitle}

+

{t('app.name')} / {activeLogbookId.substring(0, 8)}...

+
- -
-
+
+ {syncQueueCount !== undefined && syncQueueCount > 0 && ( +
+ + {t('sync.status_unsynced')} ({syncQueueCount}) +
+ )} + +
+ {online ? : } + {online ? 'Online' : t('sync.status_offline')} +
+ + +
+ {/* Active Workspace */}
@@ -201,6 +255,7 @@ function App() {
+ ) } diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index 0ce019b..c5334f4 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -4,8 +4,9 @@ import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { decryptJson, encryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' +import { downloadCsv, shareCsv } from '../services/csvExport.js' import LogEntryEditor from './LogEntryEditor.tsx' -import { FileText, Plus, Trash2, ChevronRight, Calendar } from 'lucide-react' +import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react' interface LogEntriesListProps { logbookId: string @@ -25,6 +26,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { const [entries, setEntries] = useState([]) const [selectedEntryId, setSelectedEntryId] = useState(null) const [loading, setLoading] = useState(false) + const [exporting, setExporting] = useState(false) const [error, setError] = useState(null) useEffect(() => { @@ -74,6 +76,40 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { } } + const handleDownloadCsv = async () => { + setExporting(true) + setError(null) + try { + const title = localStorage.getItem('active_logbook_title') || 'Logbook' + await downloadCsv(logbookId, title) + } catch (err: any) { + console.error('Failed to download CSV:', err) + setError(err.message || 'Failed to generate CSV export.') + } finally { + setExporting(false) + } + } + + const handleShareCsv = async () => { + setExporting(true) + setError(null) + try { + const title = localStorage.getItem('active_logbook_title') || 'Logbook' + await shareCsv(logbookId, title) + } catch (err: any) { + if (err.message === 'share_unsupported') { + const title = localStorage.getItem('active_logbook_title') || 'Logbook' + await downloadCsv(logbookId, title) + setError(t('logs.share_unsupported')) + } else { + console.error('Failed to share CSV:', err) + setError(err.message || 'Failed to share CSV export.') + } + } finally { + setExporting(false) + } + } + const handleCreate = async () => { setLoading(true) setError(null) @@ -187,10 +223,22 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {

{t('logs.title')}

- +
+ + + + + +
{error &&
{error}
} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 884816d..c2511ab 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -5,6 +5,7 @@ import { Settings, Save, Check } from 'lucide-react' export default function SettingsForm() { const { t } = useTranslation() const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '') + const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto') const [saving, setSaving] = useState(false) const [success, setSuccess] = useState(false) @@ -15,6 +16,10 @@ export default function SettingsForm() { // Save to localStorage localStorage.setItem('owm_api_key', apiKey.trim()) + localStorage.setItem('active_theme', theme) + + // Notify App of theme change + window.dispatchEvent(new Event('theme-changed')) setSaving(false) setSuccess(true) @@ -34,6 +39,7 @@ export default function SettingsForm() {
+ {/* Weather Integration card */}

{t('settings.owm_title')} @@ -58,6 +64,32 @@ export default function SettingsForm() {

+ {/* Theme customization card */} +
+

+ {t('settings.theme_title')} +

+

+ {t('settings.theme_label')} +

+ +
+ +
+
+
{success && (
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index d840ad3..2286843 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -85,7 +85,11 @@ "event_wind_pressure": "Luftdruck (hPa)", "event_heel": "Krängung (°)", "event_sails": "Segelführung / Motor", - "event_distance": "Distanz (sm)" + "event_distance": "Distanz (sm)", + "export_csv": "CSV herunterladen", + "share_csv": "CSV teilen", + "exporting": "Exportiere...", + "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen." }, "dashboard": { "title": "Ihre Logbücher", @@ -144,7 +148,13 @@ "no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.", "weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.", - "gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten." + "gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten.", + "theme_title": "Design-Anpassung", + "theme_label": "Design-Stil der App", + "theme_auto": "Automatisch (OS-Erkennung)", + "theme_ocean": "Ocean (Glassmorphismus)", + "theme_material": "Material (Android)", + "theme_cupertino": "Cupertino (iOS)" } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 56bcfb3..5e72946 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -85,7 +85,11 @@ "event_wind_pressure": "Barometer (hPa)", "event_heel": "Heel Angle (°)", "event_sails": "Sails / Motor Status", - "event_distance": "Distance (nm)" + "event_distance": "Distance (nm)", + "export_csv": "Download CSV", + "share_csv": "Share CSV", + "exporting": "Exporting...", + "share_unsupported": "Web sharing is not supported on this device. File downloaded instead." }, "dashboard": { "title": "Your Logbooks", @@ -144,7 +148,13 @@ "no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.", "weather_success": "Weather details fetched successfully!", "weather_error": "Failed to fetch weather. Check your API key and connection.", - "gps_error": "Please fetch GPS coordinates first." + "gps_error": "Please fetch GPS coordinates first.", + "theme_title": "UI Customization", + "theme_label": "Application Style / Theme", + "theme_auto": "Auto (OS Detect)", + "theme_ocean": "Ocean (Glassmorphism)", + "theme_material": "Material (Android)", + "theme_cupertino": "Cupertino (iOS)" } } } diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts new file mode 100644 index 0000000..70148e7 --- /dev/null +++ b/client/src/services/csvExport.ts @@ -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 { + 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 { + 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 { + 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'); + } +} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index aa440c7..cd3395c 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -4,6 +4,24 @@ import { getActiveMasterKey } from './auth.js' const API_BASE = 'http://localhost:5000/api/sync' const syncingLogbooks = new Set() +let isSyncing = false +const listeners = new Set<(syncing: boolean) => void>() + +export function subscribeToSyncState(listener: (syncing: boolean) => void) { + listeners.add(listener) + listener(isSyncing) + return () => { + listeners.delete(listener) + } +} + +function setSyncing(syncing: boolean) { + if (isSyncing !== syncing) { + isSyncing = syncing + listeners.forEach((l) => l(isSyncing)) + } +} + // Helper to check if a timestamp is newer function isNewer(timeA: string | Date, timeB: string | Date): boolean { return new Date(timeA).getTime() > new Date(timeB).getTime() @@ -184,6 +202,7 @@ export async function syncLogbook(logbookId: string): Promise { if (syncingLogbooks.has(logbookId)) return false syncingLogbooks.add(logbookId) + setSyncing(true) try { const pushed = await pushChanges(logbookId) @@ -191,6 +210,7 @@ export async function syncLogbook(logbookId: string): Promise { return pushed && pulled; } finally { syncingLogbooks.delete(logbookId) + setSyncing(syncingLogbooks.size > 0) } } @@ -202,6 +222,7 @@ export async function syncAllLogbooks(): Promise { if (!masterKey) return try { + setSyncing(true) // 1. Fetch latest logbook lists first (synchronizes db.logbooks index) const logbooks = await db.logbooks.toArray() @@ -211,6 +232,8 @@ export async function syncAllLogbooks(): Promise { } } catch (error) { console.error('Error synchronizing all logbooks:', error) + } finally { + setSyncing(syncingLogbooks.size > 0) } } diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh new file mode 100755 index 0000000..bb4f01f --- /dev/null +++ b/scripts/start-dev.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Configuration +SERVER_PORT=5000 +CLIENT_PORT=5173 + +echo "========================================" +echo " Kapteins Daagbox Dev Environment " +echo "========================================" +echo "Preparing to (re)start services..." + +# Clean up processes running on ports +cleanup_port() { + local port=$1 + if command -v lsof >/dev/null 2>&1; then + local pid=$(lsof -t -i:$port) + if [ ! -z "$pid" ]; then + echo "Port $port is currently in use by PID $pid. Stopping process..." + kill -9 $pid 2>/dev/null + fi + elif command -v fuser >/dev/null 2>&1; then + echo "Port $port is currently in use. Stopping process..." + fuser -k $port/tcp 2>/dev/null + fi +} + +cleanup_port $SERVER_PORT +cleanup_port $CLIENT_PORT + +# Clean exit handler +cleanup_all() { + echo "" + echo "Stopping all dev servers..." + # Kill all child jobs + kill $(jobs -p) 2>/dev/null + exit 0 +} + +# Trap termination signals +trap cleanup_all SIGINT SIGTERM EXIT + +# Start backend server +echo "Starting backend API server..." +cd server +npm run dev & +cd .. + +# Sleep briefly to let server start up +sleep 1.5 + +# Start frontend client +echo "Starting frontend dev server..." +cd client +npm run dev & +cd .. + +echo "========================================" +echo "Dev services are now running:" +echo " -> Backend: http://localhost:$SERVER_PORT" +echo " -> Frontend: http://localhost:$CLIENT_PORT" +echo "========================================" +echo "Press Ctrl+C to terminate both servers." +echo "========================================" + +# Block to keep parent process alive +wait