fix(sync): Editier-Lock-Versionierung + Zeichnen-Race + Mess-Race (v1.6.4)

- 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) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-22 21:36:00 +02:00
parent d2a1e7fc1e
commit 0e65721a1c
4 changed files with 37 additions and 5 deletions

View File

@@ -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": {

View File

@@ -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)

View File

@@ -126,6 +126,9 @@ export function MapView({
const markerCleanupsRef = useRef<(() => void)[]>([])
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
const measureCoordsRef = useRef<number[][]>([])
// 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 = []

View File

@@ -8,6 +8,8 @@ interface UseRealtimeSyncOptions {
featuresRef: React.MutableRefObject<DrawFeature[]>
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<number>
}
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',