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
co-authored by Cursor
parent 847c73fda9
commit cdcef2e106
18 changed files with 640 additions and 42 deletions
+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>
)
}