27c780d2b8
Leeres Event-Formular (nur Uhrzeit) galt fälschlich als Änderung und invalidierte frische Signaturen. Speichern-Button und Hash-Sperre folgen nun echten Entwürfen und synchronisiertem Seiteninhalt. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
import {
|
|
normalizeCourseAngleString,
|
|
normalizeWindDirectionString
|
|
} from './courseAngle.js'
|
|
|
|
export interface LogEventPayload {
|
|
time: string
|
|
mgk: string
|
|
rwk: string
|
|
windPressure: string
|
|
windDirection: string
|
|
windStrength: string
|
|
seaState: string
|
|
weatherIcon: string
|
|
current: string
|
|
heel: string
|
|
sailsOrMotor: string
|
|
logReading: string
|
|
distance: string
|
|
gpsLat: string
|
|
gpsLng: string
|
|
remarks: string
|
|
}
|
|
|
|
/** Local time as HH:MM (24-hour). */
|
|
export function currentLocalTimeHHMM(date: Date = new Date()): string {
|
|
const hours = String(date.getHours()).padStart(2, '0')
|
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
return `${hours}:${minutes}`
|
|
}
|
|
|
|
/** Parse 24h or 12h (AM/PM) time strings to HH:MM. */
|
|
export function parseTimeToHHMM(value: string): string | null {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return null
|
|
|
|
const amPm = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)$/i)
|
|
if (amPm) {
|
|
let hours = parseInt(amPm[1], 10)
|
|
const minutes = parseInt(amPm[2], 10)
|
|
const isPm = amPm[3].toUpperCase() === 'PM'
|
|
if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null
|
|
if (hours === 12) hours = isPm ? 12 : 0
|
|
else if (isPm) hours += 12
|
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
}
|
|
|
|
const h24 = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/)
|
|
if (h24) {
|
|
const hours = parseInt(h24[1], 10)
|
|
const minutes = parseInt(h24[2], 10)
|
|
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function isValidTimeHHMM(value: string): boolean {
|
|
return parseTimeToHHMM(value) !== null
|
|
}
|
|
|
|
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
|
|
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
|
|
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
|
|
}
|
|
|
|
export function joinTimeHHMM(hours: string, minutes: string): string {
|
|
const h = Math.min(23, Math.max(0, parseInt(hours, 10) || 0))
|
|
const m = Math.min(59, Math.max(0, parseInt(minutes, 10) || 0))
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
}
|
|
|
|
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: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
|
|
mgk: normalizeCourseAngleString(String(e.mgk ?? ''), { allowEmpty: true }),
|
|
rwk: normalizeCourseAngleString(String(e.rwk ?? ''), { allowEmpty: true }),
|
|
windPressure: '',
|
|
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
|
|
windStrength: '',
|
|
seaState: '',
|
|
weatherIcon: '',
|
|
current: '',
|
|
heel: '',
|
|
sailsOrMotor: '',
|
|
logReading: '',
|
|
distance: '',
|
|
gpsLat: '',
|
|
gpsLng: '',
|
|
remarks: ''
|
|
}
|
|
for (const key of LOG_EVENT_FIELDS) {
|
|
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') 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])
|
|
}
|
|
|
|
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
|
|
|
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
|
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
|
return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim())
|
|
}
|
|
|
|
/** Whether the event form holds unsaved changes worth merging on page save. */
|
|
export function hasUnsavedEventDraft(
|
|
draft: LogEventPayload,
|
|
editingEventIndex: number | null,
|
|
events: LogEventPayload[]
|
|
): boolean {
|
|
if (!isValidTimeHHMM(draft.time)) return false
|
|
if (editingEventIndex !== null) {
|
|
const original = events[editingEventIndex]
|
|
return original ? !logEventsEqual(draft, original) : false
|
|
}
|
|
return !isLogEventDraftEmpty(draft)
|
|
}
|
|
|
|
/** Chronological order: earliest time first (HH:MM). */
|
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
|
}
|
|
|
|
export interface LogEntryPayloadInput {
|
|
date: string
|
|
dayOfTravel: string
|
|
departure: string
|
|
destination: string
|
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
|
trackDistanceNm?: number
|
|
trackSpeedMaxKn?: number
|
|
trackSpeedAvgKn?: number
|
|
motorHours?: number
|
|
events: LogEventPayload[]
|
|
}
|
|
|
|
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
|
const payload: Record<string, unknown> = {
|
|
date: input.date,
|
|
dayOfTravel: input.dayOfTravel.trim(),
|
|
departure: input.departure.trim(),
|
|
destination: input.destination.trim(),
|
|
freshwater: { ...input.freshwater },
|
|
fuel: { ...input.fuel },
|
|
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
|
|
}
|
|
|
|
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
|
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
|
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
|
if (input.motorHours !== undefined && input.motorHours > 0) {
|
|
payload.motorHours = Number(input.motorHours.toFixed(2))
|
|
}
|
|
|
|
return payload
|
|
}
|