fix(tides): use 00:00 default fallback for tide times and auto-save fetched tides

This commit is contained in:
2026-06-12 12:17:03 +02:00
parent e03163735e
commit abd5fe1ac8
4 changed files with 60 additions and 15 deletions
+9 -4
View File
@@ -10,18 +10,23 @@ interface EventTimeInput24hProps {
onChange: (value: string) => void onChange: (value: string) => void
disabled?: boolean disabled?: boolean
'aria-label'?: string 'aria-label'?: string
fallback?: string
} }
export default function EventTimeInput24h({ export default function EventTimeInput24h({
value, value,
onChange, onChange,
disabled = false, disabled = false,
'aria-label': ariaLabel 'aria-label': ariaLabel,
fallback
}: EventTimeInput24hProps) { }: EventTimeInput24hProps) {
const baseId = useId() const baseId = useId()
const useNativePicker = preferNativeCameraPicker() const useNativePicker = preferNativeCameraPicker()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value]) const { hours, minutes } = useMemo(() => splitTimeHHMM(value, fallback), [value, fallback])
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes]) const timeValue = useMemo(() => {
if (!value.trim()) return ''
return joinTimeHHMM(hours, minutes)
}, [value, hours, minutes])
if (useNativePicker) { if (useNativePicker) {
return ( return (
@@ -34,7 +39,7 @@ export default function EventTimeInput24h({
value={timeValue} value={timeValue}
onChange={(e) => { onChange={(e) => {
const next = e.target.value const next = e.target.value
if (next) onChange(next.slice(0, 5)) onChange(next ? next.slice(0, 5) : '')
}} }}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
+26 -9
View File
@@ -33,7 +33,7 @@ import {
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload, type LogEntryTides } 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 { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
@@ -432,7 +432,7 @@ export default function LogEntryEditor({
} }
} }
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[], tidesOverride?: LogEntryTides) => {
return buildLogEntryPayload({ return buildLogEntryPayload({
date, date,
dayOfTravel, dayOfTravel,
@@ -451,7 +451,7 @@ export default function LogEntryEditor({
consumption: parseAppDecimalOrZero(fuelConsumption) consumption: parseAppDecimalOrZero(fuelConsumption)
}, },
greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
tides: { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation }, tides: tidesOverride ?? { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation },
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
@@ -603,12 +603,14 @@ export default function LogEntryEditor({
signCrew?: SignatureValue | '' signCrew?: SignatureValue | ''
aiSummary?: string aiSummary?: string
aiSummaryGeneratedAt?: string aiSummaryGeneratedAt?: string
tidesOverride?: LogEntryTides
} }
) => { ) => {
if (readOnly) return if (readOnly) return
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {}) const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
const eventsOverride = normalized.eventsOverride const eventsOverride = normalized.eventsOverride
const tidesOverride = normalized.tidesOverride
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
@@ -636,7 +638,7 @@ export default function LogEntryEditor({
} }
const entryData: Record<string, unknown> = { const entryData: Record<string, unknown> = {
...buildPayloadForSigning(eventsOverride), ...buildPayloadForSigning(eventsOverride, tidesOverride),
signSkipper: normalizedSerializedSignature(skipperToSave), signSkipper: normalizedSerializedSignature(skipperToSave),
signCrew: normalizedSerializedSignature(crewToSave) signCrew: normalizedSerializedSignature(crewToSave)
} }
@@ -1309,14 +1311,27 @@ export default function LogEntryEditor({
} }
} }
const applyTideFetchResult = (result: { const applyTideFetchResult = async (result: {
highWater: string highWater: string
lowWater: string lowWater: string
location: TideLocationMeta location: TideLocationMeta
}) => { }) => {
if (result.highWater) setTideHighWater(result.highWater) const nextTides = {
if (result.lowWater) setTideLowWater(result.lowWater) highWater: result.highWater,
lowWater: result.lowWater,
...result.location
}
setTideHighWater(result.highWater)
setTideLowWater(result.lowWater)
setTideLocation(result.location) setTideLocation(result.location)
try {
await persistEntryToDb({ tidesOverride: nextTides })
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
} catch (err) {
console.error('Failed to auto-save after tide fetch:', err)
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
}
} }
const handleTideStationPick = async (pick: TideFetchNeedsStationPick, station: TideStation) => { const handleTideStationPick = async (pick: TideFetchNeedsStationPick, station: TideStation) => {
@@ -1330,7 +1345,7 @@ export default function LogEntryEditor({
queryLng: pick.queryLng, queryLng: pick.queryLng,
analyticsSource: 'entry_editor' analyticsSource: 'entry_editor'
}) })
applyTideFetchResult(result) await applyTideFetchResult(result)
setTideStationPicker(null) setTideStationPicker(null)
} catch (err) { } catch (err) {
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') { if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
@@ -1377,7 +1392,7 @@ export default function LogEntryEditor({
return return
} }
applyTideFetchResult(outcome as TideFetchResult) await applyTideFetchResult(outcome as TideFetchResult)
} catch (err) { } catch (err) {
if (err instanceof TidesApiError) { if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') { if (err.code === 'OFFLINE') {
@@ -2288,6 +2303,7 @@ export default function LogEntryEditor({
onChange={setTideHighWater} onChange={setTideHighWater}
disabled={readOnly || saving || tidesLoading} disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_high_water')} aria-label={t('logs.tide_high_water')}
fallback="00:00"
/> />
</div> </div>
<div className="input-group"> <div className="input-group">
@@ -2297,6 +2313,7 @@ export default function LogEntryEditor({
onChange={setTideLowWater} onChange={setTideLowWater}
disabled={readOnly || saving || tidesLoading} disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_low_water')} aria-label={t('logs.tide_low_water')}
fallback="00:00"
/> />
</div> </div>
</div> </div>
+23
View File
@@ -5,6 +5,7 @@ import {
isLogEventDraftEmpty, isLogEventDraftEmpty,
localDateString, localDateString,
normalizeLogEvent, normalizeLogEvent,
splitTimeHHMM,
type LogEventPayload type LogEventPayload
} from './logEntryPayload.js' } from './logEntryPayload.js'
@@ -112,3 +113,25 @@ describe('buildLogEntryPayload tides', () => {
}) })
}) })
}) })
describe('splitTimeHHMM', () => {
it('splits valid time HH:MM correctly', () => {
const result = splitTimeHHMM('15:45')
expect(result).toEqual({ hours: '15', minutes: '45' })
})
it('uses fallback value when time is empty', () => {
const result = splitTimeHHMM('', '00:00')
expect(result).toEqual({ hours: '00', minutes: '00' })
})
it('falls back to current local time when empty and no fallback is specified', () => {
const result = splitTimeHHMM('')
const hours = parseInt(result.hours, 10)
const minutes = parseInt(result.minutes, 10)
expect(hours).toBeGreaterThanOrEqual(0)
expect(hours).toBeLessThanOrEqual(23)
expect(minutes).toBeGreaterThanOrEqual(0)
expect(minutes).toBeLessThanOrEqual(59)
})
})
+2 -2
View File
@@ -72,8 +72,8 @@ export function isValidTimeHHMM(value: string): boolean {
return parseTimeToHHMM(value) !== null return parseTimeToHHMM(value) !== null
} }
export function splitTimeHHMM(value: string): { hours: string; minutes: string } { export function splitTimeHHMM(value: string, fallback?: string): { hours: string; minutes: string } {
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM() const parsed = parseTimeToHHMM(value) ?? fallback ?? currentLocalTimeHHMM()
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) } return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
} }