feat: Gezeiten im Logbuch per Open-Meteo Marine
HW/NW-Felder im Reisetag und Live-Journal mit Server-Proxy auf Basis von Open-Meteo Marine am GPS-Standort; neueste Position und frischer DB-Stand vor dem Abruf, Bestätigung nach Übernehmen, Accordion-Layout bereinigt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
Radio,
|
||||
Sailboat,
|
||||
Undo2,
|
||||
Waves,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
appendTankRefill as apiAppendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
patchEntryTides,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
@@ -56,6 +58,9 @@ const formatSpeedKn = (speedKn: number) =>
|
||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||
import { resolveTideFetchLocation } from '../utils/tideLocation.js'
|
||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||
import {
|
||||
geolocationErrorI18nKey,
|
||||
getCurrentPosition,
|
||||
@@ -108,6 +113,7 @@ type LiveModal =
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
| 'position'
|
||||
| 'tides'
|
||||
| 'photo'
|
||||
| 'voice'
|
||||
|
||||
@@ -190,6 +196,7 @@ export default function LiveLogView({
|
||||
const [entryId, setEntryId] = useState<string | null>(null)
|
||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [departure, setDeparture] = useState('')
|
||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||
@@ -200,6 +207,15 @@ export default function LiveLogView({
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||
const [tidesLoading, setTidesLoading] = useState(false)
|
||||
const [tidePreview, setTidePreview] = useState<{
|
||||
highWater: string
|
||||
lowWater: string
|
||||
placeName?: string
|
||||
distanceKm?: number
|
||||
source: 'gps' | 'departure'
|
||||
departureQuery?: string
|
||||
} | null>(null)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
@@ -301,6 +317,7 @@ export default function LiveLogView({
|
||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||
setDate(String(loaded.data.date || ''))
|
||||
setDeparture(String(loaded.data.departure || ''))
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||
@@ -784,6 +801,105 @@ export default function LiveLogView({
|
||||
})()
|
||||
}
|
||||
|
||||
const handleFetchTides = () => {
|
||||
if (!entryId || busy || tidesLoading) return
|
||||
if (!isOnline) {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
|
||||
setTidesLoading(true)
|
||||
setError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
const eventsForLocation = loaded
|
||||
? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || [])
|
||||
: events
|
||||
const entryDateForLocation = loaded ? String(loaded.data.date || date) : date
|
||||
const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure
|
||||
|
||||
const location = resolveTideFetchLocation({
|
||||
events: eventsForLocation,
|
||||
entryDate: entryDateForLocation,
|
||||
departure: departureForLocation
|
||||
})
|
||||
if ('error' in location) {
|
||||
void showAlert(
|
||||
location.error === 'stale'
|
||||
? t('logs.tide_position_stale')
|
||||
: t('logs.tide_location_required'),
|
||||
t('logs.tides')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const data =
|
||||
location.mode === 'nearby'
|
||||
? await fetchTidesNearby(location.lat, location.lng, {
|
||||
analyticsSource: 'live_log',
|
||||
locationSource: location.source
|
||||
})
|
||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
|
||||
|
||||
const parsed = parseTideTurtleForDate(data, date)
|
||||
if (!parsed.highWater && !parsed.lowWater) {
|
||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
|
||||
setTidePreview({
|
||||
highWater: parsed.highWater,
|
||||
lowWater: parsed.lowWater,
|
||||
placeName: parsed.placeName,
|
||||
distanceKm: parsed.distanceKm,
|
||||
source: location.source,
|
||||
departureQuery: location.mode === 'by-place' ? location.query : undefined
|
||||
})
|
||||
setModal('tides')
|
||||
} catch (err) {
|
||||
if (err instanceof TidesApiError) {
|
||||
if (err.code === 'OFFLINE') {
|
||||
void showAlert(t('logs.weather_offline'), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'PLACE_NOT_FOUND') {
|
||||
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NOT_FOUND') {
|
||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
}
|
||||
console.error('Live log tide fetch failed:', err)
|
||||
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
|
||||
} finally {
|
||||
setTidesLoading(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const confirmTides = () => {
|
||||
if (!entryId || !tidePreview || busy) return
|
||||
const preview = tidePreview
|
||||
void runQuickAction(async () => {
|
||||
await patchEntryTides(logbookId, entryId, {
|
||||
highWater: preview.highWater,
|
||||
lowWater: preview.lowWater
|
||||
})
|
||||
setTidePreview(null)
|
||||
setModal('none')
|
||||
void showAlert(
|
||||
t('logs.tide_applied_success', {
|
||||
highWater: preview.highWater || '—',
|
||||
lowWater: preview.lowWater || '—'
|
||||
}),
|
||||
t('logs.tides')
|
||||
)
|
||||
}, 'tides', false)
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
if (!entryId || busy) return
|
||||
const photoId = undoPhotoIdRef.current
|
||||
@@ -1257,6 +1373,10 @@ export default function LiveLogView({
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_position')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
|
||||
<Waves size={18} />
|
||||
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||
<MessageSquare size={18} />
|
||||
{t('logs.live_comment_btn')}
|
||||
@@ -1455,6 +1575,56 @@ export default function LiveLogView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'tides' && tidePreview && (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
|
||||
>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.tides')}</h3>
|
||||
<p className="live-log-modal-hint" role="note">
|
||||
{t('logs.tide_disclaimer')}
|
||||
</p>
|
||||
{tidePreview.source === 'departure' && tidePreview.departureQuery ? (
|
||||
<p className="live-log-modal-hint" role="status">
|
||||
{t('logs.tide_fetched_from_departure', {
|
||||
place: tidePreview.placeName || tidePreview.departureQuery
|
||||
})}
|
||||
</p>
|
||||
) : tidePreview.source === 'gps' ? (
|
||||
<p className="live-log-modal-hint" role="status">
|
||||
{t('logs.tide_fetched_at_position')}
|
||||
</p>
|
||||
) : tidePreview.placeName ? (
|
||||
<p className="live-log-modal-hint" role="status">
|
||||
{tidePreview.distanceKm != null
|
||||
? t('logs.tide_fetched_from', {
|
||||
place: tidePreview.placeName,
|
||||
distance: formatAppDecimal(tidePreview.distanceKm, { maximumFractionDigits: 1 }) ?? String(tidePreview.distanceKm)
|
||||
})
|
||||
: tidePreview.placeName}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="live-log-tide-preview">
|
||||
<div>
|
||||
<dt>{t('logs.tide_high_water')}</dt>
|
||||
<dd>{tidePreview.highWater || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('logs.tide_low_water')}</dt>
|
||||
<dd>{tidePreview.lowWater || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={confirmTides} disabled={busy}>
|
||||
{t('logs.tide_apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal === 'comment' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders, Waves } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
@@ -43,13 +43,16 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||
import { resolveTideFetchLocation } from '../utils/tideLocation.js'
|
||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||
import {
|
||||
buildTravelDayContext,
|
||||
fetchTravelDaySummaryUsage,
|
||||
generateTravelDaySummary,
|
||||
TravelDaySummaryApiError
|
||||
} from '../services/aiSummary.js'
|
||||
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
import {
|
||||
getDecryptedTrack,
|
||||
@@ -107,6 +110,7 @@ import {
|
||||
} from '../utils/tankCapacity.js'
|
||||
import {
|
||||
formatAppCoordinate,
|
||||
formatAppDecimal,
|
||||
parseAppDecimal,
|
||||
parseAppDecimalOrZero
|
||||
} from '../utils/numberFormat.js'
|
||||
@@ -164,6 +168,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
||||
: undefined,
|
||||
tides: readLogEntryTides(decrypted),
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
})
|
||||
@@ -298,6 +303,11 @@ export default function LogEntryEditor({
|
||||
|
||||
const [eventsCollapsed, setEventsCollapsed] = useState(true)
|
||||
const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false)
|
||||
const [tidesCollapsed, setTidesCollapsed] = useState(true)
|
||||
const [tideHighWater, setTideHighWater] = useState('')
|
||||
const [tideLowWater, setTideLowWater] = useState('')
|
||||
const [tidesLoading, setTidesLoading] = useState(false)
|
||||
const [tideFetchHint, setTideFetchHint] = useState('')
|
||||
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
||||
|
||||
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
||||
@@ -430,6 +440,7 @@ export default function LogEntryEditor({
|
||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||
},
|
||||
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||
tides: { highWater: tideHighWater, lowWater: tideLowWater },
|
||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||
@@ -442,6 +453,7 @@ export default function LogEntryEditor({
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
tideHighWater, tideLowWater,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events,
|
||||
entryCrew
|
||||
@@ -921,6 +933,11 @@ export default function LogEntryEditor({
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
const preloadedTides = readLogEntryTides(preloadedEntry as Record<string, unknown>)
|
||||
setTideHighWater(preloadedTides.highWater)
|
||||
setTideLowWater(preloadedTides.lowWater)
|
||||
setTideFetchHint('')
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
@@ -962,6 +979,11 @@ export default function LogEntryEditor({
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>)
|
||||
setTideHighWater(loadedTides.highWater)
|
||||
setTideLowWater(loadedTides.lowWater)
|
||||
setTideFetchHint('')
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||
@@ -1271,6 +1293,93 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchTides = async () => {
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
|
||||
setTidesLoading(true)
|
||||
setTideFetchHint('')
|
||||
try {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
const eventsForLocation = loaded
|
||||
? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || [])
|
||||
: events
|
||||
const entryDateForLocation = loaded ? String(loaded.data.date || date) : date
|
||||
const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure
|
||||
|
||||
const location = resolveTideFetchLocation({
|
||||
events: eventsForLocation,
|
||||
entryDate: entryDateForLocation,
|
||||
departure: departureForLocation
|
||||
})
|
||||
if ('error' in location) {
|
||||
if (location.error === 'stale') {
|
||||
showAlert(t('logs.tide_position_stale'), t('logs.tide_fetch_btn'))
|
||||
} else {
|
||||
showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data =
|
||||
location.mode === 'nearby'
|
||||
? await fetchTidesNearby(location.lat, location.lng, {
|
||||
analyticsSource: 'entry_editor',
|
||||
locationSource: location.source
|
||||
})
|
||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
|
||||
|
||||
const parsed = parseTideTurtleForDate(data, date)
|
||||
if (!parsed.highWater && !parsed.lowWater) {
|
||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.highWater) setTideHighWater(parsed.highWater)
|
||||
if (parsed.lowWater) setTideLowWater(parsed.lowWater)
|
||||
|
||||
if (location.source === 'departure') {
|
||||
setTideFetchHint(
|
||||
t('logs.tide_fetched_from_departure', {
|
||||
place: parsed.placeName || location.query
|
||||
})
|
||||
)
|
||||
} else if (location.source === 'gps') {
|
||||
setTideFetchHint(t('logs.tide_fetched_at_position'))
|
||||
} else if (parsed.placeName) {
|
||||
setTideFetchHint(
|
||||
parsed.distanceKm != null
|
||||
? t('logs.tide_fetched_from', {
|
||||
place: parsed.placeName,
|
||||
distance: formatAppDecimal(parsed.distanceKm, { maximumFractionDigits: 1 }) ?? String(parsed.distanceKm)
|
||||
})
|
||||
: parsed.placeName
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof TidesApiError) {
|
||||
if (err.code === 'OFFLINE') {
|
||||
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'PLACE_NOT_FOUND') {
|
||||
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NOT_FOUND') {
|
||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
}
|
||||
console.error('Tide fetch failed:', err)
|
||||
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
|
||||
} finally {
|
||||
setTidesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAiSummary = async () => {
|
||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||
if (!getAiAuthorized()) {
|
||||
@@ -2113,6 +2222,77 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tides */}
|
||||
<div className="form-card">
|
||||
<div
|
||||
className="form-header mb-4 accordion-header"
|
||||
onClick={() => setTidesCollapsed(!tidesCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setTidesCollapsed(!tidesCollapsed)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
aria-expanded={!tidesCollapsed}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="accordion-header-title">
|
||||
<Waves size={20} className="form-icon" />
|
||||
<h3>{t('logs.tides')}</h3>
|
||||
</div>
|
||||
{tidesCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
|
||||
</div>
|
||||
|
||||
{!tidesCollapsed && (
|
||||
<div className="tides-panel">
|
||||
<div className="tides-panel__hints">
|
||||
<p className="form-hint" role="note">
|
||||
{t('logs.tide_disclaimer')}
|
||||
</p>
|
||||
{tideFetchHint ? (
|
||||
<p className="form-hint" role="status">
|
||||
{tideFetchHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="form-grid tides-panel__fields">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.tide_high_water')}</label>
|
||||
<EventTimeInput24h
|
||||
value={tideHighWater}
|
||||
onChange={setTideHighWater}
|
||||
disabled={readOnly || saving || tidesLoading}
|
||||
aria-label={t('logs.tide_high_water')}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.tide_low_water')}</label>
|
||||
<EventTimeInput24h
|
||||
value={tideLowWater}
|
||||
onChange={setTideLowWater}
|
||||
disabled={readOnly || saving || tidesLoading}
|
||||
aria-label={t('logs.tide_low_water')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="tides-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleFetchTides()}
|
||||
disabled={saving || tidesLoading}
|
||||
>
|
||||
<Waves size={16} />
|
||||
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tide_fetch_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */}
|
||||
<div className="form-card">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user