From 55ce94066e37ff5491aa4b7af7e927d73e3517b0 Mon Sep 17 00:00:00 2001 From: BuTzZ Date: Sun, 26 Jul 2026 12:31:49 +0200 Subject: [PATCH] =?UTF-8?q?Bootssymbol=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +- apps/web/src/App.tsx | 88 +++- apps/web/src/components/AnchorWatchPanel.css | 20 + apps/web/src/components/AnchorWatchPanel.tsx | 53 +- apps/web/src/components/BoatProfilePanel.css | 293 +++++++++++ apps/web/src/components/BoatProfilePanel.tsx | 459 ++++++++++++++++++ .../web/src/components/NavigationToolRail.tsx | 13 +- apps/web/src/components/RoutePlanner.tsx | 150 +++--- apps/web/src/hooks/useAnchorWatch.ts | 151 +++++- apps/web/src/hooks/useBoatProfile.ts | 49 ++ apps/web/src/lib/boat-profile.ts | 276 +++++++++++ apps/web/src/styles/app.css | 87 +++- apps/web/tests/anchor-watch-hook.test.tsx | 122 +++++ apps/web/tests/boat-profile-panel.test.tsx | 164 +++++++ apps/web/tests/boat-profile.test.ts | 83 ++++ apps/web/tests/e2e/app.spec.ts | 4 +- apps/web/tests/navigation-workspace.test.tsx | 5 +- apps/web/tests/route-planner.test.tsx | 21 +- packages/shared/src/types.ts | 26 + 19 files changed, 1969 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/components/BoatProfilePanel.css create mode 100644 apps/web/src/components/BoatProfilePanel.tsx create mode 100644 apps/web/src/hooks/useBoatProfile.ts create mode 100644 apps/web/src/lib/boat-profile.ts create mode 100644 apps/web/tests/boat-profile-panel.test.tsx create mode 100644 apps/web/tests/boat-profile.test.ts diff --git a/README.md b/README.md index 0426087..c8b4204 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle ## Funktionen - Routing auf See- und Binnenwasserstraßen statt Luftlinien +- zentrales „Aktuelles Boot“ mit Name, Kategorie, Abmessungen, Tiefgang, Reisegeschwindigkeit und Anker-Bugrollenhöhe als Browser-Cookie - explizit gestarteter Kursassistent mit dynamischem Sollkurs über Grund, Querabweichung, Reststrecke und Kurswechsel-Hinweisen - zweistufige Ankerwache mit festem Ankerpunkt, GPS-Schwojkreis, Alarm, Tidenanstieg und nachvollziehbarer Ketten-/Leinenreserve - bis zu drei ausreichend unterschiedliche Routenvorschläge, sofern der Wasserstraßengraph echte Alternativen enthält @@ -20,6 +21,21 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle - GPX-1.1-Export, lokal gespeicherte Offline-Routen und ein nur auf Nutzeraktion gestarteter Kursabweichungsalarm - begrenztes Offline-Caching bereits besuchter Kartenressourcen ohne ungefragten Kacheldownload +## Aktuelles Boot + +Über das Bootssymbol in der Navigationsleiste wird ein zentrales Bootsprofil +gepflegt. Routenplanung, Tiefen- und Brückenprüfung, Fahrtdauer, +Etappenplanung sowie die bootsabhängigen Werte der Ankerwache verwenden dieses +Profil automatisch. Eine Änderung verwirft eine bereits berechnete Route, weil +deren Sicherheitsprüfung noch auf den alten Maßen beruht. + +Das versionierte Profil wird für ein Jahr im Cookie +`watermaps.vessel-profile` dieses Browsers gespeichert. Es enthält nur +Bootsname, Kategorie und technische Fahrtwerte; persönliche Daten, MMSI, +Rufzeichen oder Kontaktdaten gehören nicht hinein. Alte Offline-Routen behalten +ihren damaligen Routingprofil-Snapshot und überschreiben das aktuelle Boot +nicht. + ## Start Der reguläre lokale Betrieb läuft als einzelner Produktionscontainer. Der @@ -169,8 +185,15 @@ Der Kursassistent steuert weder Ruder noch Maschine und gibt keine NMEA-Kommando Die Ankerwache ist unabhängig von einer geplanten Route über das Ankersymbol rechts auf der Karte erreichbar. Sie startet nie automatisch: 1. Live-GPS starten und genau beim Erreichen des Grundes „Anker gefallen – Position jetzt setzen“ wählen. Der Fix muss jünger als zehn Sekunden und auf höchstens 30 m genau sein. Der gespeicherte Ankerpunkt bleibt danach fest. -2. Tiefe beim Setzen, Höhe der Bugrolle über Wasser, ausgesteckte Ketten-/Leinenlänge, gewünschtes Verhältnis, zusätzliche Wasserstandsreserve, Alarmradius und Tidenzeitraum prüfen. -3. Erst „Wache starten“ schaltet Positionsalarm, Warnton, Vibration, optionale Browser-Mitteilungen und – soweit unterstützt – eine Bildschirm-Wachhalteanforderung ein. +2. Die Höhe der Bugrolle und die Bootslänge werden aus „Mein Boot“ übernommen. + Tiefe beim Setzen, ausgesteckte Ketten-/Leinenlänge, gewünschtes Verhältnis, + zusätzliche Wasserstandsreserve, Alarmradius und Tidenzeitraum bleiben + situative Angaben und müssen geprüft werden. +3. Erst „Wache starten“ schaltet Positionsalarm, Vibration, optionale + Browser-Mitteilungen und – soweit unterstützt – eine + Bildschirm-Wachhalteanforderung ein. Bei sicher festgestelltem Abdriften + außerhalb des Radius ertönt der Warnton fortlaufend, bis er ausdrücklich + quittiert wird; die optische Warnung bleibt danach aktiv. Kursassistent, separater Kursalarm und Ankerwache laufen nicht parallel. Beim Öffnen der Ankerfunktion wird eine laufende Kursführung beendet; die geplante Route selbst bleibt erhalten. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a693e63..45810c6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,9 @@ -import { AlertTriangle, Compass, Navigation, ShieldAlert } from "lucide-react"; +import { AlertTriangle, Compass, Navigation, ShieldAlert, Ship } from "lucide-react"; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { calculateRouteGuidance, haversineDistanceNm, + type BoatProfile, type VoyageHarbour, type AppConfig, type Coordinate, @@ -37,10 +38,12 @@ import type { RouteTidePlan } from "./components/RouteTidePanel"; import { StatusBar } from "./components/StatusBar"; import { useCompass } from "./hooks/useCompass"; import { useAnchorWatch } from "./hooks/useAnchorWatch"; +import { useBoatProfile } from "./hooks/useBoatProfile"; import { useCourseAssistant } from "./hooks/useCourseAssistant"; import { useGeolocation } from "./hooks/useGeolocation"; import { useMarineData } from "./hooks/useMarineData"; import type { OfflineVoyage } from "./lib/offline-route"; +import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile"; import { upcomingRouteEvents, type RouteEventEtaBasis, @@ -61,6 +64,11 @@ const LazyAnchorWatchPanel = lazy(() => default: module.AnchorWatchPanel })) ); +const LazyBoatProfilePanel = lazy(() => + import("./components/BoatProfilePanel").then((module) => ({ + default: module.BoatProfilePanel + })) +); const LazyCourseAssistantPanel = lazy(() => import("./components/CourseAssistantPanel").then((module) => ({ default: module.CourseAssistantPanel @@ -93,7 +101,15 @@ export function App() { const gps = useGeolocation(); const compass = useCompass(gps.courseDeg); const marineData = useMarineData(gps.position); - const anchorWatch = useAnchorWatch(gps); + const { + profile: currentBoat, + stored: boatProfileStored, + save: persistBoatProfile, + remove: removeBoatProfile + } = useBoatProfile(); + const anchorWatch = useAnchorWatch(gps, { + bowRollerHeightM: currentBoat.bowRollerHeightM + }); const [activeTool, setActiveTool] = useState("route"); const [workspaceSheetState, setWorkspaceSheetState] = useState("half"); @@ -450,6 +466,7 @@ export function App() { ); const toolStatuses = useMemo>>( () => ({ + boat: boatProfileStored ? "active" : "caution", anchor: anchorAlarm ? "alarm" : anchorWatch.phase === "idle" @@ -481,6 +498,7 @@ export function App() { [ anchorAlarm, anchorWatch.phase, + boatProfileStored, courseAssistant.active, guidanceAlarm, eventWarningCount, @@ -527,14 +545,12 @@ export function App() { start, destination: requestedDestination, waypoints: requestedWaypoints, - departureTime, - vesselProfile + departureTime }: { start: Coordinate; destination: Coordinate; waypoints: Coordinate[]; departureTime: string; - vesselProfile: VesselProfile; }) => { if (!start) { setRouteError("Startpunkt fehlt"); @@ -552,6 +568,7 @@ export function App() { routeWeatherRequestId.current += 1; const requestId = routeRequestId.current + 1; routeRequestId.current = requestId; + const vesselProfile = toVesselProfile(currentBoat); try { const result = await createRoute({ @@ -581,7 +598,7 @@ export function App() { } } }, - [loadRouteWeather] + [currentBoat, loadRouteWeather] ); const clearRouteOutcome = useCallback(() => { @@ -597,6 +614,20 @@ export function App() { setRouteLoading(false); }, []); + const saveCurrentBoat = useCallback((profile: BoatProfile) => { + const stored = persistBoatProfile(profile); + clearRouteOutcome(); + return stored; + }, [clearRouteOutcome, persistBoatProfile]); + + const deleteCurrentBoat = useCallback(() => { + const removed = removeBoatProfile(); + if (removed) { + clearRouteOutcome(); + } + return removed; + }, [clearRouteOutcome, removeBoatProfile]); + const setRouteStartPoint = useCallback((coordinate: Coordinate) => { setRouteStart(coordinate); clearRouteOutcome(); @@ -761,7 +792,9 @@ export function App() { ? "GPS wird gestartet" : "GPS starten"; const workspaceSummary = - activeTool === "anchor" + activeTool === "boat" + ? boatWorkspaceSummary(currentBoat, boatProfileStored) + : activeTool === "anchor" ? anchorWorkspaceSummary(anchorWatch) : activeTool === "conditions" ? conditionsWorkspaceSummary(marineData.forecast, marineData.tide) @@ -865,6 +898,7 @@ export function App() { onSelect={selectTool} statuses={toolStatuses} badges={{ + boat: boatProfileStored ? null : "!", anchor: anchorAlarm ? "!" : null, upcoming: eventWarningCount, route: guidanceAlarm ? "!" : routeWarningCount @@ -875,11 +909,27 @@ export function App() { activeTool={activeTool} onClose={closeWorkspace} summary={workspaceSummary} + leading={activeTool === "boat" ? : undefined} status={activeToolStatus} sheetState={workspaceSheetState} onSheetStateChange={setWorkspaceSheetState} busy={workspaceBusy} > + {activeTool === "boat" && ( + } + failed={} + > + + + )} + {activeTool === "anchor" && ( } @@ -888,6 +938,7 @@ export function App() { @@ -908,8 +959,8 @@ export function App() { kind: "gps", label: gps.accuracyM === null - ? "Position am Boot" - : `Position am Boot · ±${Math.round(gps.accuracyM)} m` + ? `Position auf ${currentBoat.name}` + : `Position auf ${currentBoat.name} · ±${Math.round(gps.accuracyM)} m` } : { kind: "unknown", label: "GPS noch nicht aktiv" } } @@ -983,6 +1034,8 @@ export function App() { routeTides={routeTides} routeTidesLoading={routeTidesLoading} routeTidesError={routeTidesError} + boatProfile={currentBoat} + routeVesselProfile={activeVesselProfile} onLoadOfflineVoyage={loadOfflineVoyage} loading={routeLoading} error={routeError} @@ -999,6 +1052,10 @@ export function App() { onSelectRoute={selectRouteOption} guidanceActive={courseAssistant.active} onStartGuidance={startCourseAssistant} + onEditBoat={() => { + setActiveTool("boat"); + setWorkspaceSheetState("half"); + }} onCollapse={closeWorkspace} operationalPanelsVisible={false} embedded @@ -1018,7 +1075,7 @@ export function App() { {anchorAlarm ? anchorWatch.positionAlarm - ? "Ankeralarm: Das Boot liegt außerhalb des überwachten Radius." + ? `Ankeralarm: ${currentBoat.name} liegt außerhalb des überwachten Radius.` : "Ankerwarnung: Die gegebene Kette oder Leine reicht für den erwarteten Wasserstand nicht aus." : courseAssistant.fixStale ? "Navigation pausiert: Der GPS-Fix ist veraltet." @@ -1155,6 +1212,15 @@ function conditionsToolStatus( return Date.now() - oldestTimestamp > 90 * 60_000 ? "stale" : "active"; } +function boatWorkspaceSummary(profile: BoatProfile, stored: boolean) { + if (!stored) { + return "Vorgabewerte aktiv · bitte Bootsdaten speichern"; + } + return `${profile.name} · ${boatCategoryLabel(profile.category)} · ${profile.lengthM.toLocaleString("de-DE", { + maximumFractionDigits: 2 + })} × ${profile.beamM.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`; +} + function anchorWorkspaceSummary(watch: ReturnType) { const distanceM = watch.watchResult?.distanceFromAnchorM; if (watch.phase === "armed") { @@ -1210,7 +1276,7 @@ function routeWorkspaceSummary( guidance: RouteGuidanceResult | null ) { if (!route) { - return "Start, Ziel und Bootsprofil festlegen"; + return "Start und Ziel festlegen · aktuelles Boot wird verwendet"; } if (guidanceActive) { const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null; diff --git a/apps/web/src/components/AnchorWatchPanel.css b/apps/web/src/components/AnchorWatchPanel.css index 01bebf6..1e4e284 100644 --- a/apps/web/src/components/AnchorWatchPanel.css +++ b/apps/web/src/components/AnchorWatchPanel.css @@ -175,6 +175,26 @@ font-weight: 850; } +.anchor-profile-setting { + min-width: 0; + min-height: 58px; + border: 1px solid #c5d8d2; + border-radius: 9px; + padding: 7px 8px; + display: grid; + align-content: center; + gap: 4px; + background: #e3efeb; + color: #526a72; + font-size: 12px; + font-weight: 850; +} + +.anchor-profile-setting strong { + color: #0f4c5c; + font-size: 13px; +} + .anchor-settings label > span:last-child { display: flex; align-items: center; diff --git a/apps/web/src/components/AnchorWatchPanel.tsx b/apps/web/src/components/AnchorWatchPanel.tsx index 05d6f19..8d2c8c1 100644 --- a/apps/web/src/components/AnchorWatchPanel.tsx +++ b/apps/web/src/components/AnchorWatchPanel.tsx @@ -21,11 +21,18 @@ type AnchorWatchModel = ReturnType; export type AnchorWatchPanelProps = { watch: AnchorWatchModel; gps: Pick; + boatLengthM?: number; onStartGps: () => void; onClose: () => void; }; -export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) { +export function AnchorWatchPanel({ + watch, + gps, + boatLengthM, + onStartGps, + onClose +}: AnchorWatchPanelProps) { const [confirmStop, setConfirmStop] = useState(false); const armed = watch.phase === "armed"; const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall); @@ -83,7 +90,7 @@ export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatc onStop={stop} /> ) : ( - + )} ); @@ -129,12 +136,24 @@ function AnchorCapture({ ); } -function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) { +function AnchorWatchSetup({ + watch, + boatLengthM, + onCancel +}: { + watch: AnchorWatchModel; + boatLengthM?: number; + onCancel: () => void; +}) { + const boatAllowanceM = + typeof boatLengthM === "number" && Number.isFinite(boatLengthM) && boatLengthM > 0 + ? boatLengthM + : 0; const suggestedReachM = watch.rodePlan ? Math.ceil(Math.max( watch.rodePlan.horizontalReachAtSetM, watch.rodePlan.horizontalReachM ?? 0 - ) + 5) + ) + boatAllowanceM + 5) : null; const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM; @@ -161,7 +180,11 @@ function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCanc {radiusTooSmall && (

)} {watch.settingsError &&

{watch.settingsError}

} @@ -191,7 +214,6 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode step: number; }> = [ { key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 }, - { key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 }, { key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 }, { key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 }, { key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 } @@ -199,6 +221,10 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode return (
+
+ Bugrolle über Wasser + {formatMetersCompact(watch.settings.bowRollerHeightM)} · Mein Boot +
{fields.map((field) => (
+ {watch.audibleDriftAlarmActive && ( +

+ Akustischer Driftalarm aktiv · ertönt bis zur ausdrücklichen Quittierung. +

+ )} {watch.positionAlarm && !watch.alarmAcknowledged && ( )} {watch.positionAlarm && watch.alarmAcknowledged && ( -

Alarmton quittiert · rote Warnanzeige bleibt aktiv

+

+ {watch.driftAlarm ? "Driftalarm quittiert" : "Alarmton quittiert"} · rote Warnanzeige bleibt aktiv +

)} {watch.rodeShortfall && (

@@ -417,6 +450,10 @@ function formatCoordinate(coordinate: { lat: number; lon: number }) { return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`; } +function formatMetersCompact(value: number) { + return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`; +} + function formatClock(timestampMs: number) { return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs); } diff --git a/apps/web/src/components/BoatProfilePanel.css b/apps/web/src/components/BoatProfilePanel.css new file mode 100644 index 0000000..71c8f1b --- /dev/null +++ b/apps/web/src/components/BoatProfilePanel.css @@ -0,0 +1,293 @@ +.boat-profile-panel { + display: grid; + gap: 12px; + color: #10242b; +} + +.boat-profile-panel-intro { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px; +} + +.boat-profile-panel-icon { + width: 42px; + height: 42px; + border-radius: 11px; + display: grid; + place-items: center; + background: #dceee6; + color: #196f5c; +} + +.boat-profile-panel-title { + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 9px; +} + +.boat-profile-panel-title h2 { + margin: 0; + color: #17343c; + font-size: 18px; +} + +.boat-profile-panel-title span { + min-height: 24px; + border-radius: 999px; + padding: 0 8px; + display: inline-flex; + align-items: center; + background: #fff1cc; + color: #805900; + font-size: 10px; + font-weight: 850; +} + +.boat-profile-panel-title span[data-stored="true"] { + background: #dceee6; + color: #196f5c; +} + +.boat-profile-panel-intro p { + margin: 4px 0 0; + color: #526a72; + font-size: 12px; + font-weight: 700; + line-height: 1.4; +} + +.boat-profile-route-warning, +.boat-profile-cookie-note, +.boat-profile-feedback { + margin: 0; + border-radius: 9px; + padding: 9px 10px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 8px; + font-size: 12px; + line-height: 1.4; +} + +.boat-profile-route-warning { + border: 1px solid rgba(128, 89, 0, 0.18); + background: #fff1cc; + color: #805900; +} + +.boat-profile-route-warning p, +.boat-profile-cookie-note p { + margin: 0; +} + +.boat-profile-form { + display: grid; + gap: 12px; +} + +.boat-profile-identity, +.boat-profile-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.boat-profile-field { + min-width: 0; + display: grid; + align-content: start; + gap: 5px; + color: #314b54; +} + +.boat-profile-field > label { + font-size: 12px; + font-weight: 850; +} + +.boat-profile-field input, +.boat-profile-field select { + width: 100%; + height: 44px; + min-width: 0; + border: 1px solid rgba(18, 46, 55, 0.22); + border-radius: 8px; + padding: 0 9px; + background: #ffffff; + color: #10242b; +} + +.boat-profile-field select { + cursor: pointer; +} + +.boat-profile-field input:invalid:not(:focus), +.boat-profile-field select:invalid:not(:focus) { + border-color: rgba(157, 44, 34, 0.6); +} + +.boat-profile-field small { + min-height: 30px; + color: #607278; + font-size: 10px; + font-weight: 700; + line-height: 1.35; +} + +.boat-profile-dimensions { + min-width: 0; + margin: 0; + border: 1px solid rgba(15, 76, 92, 0.13); + border-radius: 11px; + padding: 10px; + display: grid; + gap: 9px; + background: rgba(237, 243, 241, 0.72); +} + +.boat-profile-dimensions legend { + padding: 0 5px; + color: #17343c; + font-size: 13px; + font-weight: 900; +} + +.boat-profile-dimensions > p { + margin: 0; + color: #526a72; + font-size: 11px; + font-weight: 700; + line-height: 1.4; +} + +.boat-profile-anchor-details .boat-profile-field-grid { + grid-template-columns: minmax(0, 1fr); +} + +.boat-profile-anchor-details .boat-profile-field { + max-width: 360px; +} + +.boat-profile-number-input { + position: relative; + display: block; +} + +.boat-profile-number-input input { + padding-right: 38px; +} + +.boat-profile-number-input > span { + position: absolute; + top: 50%; + right: 10px; + color: #607278; + font-size: 11px; + font-weight: 900; + transform: translateY(-50%); + pointer-events: none; +} + +.boat-profile-cookie-note { + border: 1px solid rgba(15, 76, 92, 0.12); + background: #e8f1f3; + color: #3d5962; +} + +.boat-profile-feedback { + align-items: center; + background: #dceee6; + color: #196f5c; + font-weight: 800; +} + +.boat-profile-feedback[data-kind="error"] { + background: #ffe1dc; + color: #9d2c22; +} + +.boat-profile-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.boat-profile-actions > button, +.boat-profile-delete-confirmation button { + min-height: 44px; + border-radius: 8px; + padding: 7px 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 12px; + font-weight: 850; +} + +.boat-profile-save { + background: #0f4c5c; + color: #ffffff; +} + +.boat-profile-delete { + border: 1px solid rgba(157, 44, 34, 0.24); + background: #fff0ed; + color: #9d2c22; +} + +.boat-profile-actions > .boat-profile-save:only-child { + grid-column: 1 / -1; +} + +.boat-profile-delete-confirmation { + border: 1px solid rgba(157, 44, 34, 0.26); + border-radius: 10px; + padding: 10px; + display: grid; + gap: 9px; + background: #fff0ed; + color: #81251d; +} + +.boat-profile-delete-confirmation p { + margin: 0; + font-size: 12px; + font-weight: 750; + line-height: 1.4; +} + +.boat-profile-delete-confirmation > div { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 7px; +} + +.boat-profile-delete-confirmation button { + border: 1px solid rgba(157, 44, 34, 0.2); + background: #ffffff; + color: #81251d; +} + +.boat-profile-delete-confirmation button:last-child { + background: #9d2c22; + color: #ffffff; +} + +@media (max-width: 420px) { + .boat-profile-identity, + .boat-profile-field-grid, + .boat-profile-actions { + grid-template-columns: 1fr; + } + + .boat-profile-actions > .boat-profile-save:only-child { + grid-column: auto; + } +} diff --git a/apps/web/src/components/BoatProfilePanel.tsx b/apps/web/src/components/BoatProfilePanel.tsx new file mode 100644 index 0000000..4d06441 --- /dev/null +++ b/apps/web/src/components/BoatProfilePanel.tsx @@ -0,0 +1,459 @@ +import { + AlertTriangle, + CheckCircle2, + Cookie, + Save, + Sailboat, + Trash2, + X +} from "lucide-react"; +import { useEffect, useId, useState, type FormEvent } from "react"; +import type { BoatCategory, BoatProfile } from "@watermaps/shared"; +import { + BOAT_CATEGORY_OPTIONS, + validateBoatProfile +} from "../lib/boat-profile"; +import "./BoatProfilePanel.css"; + +export type BoatProfilePanelProps = { + profile: BoatProfile; + stored: boolean; + hasActiveRoute?: boolean; + onSave: (profile: BoatProfile) => boolean; + onDelete: () => boolean; +}; + +type Feedback = { + kind: "success" | "error"; + message: string; +}; + +type BoatProfileDraft = { + name: string; + category: BoatCategory; + lengthM: string; + beamM: string; + airDraftM: string; + draughtM: string; + safetyReserveM: string; + cruiseSpeedKn: string; + bowRollerHeightM: string; +}; + +type NumericDraftKey = Exclude; + +export function BoatProfilePanel({ + profile, + stored, + hasActiveRoute = false, + onSave, + onDelete +}: BoatProfilePanelProps) { + const headingId = useId(); + const categoryHelpId = useId(); + const deleteConfirmationId = useId(); + const [draft, setDraft] = useState(() => toDraft(profile)); + const [feedback, setFeedback] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + + useEffect(() => { + setDraft(toDraft(profile)); + }, [profile]); + + useEffect(() => { + if (!stored) { + setConfirmDelete(false); + } + }, [stored]); + + const updateName = (name: string) => { + setDraft((current) => ({ ...current, name })); + setFeedback(null); + }; + + const updateCategory = (category: BoatCategory) => { + setDraft((current) => ({ ...current, category })); + setFeedback(null); + }; + + const updateNumber = (key: NumericDraftKey, value: string) => { + setDraft((current) => ({ ...current, [key]: value })); + setFeedback(null); + }; + + const save = (event: FormEvent) => { + event.preventDefault(); + setConfirmDelete(false); + + const validation = validateBoatProfile(toProfile(draft)); + if (!validation.valid) { + setFeedback({ kind: "error", message: validation.error }); + return; + } + + if (!onSave(validation.profile)) { + setFeedback({ + kind: "error", + message: + "Die Bootsdaten konnten nicht im Cookie gespeichert werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers." + }); + return; + } + + setDraft(toDraft(validation.profile)); + setFeedback({ + kind: "success", + message: "Die Bootsdaten wurden in diesem Browser gespeichert." + }); + }; + + const deleteProfile = () => { + if (!onDelete()) { + setFeedback({ + kind: "error", + message: + "Die gespeicherten Bootsdaten konnten nicht gelöscht werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers." + }); + return; + } + + setConfirmDelete(false); + setFeedback({ + kind: "success", + message: "Die gespeicherten Bootsdaten wurden aus diesem Browser gelöscht." + }); + }; + + return ( +

+
+ +
+
+

Aktuelles Boot

+ + {stored ? "Im Cookie gespeichert" : "Noch nicht gespeichert"} + +
+

+ Diese Werte gelten automatisch für neue Routen und die weiteren + Navigationswerkzeuge. +

+
+
+ + {hasActiveRoute && ( +
+
+ )} + +
+
+ + +
+ + + + Beschreibt den Bootstyp; beeinflusst das Routing derzeit nicht. + +
+
+ +
+ Abmessungen und Fahrtwerte +

+ Bitte die Werte für den normalen Fahrzustand eintragen. Sie + beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer. +

+ +
+ updateNumber("lengthM", value)} + /> + updateNumber("beamM", value)} + /> + updateNumber("airDraftM", value)} + /> + updateNumber("draughtM", value)} + /> + updateNumber("safetyReserveM", value)} + /> + updateNumber("cruiseSpeedKn", value)} + /> +
+
+ +
+ Ankern +

+ Dieser Wert ist von der Durchfahrtshöhe getrennt und wird für die + Ketten- und Trossenberechnung verwendet. +

+ +
+ updateNumber("bowRollerHeightM", value)} + /> +
+
+ +
+
+ + {feedback && ( +

+ {feedback.kind === "success" ? ( +

+ )} + +
+ {stored && ( + + )} + +
+ + {stored && confirmDelete && ( +
+

+ Bootsdaten wirklich aus diesem Browser löschen? Neue Routen + verwenden danach bis zum erneuten Speichern wieder die + Standardwerte. +

+
+ + +
+
+ )} +
+
+ ); +} + +function TextField({ + label, + help, + value, + onChange +}: { + label: string; + help: string; + value: string; + onChange: (value: string) => void; +}) { + const id = useId(); + const helpId = `${id}-help`; + + return ( +
+ + onChange(event.target.value)} + aria-describedby={helpId} + autoComplete="off" + minLength={1} + maxLength={80} + required + /> + {help} +
+ ); +} + +function NumberField({ + label, + help, + unit, + min, + max, + value, + onChange +}: { + label: string; + help: string; + unit: "m" | "kn"; + min: number; + max: number; + value: string; + onChange: (value: string) => void; +}) { + const id = useId(); + const helpId = `${id}-help`; + + return ( +
+ + + onChange(event.target.value)} + aria-describedby={helpId} + min={min} + max={max} + step="any" + required + /> + + + {help} +
+ ); +} + +function toDraft(profile: BoatProfile): BoatProfileDraft { + return { + name: profile.name, + category: profile.category, + lengthM: String(profile.lengthM), + beamM: String(profile.beamM), + airDraftM: String(profile.airDraftM), + draughtM: String(profile.draughtM), + safetyReserveM: String(profile.safetyReserveM), + cruiseSpeedKn: String(profile.cruiseSpeedKn), + bowRollerHeightM: String(profile.bowRollerHeightM) + }; +} + +function toProfile(draft: BoatProfileDraft): BoatProfile { + return { + name: draft.name, + category: draft.category, + lengthM: parseNumber(draft.lengthM), + beamM: parseNumber(draft.beamM), + airDraftM: parseNumber(draft.airDraftM), + draughtM: parseNumber(draft.draughtM), + safetyReserveM: parseNumber(draft.safetyReserveM), + cruiseSpeedKn: parseNumber(draft.cruiseSpeedKn), + bowRollerHeightM: parseNumber(draft.bowRollerHeightM) + }; +} + +function parseNumber(value: string): number { + return value.trim() ? Number(value) : Number.NaN; +} diff --git a/apps/web/src/components/NavigationToolRail.tsx b/apps/web/src/components/NavigationToolRail.tsx index 609fea6..fdda315 100644 --- a/apps/web/src/components/NavigationToolRail.tsx +++ b/apps/web/src/components/NavigationToolRail.tsx @@ -1,6 +1,14 @@ -import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react"; +import { + Anchor, + Bell, + CloudSun, + Route as RouteIcon, + Ship, + type LucideIcon +} from "lucide-react"; export const NAVIGATION_TOOL_ORDER = [ + "boat", "anchor", "conditions", "upcoming", @@ -18,6 +26,7 @@ type NavigationToolDefinition = { }; const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [ + { id: "boat", label: "Mein Boot", Icon: Ship }, { id: "anchor", label: "Ankerwache", Icon: Anchor }, { id: "conditions", label: "Wetter und Tide", Icon: CloudSun }, { id: "upcoming", label: "Als Nächstes", Icon: Bell }, @@ -43,7 +52,7 @@ export function NavigationToolRail({ disabledTools = {}, workspaceId = "navigation-workspace", className, - ariaLabel = "Kartenwerkzeuge" + ariaLabel = "Navigationswerkzeuge" }: NavigationToolRailProps) { return (