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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.6.3",
|
"version": "1.6.4",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export default function AppPage() {
|
|||||||
featuresRef,
|
featuresRef,
|
||||||
setFeatures,
|
setFeatures,
|
||||||
toast: toast as any,
|
toast: toast as any,
|
||||||
|
featuresVersionRef,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
|
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export function MapView({
|
|||||||
const markerCleanupsRef = useRef<(() => void)[]>([])
|
const markerCleanupsRef = useRef<(() => void)[]>([])
|
||||||
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
|
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
|
||||||
const measureCoordsRef = useRef<number[][]>([])
|
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 [isMapLoaded, setIsMapLoaded] = useState(false)
|
||||||
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
|
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
|
||||||
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
||||||
@@ -454,6 +457,8 @@ export function MapView({
|
|||||||
const fetchElevationsAndUpdate = useCallback(async () => {
|
const fetchElevationsAndUpdate = useCallback(async () => {
|
||||||
const coords = measureCoordsRef.current
|
const coords = measureCoordsRef.current
|
||||||
if (coords.length < 2 || !map.current) return
|
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
|
// Interpolate extra points along the line (every ~30m) for better elevation accuracy
|
||||||
const sampledCoords: number[][] = [coords[0]]
|
const sampledCoords: number[][] = [coords[0]]
|
||||||
@@ -560,6 +565,10 @@ export function MapView({
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES
|
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
|
let totalDist = 0
|
||||||
for (let i = 1; i < coords.length; i++) {
|
for (let i = 1; i < coords.length; i++) {
|
||||||
totalDist += haversineDistance(coords[i - 1], coords[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)
|
// Reset measurement state when leaving measure mode (but keep info panel — user closes it)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (drawMode !== 'measure') {
|
if (drawMode !== 'measure') {
|
||||||
|
measureGenRef.current++ // pendende Höhen-Abfrage beim Verlassen entwerten
|
||||||
measureMarkersRef.current.forEach(m => m.remove())
|
measureMarkersRef.current.forEach(m => m.remove())
|
||||||
measureMarkersRef.current = []
|
measureMarkersRef.current = []
|
||||||
measureCoordsRef.current = []
|
measureCoordsRef.current = []
|
||||||
@@ -2156,6 +2166,7 @@ export function MapView({
|
|||||||
|
|
||||||
// Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand)
|
// Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand)
|
||||||
const clearMeasurement = useCallback(() => {
|
const clearMeasurement = useCallback(() => {
|
||||||
|
measureGenRef.current++ // laufende Höhen-Abfragen der alten Messung entwerten
|
||||||
measureMarkersRef.current.forEach(m => m.remove())
|
measureMarkersRef.current.forEach(m => m.remove())
|
||||||
measureMarkersRef.current = []
|
measureMarkersRef.current = []
|
||||||
measureCoordsRef.current = []
|
measureCoordsRef.current = []
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface UseRealtimeSyncOptions {
|
|||||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||||
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
|
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
|
||||||
toast: (opts: { title: string; description?: string; variant?: string }) => 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({
|
export function useRealtimeSync({
|
||||||
@@ -16,6 +18,7 @@ export function useRealtimeSync({
|
|||||||
featuresRef,
|
featuresRef,
|
||||||
setFeatures,
|
setFeatures,
|
||||||
toast,
|
toast,
|
||||||
|
featuresVersionRef,
|
||||||
}: UseRealtimeSyncOptions) {
|
}: UseRealtimeSyncOptions) {
|
||||||
// Live editing lock state
|
// Live editing lock state
|
||||||
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
|
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
|
||||||
@@ -198,7 +201,10 @@ export function useRealtimeSync({
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
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
|
return false
|
||||||
}
|
}
|
||||||
setIsEditingByMe(true)
|
setIsEditingByMe(true)
|
||||||
@@ -227,13 +233,27 @@ export function useRealtimeSync({
|
|||||||
if (!currentProject?.id) return
|
if (!currentProject?.id) return
|
||||||
setEditingLoading(true)
|
setEditingLoading(true)
|
||||||
try {
|
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
|
const currentFeatures = featuresRef.current
|
||||||
await fetch(`/api/projects/${currentProject.id}/features`, {
|
const saveRes = await fetch(`/api/projects/${currentProject.id}/features`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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
|
// Release lock
|
||||||
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user