From 0e65721a1c031b021fa4f9ebe304713d87cd227d Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 22 Jul 2026 21:36:00 +0200 Subject: [PATCH] fix(sync): Editier-Lock-Versionierung + Zeichnen-Race + Mess-Race (v1.6.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleStopEditing speichert jetzt MIT baseVersion und aktualisiert featuresVersionRef aus der Antwort (409 → Server-Stand übernehmen). Verhindert Fehl-Konflikte/Konflikt-Schleifen beim erneuten Bearbeiten nach dem Freigeben. - Lock-Ablehnung (zwei zeichnen bei freier Karte gleichzeitig): der abgelehnte Client lädt den echten Sperr-Halter nach → Karte wird lokal read-only, keine weiteren nicht-speicherbaren Änderungen mehr. - Mess-Höhenabfrage: Generation-Zähler bricht veraltete asynchrone Abfragen ab (Neue Messung/Verlassen) → keine falschen Höhen-/Druckwerte der alten Messung. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/app/app/page.tsx | 1 + src/components/map/map-view.tsx | 11 +++++++++++ src/hooks/use-realtime-sync.ts | 28 ++++++++++++++++++++++++---- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9bde3c3..00cffab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.6.3", + "version": "1.6.4", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index d301180..c8b7b6a 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -97,6 +97,7 @@ export default function AppPage() { featuresRef, setFeatures, toast: toast as any, + featuresVersionRef, }) // Capture map screenshot when switching to journal tab (coordinate-based rendering) diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 919393d..ca2a22b 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -126,6 +126,9 @@ export function MapView({ const markerCleanupsRef = useRef<(() => void)[]>([]) const measureMarkersRef = useRef([]) const measureCoordsRef = useRef([]) + // Generation-Zähler: bricht veraltete (asynchrone) Höhen-Abfragen ab, wenn + // zwischenzeitlich eine neue Messung gestartet/geleert wurde. + const measureGenRef = useRef(0) const [isMapLoaded, setIsMapLoaded] = useState(false) const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm') const [layerDropdownOpen, setLayerDropdownOpen] = useState(false) @@ -454,6 +457,8 @@ export function MapView({ const fetchElevationsAndUpdate = useCallback(async () => { const coords = measureCoordsRef.current if (coords.length < 2 || !map.current) return + // Diese Abfrage gehört zur aktuellen Messung; wird sie überholt, verwerfen wir das Ergebnis. + const gen = measureGenRef.current // Interpolate extra points along the line (every ~30m) for better elevation accuracy const sampledCoords: number[][] = [coords[0]] @@ -560,6 +565,10 @@ export function MapView({ } catch { /* ignore */ } if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES + // Überholt? (neue/geleerte Messung während der async Abfragen) → Ergebnis verwerfen, + // damit keine Höhen-Labels/Druckwerte der ALTEN Messung erscheinen. + if (gen !== measureGenRef.current) return + let totalDist = 0 for (let i = 1; i < coords.length; i++) { totalDist += haversineDistance(coords[i - 1], coords[i]) @@ -1906,6 +1915,7 @@ export function MapView({ // Reset measurement state when leaving measure mode (but keep info panel — user closes it) useEffect(() => { if (drawMode !== 'measure') { + measureGenRef.current++ // pendende Höhen-Abfrage beim Verlassen entwerten measureMarkersRef.current.forEach(m => m.remove()) measureMarkersRef.current = [] measureCoordsRef.current = [] @@ -2156,6 +2166,7 @@ export function MapView({ // Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand) const clearMeasurement = useCallback(() => { + measureGenRef.current++ // laufende Höhen-Abfragen der alten Messung entwerten measureMarkersRef.current.forEach(m => m.remove()) measureMarkersRef.current = [] measureCoordsRef.current = [] diff --git a/src/hooks/use-realtime-sync.ts b/src/hooks/use-realtime-sync.ts index ce1e16f..ee9fe09 100644 --- a/src/hooks/use-realtime-sync.ts +++ b/src/hooks/use-realtime-sync.ts @@ -8,6 +8,8 @@ interface UseRealtimeSyncOptions { featuresRef: React.MutableRefObject setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void toast: (opts: { title: string; description?: string; variant?: string }) => void + /** Optimistischer Lock: aktuelle Features-Version mitführen/aktualisieren */ + featuresVersionRef?: React.MutableRefObject } export function useRealtimeSync({ @@ -16,6 +18,7 @@ export function useRealtimeSync({ featuresRef, setFeatures, toast, + featuresVersionRef, }: UseRealtimeSyncOptions) { // Live editing lock state const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null) @@ -198,7 +201,10 @@ export function useRealtimeSync({ }) if (!res.ok) { const data = await res.json() - toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' }) + toast({ title: 'Gesperrt', description: data.error || 'Ein anderes Gerät bearbeitet gerade die Karte.', variant: 'destructive' }) + // Realen Sperr-Halter nachladen → Karte wird lokal read-only, damit keine + // weiteren (nicht speicherbaren) Änderungen mehr gezeichnet werden. + checkEditingStatus(currentProject.id) return false } setIsEditingByMe(true) @@ -227,13 +233,27 @@ export function useRealtimeSync({ if (!currentProject?.id) return setEditingLoading(true) try { - // Save features before releasing lock + // Save features before releasing lock — MIT baseVersion (optimistischer Lock), + // damit die Version danach stimmt und der nächste Auto-Save keinen Fehl-Konflikt auslöst. const currentFeatures = featuresRef.current - await fetch(`/api/projects/${currentProject.id}/features`, { + const saveRes = await fetch(`/api/projects/${currentProject.id}/features`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ features: currentFeatures }), + body: JSON.stringify({ features: currentFeatures, baseVersion: featuresVersionRef?.current }), }) + if (saveRes.ok) { + const data = await saveRes.json().catch(() => null) + if (data && typeof data.featuresVersion === 'number' && featuresVersionRef) { + featuresVersionRef.current = data.featuresVersion + } + } else if (saveRes.status === 409) { + // Zwischenzeitlich geändert: Server-Stand übernehmen statt still zu überschreiben + const data = await saveRes.json().catch(() => null) + if (data?.features) setFeatures(data.features) + if (data && typeof data.featuresVersion === 'number' && featuresVersionRef) { + featuresVersionRef.current = data.featuresVersion + } + } // Release lock await fetch(`/api/projects/${currentProject.id}/editing`, { method: 'POST',