feat(logs): Sichtweite und kompakte Wetter-Slider im Ereignisprotokoll

Ergänzt visibility in Editor und Live-Log inkl. OWM-Übernahme, CSV-Export
und touch-taugliche Slider für Luftdruck, Seegang, Sichtweite und Krängung.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 21:50:05 +02:00
parent 847c73fda9
commit cdcef2e106
18 changed files with 640 additions and 42 deletions
+73
View File
@@ -4241,6 +4241,79 @@ html.theme-cupertino .events-scroll-container {
}
}
/* Compact weather metric sliders (LogEntryEditor) */
.weather-metrics-grid {
gap: 12px 16px;
}
.weather-metrics-grid .weather-metrics-span-2 {
grid-column: 1 / -1;
}
.metric-range-input--compact {
gap: 0;
margin: 0;
}
.metric-range-input--compact .metric-range-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.metric-range-input--compact .metric-range-header label {
margin: 0;
font-size: 13px;
line-height: 1.25;
}
.metric-range-input--compact .metric-range-value {
font-size: 0.8125rem;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.metric-range-input--compact .metric-range-control-row {
display: flex;
align-items: center;
gap: 10px;
}
.metric-range-input--compact .metric-range-slider {
flex: 1;
min-width: 0;
width: auto;
margin: 0;
}
.metric-range-input--compact .metric-range-number {
width: 4.25rem;
min-width: 4.25rem;
max-width: 4.25rem;
flex-shrink: 0;
padding: 8px 6px;
text-align: center;
font-size: 0.9375rem;
}
@media (max-width: 480px) {
.metric-range-input--compact .metric-range-slider {
--tank-slider-thumb: 28px;
}
.metric-range-input--compact .metric-range-number {
width: 3.75rem;
min-width: 3.75rem;
max-width: 3.75rem;
padding: 10px 4px;
}
}
.vessel-tanks-section {
grid-column: 1 / -1;
margin-top: 8px;
+24 -2
View File
@@ -82,6 +82,7 @@ type LiveModal =
| 'temp'
| 'precip'
| 'sea_state'
| 'visibility'
| 'course'
| 'fuel'
| 'water'
@@ -557,6 +558,12 @@ export default function LiveLogView({
remarks: LIVE_EVENT_CODES.PRESSURE
})
}
if (parsed.visibility) {
partials.push({
visibility: parsed.visibility,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}
if (parsed.tempC) {
partials.push({ remarks: liveTempRemark(parsed.tempC) })
}
@@ -724,6 +731,16 @@ export default function LiveLogView({
})
}, 'sea_state')
break
case 'visibility':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
visibility: primary,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}, 'visibility')
break
case 'course': {
const course = primary || lastCourseFromEvents(events)
if (!course) return
@@ -923,6 +940,9 @@ export default function LiveLogView({
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div>
)}
</div>
@@ -1165,7 +1185,7 @@ export default function LiveLogView({
</div>
)}
{['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
{['pressure', 'temp', 'precip', 'sea_state', 'visibility', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>
@@ -1173,6 +1193,7 @@ export default function LiveLogView({
{modal === 'temp' && t('logs.live_temp_btn')}
{modal === 'precip' && t('logs.live_precip_btn')}
{modal === 'sea_state' && t('logs.live_sea_state_btn')}
{modal === 'visibility' && t('logs.live_visibility_btn')}
{modal === 'fuel' && t('logs.live_fuel_btn')}
{modal === 'water' && t('logs.live_water_btn')}
{modal === 'sog' && t('logs.live_sog_btn')}
@@ -1192,7 +1213,8 @@ export default function LiveLogView({
: modal === 'temp' ? t('logs.live_temp_placeholder')
: modal === 'precip' ? t('logs.live_precip_placeholder')
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: modal === 'visibility' ? t('logs.live_visibility_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: modal === 'water' ? t('logs.live_water_placeholder')
: modal === 'sog' ? t('logs.live_sog_placeholder')
: t('logs.live_stw_placeholder')
+95 -36
View File
@@ -56,6 +56,25 @@ import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
import TankLiterInput from './TankLiterInput.tsx'
import MetricRangeInput from './MetricRangeInput.tsx'
import {
formatHeelDeg,
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
HEEL_MAX_DEG,
HEEL_MIN_DEG,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
PRESSURE_DEFAULT_HPA,
PRESSURE_MAX_HPA,
PRESSURE_MIN_HPA,
SEA_STATE_MAX,
SEA_STATE_MIN,
VISIBILITY_STEPS_M
} from '../utils/weatherMetrics.js'
import {
computeEveningTankMaxLiters,
computeRefilledTankMaxLiters,
@@ -201,6 +220,7 @@ export default function LogEntryEditor({
const [evWindDirection, setEvWindDirection] = useState('')
const [evWindStrength, setEvWindStrength] = useState('')
const [evSeaState, setEvSeaState] = useState('')
const [evVisibility, setEvVisibility] = useState('')
const [evWeatherIcon, setEvWeatherIcon] = useState('')
const [evCurrent, setEvCurrent] = useState('')
const [evHeel, setEvHeel] = useState('')
@@ -361,6 +381,7 @@ export default function LogEntryEditor({
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
visibility: evVisibility,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
@@ -383,7 +404,7 @@ export default function LogEntryEditor({
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
@@ -985,6 +1006,7 @@ export default function LogEntryEditor({
setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.visibility) setEvVisibility(parsed.visibility)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success'))
@@ -1047,6 +1069,7 @@ export default function LogEntryEditor({
setEvWindDirection('')
setEvWindStrength('')
setEvSeaState('')
setEvVisibility('')
setEvWeatherIcon('')
setEvCurrent('')
setEvHeel('')
@@ -1070,6 +1093,7 @@ export default function LogEntryEditor({
setEvWindDirection(normalized.windDirection)
setEvWindStrength(normalized.windStrength)
setEvSeaState(normalized.seaState)
setEvVisibility(normalized.visibility)
setEvWeatherIcon(normalized.weatherIcon)
setEvCurrent(normalized.current)
setEvHeel(normalized.heel)
@@ -1698,8 +1722,8 @@ export default function LogEntryEditor({
</div>
</div>
<div className="form-grid mb-4">
<div className="input-group course-dial-section">
<div className="form-grid weather-metrics-grid mb-4">
<div className="input-group course-dial-section weather-metrics-span-2">
<label>{t('logs.event_wind_direction')}</label>
<CourseDialInput
value={evWindDirection}
@@ -1723,41 +1747,76 @@ export default function LogEntryEditor({
/>
</div>
<div className="input-group">
<label>{t('logs.event_wind_pressure')}</label>
<input
type="text"
placeholder="e.g. 1013 hPa"
className="input-text"
value={evWindPressure}
onChange={(e) => setEvWindPressure(e.target.value)}
disabled={saving || weatherLoading}
/>
</div>
<MetricRangeInput
label={t('logs.event_wind_pressure')}
value={evWindPressure}
onChange={setEvWindPressure}
disabled={saving || weatherLoading}
min={PRESSURE_MIN_HPA}
max={PRESSURE_MAX_HPA}
step={1}
defaultNumeric={PRESSURE_DEFAULT_HPA}
parse={parsePressureHpa}
format={formatPressureHpa}
formatDisplay={(hpa) =>
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
numberMin={PRESSURE_MIN_HPA}
numberMax={PRESSURE_MAX_HPA}
numberStep={1}
numberPlaceholder="1013"
/>
<div className="input-group">
<label>{t('logs.event_sea_state')}</label>
<input
type="text"
placeholder="e.g. 3"
className="input-text"
value={evSeaState}
onChange={(e) => setEvSeaState(e.target.value)}
disabled={saving}
/>
</div>
<MetricRangeInput
label={t('logs.event_sea_state')}
value={evSeaState}
onChange={setEvSeaState}
disabled={saving}
min={SEA_STATE_MIN}
max={SEA_STATE_MAX}
step={1}
defaultNumeric={0}
parse={parseSeaState}
format={formatSeaState}
formatDisplay={(level) =>
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
numberMin={SEA_STATE_MIN}
numberMax={SEA_STATE_MAX}
numberStep={1}
numberPlaceholder="3"
allowLegacyText
/>
<div className="input-group">
<label>{t('logs.event_heel')}</label>
<input
type="text"
placeholder="e.g. 5"
className="input-text"
value={evHeel}
onChange={(e) => setEvHeel(e.target.value)}
disabled={saving}
/>
</div>
<MetricRangeInput
label={t('logs.event_visibility')}
value={evVisibility}
onChange={setEvVisibility}
disabled={saving || weatherLoading}
discreteValues={VISIBILITY_STEPS_M}
defaultNumeric={10000}
parse={parseVisibilityMeters}
format={formatVisibilityMeters}
formatDisplay={(m) => formatVisibilityMeters(m)}
hideNumberInput
/>
<MetricRangeInput
label={t('logs.event_heel')}
value={evHeel}
onChange={setEvHeel}
disabled={saving}
min={HEEL_MIN_DEG}
max={HEEL_MAX_DEG}
step={1}
defaultNumeric={0}
parse={parseHeelDeg}
format={formatHeelDeg}
formatDisplay={(deg) =>
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
numberMin={HEEL_MIN_DEG}
numberMax={HEEL_MAX_DEG}
numberStep={1}
numberPlaceholder="5"
/>
</div>
<div className="form-grid mb-4">
+196
View File
@@ -0,0 +1,196 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export interface MetricRangeInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
min?: number
max?: number
step?: number
discreteValues?: readonly number[]
parse: (value: string) => number | null
format: (numeric: number) => string
defaultNumeric: number
/** Shown next to the label (current value). */
formatDisplay: (numeric: number, unset: boolean) => string
numberMin?: number
numberMax?: number
numberStep?: number | 'any'
numberPlaceholder?: string
allowLegacyText?: boolean
hideNumberInput?: boolean
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export default function MetricRangeInput({
id,
label,
value,
onChange,
disabled = false,
min,
max,
discreteValues,
parse,
format,
defaultNumeric,
formatDisplay,
numberMin,
numberMax,
numberStep = 'any',
numberPlaceholder,
allowLegacyText = false,
hideNumberInput = false
}: MetricRangeInputProps) {
const { t } = useTranslation()
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
const isLegacyText =
allowLegacyText && value.trim() !== '' && parse(value) === null
const emitNumeric = useCallback(
(numeric: number) => {
onChange(format(numeric))
},
[onChange, format]
)
const parsed = parse(value)
const unset = parsed === null
const sliderNumeric = unset ? defaultNumeric : parsed
const useDiscrete = discreteValues != null && discreteValues.length > 1
let sliderMin = 0
let sliderMax = 0
let sliderValue = 0
if (useDiscrete) {
sliderMin = 0
sliderMax = discreteValues.length - 1
if (unset) {
sliderValue = 0
} else {
let bestIdx = 0
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - sliderNumeric)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
sliderValue = bestIdx
}
} else if (min != null && max != null) {
sliderMin = min
sliderMax = max
sliderValue = clamp(sliderNumeric, min, max)
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value)
if (useDiscrete && discreteValues) {
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
return
}
if (min != null && max != null) {
emitNumeric(Number(e.target.value))
}
}
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
const next = parse(value)
if (next == null) {
if (!value.trim()) onChange('')
return
}
onChange(format(next))
}
const hintNumeric = useDiscrete && discreteValues
? discreteValues[sliderValue]
: sliderValue
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
if (isLegacyText) {
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={id}>{label}</label>
</div>
<input
id={id}
type="text"
className="input-text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={numberPlaceholder}
/>
</div>
)
}
const hasSlider = useDiscrete || (min != null && max != null)
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
{hasSlider && (
<span className="metric-range-value" aria-live="polite">
{displayLabel}
</span>
)}
</div>
{hasSlider && (
<div className="metric-range-control-row">
<input
type="range"
className="tank-liter-slider metric-range-slider"
min={sliderMin}
max={sliderMax}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
aria-valuemin={sliderMin}
aria-valuemax={sliderMax}
aria-valuenow={sliderValue}
aria-label={label}
aria-valuetext={displayLabel}
/>
{!hideNumberInput && (
<input
id={id}
type="number"
className="input-text metric-range-number"
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={numberMin}
max={numberMax}
step={numberStep}
placeholder={numberPlaceholder}
inputMode="decimal"
aria-label={label}
/>
)}
</div>
)}
</div>
)
}
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_visibility_entry": "Sigtbarhed {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sigtbarhed",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Trin {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Vejret",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke",
"event_sea_state": "Seegang",
"event_visibility": "Sichtweite",
"event_visibility_placeholder": "z. B. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Stufe {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Wetter",
"event_log": "Logge (sm)",
"event_gps": "GPS-Position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
@@ -339,6 +342,12 @@
"event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str",
"event_sea_state": "Sea State",
"event_visibility": "Visibility",
"event_visibility_placeholder": "e.g. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "State {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Weather",
"event_log": "Log (nm)",
"event_gps": "GPS Position",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sikt",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Været",
"event_log": "Logg (sm)",
"event_gps": "GPS-posisjon",
+9
View File
@@ -280,6 +280,7 @@
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
@@ -288,6 +289,7 @@
"live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
@@ -298,6 +300,7 @@
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_visibility_placeholder": "t.ex. 10 km",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
@@ -339,6 +342,12 @@
"event_wind_direction": "Vindriktning",
"event_wind_strength": "Vindstyrka",
"event_sea_state": "Havets tillstånd",
"event_visibility": "Sikt",
"event_visibility_placeholder": "t.ex. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Väder",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
+3 -2
View File
@@ -78,7 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
@@ -129,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
signS, signC,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
@@ -147,6 +147,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
@@ -24,6 +24,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
@@ -94,6 +95,15 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
it('formats visibility entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.VISIBILITY,
visibility: '10 km'
})
expect(formatEventSummary(event, t)).toBe('Visibility 10 km')
})
it('formats SOG entry', () => {
const event = normalizeLogEvent({
time: '10:15',
+5
View File
@@ -81,6 +81,10 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code === LIVE_EVENT_CODES.VISIBILITY && event.visibility) {
return t('logs.live_visibility_entry', { value: event.visibility })
}
if (code && !code.startsWith('__live:')) {
return code
}
@@ -92,6 +96,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
}
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
if (event.visibility) parts.push(`${t('logs.event_visibility')}: ${event.visibility}`)
if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
}
+2 -1
View File
@@ -9,7 +9,8 @@ export const LIVE_EVENT_CODES = {
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
SEA_STATE: '__live:sea_state',
VISIBILITY: '__live:visibility'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
+3 -1
View File
@@ -12,6 +12,7 @@ export interface LogEventPayload {
windDirection: string
windStrength: string
seaState: string
visibility: string
weatherIcon: string
current: string
heel: string
@@ -75,7 +76,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
@@ -91,6 +92,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
windStrength: '',
seaState: '',
visibility: '',
weatherIcon: '',
current: '',
heel: '',
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
formatOwmVisibilityMeters,
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
@@ -13,15 +14,23 @@ describe('openWeatherMap', () => {
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('formats visibility in metres', () => {
expect(formatOwmVisibilityMeters(500)).toBe('500 m')
expect(formatOwmVisibilityMeters(10000)).toBe('10 km')
expect(formatOwmVisibilityMeters(2500)).toBe('2.5 km')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
visibility: 10000,
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.visibility).toBe('10 km')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
+12
View File
@@ -1,9 +1,14 @@
import { degreesToCardinal } from './courseAngle.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
export const formatOwmVisibilityMeters = formatVisibilityMeters
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
visibility: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
@@ -57,10 +62,17 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
const visibilityRaw = data.visibility
const visibility =
typeof visibilityRaw === 'number'
? formatVisibilityMeters(visibilityRaw)
: ''
return {
windDirection,
windStrength,
windPressure,
visibility,
tempC,
precipText,
weatherIcon
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
visibilityMetersFromStepIndex,
visibilityStepIndex
} from './weatherMetrics.js'
describe('weatherMetrics', () => {
it('parses and formats pressure', () => {
expect(parsePressureHpa('1014')).toBe(1014)
expect(parsePressureHpa('1014 hPa')).toBe(1014)
expect(parsePressureHpa('')).toBeNull()
expect(formatPressureHpa(1014)).toBe('1014')
})
it('parses and formats sea state', () => {
expect(parseSeaState('3')).toBe(3)
expect(parseSeaState('leicht')).toBeNull()
expect(formatSeaState(3)).toBe('3')
})
it('parses and formats heel', () => {
expect(parseHeelDeg('12')).toBe(12)
expect(parseHeelDeg('12°')).toBe(12)
})
it('parses visibility with units', () => {
expect(parseVisibilityMeters('10 km')).toBe(10000)
expect(parseVisibilityMeters('500 m')).toBe(500)
expect(formatVisibilityMeters(10000)).toBe('10 km')
expect(formatVisibilityMeters(500)).toBe('500 m')
})
it('maps visibility to log steps', () => {
expect(visibilityStepIndex(10000)).toBe(8)
expect(visibilityMetersFromStepIndex(8)).toBe(10000)
expect(visibilityMetersFromStepIndex(0)).toBe(0)
})
})
+118
View File
@@ -0,0 +1,118 @@
/** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050
export const PRESSURE_DEFAULT_HPA = 1013
/** Douglas sea state 09. */
export const SEA_STATE_MIN = 0
export const SEA_STATE_MAX = 9
/** Heel angle in degrees. */
export const HEEL_MIN_DEG = 0
export const HEEL_MAX_DEG = 45
/** Log-spaced visibility steps in metres; index 0 = not set. */
export const VISIBILITY_STEPS_M = [
0, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000
] as const
function parseDecimal(value: string): number | null {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return null
const n = Number(trimmed)
return Number.isFinite(n) ? n : null
}
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export function parsePressureHpa(value: string): number | null {
const raw = value.trim().replace(/\s*hPa\s*$/i, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA)
}
export function formatPressureHpa(hpa: number): string {
return String(clamp(Math.round(hpa), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA))
}
export function parseSeaState(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
if (!Number.isInteger(n) || n < SEA_STATE_MIN || n > SEA_STATE_MAX) return null
return n
}
export function formatSeaState(level: number): string {
return String(clamp(Math.round(level), SEA_STATE_MIN, SEA_STATE_MAX))
}
export function parseHeelDeg(value: string): number | null {
const raw = value.trim().replace(/°\s*$/, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), HEEL_MIN_DEG, HEEL_MAX_DEG)
}
export function formatHeelDeg(deg: number): string {
return String(clamp(Math.round(deg), HEEL_MIN_DEG, HEEL_MAX_DEG))
}
export function parseVisibilityMeters(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const kmMatch = raw.match(/^([\d.,]+)\s*km$/i)
if (kmMatch) {
const km = parseDecimal(kmMatch[1])
return km == null ? null : Math.round(km * 1000)
}
const mMatch = raw.match(/^([\d.,]+)\s*m$/i)
if (mMatch) {
const m = parseDecimal(mMatch[1])
return m == null ? null : Math.round(m)
}
const bare = parseDecimal(raw)
if (bare == null) return null
return Math.round(bare >= 100 ? bare : bare)
}
export function formatVisibilityMeters(meters: number): string {
if (meters <= 0) return ''
if (meters >= 1000) {
const km = meters / 1000
const rounded = Math.round(km * 10) / 10
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
}
return `${Math.round(meters)} m`
}
export function visibilityStepIndex(meters: number): number {
if (meters <= 0) return 0
let bestIdx = 1
let bestDiff = Math.abs(VISIBILITY_STEPS_M[1] - meters)
for (let i = 2; i < VISIBILITY_STEPS_M.length; i++) {
const diff = Math.abs(VISIBILITY_STEPS_M[i] - meters)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
return bestIdx
}
export function visibilityMetersFromStepIndex(index: number): number {
const i = clamp(Math.round(index), 0, VISIBILITY_STEPS_M.length - 1)
return VISIBILITY_STEPS_M[i]
}
/** Re-export for OWM formatting consistency. */
export { formatOwmVisibilityMeters } from './openWeatherMap.js'