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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) : ''
|
||||
}
|
||||
}
|
||||
+1274
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user