Compare commits

..

8 Commits

Author SHA1 Message Date
elpatron 7d28b5745a chore: release v0.1.0.1 2026-05-29 16:10:20 +02:00
elpatron affe745250 feat: Tankstände vom Vortag bei neuem Reisetag mit Bestätigung übernehmen.
Abendstände werden als Morgenstände vorgeschlagen; der Nutzer kann übernehmen oder mit 0 starten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:10:01 +02:00
elpatron cb96343d8c fix: Tankstände im Logbuch einheitlich ausrichten und formatieren.
Kürzere Labels, gemeinsames Input-Styling ohne cell-input und CSS-Grid-Fix für Frischwasser- und Kraftstoff-Felder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:05:53 +02:00
elpatron 56af7a3c60 feat: Geschwindigkeits-Farbverlauf auf der Track-Karte und stabiler Leaflet-Lifecycle.
Verzögertes fitBounds, Error Boundary und sauberes Map-Cleanup beheben den Absturz, der die Logbuch-Ansicht leer ließ.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:56:45 +02:00
elpatron 95856800de feat: GPS-Track auf OpenSeaMap-Karte mit Leaflet visualisieren.
Leaflet wieder eingebunden, CSS über Vite gebündelt und doppelte Karten-Initialisierung behoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:42:04 +02:00
elpatron b1b0c798b3 feat: GPS-Track-Statistiken automatisch ins Logbuch übernehmen.
Strecke, Max- und Durchschnittsgeschwindigkeit werden beim Track-Upload berechnet, gespeichert und in PDF/CSV exportiert. Test-GPX für die Kieler Förde (5 sm) hinzugefügt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:36:21 +02:00
elpatron cffe934d5e feat: Unterschriftsfelder im Logbuch per Touch, Stift oder Maus.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:28:52 +02:00
elpatron 3c7aec1573 fix: Daagbok-Branding, Track-Upload statt GPS-Tracker und zentrale Account-Löschung.
Ersetzt gpsTracker/Leaflet durch trackUpload, korrigiert App-Bezeichner und Login-Abstände, und macht die Account-Gefahrenzone auf dem Dashboard erreichbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:25:39 +02:00
29 changed files with 2671 additions and 378 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.1
0.1.0.2
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Daagbox" />
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#1e293b" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>Kapteins Daagbok</title>
+231 -13
View File
@@ -1,4 +1,4 @@
/* Kapteins Daagbox App styling */
/* Kapteins Daagbok App styling */
body {
background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
@@ -55,7 +55,8 @@ body {
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 8px 0;
margin: 0 0 14px 0;
line-height: 1.25;
letter-spacing: -0.5px;
}
@@ -63,7 +64,7 @@ body {
font-size: 14.5px;
color: #94a3b8;
margin: 0;
line-height: 140%;
line-height: 1.45;
}
.auth-form {
@@ -449,6 +450,51 @@ body {
}
}
.dashboard-account-section {
width: 100%;
max-width: 720px;
margin: 40px auto 48px;
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
}
.account-danger-zone {
border-top: 1px solid rgba(239, 68, 68, 0.2);
padding-top: 24px;
}
.account-danger-zone__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.account-danger-zone__icon {
color: #ef4444;
flex-shrink: 0;
}
.account-danger-zone__title {
margin: 0;
color: #ef4444;
font-size: 16px;
}
.account-danger-zone__desc {
font-size: 13.5px;
color: #94a3b8;
line-height: 1.45;
margin: 0 0 16px 0;
}
.account-danger-zone__actions {
justify-content: flex-start;
}
.account-danger-zone__actions .btn {
width: auto;
}
.create-section {
background: rgba(11, 12, 16, 0.6);
backdrop-filter: blur(20px);
@@ -1518,6 +1564,7 @@ body:has(.theme-cupertino) {
color: #e2e8f0;
line-height: 1.5;
margin: 0 0 24px 0;
white-space: pre-line;
}
.custom-dialog-actions {
@@ -1845,6 +1892,41 @@ body:has(.theme-cupertino) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: end;
}
.consumption-grid .input-group {
display: flex;
flex-direction: column;
height: 100%;
}
.consumption-grid .input-group label {
flex: 1 1 auto;
min-height: 2.75em;
display: flex;
align-items: flex-end;
margin-bottom: 8px;
line-height: 1.35;
}
.consumption-grid .input-group .input-text {
flex-shrink: 0;
-moz-appearance: textfield;
}
.consumption-grid .input-text::-webkit-outer-spin-button,
.consumption-grid .input-text::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.consumption-grid .consumption-value {
color: #4ade80;
font-weight: 700;
text-align: center;
pointer-events: none;
cursor: default;
}
@media (max-width: 480px) {
@@ -1894,16 +1976,6 @@ body:has(.theme-cupertino) {
margin-top: 4px;
}
#openseamap-container {
width: 100%;
height: 400px;
border-radius: 12px;
border: 1px solid rgba(212, 175, 55, 0.2);
position: relative;
z-index: 1;
box-sizing: border-box;
}
.track-info-header {
display: flex;
justify-content: space-between;
@@ -1944,6 +2016,152 @@ body:has(.theme-cupertino) {
margin-bottom: 12px;
}
.track-stats-grid {
margin-top: 16px;
align-items: start;
}
.track-map-wrapper {
margin-top: 12px;
}
#openseamap-container,
.track-map-container {
width: 100%;
height: 400px;
border-radius: 12px;
border: 1px solid rgba(212, 175, 55, 0.2);
position: relative;
z-index: 1;
box-sizing: border-box;
overflow: hidden;
background: #dbeafe;
}
.track-map-legend {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 11px;
color: #94a3b8;
}
.track-map-legend-bar {
flex: 1;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, hsl(120, 72%, 42%), hsl(60, 72%, 42%), hsl(0, 72%, 42%));
border: 1px solid rgba(255, 255, 255, 0.12);
}
.track-map-container.leaflet-container {
font-family: inherit;
}
.track-map-container .leaflet-tile,
.track-map-container img.leaflet-tile {
max-width: none !important;
max-height: none !important;
}
.track-map-container .leaflet-control-attribution {
font-size: 10px;
background: rgba(255, 255, 255, 0.8);
}
.signature-grid {
align-items: start;
}
.signature-pad-group {
width: 100%;
}
.signature-pad-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.signature-pad-header label {
display: block;
font-size: 13.5px;
color: #94a3b8;
font-weight: 500;
}
.signature-pad-clear {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
padding: 0;
}
.signature-pad-clear:hover {
color: #e2e8f0;
text-decoration: underline;
}
.signature-pad {
position: relative;
width: 100%;
height: 132px;
border-radius: 10px;
border: 1px dashed rgba(148, 163, 184, 0.45);
background: rgba(255, 255, 255, 0.96);
overflow: hidden;
touch-action: none;
}
.signature-pad.disabled {
opacity: 0.65;
}
.signature-pad-canvas,
.signature-pad-image {
display: block;
width: 100%;
height: 100%;
}
.signature-pad-canvas {
cursor: crosshair;
}
.signature-pad-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
pointer-events: none;
user-select: none;
}
.signature-legacy-text {
min-height: 132px;
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #e2e8f0;
font-size: 18px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* PWA install prompt */
.pwa-install-banner {
position: fixed;
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { deleteAccount } from '../services/auth.js'
interface AccountDangerZoneProps {
className?: string
}
export default function AccountDangerZone({ className = '' }: AccountDangerZoneProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const [deleting, setDeleting] = useState(false)
const handleDeleteAccount = async () => {
const confirmed = await showConfirm(
t('settings.delete_account_confirm_desc'),
t('settings.delete_account_confirm_title'),
t('settings.delete_account_confirm_yes'),
t('settings.delete_account_confirm_no')
)
if (!confirmed) return
setDeleting(true)
try {
const success = await deleteAccount()
if (success) {
window.location.reload()
} else {
showAlert(t('settings.delete_account_failed'))
}
} catch (err: any) {
showAlert(err.message || t('settings.delete_account_failed'))
} finally {
setDeleting(false)
}
}
return (
<div className={`account-danger-zone member-editor-card glass ${className}`.trim()}>
<div className="account-danger-zone__header">
<AlertTriangle size={20} className="account-danger-zone__icon" />
<h3 className="account-danger-zone__title">{t('settings.danger_zone_title')}</h3>
</div>
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
<div className="form-actions account-danger-zone__actions">
<button
type="button"
className="btn danger"
onClick={handleDeleteAccount}
disabled={deleting}
>
<Trash2 size={16} />
{deleting ? t('settings.deleting_account') : t('settings.delete_account_btn')}
</button>
</div>
</div>
)
}
+1 -1
View File
@@ -382,7 +382,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
return (
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbox" className="auth-logo-img" />
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<h1>{t('app.name')}</h1>
<p className="tagline">{t('auth.tagline')}</p>
</div>
+43 -8
View File
@@ -10,6 +10,15 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import { useDialog } from './ModalDialog.tsx'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
carryOverTankLevelsFromPreviousDay,
compareTravelDaysChronological,
emptyTankLevels,
formatTankLiters,
getNextTravelDayNumber,
type LogEntryTankSource,
type TravelDaySortable
} from '../utils/logEntryTankLevels.js'
interface LogEntriesListProps {
logbookId: string
@@ -179,26 +188,52 @@ export default function LogEntriesList({
const handleCreate = async () => {
if (readOnly) return
setLoading(true)
setError(null)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
t('logs.carry_over_tanks_no')
)
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
}
}
setLoading(true)
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
// Calculate next travel day number
const nextDayNum = String(entries.length + 1)
const initialPayload = {
date: todayStr,
dayOfTravel: nextDayNum,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
departure: '',
destination: '',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
freshwater,
fuel,
signSkipper: '',
signCrew: '',
events: []
@@ -275,7 +310,7 @@ export default function LogEntriesList({
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/>
)
}
+163 -172
View File
@@ -6,20 +6,21 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Navigation } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { isSignatureImage } from '../utils/signatures.js'
import {
getDecryptedGpsTrack,
saveUploadedGpsTrack,
deleteGpsTrack,
getDecryptedTrack,
saveUploadedTrack,
deleteTrack,
downloadTrackFile,
parseTrackFile,
type GpsWaypoint,
type SavedGpsTrack
} from '../services/gpsTracker.js'
type SavedTrack
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
interface LogEntryEditorProps {
entryId: string
@@ -28,7 +29,7 @@ interface LogEntryEditorProps {
readOnly?: boolean
preloadedEntry?: any
preloadedPhotos?: any[]
preloadedGpsTrack?: any
preloadedTrack?: any
preloadedYacht?: any
}
@@ -58,7 +59,7 @@ export default function LogEntryEditor({
readOnly = false,
preloadedEntry,
preloadedPhotos,
preloadedGpsTrack,
preloadedTrack,
preloadedYacht
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
@@ -87,6 +88,11 @@ export default function LogEntryEditor({
const [signSkipper, setSignSkipper] = useState('')
const [signCrew, setSignCrew] = useState('')
// GPS track stats (from uploaded track)
const [trackDistanceNm, setTrackDistanceNm] = useState('')
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Events list state
const [events, setEvents] = useState<LogEvent[]>([])
@@ -116,14 +122,32 @@ export default function LogEntryEditor({
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
// GPS Tracking States
const [savedTrack, setSavedTrack] = useState<SavedGpsTrack | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const mapInstanceRef = useRef<L.Map | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
if (!stats) return
const formatted = formatTrackStats(stats)
setTrackDistanceNm(formatted.distanceNm)
setTrackSpeedMaxKn(formatted.speedMaxKn)
setTrackSpeedAvgKn(formatted.speedAvgKn)
}
const loadTrackStatsFromEntry = (entry: any) => {
if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') {
setTrackDistanceNm(String(entry.trackDistanceNm))
}
if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') {
setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn))
}
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
}
}
// Auto-calculate Freshwater Consumption
useEffect(() => {
@@ -193,6 +217,7 @@ export default function LogEntryEditor({
setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
return
}
@@ -222,6 +247,7 @@ export default function LogEntryEditor({
setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
}
}
@@ -236,91 +262,30 @@ export default function LogEntryEditor({
loadEntry()
}, [entryId, preloadedEntry])
// GPS Track Loader
const loadGpsTrack = async () => {
if (readOnly && preloadedGpsTrack) {
setSavedTrack(preloadedGpsTrack)
const loadTrack = async () => {
if (readOnly && preloadedTrack) {
setSavedTrack(preloadedTrack)
return
}
try {
const track = await getDecryptedGpsTrack(entryId)
const track = await getDecryptedTrack(entryId)
setSavedTrack(track)
} catch (e) {
console.warn('Failed to load GPS track:', e)
console.warn('Failed to load track file:', e)
}
}
useEffect(() => {
loadGpsTrack()
}, [entryId, preloadedGpsTrack])
loadTrack()
}, [entryId, preloadedTrack])
// Leaflet Map Initialization and Rendering
useEffect(() => {
if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
return
}
if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
applyTrackStats(savedTrack.waypoints)
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
const startWp = savedTrack.waypoints[0]
const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13)
mapInstanceRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
}).addTo(map)
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const latLngs = savedTrack.waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
const polyline = L.polyline(latLngs, {
color: '#fbbf24',
weight: 4,
opacity: 0.85
}).addTo(map)
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
if (savedTrack.waypoints.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('Start Position')
if (savedTrack.waypoints.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('End Position')
}
}
setTimeout(() => {
map.invalidateSize()
}, 100)
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
}
}, [savedTrack])
// GPX/KML/GeoJSON Upload Handlers
// Track file upload handlers
const handleFileUpload = async (file: File) => {
if (readOnly) return
setUploadError(null)
@@ -338,8 +303,9 @@ export default function LogEntryEditor({
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
}
await saveUploadedGpsTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
await loadGpsTrack()
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack()
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
@@ -380,38 +346,17 @@ export default function LogEntryEditor({
return
}
try {
await deleteGpsTrack(logbookId, entryId)
await deleteTrack(logbookId, entryId)
setSavedTrack(null)
setTrackDistanceNm('')
setTrackSpeedMaxKn('')
setTrackSpeedAvgKn('')
setUploadError(null)
} catch (err: any) {
showAlert(err.message || 'Failed to delete track')
}
}
const calculateTrackDistance = (wps: GpsWaypoint[]) => {
if (wps.length < 2) return 0
let totalMeters = 0
for (let i = 1; i < wps.length; i++) {
const lat1 = wps[i - 1].lat
const lon1 = wps[i - 1].lng
const lat2 = wps[i].lat
const lon2 = wps[i].lng
const R = 6371e3
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))
totalMeters += R * c
}
return Number((totalMeters / 1852).toFixed(2))
}
const handleGetGps = () => {
if (readOnly) return
const lookupFallback = async () => {
@@ -663,8 +608,11 @@ export default function LogEntryEditor({
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
signSkipper: signSkipper.trim(),
signCrew: signCrew.trim(),
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
}
@@ -816,7 +764,7 @@ export default function LogEntryEditor({
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
@@ -827,7 +775,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
@@ -838,7 +786,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
@@ -851,11 +799,12 @@ export default function LogEntryEditor({
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input text-green"
style={{ color: '#4ade80', fontWeight: 'bold' }}
type="number"
className="input-text consumption-value"
value={fwConsumption}
disabled
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div>
@@ -869,7 +818,7 @@ export default function LogEntryEditor({
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
@@ -880,7 +829,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
@@ -891,7 +840,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
@@ -904,11 +853,12 @@ export default function LogEntryEditor({
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input text-green"
style={{ color: '#4ade80', fontWeight: 'bold' }}
type="number"
className="input-text consumption-value"
value={fuelConsumption}
disabled
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div>
@@ -1234,17 +1184,16 @@ export default function LogEntryEditor({
)}
</div>
{/* GPS Track Upload & Map Visualization */}
{/* Track file upload */}
<div className="form-card">
<div className="form-header">
<Navigation size={20} className="form-icon" />
<h3>{t('logs.gps_tracking_title')}</h3>
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
</div>
{uploadError && <div className="track-error-msg">{uploadError}</div>}
{!savedTrack ? (
/* Upload Zone when no track is loaded */
<div
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
onDragOver={handleDragOver}
@@ -1260,27 +1209,33 @@ export default function LogEntryEditor({
onChange={handleFileChange}
disabled={saving}
/>
<Download size={36} className="track-upload-icon" />
<Upload size={36} className="track-upload-icon" />
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
</div>
) : (
/* Map and Details when track is loaded */
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<>
<div className="track-info-header">
<div className="track-info-left">
<Navigation size={16} style={{ color: '#fbbf24' }} />
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
</div>
<div className="track-info-stats">
<span style={{ marginRight: '12px' }}>
{t('logs.gps_tracking_stat_distance')}: <strong>{calculateTrackDistance(savedTrack.waypoints)} sm</strong>
</span>
<span>
{t('logs.gps_tracking_stat_waypoints')}: <strong>{savedTrack.waypoints.length}</strong>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
{trackDistanceNm && (
<> · {trackDistanceNm} sm</>
)}
{trackSpeedMaxKn && (
<> · max {trackSpeedMaxKn} kn</>
)}
{trackSpeedAvgKn && (
<> · Ø {trackSpeedAvgKn} kn</>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn secondary"
@@ -1304,8 +1259,50 @@ export default function LogEntryEditor({
</div>
</div>
{/* Leaflet Map Div */}
<div id="openseamap-container" ref={mapContainerRef} />
{savedTrack.waypoints.length > 0 && (
<TrackMap waypoints={savedTrack.waypoints} />
)}
</>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid">
<div className="input-group">
<label>{t('logs.track_distance')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 5.0"
className="input-text"
value={trackDistanceNm}
onChange={(e) => setTrackDistanceNm(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_max')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 7.8"
className="input-text"
value={trackSpeedMaxKn}
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_avg')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 4.6"
className="input-text"
value={trackSpeedAvgKn}
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
</div>
)}
</div>
@@ -1318,30 +1315,24 @@ export default function LogEntryEditor({
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
<div className="form-grid">
<div className="input-group">
<label>{t('logs.sign_skipper')}</label>
<input
type="text"
placeholder="e.g. MARKUS SKIPPER"
className="input-text"
value={signSkipper}
onChange={(e) => setSignSkipper(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="form-grid signature-grid">
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={signSkipper}
onChange={setSignSkipper}
disabled={saving}
readOnly={readOnly}
/>
<div className="input-group">
<label>{t('logs.sign_crew')}</label>
<input
type="text"
placeholder="e.g. JAN MATE"
className="input-text"
value={signCrew}
onChange={(e) => setSignCrew(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={signCrew}
onChange={setSignCrew}
disabled={saving}
readOnly={readOnly}
/>
</div>
</div>
@@ -5,6 +5,7 @@ import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
interface LogbookDashboardProps {
@@ -227,6 +228,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
)}
</section>
</main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div>
)
}
+4 -52
View File
@@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { deleteAccount } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
interface SettingsFormProps {
logbookId?: string | null
@@ -48,31 +48,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const [shareCopied, setShareCopied] = useState(false)
const [loadingShareLink, setLoadingShareLink] = useState(false)
const handleDeleteAccount = async () => {
const confirmed = await showConfirm(
t('settings.delete_account_confirm_desc'),
t('settings.delete_account_confirm_title'),
t('settings.delete_account_confirm_yes'),
t('settings.delete_account_confirm_no')
)
if (confirmed) {
setSaving(true)
try {
const success = await deleteAccount()
if (success) {
window.location.reload()
} else {
showAlert(t('settings.delete_account_failed'))
}
} catch (err: any) {
showAlert(err.message || t('settings.delete_account_failed'))
} finally {
setSaving(false)
}
}
}
useEffect(() => {
if (logbookId) {
loadCollaborators()
@@ -514,30 +489,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
)}
{/* Danger Zone / Account Deletion */}
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(239,68,68,0.2)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<AlertTriangle size={20} style={{ color: '#ef4444' }} />
<h3 style={{ margin: 0, color: '#ef4444', fontSize: '16px' }}>
{t('settings.danger_zone_title')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.danger_zone_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<button
type="button"
className="btn danger"
onClick={handleDeleteAccount}
style={{ width: 'auto' }}
>
<Trash2 size={16} />
{t('settings.delete_account_btn')}
</button>
</div>
</div>
<AccountDangerZone className="mt-6" />
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Eraser } from 'lucide-react'
import { isSignatureImage } from '../utils/signatures.js'
interface SignaturePadProps {
id: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
readOnly?: boolean
}
const STROKE_COLOR = '#0f172a'
const STROKE_WIDTH = 2.2
export default function SignaturePad({
id,
label,
value,
onChange,
disabled = false,
readOnly = false
}: SignaturePadProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const isDrawing = useRef(false)
const lastPoint = useRef<{ x: number; y: number } | null>(null)
const skipExternalRedraw = useRef(false)
const hasInk = useRef(false)
const [showHint, setShowHint] = useState(() => !value)
const getContext = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return null
return canvas.getContext('2d')
}, [])
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current
const ctx = getContext()
if (!canvas || !ctx) return
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.restore()
hasInk.current = false
}, [getContext])
const drawImageValue = useCallback((dataUrl: string) => {
const canvas = canvasRef.current
const ctx = getContext()
if (!canvas || !ctx) return
const img = new Image()
img.onload = () => {
clearCanvas()
const width = canvas.clientWidth
const height = canvas.clientHeight
ctx.drawImage(img, 0, 0, width, height)
hasInk.current = true
}
img.src = dataUrl
}, [clearCanvas, getContext])
const setupCanvas = useCallback(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const rect = container.getBoundingClientRect()
const width = Math.max(rect.width, 1)
const height = Math.max(rect.height, 1)
const dpr = window.devicePixelRatio || 1
canvas.width = Math.floor(width * dpr)
canvas.height = Math.floor(height * dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = STROKE_WIDTH
ctx.strokeStyle = STROKE_COLOR
if (value && isSignatureImage(value)) {
drawImageValue(value)
} else {
clearCanvas()
}
}, [clearCanvas, drawImageValue, value])
useEffect(() => {
setupCanvas()
window.addEventListener('resize', setupCanvas)
return () => window.removeEventListener('resize', setupCanvas)
}, [setupCanvas])
useEffect(() => {
if (skipExternalRedraw.current) {
skipExternalRedraw.current = false
return
}
if (value && isSignatureImage(value)) {
drawImageValue(value)
setShowHint(false)
} else if (!value) {
clearCanvas()
setShowHint(true)
}
}, [value, clearCanvas, drawImageValue])
const getPoint = (event: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return null
const rect = canvas.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
}
}
const commitCanvas = () => {
const canvas = canvasRef.current
if (!canvas) return
if (!hasInk.current) {
skipExternalRedraw.current = true
onChange('')
return
}
skipExternalRedraw.current = true
onChange(canvas.toDataURL('image/png'))
}
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (readOnly || disabled) return
event.preventDefault()
const point = getPoint(event)
if (!point) return
isDrawing.current = true
lastPoint.current = point
setShowHint(false)
event.currentTarget.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current || readOnly || disabled) return
event.preventDefault()
const point = getPoint(event)
const ctx = getContext()
const prev = lastPoint.current
if (!point || !ctx || !prev) return
ctx.beginPath()
ctx.moveTo(prev.x, prev.y)
ctx.lineTo(point.x, point.y)
ctx.stroke()
lastPoint.current = point
hasInk.current = true
}
const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current) return
isDrawing.current = false
lastPoint.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
commitCanvas()
}
const handleClear = () => {
if (readOnly || disabled) return
clearCanvas()
skipExternalRedraw.current = true
setShowHint(true)
onChange('')
}
const interactive = !readOnly && !disabled
if (readOnly && value && !isSignatureImage(value)) {
return (
<div className="input-group signature-pad-group">
<label htmlFor={id}>{label}</label>
<div className="signature-legacy-text">{value}</div>
</div>
)
}
return (
<div className="input-group signature-pad-group">
<div className="signature-pad-header">
<label htmlFor={id}>{label}</label>
{interactive && (
<button type="button" className="signature-pad-clear" onClick={handleClear}>
<Eraser size={14} />
{t('logs.sign_clear')}
</button>
)}
</div>
<div
ref={containerRef}
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
>
{readOnly && value && isSignatureImage(value) ? (
<img src={value} alt={label} className="signature-pad-image" />
) : (
<canvas
id={id}
ref={canvasRef}
className="signature-pad-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishStroke}
onPointerLeave={finishStroke}
onPointerCancel={finishStroke}
/>
)}
{interactive && showHint && (
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
)}
</div>
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
import { Component, useEffect, useMemo, useRef } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import type { TrackWaypoint } from '../services/trackUpload.js'
import {
getSegmentSpeedsKn,
getTrackLineColor,
hasSpeedGradientData,
speedToTrackColor
} from '../utils/trackMapColors.js'
interface TrackMapProps {
waypoints: TrackWaypoint[]
}
const LINE_WEIGHT = 5
const LINE_OPACITY = 0.92
function isValidWaypoint(wp: TrackWaypoint): boolean {
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
}
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
return waypoints
.filter(isValidWaypoint)
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
}
function getTrackCenter(latLngs: [number, number][]): [number, number] {
const avgLat = latLngs.reduce((sum, point) => sum + point[0], 0) / latLngs.length
const avgLng = latLngs.reduce((sum, point) => sum + point[1], 0) / latLngs.length
return [avgLat, avgLng]
}
function scheduleFitMap(
map: L.Map,
latLngs: [number, number][],
isCancelled: () => boolean,
frameIds: number[]
) {
if (latLngs.length === 0) return
const fallbackCenter = latLngs.length === 1 ? latLngs[0] : getTrackCenter(latLngs)
const fallbackZoom = latLngs.length === 1 ? 14 : 11
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
map.invalidateSize({ animate: false })
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
try {
if (latLngs.length === 1) {
map.setView(L.latLng(latLngs[0]), 14, { animate: false })
return
}
const bounds = L.latLngBounds(latLngs.map(([lat, lng]) => L.latLng(lat, lng)))
if (!bounds.isValid()) {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
return
}
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 14, animate: false })
} catch {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
}
})
)
})
)
}
function TrackMapInner({ waypoints }: TrackMapProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const validWaypoints = useMemo(() => waypoints.filter(isValidWaypoint), [waypoints])
const segmentSpeeds = useMemo(() => getSegmentSpeedsKn(validWaypoints), [validWaypoints])
const useGradient = hasSpeedGradientData(segmentSpeeds)
const speedRange = useMemo(() => {
const valid = segmentSpeeds.filter((speed) => speed > 0)
if (valid.length === 0) return { min: 0, max: 0 }
return { min: Math.min(...valid), max: Math.max(...valid) }
}, [segmentSpeeds])
const trackKey = useMemo(
() =>
validWaypoints
.map((wp, index) => {
const speed = index > 0 ? segmentSpeeds[index - 1] : 0
return `${wp.lat},${wp.lng},${speed.toFixed(1)}`
})
.join('|'),
[validWaypoints, segmentSpeeds]
)
useEffect(() => {
const container = containerRef.current
if (!container || validWaypoints.length === 0) return
let cancelled = false
const pendingFrames: number[] = []
const isCancelled = () => cancelled
const map = L.map(container, {
zoomControl: true,
attributionControl: true
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
}).addTo(map)
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const trackGroup = L.layerGroup().addTo(map)
const latLngs = toLatLngs(validWaypoints)
if (useGradient && latLngs.length >= 2) {
for (let i = 1; i < latLngs.length; i++) {
const speedKn = segmentSpeeds[i - 1] ?? 0
const color = speedToTrackColor(speedKn, speedRange.min, speedRange.max)
L.polyline([latLngs[i - 1], latLngs[i]], {
color,
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
} else if (latLngs.length >= 2) {
L.polyline(latLngs, {
color: getTrackLineColor(segmentSpeeds),
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
if (latLngs.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(t('logs.track_map_start'))
}
if (latLngs.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(t('logs.track_map_end'))
}
scheduleFitMap(map, latLngs, isCancelled, pendingFrames)
return () => {
cancelled = true
pendingFrames.forEach((id) => cancelAnimationFrame(id))
map.remove()
}
}, [trackKey, validWaypoints, segmentSpeeds, speedRange.min, speedRange.max, useGradient, t])
if (validWaypoints.length === 0) return null
return (
<div className="track-map-wrapper">
<div
className="track-map-container"
ref={containerRef}
aria-label={t('logs.track_map_title')}
/>
{useGradient && (
<div className="track-map-legend" aria-hidden="true">
<span>{t('logs.track_map_speed_slow')}</span>
<div className="track-map-legend-bar" />
<span>{t('logs.track_map_speed_fast')}</span>
</div>
)}
</div>
)
}
class TrackMapErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('TrackMap render failed:', error, info)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export default function TrackMap(props: TrackMapProps) {
const { t } = useTranslation()
const remountKey = props.waypoints.filter(isValidWaypoint).length
return (
<TrackMapErrorBoundary
key={remountKey}
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
>
<TrackMapInner {...props} />
</TrackMapErrorBoundary>
)
}
+24 -9
View File
@@ -1,7 +1,7 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbox",
"name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch"
},
"nav": {
@@ -13,7 +13,7 @@
"settings": "Einstellungen"
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbox",
"welcome": "Willkommen bei Kapteins Daagbok",
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"register": "Mit Passkey registrieren",
"login": "Mit Passkey anmelden",
@@ -55,7 +55,7 @@
},
"pwa": {
"title": "App installieren",
"generic_benefit": "Installieren Sie Kapteins Daagbox auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
@@ -113,8 +113,10 @@
"evening": "Stand abends",
"consumption": "Tagesverbrauch",
"signatures": "Unterschriften / Freigabe",
"sign_skipper": "Skipper (Blockschrift)",
"sign_crew": "Crew-Mitglied (Blockschrift)",
"sign_skipper": "Skipper-Unterschrift",
"sign_crew": "Crew-Unterschrift",
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
"sign_clear": "Löschen",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
@@ -123,6 +125,10 @@
"loading": "Journal wird geladen...",
"delete_entry": "Tag löschen",
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
"carry_over_tanks_title": "Tankstände übernehmen?",
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit",
@@ -157,14 +163,22 @@
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
"confirm_yes": "Ja",
"confirm_no": "Nein",
"gps_tracking_title": "GPS-Route (OpenSeaMap)",
"track_upload_title": "GPS-Track (Datei)",
"track_upload_points": "Punkte",
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
"gps_tracking_stat_distance": "Track-Distanz",
"gps_tracking_stat_waypoints": "Wegpunkte",
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
"gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"track_distance": "GPS-Strecke (sm)",
"track_speed_max": "Max. Geschwindigkeit (kn)",
"track_speed_avg": "Ø Geschwindigkeit (kn)",
"track_map_title": "GPS-Track auf OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
@@ -253,7 +267,8 @@
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleting_account": "Konto wird gelöscht…"
}
}
}
+25 -10
View File
@@ -1,7 +1,7 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbox",
"name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook"
},
"nav": {
@@ -13,7 +13,7 @@
"settings": "Settings"
},
"auth": {
"welcome": "Welcome to Kapteins Daagbox",
"welcome": "Welcome to Kapteins Daagbok",
"tagline": "Secure, E2E encrypted maritime logbook.",
"register": "Register with Passkey",
"login": "Login with Passkey",
@@ -55,7 +55,7 @@
},
"pwa": {
"title": "Install app",
"generic_benefit": "Install Kapteins Daagbox on your device for faster access, offline use, and persistent data storage.",
"generic_benefit": "Install Kapteins Daagbok on your device for faster access, offline use, and persistent data storage.",
"ios_instructions": "On iPad/iPhone: Add the app to your Home Screen so your logbook data stays protected and the app launches like a native app.",
"ios_step_share": "Tap the Share button in the Safari toolbar",
"ios_step_add": "Choose “Add to Home Screen”",
@@ -113,8 +113,10 @@
"evening": "Evening Level",
"consumption": "Consumption",
"signatures": "Signatures / Sign-Off",
"sign_skipper": "Skipper Signature",
"sign_crew": "Crew Signature",
"sign_skipper": "Skipper signature",
"sign_crew": "Crew signature",
"sign_hint": "Sign with finger, stylus, or mouse",
"sign_clear": "Clear",
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List",
"save": "Save Logbook Page",
@@ -123,6 +125,10 @@
"loading": "Loading journal...",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over tank levels?",
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
"no_events": "No events logged for this travel day yet.",
"event_time": "Time",
@@ -157,14 +163,22 @@
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
"confirm_yes": "Yes",
"confirm_no": "No",
"gps_tracking_title": "GPS Route (OpenSeaMap)",
"gps_tracking_btn_gpx": "Download Track File",
"gps_tracking_stat_distance": "Track Distance",
"gps_tracking_stat_waypoints": "Points",
"track_upload_title": "GPS track file",
"track_upload_points": "points",
"gps_tracking_btn_gpx": "Download track file",
"gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select",
"gps_track_upload_btn": "Upload GPS Track File",
"gps_track_delete": "Delete Track File",
"gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
"track_distance": "GPS distance (nm)",
"track_speed_max": "Max speed (kn)",
"track_speed_avg": "Avg speed (kn)",
"track_map_title": "GPS track on OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
@@ -253,7 +267,8 @@
"delete_account_confirm_desc": "Are you absolutely sure you want to permanently delete your account and all associated logbooks and E2E-encrypted data?",
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again."
"delete_account_failed": "Failed to delete account. Please try again.",
"deleting_account": "Deleting account…"
}
}
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './index.css'
import App from './App.tsx'
import './i18n'
+10 -3
View File
@@ -2,6 +2,7 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport } from '../utils/signatures.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -77,6 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -93,8 +95,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || '';
const dest = entry.destination || '';
const signS = entry.signSkipper || '';
const signC = entry.signCrew || '';
const signS = formatSignatureForExport(entry.signSkipper);
const signC = formatSignatureForExport(entry.signCrew);
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
@@ -110,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
'', '', '',
'', '', '', '',
'', '', '', '', '',
@@ -125,6 +131,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
@@ -166,7 +173,7 @@ export async function shareCsv(logbookId: string, title: string, preloadedData?:
try {
await navigator.share({
files: [file],
title: `Kapteins Daagbox - ${title}`,
title: `Kapteins Daagbok - ${title}`,
text: `Logbook export for yacht ${title}`
});
} catch (e: any) {
+28 -10
View File
@@ -3,6 +3,7 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { isSignatureImage } from '../utils/signatures.js'
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
@@ -77,10 +78,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
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);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal');
doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10,
27
);
}
// Divider line
doc.setLineWidth(0.3);
@@ -219,14 +229,22 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
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.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
if (isSignatureImage(entry.signSkipper)) {
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
} else {
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);
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
if (isSignatureImage(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else {
doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
}
return doc;
}
@@ -4,7 +4,7 @@ import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
export interface GpsWaypoint {
export interface TrackWaypoint {
timestamp: number
lat: number
lng: number
@@ -12,15 +12,14 @@ export interface GpsWaypoint {
heading?: number
}
export interface SavedGpsTrack {
waypoints: GpsWaypoint[]
gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON)
export interface SavedTrack {
waypoints: TrackWaypoint[]
gpxContent: string
filename: string
fileType: string // 'gpx' | 'kml' | 'geojson'
fileType: string
}
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
export async function getDecryptedTrack(entryId: string): Promise<SavedTrack | null> {
const record = await db.gpsTracks.get(entryId)
if (!record) return null
@@ -33,45 +32,41 @@ export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTra
try {
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (Array.isArray(decrypted)) {
// Legacy format (just coordinate array)
return {
waypoints: decrypted,
gpxContent: generateLegacyGpxString(decrypted, 'legacy'),
gpxContent: buildLegacyGpx(decrypted, 'legacy'),
filename: 'track_legacy.gpx',
fileType: 'gpx'
}
}
return decrypted
return decrypted as SavedTrack
} catch (err) {
console.error('Failed to decrypt GPS track:', err)
console.error('Failed to decrypt track file:', err)
return null
}
}
// Encrypt and save uploaded GPS track to local Dexie and remote sync
export async function saveUploadedGpsTrack(
export async function saveUploadedTrack(
logbookId: string,
entryId: string,
gpxContent: string,
waypoints: GpsWaypoint[],
fileContent: string,
waypoints: TrackWaypoint[],
filename: string,
fileType: string
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const trackData: SavedGpsTrack = {
const trackData: SavedTrack = {
waypoints,
gpxContent,
gpxContent: fileContent,
filename,
fileType
}
// Encrypt JSON
const encrypted = await encryptJson(trackData, masterKey)
const now = new Date().toISOString()
// Save to Dexie
await db.gpsTracks.put({
entryId,
logbookId,
@@ -81,7 +76,6 @@ export async function saveUploadedGpsTrack(
updatedAt: now
})
// Add to Sync queue (payloadId is entryId)
await db.syncQueue.put({
action: 'create',
type: 'gpsTrack',
@@ -91,18 +85,14 @@ export async function saveUploadedGpsTrack(
updatedAt: now
})
// Trigger sync
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
// Delete GPS track from local DB and sync queue
export async function deleteGpsTrack(logbookId: string, entryId: string): Promise<void> {
export async function deleteTrack(logbookId: string, entryId: string): Promise<void> {
const now = new Date().toISOString()
// Delete from Dexie
await db.gpsTracks.delete(entryId)
// Add to Sync queue
await db.syncQueue.put({
action: 'delete',
type: 'gpsTrack',
@@ -112,12 +102,10 @@ export async function deleteGpsTrack(logbookId: string, entryId: string): Promis
updatedAt: now
})
// Trigger sync
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
// Download the track file exactly as uploaded
export function downloadTrackFile(track: SavedGpsTrack): void {
export function downloadTrackFile(track: SavedTrack): void {
const blob = new Blob([track.gpxContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
@@ -130,24 +118,22 @@ export function downloadTrackFile(track: SavedGpsTrack): void {
URL.revokeObjectURL(url)
}
// Main parser entry point
export function parseTrackFile(text: string, filename: string): { waypoints: GpsWaypoint[]; type: string } {
export function parseTrackFile(text: string, filename: string): { waypoints: TrackWaypoint[]; type: string } {
const lowerName = filename.toLowerCase()
if (lowerName.endsWith('.kml') || text.includes('<kml')) {
return { waypoints: parseKmlFile(text), type: 'kml' }
} else if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
} else {
return { waypoints: parseGpxFile(text), type: 'gpx' }
}
if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
}
return { waypoints: parseGpxFile(text), type: 'gpx' }
}
// 1. GPX Parser
export function parseGpxFile(gpxText: string): GpsWaypoint[] {
function parseGpxFile(gpxText: string): TrackWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(gpxText, 'text/xml')
const trackPoints = xmlDoc.getElementsByTagName('trkpt')
const waypoints: GpsWaypoint[] = []
const waypoints: TrackWaypoint[] = []
for (let i = 0; i < trackPoints.length; i++) {
const el = trackPoints[i]
@@ -156,13 +142,13 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
if (isNaN(lat) || isNaN(lon)) continue
const timeEl = el.getElementsByTagName('time')[0]
const timestamp = timeEl && timeEl.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
const timestamp = timeEl?.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
const speedEl = el.getElementsByTagName('speed')[0]
const speedKnots = speedEl && speedEl.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
const speedKnots = speedEl?.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
const courseEl = el.getElementsByTagName('course')[0] || el.getElementsByTagName('heading')[0]
const heading = courseEl && courseEl.textContent ? parseFloat(courseEl.textContent) : undefined
const heading = courseEl?.textContent ? parseFloat(courseEl.textContent) : undefined
waypoints.push({
timestamp,
@@ -175,18 +161,15 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
return waypoints
}
// 2. KML Parser
export function parseKmlFile(kmlText: string): GpsWaypoint[] {
function parseKmlFile(kmlText: string): TrackWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(kmlText, 'text/xml')
const waypoints: GpsWaypoint[] = []
const waypoints: TrackWaypoint[] = []
// Check for standard KML <coordinates> tags
const coordsTags = xmlDoc.getElementsByTagName('coordinates')
for (let i = 0; i < coordsTags.length; i++) {
const text = coordsTags[i].textContent || ''
const coordStrings = text.trim().split(/\s+/)
for (const str of coordStrings) {
for (const str of text.trim().split(/\s+/)) {
const parts = str.split(',')
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
@@ -202,22 +185,18 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
}
}
// Check for gx:coord extensions (commonly used in Google Earth tracks)
const gxCoords = xmlDoc.getElementsByTagName('gx:coord')
if (gxCoords.length > 0) {
for (let i = 0; i < gxCoords.length; i++) {
const text = gxCoords[i].textContent || ''
const parts = text.trim().split(/\s+/)
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
for (let i = 0; i < gxCoords.length; i++) {
const parts = (gxCoords[i].textContent || '').trim().split(/\s+/)
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
}
@@ -225,9 +204,8 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
return waypoints
}
// 3. GeoJSON Parser
export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
const waypoints: GpsWaypoint[] = []
function parseGeoJsonFile(geoJsonText: string): TrackWaypoint[] {
const waypoints: TrackWaypoint[] = []
try {
const data = JSON.parse(geoJsonText)
@@ -235,40 +213,22 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
if (!geom) return
if (geom.type === 'LineString' && Array.isArray(geom.coordinates)) {
for (const coord of geom.coordinates) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
pushCoord(waypoints, coord)
}
} else if (geom.type === 'MultiLineString' && Array.isArray(geom.coordinates)) {
for (const line of geom.coordinates) {
if (Array.isArray(line)) {
for (const coord of line) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
pushCoord(waypoints, coord)
}
}
}
}
};
}
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
for (const feature of data.features) {
if (feature && feature.geometry) {
processGeometry(feature.geometry)
}
if (feature?.geometry) processGeometry(feature.geometry)
}
} else if (data.type === 'Feature' && data.geometry) {
processGeometry(data.geometry)
@@ -282,8 +242,19 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
return waypoints
}
// Generate legacy fallback GPX string
function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
function pushCoord(waypoints: TrackWaypoint[], coord: number[]) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
function buildLegacyGpx(waypoints: TrackWaypoint[], dateStr: string): string {
const trkpts = waypoints
.map((wp) => {
const timeISO = new Date(wp.timestamp).toISOString()
@@ -294,7 +265,7 @@ function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): str
.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">
<gpx version="1.1" creator="Kapteins Daagbok" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<time>${new Date().toISOString()}</time>
</metadata>
+64
View File
@@ -0,0 +1,64 @@
export interface TankLevels {
morning: number
refilled: number
evening: number
consumption: number
}
export interface TravelDaySortable {
date?: string
dayOfTravel?: string | number
}
/** Chronological order: date ascending, then day of travel ascending. */
export function compareTravelDaysChronological(a: TravelDaySortable, b: TravelDaySortable): number {
const dateCompare = new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime()
if (dateCompare !== 0) return dateCompare
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0)
}
export function getNextTravelDayNumber(entries: TravelDaySortable[]): string {
const maxDay = entries.reduce((max, entry) => Math.max(max, Number(entry.dayOfTravel) || 0), 0)
return String(maxDay + 1)
}
/** Closing level at end of travel day: evening stand, else calculated balance, else morning. */
export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
if (!tank) return 0
const evening = Number(tank.evening) || 0
if (evening > 0) return evening
const morning = Number(tank.morning) || 0
const refilled = Number(tank.refilled) || 0
const consumption = Number(tank.consumption) || 0
const fromBalance = morning + refilled - consumption
if (fromBalance > 0) return fromBalance
return morning
}
export interface LogEntryTankSource {
freshwater?: Partial<TankLevels>
fuel?: Partial<TankLevels>
}
export function emptyTankLevels(morning = 0): TankLevels {
return { morning, refilled: 0, evening: 0, consumption: 0 }
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return '0'
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
if (!previousEntry) {
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
}
return {
freshwater: emptyTankLevels(getClosingTankLevel(previousEntry.freshwater)),
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
}
}
+9
View File
@@ -0,0 +1,9 @@
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
export function formatSignatureForExport(value: string | undefined | null): string {
if (!value) return ''
if (isSignatureImage(value)) return '[Unterschrift]'
return value
}
+75
View File
@@ -0,0 +1,75 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
const FALLBACK_GREEN = '#16a34a'
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000
const p1 = (lat1 * Math.PI) / 180
const p2 = (lat2 * Math.PI) / 180
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
if (waypoints.length < 2) return false
const first = waypoints[0].timestamp
const last = waypoints[waypoints.length - 1].timestamp
return last > first + 60_000
}
export function getSegmentSpeedsKn(waypoints: TrackWaypoint[]): number[] {
if (waypoints.length < 2) return []
const timed = hasMeaningfulTimestamps(waypoints)
const speeds: number[] = []
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1]
const curr = waypoints[i]
let speedKn = 0
const tagged = [prev.speedKnots, curr.speedKnots].filter(
(value): value is number => value != null && value > 0
)
if (tagged.length > 0) {
speedKn = tagged.reduce((sum, value) => sum + value, 0) / tagged.length
} else if (timed) {
const dtMs = curr.timestamp - prev.timestamp
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
if (dtMs > 0 && segmentM > 0) {
speedKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
}
}
if (speedKn > MAX_PLAUSIBLE_KNOTS) speedKn = 0
speeds.push(speedKn)
}
return speeds
}
export function hasSpeedGradientData(speeds: number[]): boolean {
const valid = speeds.filter((speed) => speed > 0)
if (valid.length < 2) return false
const min = Math.min(...valid)
const max = Math.max(...valid)
return max - min >= 0.3
}
/** Green (slow) → yellow → red (fast) */
export function speedToTrackColor(speedKn: number, minKn: number, maxKn: number): string {
if (speedKn <= 0 || maxKn <= minKn) return FALLBACK_GREEN
const t = Math.max(0, Math.min(1, (speedKn - minKn) / (maxKn - minKn)))
const hue = 120 - t * 120
return `hsl(${hue}, 72%, 42%)`
}
export function getTrackLineColor(speeds: number[]): string {
return hasSpeedGradientData(speeds) ? '' : FALLBACK_GREEN
}
+107
View File
@@ -0,0 +1,107 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
export interface TrackStats {
distanceNm: number
speedMaxKn: number
speedAvgKn: number
durationMinutes: number
}
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000
const p1 = (lat1 * Math.PI) / 180
const p2 = (lat2 * Math.PI) / 180
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
if (waypoints.length < 2) return false
const first = waypoints[0].timestamp
const last = waypoints[waypoints.length - 1].timestamp
return last > first + 60_000
}
export function computeTrackStats(waypoints: TrackWaypoint[]): TrackStats | null {
if (waypoints.length < 2) return null
let totalMeters = 0
let maxSegmentKn = 0
let maxTaggedKn = 0
let hasTaggedSpeed = false
const timed = hasMeaningfulTimestamps(waypoints)
const firstTs = waypoints[0].timestamp
const lastTs = waypoints[waypoints.length - 1].timestamp
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1]
const curr = waypoints[i]
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
totalMeters += segmentM
if (timed) {
const dtMs = curr.timestamp - prev.timestamp
if (dtMs > 0 && segmentM > 0) {
const segmentKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
if (segmentKn <= MAX_PLAUSIBLE_KNOTS) {
maxSegmentKn = Math.max(maxSegmentKn, segmentKn)
}
}
}
if (curr.speedKnots != null && curr.speedKnots > 0) {
hasTaggedSpeed = true
maxTaggedKn = Math.max(maxTaggedKn, curr.speedKnots)
}
}
const distanceNm = totalMeters / NM_IN_METERS
if (distanceNm <= 0) return null
let speedMaxKn = 0
let speedAvgKn = 0
let durationMinutes = 0
if (timed) {
const durationHours = (lastTs - firstTs) / 3_600_000
durationMinutes = Math.round((lastTs - firstTs) / 60_000)
speedAvgKn = durationHours > 0 ? distanceNm / durationHours : 0
speedMaxKn = Math.max(maxSegmentKn, hasTaggedSpeed ? maxTaggedKn : 0)
} else if (hasTaggedSpeed) {
const taggedSpeeds = waypoints
.map((wp) => wp.speedKnots)
.filter((speed): speed is number => speed != null && speed > 0)
speedMaxKn = maxTaggedKn
speedAvgKn =
taggedSpeeds.length > 0
? taggedSpeeds.reduce((sum, speed) => sum + speed, 0) / taggedSpeeds.length
: 0
}
return {
distanceNm: Number(distanceNm.toFixed(2)),
speedMaxKn: Number(speedMaxKn.toFixed(1)),
speedAvgKn: Number(speedAvgKn.toFixed(1)),
durationMinutes
}
}
export function formatTrackStats(stats: TrackStats): {
distanceNm: string
speedMaxKn: string
speedAvgKn: string
} {
return {
distanceNm: stats.distanceNm.toFixed(2),
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
}
}
+5 -2
View File
@@ -23,6 +23,9 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(readAppVersion())
},
optimizeDeps: {
include: ['leaflet']
},
server: {
port: 5173,
proxy: {
@@ -38,8 +41,8 @@ export default defineConfig({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo.png'],
manifest: {
name: 'Kapteins Daagbox',
short_name: 'Daagbox',
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
theme_color: '#1e293b',
background_color: '#0f172a',
+1 -1
View File
@@ -5,7 +5,7 @@ COMPOSE_FILE="docker-compose.yml"
BACKEND_CONTAINER="daagbox-prod-backend"
echo "=================================================="
echo " Kapteins Daagbox Docker Environment Manager "
echo " Kapteins Daagbok Docker Environment Manager "
echo "=================================================="
echo "Stopping any existing container stack..."
docker compose -f $COMPOSE_FILE down
+1 -1
View File
@@ -5,7 +5,7 @@ SERVER_PORT=5000
CLIENT_PORT=5173
echo "========================================"
echo " Kapteins Daagbox Dev Environment "
echo " Kapteins Daagbok Dev Environment "
echo "========================================"
echo "Preparing to (re)start services..."
+1 -1
View File
@@ -21,7 +21,7 @@ VERSION_FILE="$REPO_ROOT/VERSION"
DEFAULT_VERSION="0.1.0.0"
echo "=================================================="
echo " Kapteins Daagbox Prod Environment Update "
echo " Kapteins Daagbok Prod Environment Update "
echo "=================================================="
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "server",
"version": "1.0.0",
"description": "Backend API for Kapteins Daagbox",
"description": "Backend API for Kapteins Daagbok",
"main": "dist/index.js",
"type": "module",
"scripts": {
+2 -2
View File
@@ -29,7 +29,7 @@ app.get('/api/health', async (req, res) => {
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbox Backend'
service: 'Kapteins Daagbok Backend'
})
} catch (err: any) {
res.status(500).json({
@@ -37,7 +37,7 @@ app.get('/api/health', async (req, res) => {
database: 'disconnected',
error: err.message,
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbox Backend'
service: 'Kapteins Daagbok Backend'
})
}
})
+1 -1
View File
@@ -9,7 +9,7 @@ import { prisma } from '../db.js'
const router = Router()
const rpName = 'Kapteins Daagbox'
const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
File diff suppressed because it is too large Load Diff