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
+5 -5
View File
@@ -72,8 +72,8 @@ Plans:
**Plans**: 2 plans **Plans**: 2 plans
Plans: Plans:
- [ ] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API. - [x] 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-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes.
## Progress ## Progress
@@ -83,6 +83,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 | | 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 |
| 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - | | 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | 2026-05-27 |
| 3. Master Data & Log entries | 0/3 | Not started | - | | 3. Master Data & Log entries | 3/3 | Completed | 2026-05-27 |
| 4. CSV Export & UI Polish | 0/2 | Not started | - | | 4. CSV Export & UI Polish | 2/2 | Completed | 2026-05-28 |
+8 -8
View File
@@ -9,19 +9,19 @@ See: .planning/PROJECT.md (updated 2026-05-26)
## Current Position ## Current Position
Phase: 3 of 4 (Master Data & Log entries) Phase: 4 of 4 (CSV Export & UI Polish)
Plan: 3 of 3 in current phase Plan: 2 of 2 in current phase
Status: Completed 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 ## Performance Metrics
**Velocity:** **Velocity:**
- Total plans completed: 8 - Total plans completed: 10
- Average duration: 15 min - Average duration: 15 min
- Total execution time: 2.0 hours - Total execution time: 2.5 hours
**By Phase:** **By Phase:**
@@ -30,10 +30,10 @@ Progress: [████████░░] 80%
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - | | 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - |
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - | | 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - |
| 3. Master Data & Log entries | 3/3 | 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:** **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 - Trend: Stable
*Updated after each plan completion* *Updated after each plan completion*
+280
View File
@@ -1059,3 +1059,283 @@ body {
.text-sm { .text-sm {
font-size: 12px; 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;
}
+57 -2
View File
@@ -8,7 +8,9 @@ import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx' import LogEntriesList from './components/LogEntriesList.tsx'
import SettingsForm from './components/SettingsForm.tsx' import SettingsForm from './components/SettingsForm.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js' 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 { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -19,6 +21,43 @@ function App() {
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null) const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs') const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
const [online, setOnline] = useState(navigator.onLine) 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(() => { useEffect(() => {
const handleOnline = () => { const handleOnline = () => {
@@ -93,19 +132,27 @@ function App() {
} }
if (!isAuthenticated) { if (!isAuthenticated) {
return <AuthOnboarding onAuthenticated={handleAuthenticated} /> return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<AuthOnboarding onAuthenticated={handleAuthenticated} />
</div>
)
} }
if (!activeLogbookId) { if (!activeLogbookId) {
return ( return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<LogbookDashboard <LogbookDashboard
onSelectLogbook={handleSelectLogbook} onSelectLogbook={handleSelectLogbook}
onLogout={handleLogout} onLogout={handleLogout}
/> />
</div>
) )
} }
return ( return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
{isSyncing && <div className="sync-progress-bar" />}
<div className="app-layout"> <div className="app-layout">
{/* Active Logbook Header */} {/* Active Logbook Header */}
<header className="app-header"> <header className="app-header">
@@ -121,6 +168,13 @@ function App() {
</div> </div>
<div className="header-actions"> <div className="header-actions">
{syncQueueCount !== undefined && syncQueueCount > 0 && (
<div className="conn-status warning" title={`${syncQueueCount} unsynced changes`}>
<span className="pulse-dot"></span>
<span>{t('sync.status_unsynced')} ({syncQueueCount})</span>
</div>
)}
<div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}> <div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}>
{online ? <Wifi size={18} /> : <WifiOff size={18} />} {online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span> <span>{online ? 'Online' : t('sync.status_offline')}</span>
@@ -201,6 +255,7 @@ function App() {
</main> </main>
</div> </div>
</div> </div>
</div>
) )
} }
+50 -2
View File
@@ -4,8 +4,9 @@ import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { decryptJson, encryptJson } from '../services/crypto.js' import { decryptJson, encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import LogEntryEditor from './LogEntryEditor.tsx' 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 { interface LogEntriesListProps {
logbookId: string logbookId: string
@@ -25,6 +26,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
const [entries, setEntries] = useState<DecryptedEntryItem[]>([]) const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null) const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { 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 () => { const handleCreate = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -187,11 +223,23 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
<Calendar size={24} className="form-icon" /> <Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2> <h2>{t('logs.title')}</h2>
</div> </div>
<button className="btn primary" onClick={handleCreate} style={{ width: 'auto', padding: '8px 16px' }}> <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
</button>
<button className="btn secondary" onClick={handleShareCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
<Share2 size={16} />
<span className="hide-mobile">{t('logs.share_csv')}</span>
</button>
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} /> <Plus size={16} />
{t('logs.new_entry')} {t('logs.new_entry')}
</button> </button>
</div> </div>
</div>
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
+32
View File
@@ -5,6 +5,7 @@ import { Settings, Save, Check } from 'lucide-react'
export default function SettingsForm() { export default function SettingsForm() {
const { t } = useTranslation() const { t } = useTranslation()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '') const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
@@ -15,6 +16,10 @@ export default function SettingsForm() {
// Save to localStorage // Save to localStorage
localStorage.setItem('owm_api_key', apiKey.trim()) 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) setSaving(false)
setSuccess(true) setSuccess(true)
@@ -34,6 +39,7 @@ export default function SettingsForm() {
</div> </div>
<form onSubmit={handleSubmit} className="vessel-form mt-6"> <form onSubmit={handleSubmit} className="vessel-form mt-6">
{/* Weather Integration card */}
<div className="member-editor-card glass"> <div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}> <h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.owm_title')} {t('settings.owm_title')}
@@ -58,6 +64,32 @@ export default function SettingsForm() {
</div> </div>
</div> </div>
{/* Theme customization card */}
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.theme_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.theme_label')}
</p>
<div className="input-group">
<select
id="app-theme"
className="input-text"
value={theme}
onChange={(e) => setTheme(e.target.value)}
disabled={saving}
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
>
<option value="auto">{t('settings.theme_auto')}</option>
<option value="ocean">{t('settings.theme_ocean')}</option>
<option value="material">{t('settings.theme_material')}</option>
<option value="cupertino">{t('settings.theme_cupertino')}</option>
</select>
</div>
</div>
<div className="form-actions mt-4"> <div className="form-actions mt-4">
{success && ( {success && (
<div className="success-toast"> <div className="success-toast">
+12 -2
View File
@@ -85,7 +85,11 @@
"event_wind_pressure": "Luftdruck (hPa)", "event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "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": { "dashboard": {
"title": "Ihre Logbücher", "title": "Ihre Logbücher",
@@ -144,7 +148,13 @@
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.", "no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
"weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.", "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)"
} }
} }
} }
+12 -2
View File
@@ -85,7 +85,11 @@
"event_wind_pressure": "Barometer (hPa)", "event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "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": { "dashboard": {
"title": "Your Logbooks", "title": "Your Logbooks",
@@ -144,7 +148,13 @@
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.", "no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
"weather_success": "Weather details fetched successfully!", "weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.", "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)"
} }
} }
} }
+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');
}
}
+23
View File
@@ -4,6 +4,24 @@ import { getActiveMasterKey } from './auth.js'
const API_BASE = 'http://localhost:5000/api/sync' const API_BASE = 'http://localhost:5000/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
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 // Helper to check if a timestamp is newer
function isNewer(timeA: string | Date, timeB: string | Date): boolean { function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime() return new Date(timeA).getTime() > new Date(timeB).getTime()
@@ -184,6 +202,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
if (syncingLogbooks.has(logbookId)) return false if (syncingLogbooks.has(logbookId)) return false
syncingLogbooks.add(logbookId) syncingLogbooks.add(logbookId)
setSyncing(true)
try { try {
const pushed = await pushChanges(logbookId) const pushed = await pushChanges(logbookId)
@@ -191,6 +210,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
return pushed && pulled; return pushed && pulled;
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
setSyncing(syncingLogbooks.size > 0)
} }
} }
@@ -202,6 +222,7 @@ export async function syncAllLogbooks(): Promise<void> {
if (!masterKey) return if (!masterKey) return
try { try {
setSyncing(true)
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index) // 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray() const logbooks = await db.logbooks.toArray()
@@ -211,6 +232,8 @@ export async function syncAllLogbooks(): Promise<void> {
} }
} catch (error) { } catch (error) {
console.error('Error synchronizing all logbooks:', error) console.error('Error synchronizing all logbooks:', error)
} finally {
setSyncing(syncingLogbooks.size > 0)
} }
} }
+66
View File
@@ -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