From 73e7613a1be4134558a347454a46e8dc12c433a0 Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 3 Jun 2026 19:39:15 +0200 Subject: [PATCH] feat(logbook): attribute log events to creator and show in exports --- client/src/components/CreatorAvatar.tsx | 114 +++++++++++++++++++++++ client/src/components/LiveLogView.tsx | 79 +++++++++++++++- client/src/components/LogEntryEditor.tsx | 44 ++++++++- client/src/i18n/locales/da.json | 1 + client/src/i18n/locales/de.json | 1 + client/src/i18n/locales/en.json | 1 + client/src/i18n/locales/nb.json | 1 + client/src/i18n/locales/sv.json | 1 + client/src/services/csvExport.ts | 17 +++- client/src/services/pdfExport.ts | 78 +++++++++++++--- client/src/services/quickEventLog.ts | 11 ++- client/src/utils/logEntryPayload.ts | 10 +- 12 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 client/src/components/CreatorAvatar.tsx diff --git a/client/src/components/CreatorAvatar.tsx b/client/src/components/CreatorAvatar.tsx new file mode 100644 index 0000000..f067502 --- /dev/null +++ b/client/src/components/CreatorAvatar.tsx @@ -0,0 +1,114 @@ +import React from 'react' + +interface PersonSnapshot { + name: string + photo?: string | null + role?: string +} + +interface CreatorAvatarProps { + creatorId?: string + crewSnapshotsById?: Record + 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 ( + {name} + ) + } + + return ( +
+ {initial} +
+ ) +} diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 6ef7734..9fc1b93 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -23,13 +23,14 @@ import { } from 'lucide-react' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { - appendQuickEvent, - appendQuickEvents, - appendTankRefill, + appendQuickEvent as apiAppendQuickEvent, + appendQuickEvents as apiAppendQuickEvents, + appendTankRefill as apiAppendTankRefill, findOrCreateTodayEntry, loadEntry, removeLastEvent } from '../services/quickEventLog.js' +import CreatorAvatar from './CreatorAvatar.tsx' import { formatEventSummary } from '../utils/formatEventSummary.js' import { getLastAutoPositionMs, @@ -160,6 +161,24 @@ function gpsFailureAlertBody( return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` } +function findActiveCreatorId( + activeUsername: string | null, + crewSnapshotsById: Record, + 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({ logbookId, onOpenEditor, @@ -173,6 +192,8 @@ export default function LiveLogView({ const [dayOfTravel, setDayOfTravel] = useState('') const [date, setDate] = useState('') const [events, setEvents] = useState([]) + const [crewSnapshotsById, setCrewSnapshotsById] = useState>({}) + const [selectedSkipperId, setSelectedSkipperId] = useState(null) const [yachtSails, setYachtSails] = useState([]) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) @@ -214,6 +235,51 @@ export default function LiveLogView({ dateRef.current = date 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, + headerPatch?: { departure?: string; destination?: string } + ) => { + return apiAppendQuickEvent( + logbookId, + entryId, + { creatorId: getActiveCreatorId(), ...partialEvent }, + headerPatch + ) + }, [getActiveCreatorId]) + + const appendQuickEvents = useCallback(( + logbookId: string, + entryId: string, + partialEvents: Partial[] + ) => { + 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 + ) => { + return apiAppendTankRefill( + logbookId, + entryId, + tank, + addLiters, + { creatorId: getActiveCreatorId(), ...event } + ) + }, [getActiveCreatorId]) + const defaultSails = useMemo( () => (i18n.language === 'de' ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] @@ -237,6 +303,8 @@ export default function LiveLogView({ setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDate(String(loaded.data.date || '')) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) + setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record) || {}) + setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null) }, []) const refreshEntry = useCallback(async (id: string) => { @@ -1152,6 +1220,11 @@ export default function LiveLogView({ return (
  • +
    {summary} {voiceId && ( diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 0550e4e..7733869 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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 PhotoCapture from './PhotoCapture.tsx' import EventRemarksCell from './EventRemarksCell.tsx' +import CreatorAvatar from './CreatorAvatar.tsx' import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' @@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record): string }) } +function findActiveCreatorId( + activeUsername: string | null, + crewSnapshotsById: Record, + 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 { entryId: string logbookId: string @@ -418,8 +437,17 @@ export default function LogEntryEditor({ }) }, [buildPayloadForSigning, signSkipper, signCrew]) - const buildEventFromForm = (): LogEvent => - normalizeLogEvent({ + const buildEventFromForm = (): LogEvent => { + 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, mgk: evMgk, rwk: evRwk, @@ -436,8 +464,10 @@ export default function LogEntryEditor({ distance: evDistance, gpsLat: evGpsLat, gpsLng: evGpsLng, - remarks: evRemarks + remarks: evRemarks, + creatorId }) + } const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => { if (editingEventIndex !== null) { @@ -1815,6 +1845,7 @@ export default function LogEntryEditor({ {t('logs.event_time')} + {t('logs.event_creator')} {t('logs.event_mgk')} {t('logs.event_rwk')} {t('logs.event_wind_direction')} @@ -1831,6 +1862,13 @@ export default function LogEntryEditor({ {events.map((ev, idx) => ( {ev.time} + + + {ev.mgk ? `${ev.mgk}°` : '—'} {ev.rwk ? `${ev.rwk}°` : '—'} {ev.windDirection || '—'} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index ae098f7..ec2c4b1 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -344,6 +344,7 @@ "carry_over_tanks_yes": "Tag over", "carry_over_tanks_no": "Start med 0", "event_title": "Kronologisk hændelseslog", + "event_creator": "Indtastet af", "no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.", "event_time": "Tidspunkt på dagen", "event_mgk": "MgK-kursus", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8a204df..5e688b8 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -344,6 +344,7 @@ "carry_over_tanks_yes": "Übernehmen", "carry_over_tanks_no": "Mit 0 starten", "event_title": "Chronologisches Ereignisprotokoll", + "event_creator": "Eingetragen von", "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", "event_time": "Uhrzeit", "event_mgk": "MgK Kurs", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b979c07..3a3af5d 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -344,6 +344,7 @@ "carry_over_tanks_yes": "Carry over", "carry_over_tanks_no": "Start at 0", "event_title": "Chronological Event Logbook", + "event_creator": "Entered by", "no_events": "No events logged for this travel day yet.", "event_time": "Time", "event_mgk": "MgK Course", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index efd789f..c2333d9 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -344,6 +344,7 @@ "carry_over_tanks_yes": "Ta over", "carry_over_tanks_no": "Begynn med 0", "event_title": "Kronologisk hendelseslogg", + "event_creator": "Registrert av", "no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.", "event_time": "Tid på døgnet", "event_mgk": "MgK-kurs", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 81f8103..44d6a35 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -344,6 +344,7 @@ "carry_over_tanks_yes": "Ta över", "carry_over_tanks_no": "Börja med 0", "event_title": "Kronologisk händelselogg", + "event_creator": "Registrerad av", "no_events": "Inga händelser inlagda för denna resdag ännu.", "event_time": "Tid på dygnet", "event_mgk": "MgK-kurs", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 40fa083..aca394f 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary', 'Skipper Signature', 'Crew Signature', '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', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Latitude', 'Longitude', 'Remarks', @@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya const greywaterLevel = entry.greywater?.level ?? ''; const aiSummary = entry.aiSummary ?? ''; + const crewSnapshots = (entry.crewSnapshotsById as Record) || {}; const eventsList = entry.events || []; if (eventsList.length === 0) { // 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, signS, signC, trackDist, trackMax, trackAvg, motorH, - '', '', '', + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', @@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya // Sort events chronologically by time const sortedEvents = sortLogEventsByTime(eventsList); 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([ dateVal, travelDay, dep, dest, aiSummary, signS, signC, 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.visibility || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 15cf18b..f579604 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string { } export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise { - let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; + let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; let entry: any = null; if (preloadedData) { const yacht = preloadedData.yacht || {}; yachtName = yacht.name || ''; + owner = yacht.owner || ''; homePort = yacht.port || ''; registration = yacht.registrationNumber || yacht.registration || ''; callsign = yacht.callSign || ''; @@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, const yacht = await resolveVesselForLogbook(logbookId) if (yacht) { yachtName = yacht.name || '' + owner = yacht.owner || '' homePort = yacht.homePort || '' registration = yacht.registrationNumber || '' callsign = yacht.callSign || '' @@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.setFontSize(8.5); doc.setFont('Helvetica', 'normal'); doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); - doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); - doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); - doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); - doc.text(`ATIS: ${atis || '—'}`, 210, 21); - doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); + doc.text(`Eigner: ${owner || '—'}`, 55, 21); + doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21); + doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21); + doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21); + doc.text(`ATIS: ${atis || '—'}`, 230, 21); + doc.text(`MMSI: ${mmsi || '—'}`, 260, 21); - 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); + doc.text(`Datum: ${entry.date || '—'}`, 10, 24); + doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24); + doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24); + doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24); + // Format Crew names with initials + const crewSnapshots = (entry.crewSnapshotsById as Record) || {} + 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(', ')}` : '' + + doc.setFont('Helvetica', 'normal'); if (entry.trackDistanceNm) { - doc.setFont('Helvetica', 'normal'); doc.text( `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, 10, 27 ); + if (crewText) { + doc.text(crewText, 140, 27); + } + } else if (crewText) { + doc.text(crewText, 10, 27); } // Divider line @@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.text(gps, writeX + 1, y + 4.2); writeX += colWidths[11]; + const crewSnapshots = (entry.crewSnapshotsById as Record) || {}; + 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 - const remarks = ev.remarks || ''; + let remarks = ev.remarks || ''; + if (initial) { + remarks = `[${initial}] ${remarks}`; + } const maxChars = 65; const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; doc.text(clippedRemarks, writeX + 1, y + 4.2); diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index 2a60eb9..c682bbd 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -97,6 +97,14 @@ function buildEncryptedPayload( 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) || {} + } + : undefined + const payload = buildLogEntryPayload({ date: String(data.date || ''), dayOfTravel: String(data.dayOfTravel || ''), @@ -121,7 +129,8 @@ function buildEncryptedPayload( motorHoursRaw != null && motorHoursRaw !== '' ? parseFloat(String(motorHoursRaw)) : undefined, - events: options.events + events: options.events, + entryCrew }) const clear = options.clearSignatures diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 55c0c40..149ccdb 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -22,6 +22,7 @@ export interface LogEventPayload { gpsLat: string gpsLng: string remarks: string + creatorId?: string } /** 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)[] = [ 'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState', '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). */ @@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial | Record 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. */ export function isLogEventDraftEmpty(event: LogEventPayload): boolean {