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
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;
}
.track-stats-grid {
margin-top: 16px;
align-items: start;
}
.signature-grid {
align-items: start;
}
+92
View File
@@ -19,6 +19,7 @@ import {
parseTrackFile,
type SavedTrack
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
interface LogEntryEditorProps {
entryId: string
@@ -86,6 +87,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[]>([])
@@ -121,6 +127,27 @@ export default function LogEntryEditor({
const [uploadError, setUploadError] = useState<string | 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
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
@@ -189,6 +216,7 @@ export default function LogEntryEditor({
setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
return
}
@@ -218,6 +246,7 @@ export default function LogEntryEditor({
setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
}
}
@@ -249,6 +278,12 @@ export default function LogEntryEditor({
loadTrack()
}, [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
const handleFileUpload = async (file: File) => {
if (readOnly) return
@@ -268,6 +303,7 @@ export default function LogEntryEditor({
}
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack()
} catch (err: any) {
console.error('File parsing failed:', err)
@@ -311,6 +347,9 @@ export default function LogEntryEditor({
try {
await deleteTrack(logbookId, entryId)
setSavedTrack(null)
setTrackDistanceNm('')
setTrackSpeedMaxKn('')
setTrackSpeedAvgKn('')
setUploadError(null)
} catch (err: any) {
showAlert(err.message || 'Failed to delete track')
@@ -570,6 +609,9 @@ export default function LogEntryEditor({
},
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
}
@@ -1178,6 +1220,15 @@ export default function LogEntryEditor({
{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', flexWrap: 'wrap' }}>
@@ -1204,6 +1255,47 @@ export default function LogEntryEditor({
</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>
<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_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)",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
+3
View File
@@ -166,6 +166,9 @@
"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)",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
+6
View File
@@ -78,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)',
@@ -96,6 +97,9 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const dest = entry.destination || '';
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 ?? '';
@@ -111,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
'', '', '',
'', '', '', '',
'', '', '', '', '',
@@ -126,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 || '',
+13 -4
View File
@@ -78,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);
+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