Add live journal OWM weather and manual GPS fix dialog.
Fetch OpenWeatherMap in live log when a GPS fix is under six hours old, open a fix modal with manual coordinates when geolocation fails, and only show the undo bar after a successful save. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+70
-2
@@ -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 {
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [modal, setModal] = useState<LiveModal>('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<string[]>([])
|
||||
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<HTMLDivElement | null>(null)
|
||||
const undoTimerRef = useRef<number | null>(null)
|
||||
@@ -270,7 +281,7 @@ export default function LiveLogView({
|
||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
||||
|
||||
const runQuickAction = async (
|
||||
action: () => Promise<void>,
|
||||
action: () => Promise<boolean | void>,
|
||||
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<string, unknown>
|
||||
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({
|
||||
</button>
|
||||
{weatherExpanded && (
|
||||
<div className="live-log-weather-submenu">
|
||||
<button
|
||||
type="button"
|
||||
className="live-log-subaction-btn live-log-subaction-btn-owm"
|
||||
onClick={handleFetchOwmWeather}
|
||||
disabled={busy || weatherOwmLoading}
|
||||
>
|
||||
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
|
||||
{t('logs.live_wind_btn')}
|
||||
</button>
|
||||
@@ -632,7 +752,7 @@ export default function LiveLogView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="live-log-action-btn" onClick={handleFix} disabled={busy}>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}>
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_fix')}
|
||||
</button>
|
||||
@@ -665,11 +785,13 @@ export default function LiveLogView({
|
||||
<>
|
||||
{undoVisible && events.length > 0 && (
|
||||
<div className="live-log-undo-bar" role="status">
|
||||
<span>{t('logs.live_undo_hint')}</span>
|
||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||
<Undo2 size={16} />
|
||||
{t('logs.live_undo_btn')}
|
||||
</button>
|
||||
<div className="live-log-undo-bar-inner">
|
||||
<span>{t('logs.live_undo_hint')}</span>
|
||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||
<Undo2 size={16} />
|
||||
{t('logs.live_undo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -723,6 +845,73 @@ export default function LiveLogView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'fix' && (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
|
||||
>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_fix')}</h3>
|
||||
{fixGpsUnavailable && (
|
||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||
)}
|
||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||
<div className="live-log-fix-coords-row">
|
||||
<label className="live-log-fix-field">
|
||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
placeholder="54.123456"
|
||||
value={fixLat}
|
||||
onChange={(e) => setFixLat(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="live-log-fix-field">
|
||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
placeholder="10.654321"
|
||||
value={fixLng}
|
||||
onChange={(e) => setFixLng(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="live-log-fix-gps-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary live-log-fix-gps-btn"
|
||||
onClick={() => void retryFixGps()}
|
||||
title={t('logs.gps_btn')}
|
||||
disabled={fixGpsLoading}
|
||||
aria-label={t('logs.gps_btn')}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={confirmFix}
|
||||
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)}
|
||||
>
|
||||
{t('logs.live_sails_confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'comment' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<GeoCoordinates> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>): 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user