/** Machine-readable live-log markers stored in event.remarks (locale-independent). */ export const LIVE_EVENT_CODES = { MOTOR_START: '__live:motor_start', MOTOR_STOP: '__live:motor_stop', CAST_OFF: '__live:cast_off', MOOR: '__live:moor', POSITION: '__live:position', AUTO_POSITION: '__live:auto_position', COURSE: '__live:course', WIND: '__live:wind', PRESSURE: '__live:pressure', SEA_STATE: '__live:sea_state', VISIBILITY: '__live:visibility' } as const /** @deprecated Stored in older log entries; still recognized when reading events. */ export const LEGACY_LIVE_POSITION_REMARK = '__live:fix' export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES] export function liveSailsRemark(sails: string): string { return `__live:sails:${sails}` } export function liveCommentRemark(text: string): string { return `__live:comment:${text}` } export function liveTempRemark(tempC: string): string { return `__live:temp:${tempC}` } export function livePrecipRemark(text: string): string { return `__live:precip:${text}` } export function liveFuelRemark(liters: string): string { return `__live:fuel:${liters}` } export function liveWaterRemark(liters: string): string { return `__live:water:${liters}` } export function livePhotoRemark(caption?: string): string { const text = caption?.trim() return text ? `__live:photo:${text}` : '__live:photo' } export function parseLivePhotoRemark(remarks: string): string | null { if (remarks === '__live:photo') return '' const prefix = '__live:photo:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } const VOICE_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i export function liveVoiceRemark(audioId: string): string { return `__live:voice:${audioId}` } export function parseLiveVoiceRemark(remarks: string): string | null { const trimmed = remarks.trim() const prefix = '__live:voice:' if (!trimmed.startsWith(prefix)) return null const id = trimmed.slice(prefix.length) return VOICE_UUID_RE.test(id) ? id : null } export function liveSogRemark(speedKn: string): string { return `__live:sog:${speedKn}` } export function liveStwRemark(speedKn: string): string { return `__live:stw:${speedKn}` } export function parseLiveSailsRemark(remarks: string): string | null { const prefix = '__live:sails:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveCommentRemark(remarks: string): string | null { const prefix = '__live:comment:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveTempRemark(remarks: string): string | null { const prefix = '__live:temp:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLivePrecipRemark(remarks: string): string | null { const prefix = '__live:precip:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveFuelRemark(remarks: string): string | null { const prefix = '__live:fuel:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveWaterRemark(remarks: string): string | null { const prefix = '__live:water:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveSogRemark(remarks: string): string | null { const prefix = '__live:sog:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } export function parseLiveStwRemark(remarks: string): string | null { const prefix = '__live:stw:' return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null } /** Derive motor running state from event history (survives reload). */ export function isMotorRunningFromEvents( events: Array<{ remarks: string }>, motorStartCode: string = LIVE_EVENT_CODES.MOTOR_START, motorStopCode: string = LIVE_EVENT_CODES.MOTOR_STOP ): boolean { for (let i = events.length - 1; i >= 0; i--) { const code = events[i].remarks.trim() if (code === motorStartCode) return true if (code === motorStopCode) return false } return false } export function eventTimestampMs(date: string, time: string): number | null { const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/) if (!normalized || !date) return null const hours = parseInt(normalized[1], 10) const minutes = parseInt(normalized[2], 10) if (hours > 23 || minutes > 59) return null const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`) return Number.isNaN(parsed.getTime()) ? null : parsed.getTime() } export function getLastAutoPositionMs( events: Array<{ remarks: string; time: string }>, entryDate: string ): number | null { for (let i = events.length - 1; i >= 0; i--) { if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue return eventTimestampMs(entryDate, events[i].time) } return null } /** Max age of a logged position for OpenWeatherMap lookups in live log. */ export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000 export type LiveLogPositionSource = 'position' | 'auto_position' export interface LiveLogPosition { lat: string lng: string loggedAtMs: number source: LiveLogPositionSource } export function isManualPositionEventCode(code: string): boolean { return code === LIVE_EVENT_CODES.POSITION || code === LEGACY_LIVE_POSITION_REMARK } function isPositionEventCode(code: string): boolean { return isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION } /** Latest manual or auto-position event with GPS coordinates (any age). */ export function getLatestLoggedPosition( events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, entryDate: string ): LiveLogPosition | null { for (let i = events.length - 1; i >= 0; i--) { const event = events[i] const code = event.remarks.trim() if (!isPositionEventCode(code)) continue const lat = event.gpsLat?.trim() const lng = event.gpsLng?.trim() if (!lat || !lng) continue const loggedAtMs = eventTimestampMs(entryDate, event.time) if (loggedAtMs == null) continue return { lat, lng, loggedAtMs, source: isManualPositionEventCode(code) ? 'position' : 'auto_position' } } return null } /** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */ export function getLastLoggedPositionWithin( events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, entryDate: string, maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, nowMs: number = Date.now() ): LiveLogPosition | null { const latest = getLatestLoggedPosition(events, entryDate) if (!latest) return null if (nowMs - latest.loggedAtMs > maxAgeMs) return null return latest }