diff --git a/client/src/App.css b/client/src/App.css index ac62706..75a0b30 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index d129592..873196d 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -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({ + )} @@ -1165,7 +1185,7 @@ export default function LiveLogView({ )} - {['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && ( + {['pressure', 'temp', 'precip', 'sea_state', 'visibility', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
setModal('none')}>
e.stopPropagation()}>

@@ -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') diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index a7aefe0..dccaa17 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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({

-
-
+
+
-
- - setEvWindPressure(e.target.value)} - disabled={saving || weatherLoading} - /> -
+ + t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })} + numberMin={PRESSURE_MIN_HPA} + numberMax={PRESSURE_MAX_HPA} + numberStep={1} + numberPlaceholder="1013" + /> -
- - setEvSeaState(e.target.value)} - disabled={saving} - /> -
+ + t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })} + numberMin={SEA_STATE_MIN} + numberMax={SEA_STATE_MAX} + numberStep={1} + numberPlaceholder="3" + allowLegacyText + /> -
- - setEvHeel(e.target.value)} - disabled={saving} - /> -
+ formatVisibilityMeters(m)} + hideNumberInput + /> + + + t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })} + numberMin={HEEL_MIN_DEG} + numberMax={HEEL_MAX_DEG} + numberStep={1} + numberPlaceholder="5" + />
diff --git a/client/src/components/MetricRangeInput.tsx b/client/src/components/MetricRangeInput.tsx new file mode 100644 index 0000000..15c1082 --- /dev/null +++ b/client/src/components/MetricRangeInput.tsx @@ -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) => { + 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) => { + 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 ( +
+
+ +
+ onChange(e.target.value)} + disabled={disabled} + placeholder={numberPlaceholder} + /> +
+ ) + } + + const hasSlider = useDiscrete || (min != null && max != null) + + return ( +
+
+ + {hasSlider && ( + + {displayLabel} + + )} +
+ {hasSlider && ( +
+ + {!hideNumberInput && ( + + )} +
+ )} +
+ ) +} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index c4d3ce1..fb50c5c 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -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", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index af7bb0e..ad51858 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 94383b6..cfdee67 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index d233ae2..083bfd6 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -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", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 60b6fc4..5ac1905 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -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", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 3c8e9f4..f79168e 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -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, diff --git a/client/src/utils/formatEventSummary.test.ts b/client/src/utils/formatEventSummary.test.ts index e2e3d3d..3ece001 100644 --- a/client/src/utils/formatEventSummary.test.ts +++ b/client/src/utils/formatEventSummary.test.ts @@ -24,6 +24,7 @@ const t = (key: string, opts?: Record) => { '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', diff --git a/client/src/utils/formatEventSummary.ts b/client/src/utils/formatEventSummary.ts index ecdec45..e94a233 100644 --- a/client/src/utils/formatEventSummary.ts +++ b/client/src/utils/formatEventSummary.ts @@ -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}`) } diff --git a/client/src/utils/liveEventCodes.ts b/client/src/utils/liveEventCodes.ts index 5bf9b00..1519a23 100644 --- a/client/src/utils/liveEventCodes.ts +++ b/client/src/utils/liveEventCodes.ts @@ -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] diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 52e0fb0..5f7b6df 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -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 | Record { 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') diff --git a/client/src/utils/openWeatherMap.ts b/client/src/utils/openWeatherMap.ts index a887a8e..2ef5f35 100644 --- a/client/src/utils/openWeatherMap.ts +++ b/client/src/utils/openWeatherMap.ts @@ -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): 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 diff --git a/client/src/utils/weatherMetrics.test.ts b/client/src/utils/weatherMetrics.test.ts new file mode 100644 index 0000000..d21c5ce --- /dev/null +++ b/client/src/utils/weatherMetrics.test.ts @@ -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) + }) +}) diff --git a/client/src/utils/weatherMetrics.ts b/client/src/utils/weatherMetrics.ts new file mode 100644 index 0000000..20aa63b --- /dev/null +++ b/client/src/utils/weatherMetrics.ts @@ -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 0–9. */ +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'