fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen

Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 19:15:49 +02:00
parent 258fee31ab
commit 7ab0ec6061
6 changed files with 259 additions and 70 deletions
+25 -13
View File
@@ -13,6 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
@@ -48,6 +49,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
@@ -347,7 +349,14 @@ function App() {
consumePendingPushLogbook()
}
const handleLogout = () => {
const handleTabChange = async (tab: AppTab) => {
if (tab === activeTab) return
if (!(await confirmLeave())) return
setActiveTab(tab)
}
const handleLogout = async () => {
if (!(await confirmLeave())) return
void logoutUser()
setIsAuthenticated(false)
setActiveLogbookId(null)
@@ -358,7 +367,8 @@ function App() {
localStorage.removeItem('active_logbook_title')
}
const handleBackToDashboard = () => {
const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
@@ -505,7 +515,7 @@ function App() {
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
@@ -514,7 +524,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
@@ -523,7 +533,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
>
<Users size={18} />
@@ -542,7 +552,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={18} />
@@ -551,7 +561,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')}
onClick={() => void handleTabChange('settings')}
>
<Settings size={18} />
{t('nav.settings')}
@@ -604,12 +614,14 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
<UnsavedChangesProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</UnsavedChangesProvider>
</DialogProvider>
)
}
+104 -55
View File
@@ -20,7 +20,7 @@ import {
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -35,6 +35,7 @@ import {
type SavedTrack
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
@@ -248,7 +249,60 @@ export default function LogEntryEditor({
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const buildEventFromForm = (): LogEvent =>
normalizeLogEvent({
time: evTime,
mgk: evMgk,
rwk: evRwk,
windPressure: evWindPressure,
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
sailsOrMotor: evSailsOrMotor,
logReading: evLogReading,
distance: evDistance,
gpsLat: evGpsLat,
gpsLng: evGpsLng,
remarks: evRemarks
})
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) {
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
}
return sortLogEventsByTime([...events, eventData])
}
const hasPendingEventForm = useMemo(() => {
if (!evTime.trim()) return false
const draft = buildEventFromForm()
if (editingEventIndex !== null) {
const original = events[editingEventIndex]
return original ? !logEventsEqual(draft, original) : false
}
return true
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
const isDirty = savedFingerprint !== null && (
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
)
const handleBack = async () => {
if (!(await confirmLeave())) return
onBack()
}
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
@@ -483,7 +537,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime(preloadedEntry.events || []))
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -516,7 +570,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime(decrypted.events || []))
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
@@ -783,25 +837,6 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
@@ -824,22 +859,23 @@ export default function LogEntryEditor({
}
const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
const normalized = normalizeLogEvent(ev)
setEvTime(normalized.time)
setEvMgk(normalized.mgk)
setEvRwk(normalized.rwk)
setEvWindPressure(normalized.windPressure)
setEvWindDirection(normalized.windDirection)
setEvWindStrength(normalized.windStrength)
setEvSeaState(normalized.seaState)
setEvWeatherIcon(normalized.weatherIcon)
setEvCurrent(normalized.current)
setEvHeel(normalized.heel)
setEvSailsOrMotor(normalized.sailsOrMotor)
setEvLogReading(normalized.logReading)
setEvDistance(normalized.distance)
setEvGpsLat(normalized.gpsLat)
setEvGpsLng(normalized.gpsLng)
setEvRemarks(normalized.remarks)
setEvLocationName('')
}
@@ -866,27 +902,25 @@ export default function LogEntryEditor({
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
const isEdit = editingEventIndex !== null
const hadSkipperSignature = isEdit && !!signSkipper
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
if (hadSkipperSignature) {
markSkipperSignatureClearedForEventChange()
nextEvents = sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
}
const nextEvents = applyEventFormToEvents(eventData)
try {
await persistEntryToDb(nextEvents)
setEvents(nextEvents)
clearEventForm()
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = sortLogEventsByTime([...events, eventData])
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
@@ -935,13 +969,28 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !isDirty) return
if (readOnly) return
let eventsToSave = events
if (hasPendingEventForm) {
const isEdit = editingEventIndex !== null
if (isEdit && signSkipper) {
markSkipperSignatureClearedForEventChange()
}
eventsToSave = applyEventFormToEvents(buildEventFromForm())
setEvents(eventsToSave)
clearEventForm()
} else if (!isDirty) {
return
}
setSaving(true)
setError(null)
setSuccess(false)
try {
await persistEntryToDb()
await persistEntryToDb(eventsToSave)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -972,7 +1021,7 @@ export default function LogEntryEditor({
<div className="form-card" style={{ paddingBottom: '20px' }}>
<div className="section-title-bar">
<div className="section-title-left">
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} />
{t('logs.back_to_list')}
</button>
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useRef,
useMemo,
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
confirmLeave: () => Promise<boolean>
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const dirtySources = useRef(new Set<string>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
)
}, [showConfirm, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirtySources.current.size === 0) return
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
return (
<UnsavedChangesContext.Provider value={value}>
{children}
</UnsavedChangesContext.Provider>
)
}
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
const ctx = useContext(UnsavedChangesContext)
if (!ctx) {
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
}
return ctx
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
return { confirmLeave }
}
+6
View File
@@ -6,6 +6,12 @@
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
+6
View File
@@ -6,6 +6,12 @@
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
+41 -2
View File
@@ -17,8 +17,47 @@ export interface LogEventPayload {
remarks: string
}
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
const e = event as Record<string, unknown>
const timeRaw = String(e.time ?? '').trim()
const normalized: LogEventPayload = {
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
mgk: '',
rwk: '',
windPressure: '',
windDirection: '',
windStrength: '',
seaState: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: '',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: ''
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
}
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
}
/** Chronological order: earliest time first (HH:MM). */
export function sortLogEventsByTime<T extends Pick<LogEventPayload, 'time'>>(events: T[]): T[] {
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
@@ -43,7 +82,7 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
destination: input.destination.trim(),
freshwater: { ...input.freshwater },
fuel: { ...input.fuel },
events: sortLogEventsByTime(input.events.map((e) => ({ ...e })))
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
}
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm