diff --git a/client/src/App.css b/client/src/App.css index ef7aad1..1a85564 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3420,6 +3420,12 @@ html.theme-cupertino .events-scroll-container { cursor: pointer; } +.live-log-subaction-btn-owm { + border-color: rgba(59, 130, 246, 0.35); + color: var(--app-text); + font-weight: 500; +} + .live-log-subaction-btn:hover:not(:disabled) { color: var(--app-text); border-color: rgba(59, 130, 246, 0.3); @@ -3427,11 +3433,18 @@ html.theme-cupertino .events-scroll-container { .live-log-undo-bar { position: fixed; - left: 50%; + inset-inline: 0; bottom: 24px; - transform: translateX(-50%); z-index: 10060; display: flex; + justify-content: center; + padding-inline: 16px; + pointer-events: none; +} + +.live-log-undo-bar-inner { + pointer-events: auto; + display: flex; align-items: center; gap: 12px; padding: 10px 14px; @@ -3440,6 +3453,61 @@ html.theme-cupertino .events-scroll-container { border: 1px solid var(--app-border-muted); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); font-size: 14px; + max-width: min(100%, 420px); +} + +.live-log-fix-coords { + margin: 0; + padding: 0; + border: none; + min-width: 0; +} + +.live-log-fix-label { + display: block; + margin: 0 0 10px; + padding: 0; + font-size: 13px; + font-weight: 600; + color: var(--app-text-muted); +} + +.live-log-fix-coords-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + min-width: 0; +} + +.live-log-fix-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.live-log-fix-field-label { + font-size: 12px; + color: var(--app-text-muted); +} + +.live-log-fix-field .input-text { + width: 100%; + box-sizing: border-box; +} + +.live-log-fix-gps-row { + margin-top: 10px; +} + +.live-log-fix-gps-btn { + width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 14px; } .stats-event-series-block + .stats-event-series-block { diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index c35da54..92c935b 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -34,8 +34,11 @@ import { import { formatEventSummary } from '../utils/formatEventSummary.js' import { getLastAutoPositionMs, + getLastPositionFixWithin, + getLatestPositionFix, isMotorRunningFromEvents, LIVE_EVENT_CODES, + LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, liveCommentRemark, liveFuelRemark, livePrecipRemark, @@ -45,7 +48,9 @@ import { liveTempRemark, liveWaterRemark } from '../utils/liveEventCodes.js' -import { getCurrentPosition } from '../utils/geolocation.js' +import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' +import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' +import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { dedupeSailNames, @@ -77,6 +82,7 @@ type LiveModal = | 'water' | 'sog' | 'stw' + | 'fix' const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000 const AUTO_POSITION_CHECK_MS = 60_000 @@ -120,11 +126,16 @@ export default function LiveLogView({ const [error, setError] = useState(null) const [modal, setModal] = useState('none') const [weatherExpanded, setWeatherExpanded] = useState(false) + const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) const [commentText, setCommentText] = useState('') const [valueInput, setValueInput] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('') const [selectedSails, setSelectedSails] = useState([]) const [undoVisible, setUndoVisible] = useState(false) + const [fixLat, setFixLat] = useState('') + const [fixLng, setFixLng] = useState('') + const [fixGpsLoading, setFixGpsLoading] = useState(false) + const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false) const streamEndRef = useRef(null) const undoTimerRef = useRef(null) @@ -270,7 +281,7 @@ export default function LiveLogView({ }, [entryId, loading, logbookId, refreshEntry, busy]) const runQuickAction = async ( - action: () => Promise, + action: () => Promise, trackAction?: string, withUndo = true ) => { @@ -278,7 +289,8 @@ export default function LiveLogView({ setBusy(true) setError(null) try { - await action() + const saved = await action() + if (saved === false) return await refreshEntry(entryId) if (withUndo) showUndo() if (trackAction) { @@ -335,22 +347,122 @@ export default function LiveLogView({ }, 'moor') } - const handleFix = () => { + const openFixModal = async () => { + setFixLat('') + setFixLng('') + setFixGpsUnavailable(false) + setFixGpsLoading(true) + setModal('fix') + try { + const coords = await getCurrentPosition() + setFixLat(coords.lat) + setFixLng(coords.lng) + } catch { + setFixGpsUnavailable(true) + } finally { + setFixGpsLoading(false) + } + } + + const retryFixGps = async () => { + setFixGpsLoading(true) + setFixGpsUnavailable(false) + try { + const coords = await getCurrentPosition() + setFixLat(coords.lat) + setFixLng(coords.lng) + } catch { + setFixGpsUnavailable(true) + await showAlert(t('logs.live_gps_error'), t('logs.live_fix')) + } finally { + setFixGpsLoading(false) + } + } + + const confirmFix = () => { + const coords = normalizeGpsCoordinates(fixLat, fixLng) + if (!coords) { + void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix')) + return + } + setModal('none') void runQuickAction(async () => { - if (!entryId) return - try { - const coords = await getCurrentPosition() - await appendQuickEvent(logbookId, entryId, { - gpsLat: coords.lat, - gpsLng: coords.lng, - remarks: LIVE_EVENT_CODES.FIX - }) - } catch { - await showAlert(t('logs.live_gps_error'), t('logs.live_fix')) - } + if (!entryId) return false + await appendQuickEvent(logbookId, entryId, { + gpsLat: coords.lat, + gpsLng: coords.lng, + remarks: LIVE_EVENT_CODES.FIX + }) }, 'fix') } + const handleFetchOwmWeather = () => { + if (!entryId || busy || weatherOwmLoading) return + + const position = getLastPositionFixWithin( + events, + date, + LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS + ) + if (!position) { + const latest = getLatestPositionFix(events, date) + void showAlert( + latest + ? t('logs.live_weather_fix_stale') + : t('logs.live_weather_fix_required'), + t('logs.live_weather_owm_btn') + ) + return + } + + const { lat, lng } = position + setWeatherOwmLoading(true) + void runQuickAction(async () => { + let data: Record + try { + data = await fetchOpenWeatherCurrent({ lat, lon: lng }) + } catch (err) { + if (err instanceof WeatherApiError && err.code === 'NO_KEY') { + await showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn')) + return false + } + console.error('Live log OWM weather failed:', err) + await showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn')) + return false + } + + const parsed = parseOwmCurrentWeather(data) + if (parsed.windDirection || parsed.windStrength) { + await appendQuickEvent(logbookId, entryId, { + windDirection: parsed.windDirection, + windStrength: parsed.windStrength, + weatherIcon: parsed.weatherIcon || undefined, + remarks: LIVE_EVENT_CODES.WIND + }) + } + if (parsed.windPressure) { + await appendQuickEvent(logbookId, entryId, { + windPressure: parsed.windPressure, + remarks: LIVE_EVENT_CODES.PRESSURE + }) + } + if (parsed.tempC) { + await appendQuickEvent(logbookId, entryId, { + remarks: liveTempRemark(parsed.tempC) + }) + } + if (parsed.precipText) { + await appendQuickEvent(logbookId, entryId, { + remarks: livePrecipRemark(parsed.precipText) + }) + } + + await showAlert(t('settings.weather_success'), t('logs.live_weather_owm_btn')) + }, 'weather_owm').finally(() => { + setWeatherOwmLoading(false) + }) + } + const handleUndo = () => { if (!entryId || busy) return setUndoVisible(false) @@ -613,6 +725,14 @@ export default function LiveLogView({ {weatherExpanded && (
+ @@ -632,7 +752,7 @@ export default function LiveLogView({ )}
- @@ -665,11 +785,13 @@ export default function LiveLogView({ <> {undoVisible && events.length > 0 && (
- {t('logs.live_undo_hint')} - +
+ {t('logs.live_undo_hint')} + +
)} @@ -723,6 +845,73 @@ export default function LiveLogView({ )} + {modal === 'fix' && ( +
{ if (e.target === e.currentTarget) closeModal() }} + > +
e.stopPropagation()}> +

{t('logs.live_fix')}

+ {fixGpsUnavailable && ( +

{t('logs.live_fix_manual_hint')}

+ )} +
+ {t('logs.event_gps')} +
+ + +
+
+ +
+
+
+ + +
+
+
+ )} + {modal === 'comment' && (
setModal('none')}>
e.stopPropagation()}> diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 6e45f31..23f9905 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -25,7 +25,7 @@ import type { SignatureValue } from '../types/signatures.js' import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import EventTimeInput24h from './EventTimeInput24h.tsx' import CourseDialInput from './CourseDialInput.tsx' -import { degreesToCardinal } from '../utils/courseAngle.js' +import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { signLogEntry } from '../services/entrySigning.js' import { getLogbookAccess } from '../services/logbookAccess.js' @@ -965,38 +965,11 @@ export default function LogEntryEditor({ setEvGpsLng(Number(coord.lon).toFixed(6)) } - const wind = data.wind as { speed?: number; deg?: number } | undefined - const main = data.main as { pressure?: number } | undefined - - // Convert wind speed m/s to Beaufort scale - const mps = wind?.speed || 0 - let bft = 0 - if (mps < 0.3) bft = 0 - else if (mps < 1.6) bft = 1 - else if (mps < 3.4) bft = 2 - else if (mps < 5.5) bft = 3 - else if (mps < 8.0) bft = 4 - else if (mps < 10.8) bft = 5 - else if (mps < 13.9) bft = 6 - else if (mps < 17.2) bft = 7 - else if (mps < 20.8) bft = 8 - else if (mps < 24.5) bft = 9 - else if (mps < 28.5) bft = 10 - else if (mps < 32.7) bft = 11 - else bft = 12 - - setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`) - setEvWindPressure(String(main?.pressure || '')) - - // Calculate wind compass direction sector - if (wind?.deg !== undefined) { - setEvWindDirection(degreesToCardinal(wind.deg)) - } - - if (data.weather && Array.isArray(data.weather) && data.weather[0]) { - const first = data.weather[0] as { icon?: string } - if (first.icon) setEvWeatherIcon(first.icon) - } + const parsed = parseOwmCurrentWeather(data) + setEvWindStrength(parsed.windStrength) + setEvWindPressure(parsed.windPressure) + if (parsed.windDirection) setEvWindDirection(parsed.windDirection) + if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon) showAlert(t('settings.weather_success')) } catch (err) { diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 1ea81cf..0d1b6bf 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -228,12 +228,21 @@ "live_sails": "Sejl: {{sails}}", "live_fix": "Fix", "live_fix_coords": "Fix {{lat}}, {{lng}}", + "live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.", + "live_fix_gps_loading": "Henter GPS-position…", + "live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).", + "live_fix_lat_placeholder": "Bredde (Lat)", + "live_fix_lng_placeholder": "Længde (Lng)", "live_comment_btn": "Kommentar", "live_comment_placeholder": "Indtast tekst…", "live_comment_confirm": "Indtast", "live_gps_error": "GPS-position kunne ikke bestemmes.", "live_event_generic": "Hændelse", "live_weather_btn": "Vejr", + "live_weather_owm_btn": "Hent OpenWeatherMap-vejr", + "live_weather_owm_loading": "Henter vejr…", + "live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.", + "live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.", "live_wind_btn": "Vind", "live_temp_btn": "T °C", "live_pressure_btn": "Lufttryk", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index cea6b3b..71ae2b8 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -228,12 +228,21 @@ "live_sails": "Segel: {{sails}}", "live_fix": "Fix", "live_fix_coords": "Fix {{lat}}, {{lng}}", + "live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.", + "live_fix_gps_loading": "GPS-Position wird ermittelt…", + "live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).", + "live_fix_lat_placeholder": "Breite (Lat)", + "live_fix_lng_placeholder": "Länge (Lng)", "live_comment_btn": "Kommentar", "live_comment_placeholder": "Freitext eingeben…", "live_comment_confirm": "Eintragen", "live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "live_event_generic": "Ereignis", "live_weather_btn": "Wetter", + "live_weather_owm_btn": "OpenWeatherMap Wetter abrufen", + "live_weather_owm_loading": "Wetter wird geladen…", + "live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.", + "live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.", "live_wind_btn": "Wind", "live_temp_btn": "T °C", "live_pressure_btn": "Luftdruck", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 8fc1cc1..28b7e52 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -228,12 +228,21 @@ "live_sails": "Sails: {{sails}}", "live_fix": "Fix", "live_fix_coords": "Fix {{lat}}, {{lng}}", + "live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.", + "live_fix_gps_loading": "Getting GPS position…", + "live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).", + "live_fix_lat_placeholder": "Latitude (Lat)", + "live_fix_lng_placeholder": "Longitude (Lng)", "live_comment_btn": "Comment", "live_comment_placeholder": "Enter text…", "live_comment_confirm": "Log entry", "live_gps_error": "Could not determine GPS position.", "live_event_generic": "Event", "live_weather_btn": "Weather", + "live_weather_owm_btn": "Fetch OpenWeatherMap weather", + "live_weather_owm_loading": "Loading weather…", + "live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.", + "live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.", "live_wind_btn": "Wind", "live_temp_btn": "Temp °C", "live_pressure_btn": "Pressure", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 26462fd..2353ecf 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -228,12 +228,21 @@ "live_sails": "Seil: {{sails}}", "live_fix": "Fix", "live_fix_coords": "Fix {{lat}}, {{lng}}", + "live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.", + "live_fix_gps_loading": "Henter GPS-posisjon…", + "live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).", + "live_fix_lat_placeholder": "Bredde (Lat)", + "live_fix_lng_placeholder": "Lengde (Lng)", "live_comment_btn": "Kommentar", "live_comment_placeholder": "Skriv inn tekst…", "live_comment_confirm": "Loggfør", "live_gps_error": "GPS-posisjon kunne ikke bestemmes.", "live_event_generic": "Hendelse", "live_weather_btn": "Vær", + "live_weather_owm_btn": "Hent OpenWeatherMap-vær", + "live_weather_owm_loading": "Henter vær…", + "live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.", + "live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.", "live_wind_btn": "Vind", "live_temp_btn": "T °C", "live_pressure_btn": "Lufttrykk", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 808de41..7de0e76 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -228,12 +228,21 @@ "live_sails": "Segel: {{sails}}", "live_fix": "Fix", "live_fix_coords": "Fix {{lat}}, {{lng}}", + "live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.", + "live_fix_gps_loading": "Hämtar GPS-position…", + "live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).", + "live_fix_lat_placeholder": "Latitud (Lat)", + "live_fix_lng_placeholder": "Longitud (Lng)", "live_comment_btn": "Kommentar", "live_comment_placeholder": "Ange text…", "live_comment_confirm": "Logga", "live_gps_error": "GPS-position kunde inte bestämmas.", "live_event_generic": "Händelse", "live_weather_btn": "Väder", + "live_weather_owm_btn": "Hämta OpenWeatherMap-väder", + "live_weather_owm_loading": "Hämtar väder…", + "live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.", + "live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.", "live_wind_btn": "Vind", "live_temp_btn": "T °C", "live_pressure_btn": "Lufttryck", diff --git a/client/src/utils/geolocation.test.ts b/client/src/utils/geolocation.test.ts new file mode 100644 index 0000000..8caa601 --- /dev/null +++ b/client/src/utils/geolocation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js' + +describe('geolocation helpers', () => { + it('parses coordinates with comma decimals', () => { + expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123) + }) + + it('normalizes valid lat/lng', () => { + expect(normalizeGpsCoordinates('54.1', '10.2')).toEqual({ + lat: '54.100000', + lng: '10.200000' + }) + }) + + it('rejects out-of-range values', () => { + expect(normalizeGpsCoordinates('91', '0')).toBeNull() + expect(normalizeGpsCoordinates('0', '181')).toBeNull() + }) +}) diff --git a/client/src/utils/geolocation.ts b/client/src/utils/geolocation.ts index 52048bc..c43487f 100644 --- a/client/src/utils/geolocation.ts +++ b/client/src/utils/geolocation.ts @@ -7,6 +7,25 @@ export interface GeoCoordinates { speedKn: number | null } +export function parseGpsCoordinate(value: string): number | null { + const trimmed = value.trim() + if (!trimmed) return null + const n = parseFloat(trimmed.replace(',', '.')) + return Number.isFinite(n) ? n : null +} + +/** Validates lat/lng and returns normalized strings for storage, or null. */ +export function normalizeGpsCoordinates( + lat: string, + lng: string +): { lat: string; lng: string } | null { + const latN = parseGpsCoordinate(lat) + const lngN = parseGpsCoordinate(lng) + if (latN == null || lngN == null) return null + if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null + return { lat: latN.toFixed(6), lng: lngN.toFixed(6) } +} + export function getCurrentPosition(timeoutMs = 15000): Promise { return new Promise((resolve, reject) => { if (!navigator.geolocation) { diff --git a/client/src/utils/liveEventCodes.ts b/client/src/utils/liveEventCodes.ts index 78ff22b..4341452 100644 --- a/client/src/utils/liveEventCodes.ts +++ b/client/src/utils/liveEventCodes.ts @@ -120,3 +120,56 @@ export function getLastAutoPositionMs( } return null } + +/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */ +export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000 + +export type LiveLogPositionSource = 'fix' | 'auto_position' + +export interface LiveLogPositionFix { + lat: string + lng: string + loggedAtMs: number + source: LiveLogPositionSource +} + +function isPositionEventCode(code: string): boolean { + return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION +} + +/** Latest FIX or auto-position event with GPS coordinates (any age). */ +export function getLatestPositionFix( + events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, + entryDate: string +): LiveLogPositionFix | 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: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position' + } + } + return null +} + +/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */ +export function getLastPositionFixWithin( + 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() +): LiveLogPositionFix | null { + const latest = getLatestPositionFix(events, entryDate) + if (!latest) return null + if (nowMs - latest.loggedAtMs > maxAgeMs) return null + return latest +} diff --git a/client/src/utils/liveLogPosition.test.ts b/client/src/utils/liveLogPosition.test.ts new file mode 100644 index 0000000..d1a3ae3 --- /dev/null +++ b/client/src/utils/liveLogPosition.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + getLastPositionFixWithin, + getLatestPositionFix, + LIVE_EVENT_CODES, + LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS +} from './liveEventCodes.js' + +const entryDate = '2026-06-01' + +describe('live log position fix', () => { + it('returns latest fix with coordinates', () => { + const events = [ + { remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' }, + { remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' } + ] + const fix = getLatestPositionFix(events, entryDate) + expect(fix?.lat).toBe('54.2') + expect(fix?.source).toBe('fix') + }) + + it('accepts auto-position with GPS', () => { + const events = [ + { + remarks: LIVE_EVENT_CODES.AUTO_POSITION, + time: '14:00', + gpsLat: '55.0', + gpsLng: '11.0' + } + ] + expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position') + }) + + it('rejects fix older than max age for weather', () => { + const noon = new Date(`${entryDate}T12:00:00`).getTime() + const events = [ + { remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' } + ] + expect( + getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon) + ).toBeNull() + expect(getLatestPositionFix(events, entryDate)).not.toBeNull() + }) + + it('accepts fix within six hours', () => { + const noon = new Date(`${entryDate}T12:00:00`).getTime() + const events = [ + { remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' } + ] + expect( + getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon) + ).not.toBeNull() + }) +}) diff --git a/client/src/utils/openWeatherMap.test.ts b/client/src/utils/openWeatherMap.test.ts new file mode 100644 index 0000000..4f63913 --- /dev/null +++ b/client/src/utils/openWeatherMap.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + formatWindStrengthBeaufort, + mpsToBeaufort, + parseOwmCurrentWeather +} from './openWeatherMap.js' + +describe('openWeatherMap', () => { + it('maps m/s to Beaufort', () => { + expect(mpsToBeaufort(0)).toBe(0) + expect(mpsToBeaufort(5)).toBe(3) + expect(mpsToBeaufort(15)).toBe(7) + expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)') + }) + + it('parses OWM current weather payload', () => { + const parsed = parseOwmCurrentWeather({ + wind: { speed: 8.5, deg: 225 }, + main: { pressure: 1018, temp: 17.4 }, + weather: [{ icon: '04d', description: 'Bedeckt' }] + }) + expect(parsed.windDirection).toBe('SW') + expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)') + expect(parsed.windPressure).toBe('1018') + expect(parsed.tempC).toBe('17.4') + expect(parsed.precipText).toBe('Bedeckt') + expect(parsed.weatherIcon).toBe('04d') + }) +}) diff --git a/client/src/utils/openWeatherMap.ts b/client/src/utils/openWeatherMap.ts new file mode 100644 index 0000000..a887a8e --- /dev/null +++ b/client/src/utils/openWeatherMap.ts @@ -0,0 +1,68 @@ +import { degreesToCardinal } from './courseAngle.js' + +export interface ParsedOwmCurrent { + windDirection: string + windStrength: string + windPressure: string + tempC: string | null + precipText: string | null + weatherIcon: string | null +} + +/** Beaufort scale from wind speed in m/s (OWM `wind.speed`). */ +export function mpsToBeaufort(mps: number): number { + if (mps < 0.3) return 0 + if (mps < 1.6) return 1 + if (mps < 3.4) return 2 + if (mps < 5.5) return 3 + if (mps < 8.0) return 4 + if (mps < 10.8) return 5 + if (mps < 13.9) return 6 + if (mps < 17.2) return 7 + if (mps < 20.8) return 8 + if (mps < 24.5) return 9 + if (mps < 28.5) return 10 + if (mps < 32.7) return 11 + return 12 +} + +export function formatWindStrengthBeaufort(mps: number): string { + const bft = mpsToBeaufort(mps) + return `${bft} Bft (${mps.toFixed(1)} m/s)` +} + +export function parseOwmCurrentWeather(data: Record): ParsedOwmCurrent { + const wind = data.wind as { speed?: number; deg?: number } | undefined + const main = data.main as { pressure?: number; temp?: number } | undefined + const rain = data.rain as { '1h'?: number } | undefined + const weatherArr = data.weather as Array<{ icon?: string; description?: string }> | undefined + + const mps = wind?.speed ?? 0 + const windStrength = formatWindStrengthBeaufort(mps) + const windDirection = wind?.deg !== undefined ? degreesToCardinal(wind.deg) : '' + const windPressure = main?.pressure != null ? String(main.pressure) : '' + + let tempC: string | null = null + if (main?.temp != null && Number.isFinite(main.temp)) { + tempC = Number(main.temp).toFixed(1) + } + + let precipText: string | null = null + const firstWeather = weatherArr?.[0] + if (firstWeather?.description?.trim()) { + precipText = firstWeather.description.trim() + } else if (rain?.['1h'] != null && Number.isFinite(rain['1h'])) { + precipText = `${rain['1h']} mm/h` + } + + const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null + + return { + windDirection, + windStrength, + windPressure, + tempC, + precipText, + weatherIcon + } +}