fix(tides): use 00:00 default fallback for tide times and auto-save fetched tides
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user