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>
This commit is contained in:
2026-05-29 15:36:21 +02:00
co-authored by Cursor
parent cffe934d5e
commit b1b0c798b3
8 changed files with 1503 additions and 4 deletions
+5
View File
@@ -1980,6 +1980,11 @@ body:has(.theme-cupertino) {
margin-bottom: 12px; margin-bottom: 12px;
} }
.track-stats-grid {
margin-top: 16px;
align-items: start;
}
.signature-grid { .signature-grid {
align-items: start; align-items: start;
} }
+92
View File
@@ -19,6 +19,7 @@ import {
parseTrackFile, parseTrackFile,
type SavedTrack type SavedTrack
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
interface LogEntryEditorProps { interface LogEntryEditorProps {
entryId: string entryId: string
@@ -86,6 +87,11 @@ export default function LogEntryEditor({
const [signSkipper, setSignSkipper] = useState('') const [signSkipper, setSignSkipper] = useState('')
const [signCrew, setSignCrew] = 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 // Events list state
const [events, setEvents] = useState<LogEvent[]>([]) const [events, setEvents] = useState<LogEvent[]>([])
@@ -121,6 +127,27 @@ export default function LogEntryEditor({
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | 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 // Auto-calculate Freshwater Consumption
useEffect(() => { useEffect(() => {
const morning = parseFloat(fwMorning) || 0 const morning = parseFloat(fwMorning) || 0
@@ -189,6 +216,7 @@ export default function LogEntryEditor({
setSignSkipper(preloadedEntry.signSkipper || '') setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '') setSignCrew(preloadedEntry.signCrew || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || []) setEvents(preloadedEntry.events || [])
return return
} }
@@ -218,6 +246,7 @@ export default function LogEntryEditor({
setSignSkipper(decrypted.signSkipper || '') setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '') setSignCrew(decrypted.signCrew || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || []) setEvents(decrypted.events || [])
} }
} }
@@ -249,6 +278,12 @@ export default function LogEntryEditor({
loadTrack() loadTrack()
}, [entryId, preloadedTrack]) }, [entryId, preloadedTrack])
useEffect(() => {
if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
applyTrackStats(savedTrack.waypoints)
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
// Track file upload handlers // Track file upload handlers
const handleFileUpload = async (file: File) => { const handleFileUpload = async (file: File) => {
if (readOnly) return if (readOnly) return
@@ -268,6 +303,7 @@ export default function LogEntryEditor({
} }
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType) await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack() await loadTrack()
} catch (err: any) { } catch (err: any) {
console.error('File parsing failed:', err) console.error('File parsing failed:', err)
@@ -311,6 +347,9 @@ export default function LogEntryEditor({
try { try {
await deleteTrack(logbookId, entryId) await deleteTrack(logbookId, entryId)
setSavedTrack(null) setSavedTrack(null)
setTrackDistanceNm('')
setTrackSpeedMaxKn('')
setTrackSpeedAvgKn('')
setUploadError(null) setUploadError(null)
} catch (err: any) { } catch (err: any) {
showAlert(err.message || 'Failed to delete track') showAlert(err.message || 'Failed to delete track')
@@ -570,6 +609,9 @@ export default function LogEntryEditor({
}, },
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(), signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.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 events
} }
@@ -1178,6 +1220,15 @@ export default function LogEntryEditor({
{savedTrack.waypoints.length > 0 && ( {savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</> <> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)} )}
{trackDistanceNm && (
<> · {trackDistanceNm} sm</>
)}
{trackSpeedMaxKn && (
<> · max {trackSpeedMaxKn} kn</>
)}
{trackSpeedAvgKn && (
<> · Ø {trackSpeedAvgKn} kn</>
)}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
@@ -1204,6 +1255,47 @@ export default function LogEntryEditor({
</div> </div>
</div> </div>
)} )}
{(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> </div>
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} /> <PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
+3
View File
@@ -166,6 +166,9 @@
"gps_track_upload_btn": "GPS-Track hochladen", "gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen", "gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?", "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)",
"exporting": "Exportiere...", "exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen", "invite_crew": "Crew einladen",
+3
View File
@@ -166,6 +166,9 @@
"gps_track_upload_btn": "Upload GPS Track File", "gps_track_upload_btn": "Upload GPS Track File",
"gps_track_delete": "Delete Track File", "gps_track_delete": "Delete Track File",
"gps_track_delete_confirm": "Are you sure you want to permanently delete this 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)",
"exporting": "Exporting...", "exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew", "invite_crew": "Invite Crew",
+6
View File
@@ -78,6 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -96,6 +97,9 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const dest = entry.destination || ''; const dest = entry.destination || '';
const signS = formatSignatureForExport(entry.signSkipper); const signS = formatSignatureForExport(entry.signSkipper);
const signC = formatSignatureForExport(entry.signCrew); const signC = formatSignatureForExport(entry.signCrew);
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
const fwM = entry.freshwater?.morning ?? ''; const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? ''; const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? ''; const fwE = entry.freshwater?.evening ?? '';
@@ -111,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
@@ -126,6 +131,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+13 -4
View File
@@ -78,10 +78,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`ATIS: ${atis || '—'}`, 210, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 26); doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26); doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26); doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26); 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 // Divider line
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
+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) : ''
}
}
File diff suppressed because it is too large Load Diff