feat(logbook): attribute log events to creator and show in exports

This commit is contained in:
2026-06-03 19:39:15 +02:00
parent 6c8aa5af4c
commit 73e7613a1b
12 changed files with 332 additions and 26 deletions
+114
View File
@@ -0,0 +1,114 @@
import React from 'react'
interface PersonSnapshot {
name: string
photo?: string | null
role?: string
}
interface CreatorAvatarProps {
creatorId?: string
crewSnapshotsById?: Record<string, PersonSnapshot>
fallbackName?: string
size?: number
}
const colors = [
'#2563eb', // blue
'#059669', // emerald
'#d97706', // amber
'#dc2626', // red
'#7c3aed', // violet
'#db2777', // pink
'#0891b2', // cyan
'#4f46e5', // indigo
'#0f766e', // teal
'#9333ea', // purple
]
function getAvatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export default function CreatorAvatar({
creatorId,
crewSnapshotsById,
fallbackName,
size = 28
}: CreatorAvatarProps) {
let name = ''
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
// Fallback to active username if owner or no crew pool matches
if (!name) {
if (creatorId === 'skipper') {
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
role = 'skipper'
} else if (fallbackName) {
name = fallbackName
} else if (creatorId) {
// If creatorId is a username itself (fallback from LiveLogView)
name = creatorId
} else {
name = '?'
}
}
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${Math.round(size * 0.45)}px`,
fontWeight: 'bold',
color: '#ffffff',
backgroundColor: bgColor,
flexShrink: 0,
verticalAlign: 'middle',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box'
}
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
const tooltip = name + (roleText ? ` (${roleText})` : '')
if (photo) {
return (
<img
src={photo}
alt={name}
title={tooltip}
style={{
...style,
objectFit: 'cover',
backgroundColor: 'transparent'
}}
/>
)
}
return (
<div style={style} title={tooltip} className="creator-avatar-fallback">
{initial}
</div>
)
}
+76 -3
View File
@@ -23,13 +23,14 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { import {
appendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
appendTankRefill, appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
@@ -160,6 +161,24 @@ function gpsFailureAlertBody(
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
} }
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
export default function LiveLogView({ export default function LiveLogView({
logbookId, logbookId,
onOpenEditor, onOpenEditor,
@@ -173,6 +192,8 @@ export default function LiveLogView({
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
const [yachtSails, setYachtSails] = useState<string[]>([]) const [yachtSails, setYachtSails] = useState<string[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -214,6 +235,51 @@ export default function LiveLogView({
dateRef.current = date dateRef.current = date
busyRef.current = busy busyRef.current = busy
const getActiveCreatorId = useCallback(() => {
const activeUsername = localStorage.getItem('active_username')
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
}, [crewSnapshotsById, selectedSkipperId])
const appendQuickEvent = useCallback((
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
) => {
return apiAppendQuickEvent(
logbookId,
entryId,
{ creatorId: getActiveCreatorId(), ...partialEvent },
headerPatch
)
}, [getActiveCreatorId])
const appendQuickEvents = useCallback((
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
) => {
const creatorId = getActiveCreatorId()
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
return apiAppendQuickEvents(logbookId, entryId, mapped)
}, [getActiveCreatorId])
const appendTankRefill = useCallback((
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
) => {
return apiAppendTankRefill(
logbookId,
entryId,
tank,
addLiters,
{ creatorId: getActiveCreatorId(), ...event }
)
}, [getActiveCreatorId])
const defaultSails = useMemo( const defaultSails = useMemo(
() => (i18n.language === 'de' () => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -237,6 +303,8 @@ export default function LiveLogView({
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
}, []) }, [])
const refreshEntry = useCallback(async (id: string) => { const refreshEntry = useCallback(async (id: string) => {
@@ -1152,6 +1220,11 @@ export default function LiveLogView({
return ( return (
<li key={`${event.time}-${index}`} className="live-log-entry"> <li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time> <time className="live-log-time">{event.time}</time>
<CreatorAvatar
creatorId={event.creatorId}
crewSnapshotsById={crewSnapshotsById}
size={24}
/>
<div className="live-log-summary-block"> <div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span> <span className="live-log-summary">{summary}</span>
{voiceId && ( {voiceId && (
+41 -3
View File
@@ -11,6 +11,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
@@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
}) })
} }
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
interface LogEntryEditorProps { interface LogEntryEditorProps {
entryId: string entryId: string
logbookId: string logbookId: string
@@ -418,8 +437,17 @@ export default function LogEntryEditor({
}) })
}, [buildPayloadForSigning, signSkipper, signCrew]) }, [buildPayloadForSigning, signSkipper, signCrew])
const buildEventFromForm = (): LogEvent => const buildEventFromForm = (): LogEvent => {
normalizeLogEvent({ let creatorId: string | undefined = undefined
if (editingEventIndex !== null && events[editingEventIndex]) {
creatorId = events[editingEventIndex].creatorId
}
if (!creatorId) {
const activeUsername = localStorage.getItem('active_username')
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
}
return normalizeLogEvent({
time: evTime, time: evTime,
mgk: evMgk, mgk: evMgk,
rwk: evRwk, rwk: evRwk,
@@ -436,8 +464,10 @@ export default function LogEntryEditor({
distance: evDistance, distance: evDistance,
gpsLat: evGpsLat, gpsLat: evGpsLat,
gpsLng: evGpsLng, gpsLng: evGpsLng,
remarks: evRemarks remarks: evRemarks,
creatorId
}) })
}
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => { const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) { if (editingEventIndex !== null) {
@@ -1815,6 +1845,7 @@ export default function LogEntryEditor({
<thead> <thead>
<tr> <tr>
<th>{t('logs.event_time')}</th> <th>{t('logs.event_time')}</th>
<th>{t('logs.event_creator')}</th>
<th>{t('logs.event_mgk')}</th> <th>{t('logs.event_mgk')}</th>
<th>{t('logs.event_rwk')}</th> <th>{t('logs.event_rwk')}</th>
<th>{t('logs.event_wind_direction')}</th> <th>{t('logs.event_wind_direction')}</th>
@@ -1831,6 +1862,13 @@ export default function LogEntryEditor({
{events.map((ev, idx) => ( {events.map((ev, idx) => (
<tr key={idx}> <tr key={idx}>
<td className="font-mono">{ev.time}</td> <td className="font-mono">{ev.time}</td>
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
<CreatorAvatar
creatorId={ev.creatorId}
crewSnapshotsById={entryCrew.crewSnapshotsById}
size={24}
/>
</td>
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td> <td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td> <td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td> <td>{ev.windDirection || '—'}</td>
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Tag over", "carry_over_tanks_yes": "Tag over",
"carry_over_tanks_no": "Start med 0", "carry_over_tanks_no": "Start med 0",
"event_title": "Kronologisk hændelseslog", "event_title": "Kronologisk hændelseslog",
"event_creator": "Indtastet af",
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.", "no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
"event_time": "Tidspunkt på dagen", "event_time": "Tidspunkt på dagen",
"event_mgk": "MgK-kursus", "event_mgk": "MgK-kursus",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Übernehmen", "carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten", "carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll", "event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit", "event_time": "Uhrzeit",
"event_mgk": "MgK Kurs", "event_mgk": "MgK Kurs",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Carry over", "carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0", "carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook", "event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.", "no_events": "No events logged for this travel day yet.",
"event_time": "Time", "event_time": "Time",
"event_mgk": "MgK Course", "event_mgk": "MgK Course",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Ta over", "carry_over_tanks_yes": "Ta over",
"carry_over_tanks_no": "Begynn med 0", "carry_over_tanks_no": "Begynn med 0",
"event_title": "Kronologisk hendelseslogg", "event_title": "Kronologisk hendelseslogg",
"event_creator": "Registrert av",
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.", "no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
"event_time": "Tid på døgnet", "event_time": "Tid på døgnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Ta över", "carry_over_tanks_yes": "Ta över",
"carry_over_tanks_no": "Börja med 0", "carry_over_tanks_no": "Börja med 0",
"event_title": "Kronologisk händelselogg", "event_title": "Kronologisk händelselogg",
"event_creator": "Registrerad av",
"no_events": "Inga händelser inlagda för denna resdag ännu.", "no_events": "Inga händelser inlagda för denna resdag ännu.",
"event_time": "Tid på dygnet", "event_time": "Tid på dygnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
+14 -3
View File
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? ''; const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
let creatorName = '';
if (creatorSnap) {
creatorName = creatorSnap.name || '';
} else if (ev.creatorId === 'skipper') {
creatorName = 'Skipper';
} else if (ev.creatorId) {
creatorName = ev.creatorId;
}
rows.push([ rows.push([
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '', ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+66 -12
View File
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
} }
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let entry: any = null; let entry: any = null;
if (preloadedData) { if (preloadedData) {
const yacht = preloadedData.yacht || {}; const yacht = preloadedData.yacht || {};
yachtName = yacht.name || ''; yachtName = yacht.name || '';
owner = yacht.owner || '';
homePort = yacht.port || ''; homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || ''; registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || ''; callsign = yacht.callSign || '';
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const yacht = await resolveVesselForLogbook(logbookId) const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) { if (yacht) {
yachtName = yacht.name || '' yachtName = yacht.name || ''
owner = yacht.owner || ''
homePort = yacht.homePort || '' homePort = yacht.homePort || ''
registration = yacht.registrationNumber || '' registration = yacht.registrationNumber || ''
callsign = yacht.callSign || '' callsign = yacht.callSign || ''
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); doc.text(`Eigner: ${owner || '—'}`, 55, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); doc.text(`ATIS: ${atis || '—'}`, 230, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23); doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23); doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23); doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23); doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
// Format Crew names with initials
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
const crewList: string[] = []
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
crewList.push(`${name} [${initial}] (Skipper)`)
} else if (crewSnapshots['skipper']) {
const name = crewSnapshots['skipper'].name || 'Skipper'
crewList.push(`${name} [S] (Skipper)`)
}
if (Array.isArray(entry.selectedCrewIds)) {
for (const crewId of entry.selectedCrewIds) {
const snap = crewSnapshots[crewId]
if (snap) {
const name = snap.name || ''
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
crewList.push(`${name} [${initial}]`)
}
}
}
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
if (entry.trackDistanceNm) {
doc.text( doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10, 10,
27 27
); );
if (crewText) {
doc.text(crewText, 140, 27);
}
} else if (crewText) {
doc.text(crewText, 10, 27);
} }
// Divider line // Divider line
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(gps, writeX + 1, y + 4.2); doc.text(gps, writeX + 1, y + 4.2);
writeX += colWidths[11]; writeX += colWidths[11];
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
let initial = '';
if (ev.creatorId) {
const snap = crewSnapshots[ev.creatorId];
let name = '';
if (snap) {
name = snap.name || '';
} else if (ev.creatorId === 'skipper') {
name = 'Skipper';
} else {
name = ev.creatorId;
}
if (name) {
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
}
}
// Clip remarks to fit within the 94mm bounds // Clip remarks to fit within the 94mm bounds
const remarks = ev.remarks || ''; let remarks = ev.remarks || '';
if (initial) {
remarks = `[${initial}] ${remarks}`;
}
const maxChars = 65; const maxChars = 65;
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
doc.text(clippedRemarks, writeX + 1, y + 4.2); doc.text(clippedRemarks, writeX + 1, y + 4.2);
+10 -1
View File
@@ -97,6 +97,14 @@ function buildEncryptedPayload(
consumption: fuel.consumption ?? 0 consumption: fuel.consumption ?? 0
} }
const entryCrew = data.selectedSkipperId
? {
selectedSkipperId: String(data.selectedSkipperId),
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
}
: undefined
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(data.date || ''), date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''), dayOfTravel: String(data.dayOfTravel || ''),
@@ -121,7 +129,8 @@ function buildEncryptedPayload(
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw)) ? parseFloat(String(motorHoursRaw))
: undefined, : undefined,
events: options.events events: options.events,
entryCrew
}) })
const clear = options.clearSignatures const clear = options.clearSignatures
+6 -4
View File
@@ -22,6 +22,7 @@ export interface LogEventPayload {
gpsLat: string gpsLat: string
gpsLng: string gpsLng: string
remarks: string remarks: string
creatorId?: string
} }
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */ /** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
@@ -85,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [ const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState', 'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance', 'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks' 'gpsLat', 'gpsLng', 'remarks', 'creatorId'
] ]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */ /** Normalize partial/legacy events so all fields are strings (safe for form + save). */
@@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
distance: '', distance: '',
gpsLat: '', gpsLat: '',
gpsLng: '', gpsLng: '',
remarks: '' remarks: '',
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
} }
for (const key of LOG_EVENT_FIELDS) { for (const key of LOG_EVENT_FIELDS) {
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
normalized[key] = String(e[key] ?? '').trim() normalized[key] = String(e[key] ?? '').trim()
} }
return normalized return normalized
@@ -122,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key]) return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
} }
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time') const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */ /** Draft with only a time (or empty fields) — not an unsaved log entry change. */
export function isLogEventDraftEmpty(event: LogEventPayload): boolean { export function isLogEventDraftEmpty(event: LogEventPayload): boolean {