e014e997de
Fix/Standort heißen überall Position (__live:position, Legacy __live:fix). Nachfüll-Buttons + Diesel/+ Wasser, Abbruch statt Nein in Live-Modals. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
/** 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
|
|
}
|