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;
|
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) {
|
.live-log-subaction-btn:hover:not(:disabled) {
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
@@ -3427,11 +3433,18 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
|
|
||||||
.live-log-undo-bar {
|
.live-log-undo-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
inset-inline: 0;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10060;
|
z-index: 10060;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-undo-bar-inner {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -3440,6 +3453,61 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
border: 1px solid var(--app-border-muted);
|
border: 1px solid var(--app-border-muted);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
font-size: 14px;
|
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 {
|
.stats-event-series-block + .stats-event-series-block {
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ import {
|
|||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
import {
|
import {
|
||||||
getLastAutoPositionMs,
|
getLastAutoPositionMs,
|
||||||
|
getLastPositionFixWithin,
|
||||||
|
getLatestPositionFix,
|
||||||
isMotorRunningFromEvents,
|
isMotorRunningFromEvents,
|
||||||
LIVE_EVENT_CODES,
|
LIVE_EVENT_CODES,
|
||||||
|
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
||||||
liveCommentRemark,
|
liveCommentRemark,
|
||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
@@ -45,7 +48,9 @@ import {
|
|||||||
liveTempRemark,
|
liveTempRemark,
|
||||||
liveWaterRemark
|
liveWaterRemark
|
||||||
} from '../utils/liveEventCodes.js'
|
} 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 { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
dedupeSailNames,
|
dedupeSailNames,
|
||||||
@@ -77,6 +82,7 @@ type LiveModal =
|
|||||||
| 'water'
|
| 'water'
|
||||||
| 'sog'
|
| 'sog'
|
||||||
| 'stw'
|
| 'stw'
|
||||||
|
| 'fix'
|
||||||
|
|
||||||
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
||||||
const AUTO_POSITION_CHECK_MS = 60_000
|
const AUTO_POSITION_CHECK_MS = 60_000
|
||||||
@@ -120,11 +126,16 @@ export default function LiveLogView({
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [modal, setModal] = useState<LiveModal>('none')
|
const [modal, setModal] = useState<LiveModal>('none')
|
||||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||||
|
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [valueInput, setValueInput] = useState('')
|
const [valueInput, setValueInput] = useState('')
|
||||||
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
||||||
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
||||||
const [undoVisible, setUndoVisible] = useState(false)
|
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 streamEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
@@ -270,7 +281,7 @@ export default function LiveLogView({
|
|||||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
}, [entryId, loading, logbookId, refreshEntry, busy])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<void>,
|
action: () => Promise<boolean | void>,
|
||||||
trackAction?: string,
|
trackAction?: string,
|
||||||
withUndo = true
|
withUndo = true
|
||||||
) => {
|
) => {
|
||||||
@@ -278,7 +289,8 @@ export default function LiveLogView({
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await action()
|
const saved = await action()
|
||||||
|
if (saved === false) return
|
||||||
await refreshEntry(entryId)
|
await refreshEntry(entryId)
|
||||||
if (withUndo) showUndo()
|
if (withUndo) showUndo()
|
||||||
if (trackAction) {
|
if (trackAction) {
|
||||||
@@ -335,22 +347,122 @@ export default function LiveLogView({
|
|||||||
}, 'moor')
|
}, '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 () => {
|
void runQuickAction(async () => {
|
||||||
if (!entryId) return
|
if (!entryId) return false
|
||||||
try {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
const coords = await getCurrentPosition()
|
gpsLat: coords.lat,
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
gpsLng: coords.lng,
|
||||||
gpsLat: coords.lat,
|
remarks: LIVE_EVENT_CODES.FIX
|
||||||
gpsLng: coords.lng,
|
})
|
||||||
remarks: LIVE_EVENT_CODES.FIX
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
|
|
||||||
}
|
|
||||||
}, '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 = () => {
|
const handleUndo = () => {
|
||||||
if (!entryId || busy) return
|
if (!entryId || busy) return
|
||||||
setUndoVisible(false)
|
setUndoVisible(false)
|
||||||
@@ -613,6 +725,14 @@ export default function LiveLogView({
|
|||||||
</button>
|
</button>
|
||||||
{weatherExpanded && (
|
{weatherExpanded && (
|
||||||
<div className="live-log-weather-submenu">
|
<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}>
|
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
|
||||||
{t('logs.live_wind_btn')}
|
{t('logs.live_wind_btn')}
|
||||||
</button>
|
</button>
|
||||||
@@ -632,7 +752,7 @@ export default function LiveLogView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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} />
|
<MapPin size={18} />
|
||||||
{t('logs.live_fix')}
|
{t('logs.live_fix')}
|
||||||
</button>
|
</button>
|
||||||
@@ -665,11 +785,13 @@ export default function LiveLogView({
|
|||||||
<>
|
<>
|
||||||
{undoVisible && events.length > 0 && (
|
{undoVisible && events.length > 0 && (
|
||||||
<div className="live-log-undo-bar" role="status">
|
<div className="live-log-undo-bar" role="status">
|
||||||
<span>{t('logs.live_undo_hint')}</span>
|
<div className="live-log-undo-bar-inner">
|
||||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
<span>{t('logs.live_undo_hint')}</span>
|
||||||
<Undo2 size={16} />
|
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||||
{t('logs.live_undo_btn')}
|
<Undo2 size={16} />
|
||||||
</button>
|
{t('logs.live_undo_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -723,6 +845,73 @@ export default function LiveLogView({
|
|||||||
</div>
|
</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' && (
|
{modal === 'comment' && (
|
||||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
<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 { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.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 { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
@@ -965,38 +965,11 @@ export default function LogEntryEditor({
|
|||||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||||
}
|
}
|
||||||
|
|
||||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
const parsed = parseOwmCurrentWeather(data)
|
||||||
const main = data.main as { pressure?: number } | undefined
|
setEvWindStrength(parsed.windStrength)
|
||||||
|
setEvWindPressure(parsed.windPressure)
|
||||||
// Convert wind speed m/s to Beaufort scale
|
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||||
const mps = wind?.speed || 0
|
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
showAlert(t('settings.weather_success'))
|
showAlert(t('settings.weather_success'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -228,12 +228,21 @@
|
|||||||
"live_sails": "Sejl: {{sails}}",
|
"live_sails": "Sejl: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_fix": "Fix",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||||
"live_event_generic": "Hændelse",
|
"live_event_generic": "Hændelse",
|
||||||
"live_weather_btn": "Vejr",
|
"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_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryk",
|
"live_pressure_btn": "Lufttryk",
|
||||||
|
|||||||
@@ -228,12 +228,21 @@
|
|||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_fix": "Fix",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
"live_event_generic": "Ereignis",
|
"live_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"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_wind_btn": "Wind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Luftdruck",
|
"live_pressure_btn": "Luftdruck",
|
||||||
|
|||||||
@@ -228,12 +228,21 @@
|
|||||||
"live_sails": "Sails: {{sails}}",
|
"live_sails": "Sails: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_fix": "Fix",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"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_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
"live_event_generic": "Event",
|
"live_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"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_wind_btn": "Wind",
|
||||||
"live_temp_btn": "Temp °C",
|
"live_temp_btn": "Temp °C",
|
||||||
"live_pressure_btn": "Pressure",
|
"live_pressure_btn": "Pressure",
|
||||||
|
|||||||
@@ -228,12 +228,21 @@
|
|||||||
"live_sails": "Seil: {{sails}}",
|
"live_sails": "Seil: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_fix": "Fix",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
"live_event_generic": "Hendelse",
|
"live_event_generic": "Hendelse",
|
||||||
"live_weather_btn": "Vær",
|
"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_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttrykk",
|
"live_pressure_btn": "Lufttrykk",
|
||||||
|
|||||||
@@ -228,12 +228,21 @@
|
|||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_fix": "Fix",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||||
"live_event_generic": "Händelse",
|
"live_event_generic": "Händelse",
|
||||||
"live_weather_btn": "Väder",
|
"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_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryck",
|
"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
|
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> {
|
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
|
|||||||
@@ -120,3 +120,56 @@ export function getLastAutoPositionMs(
|
|||||||
}
|
}
|
||||||
return null
|
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