2 Commits

Author SHA1 Message Date
Pepe Ziberi
947a757d9b feat(robustheit): optimistische Nebenläufigkeit (featuresVersion + Konflikt-Erkennung)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28m41s
Verhindert stilles Überschreiben neuerer Änderungen durch veralteten Stand
(z.B. nach Offline-Reconnect oder Lock-Ablauf bei Multi-Device).

- Project.featuresVersion (Schema + Auto-Migration), pro Features-Speichern +1
- Features-PUT: optimistischer Lock in interaktiver Transaktion
  (updateMany WHERE featuresVersion=baseVersion -> atomar; count=0 => 409 Konflikt)
- Bei Konflikt liefert der Server den aktuellen Stand zurück (409 + features + version)
- Client sendet baseVersion (Auto-Save, manuell, Beacon, Offline-Queue), übernimmt
  bei 409 den Server-Stand und warnt den Nutzer (statt still zu überschreiben)
- Offline-Queue verwirft konfligierende 4xx (kein Clobber beim Reconnect)
- Transaktions-Timeout 15s für grosse Zeichnungen auf langsamer DB

Version 1.4.11

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:07:24 +02:00
Pepe Ziberi
8b5f4a6778 fix(robustheit+ux): atomares Speichern, Offline-Härtung, Einsatz-Beenden, Audit raus
Robustheit / Datensicherheit:
- Features-PUT in prisma.$transaction (delete+create atomar) — verhindert
  Totalverlust bei Abbruch zwischen Löschen und Neuanlegen
- localStorage-Features pro Projekt (lageplan-features-<id>) statt globalem Key
  — verhindert Datenübertrag zwischen Projekten
- Sync-Queue verwirft dauerhafte 4xx-Fehler statt Endlos-Retry
- beforeunload persistiert auch leere Feature-Listen (Löschungen gehen nicht verloren)

UX:
- Leitungsrechner-Dropdown: Popover z-[2000] (lag hinter dem Panel), Label gekürzt
- Neuer "Einsatz beenden"-Button (speichert, löst Lock, schliesst Einsatz)
- Audit-Trail-Einstiege entfernt (Toolbar-Button + Menü-Eintrag)

Version 1.4.10

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:38:13 +02:00
11 changed files with 202 additions and 71 deletions

View File

@@ -52,6 +52,17 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- Live-Sync für Journal existierte bereits (Socket → journal-refresh). - Live-Sync für Journal existierte bereits (Socket → journal-refresh).
- _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._ - _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._
## Zusatz Robustheit / Datensicherheit ✅
- [x] Features-Speichern atomar (`prisma.$transaction`) — kein Totalverlust-Fenster mehr
- [x] localStorage-Features pro Projekt (`lageplan-features-<id>`) statt globalem Key
- [x] Sync-Queue verwirft dauerhafte 4xx-Fehler (kein Endlos-Retry/verstopfte Queue)
- [x] `beforeunload` persistiert auch leere Listen (gelöschte Elemente gehen nicht verloren)
- [x] **5: Optimistische Nebenläufigkeit**`Project.featuresVersion` + Versions-Check
beim Speichern. Ein veralteter (z.B. offline) Stand überschreibt neuere Änderungen nicht
mehr still, sondern erzeugt 409 → Client übernimmt Server-Stand + Warnung. Offline-Queue
verwirft konfliktierende 4xx. (Merge pro Element / echtes Co-Drawing weiterhin offen.)
## Zusatz Leitungsrechner professionalisiert ✅ ## Zusatz Leitungsrechner professionalisiert ✅
- [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar) - [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar)

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.4.9", "version": "1.4.11",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -73,6 +73,7 @@ async function migrate() {
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "featuresVersion" INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,

View File

@@ -166,6 +166,10 @@ model Project {
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}") mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
mapZoom Float @default(15) mapZoom Float @default(15)
isLocked Boolean @default(false) isLocked Boolean @default(false)
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand
// neuere Änderungen stillschweigend überschreibt.
featuresVersion Int @default(0)
// Live editing lock (session-based for same-account multi-device) // Live editing lock (session-based for same-account multi-device)
editingById String? editingById String?

View File

@@ -20,12 +20,15 @@ export async function GET(
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
} }
const features = await (prisma as any).feature.findMany({ const [features, proj] = await Promise.all([
where: { projectId: id }, (prisma as any).feature.findMany({
orderBy: { createdAt: 'asc' }, where: { projectId: id },
}) orderBy: { createdAt: 'asc' },
}),
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
])
return NextResponse.json({ features }) return NextResponse.json({ features, featuresVersion: proj?.featuresVersion ?? 0 })
} catch (error) { } catch (error) {
console.error('Error fetching features:', error) console.error('Error fetching features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
@@ -113,40 +116,74 @@ export async function PUT(
} }
const body = await request.json() const body = await request.json()
const { features, mapCenter, mapZoom } = body as { const { features, mapCenter, mapZoom, baseVersion } = body as {
features: Array<{ id?: string; type: string; geometry: object; properties?: object }> features: Array<{ id?: string; type: string; geometry: object; properties?: object }>
mapCenter?: { lng: number; lat: number } mapCenter?: { lng: number; lat: number }
mapZoom?: number mapZoom?: number
baseVersion?: number
} }
// Persist map viewport alongside features (if provided) try {
if (mapCenter && mapZoom !== undefined) { // ATOMAR + optimistischer Lock in EINER interaktiven Transaktion:
await (prisma as any).project.update({ // - Version prüfen/hochzählen (verhindert Überschreiben durch veralteten Stand)
where: { id }, // - Features löschen + neu anlegen (kein Totalverlust-Fenster)
data: { mapCenter, mapZoom }, const result = await (prisma as any).$transaction(async (tx: any) => {
}) if (typeof baseVersion === 'number') {
// Nur speichern, wenn die Version noch stimmt (atomar über WHERE-Bedingung)
const bump = await tx.project.updateMany({
where: { id, featuresVersion: baseVersion },
data: { featuresVersion: { increment: 1 } },
})
if (bump.count === 0) {
const conflict: any = new Error('VERSION_CONFLICT')
conflict.code = 'VERSION_CONFLICT'
throw conflict
}
} else {
// Kein baseVersion mitgeschickt (Alt-Client/Beacon) → nur hochzählen
await tx.project.update({ where: { id }, data: { featuresVersion: { increment: 1 } } })
}
if (mapCenter && mapZoom !== undefined) {
await tx.project.update({ where: { id }, data: { mapCenter, mapZoom } })
}
await tx.feature.deleteMany({ where: { projectId: id } })
if (features && features.length > 0) {
await tx.feature.createMany({
data: features.map((f: any) => ({
projectId: id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
})
}
const [updatedFeatures, proj] = await Promise.all([
tx.feature.findMany({ where: { projectId: id } }),
tx.project.findUnique({ where: { id }, select: { featuresVersion: true } }),
])
return { features: updatedFeatures, featuresVersion: proj?.featuresVersion ?? 0 }
}, { timeout: 15000, maxWait: 5000 })
return NextResponse.json(result)
} catch (txError: any) {
if (txError?.code === 'VERSION_CONFLICT') {
// Konflikt: aktuellen Server-Stand zurückgeben, damit der Client abgleichen kann
const [currentFeatures, proj] = await Promise.all([
(prisma as any).feature.findMany({ where: { projectId: id }, orderBy: { createdAt: 'asc' } }),
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
])
return NextResponse.json({
error: 'Der Einsatz wurde zwischenzeitlich geändert.',
conflict: true,
features: currentFeatures,
featuresVersion: proj?.featuresVersion ?? 0,
}, { status: 409 })
}
throw txError
} }
await (prisma as any).feature.deleteMany({
where: { projectId: id },
})
if (features && features.length > 0) {
await (prisma as any).feature.createMany({
data: features.map((f: any) => ({
projectId: id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
})
}
const updatedFeatures = await (prisma as any).feature.findMany({
where: { projectId: id },
})
return NextResponse.json({ features: updatedFeatures })
} catch (error) { } catch (error) {
console.error('Error updating features:', error) console.error('Error updating features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })

View File

@@ -72,6 +72,9 @@ export default function AppPage() {
const featuresRef = useRef<DrawFeature[]>(features) const featuresRef = useRef<DrawFeature[]>(features)
useEffect(() => { featuresRef.current = features }, [features]) useEffect(() => { featuresRef.current = features }, [features])
// Optimistischer Lock: Features-Version des aktuellen Projekts mitführen
const featuresVersionRef = useRef<number>(0)
// Ref for undo-draw-point (removes last point during line drawing) // Ref for undo-draw-point (removes last point during line drawing)
const undoDrawPointRef = useRef<(() => boolean) | null>(null) const undoDrawPointRef = useRef<(() => boolean) | null>(null)
@@ -385,6 +388,24 @@ export default function AppPage() {
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
const isReadOnly = !!editingBy && !isEditingByMe const isReadOnly = !!editingBy && !isEditingByMe
// Features-Version bei Projektwechsel initialisieren
useEffect(() => {
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
}, [currentProject?.id])
// Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen
const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => {
setFeatures(serverFeatures)
featuresVersionRef.current = serverVersion
undoStackRef.current = []
redoStackRef.current = []
toast({
title: 'Konflikt erkannt',
description: 'Der Einsatz wurde von jemand anderem geändert — der aktuelle Stand wurde geladen. Bitte prüfe deine Änderungen und mache sie ggf. erneut.',
variant: 'destructive',
})
}, [toast])
// Auto-save: localStorage persistence + debounced API save + beacon on unload // Auto-save: localStorage persistence + debounced API save + beacon on unload
useAutoSave({ useAutoSave({
currentProject, currentProject,
@@ -394,6 +415,8 @@ export default function AppPage() {
socketRef, socketRef,
isEditingByMe, isEditingByMe,
setSyncQueueCount, setSyncQueueCount,
featuresVersionRef,
onConflict: handleSaveConflict,
}) })
// Fullscreen toggle // Fullscreen toggle
@@ -437,7 +460,7 @@ export default function AppPage() {
if (apiFeatures.length > 0) { if (apiFeatures.length > 0) {
setFeatures(apiFeatures) setFeatures(apiFeatures)
} else { } else {
const savedFeatures = localStorage.getItem('lageplan-features') const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
} }
}) })
@@ -445,14 +468,14 @@ export default function AppPage() {
// Project not accessible (different tenant, deleted, etc.) — clear // Project not accessible (different tenant, deleted, etc.) — clear
console.log('[Restore] Project not accessible for current user, clearing') console.log('[Restore] Project not accessible for current user, clearing')
localStorage.removeItem('lageplan-project') localStorage.removeItem('lageplan-project')
localStorage.removeItem('lageplan-features') localStorage.removeItem(`lageplan-features-${proj.id}`)
setCurrentProject(null) setCurrentProject(null)
setFeatures([]) setFeatures([])
} }
}).catch(() => { }).catch(() => {
// Network error — use cached data as fallback // Network error — use cached data as fallback (nur Features DESSELBEN Projekts)
setCurrentProject(proj) setCurrentProject(proj)
const savedFeatures = localStorage.getItem('lageplan-features') const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
}) })
} }
@@ -508,7 +531,7 @@ export default function AppPage() {
undoStackRef.current = [] undoStackRef.current = []
redoStackRef.current = [] redoStackRef.current = []
localStorage.removeItem('lageplan-project') localStorage.removeItem('lageplan-project')
localStorage.removeItem('lageplan-features') localStorage.removeItem(`lageplan-features-${deletedId}`)
} }
addAudit(`Einsatz gelöscht`) addAudit(`Einsatz gelöscht`)
toast({ toast({
@@ -522,7 +545,7 @@ export default function AppPage() {
setIsSaving(true) setIsSaving(true)
try { try {
const saveBody: any = { features } const saveBody: any = { features, baseVersion: featuresVersionRef.current }
if (mapRef.current) { if (mapRef.current) {
const c = mapRef.current.getCenter() const c = mapRef.current.getCenter()
saveBody.mapCenter = { lng: c.lng, lat: c.lat } saveBody.mapCenter = { lng: c.lng, lat: c.lat }
@@ -554,19 +577,34 @@ export default function AppPage() {
} }
const created = await createRes.json() const created = await createRes.json()
setCurrentProject(created.project) setCurrentProject(created.project)
featuresVersionRef.current = created.project?.featuresVersion ?? 0
// Retry save with new project ID // Retry save with new project ID
res = await fetch(`/api/projects/${created.project.id}/features`, { res = await fetch(`/api/projects/${created.project.id}/features`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features }), body: JSON.stringify({ features, baseVersion: featuresVersionRef.current }),
}) })
} }
// Versionskonflikt: Server-Stand übernehmen statt zu überschreiben
if (res.status === 409) {
const data = await res.json().catch(() => null)
if (data?.conflict) {
handleSaveConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
return
}
}
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Speichern fehlgeschlagen') throw new Error(err.error || 'Speichern fehlgeschlagen')
} }
const okData = await res.json().catch(() => null)
if (okData && typeof okData.featuresVersion === 'number') {
featuresVersionRef.current = okData.featuresVersion
}
// Broadcast features to other clients // Broadcast features to other clients
socketRef.current?.emit('features-updated', { socketRef.current?.emit('features-updated', {
projectId: currentProject.id, projectId: currentProject.id,
@@ -588,6 +626,22 @@ export default function AppPage() {
} }
} }
// Einsatz beenden: speichern, Bearbeitungs-Lock lösen und zurück zur Übersicht.
const handleEndProject = async () => {
if (!currentProject) return
if (!confirm(`Einsatz "${currentProject.title}" beenden? Alle Änderungen werden gespeichert und der Einsatz geschlossen.`)) return
const title = currentProject.title
try { await handleSaveProject() } catch { /* Save meldet Fehler selbst */ }
if (isEditingByMe) { try { await handleStopEditing() } catch { /* ignore */ } }
addAudit(`Einsatz "${title}" beendet`)
setCurrentProject(null)
setFeatures([])
undoStackRef.current = []
redoStackRef.current = []
localStorage.removeItem('lageplan-project')
toast({ title: 'Einsatz beendet', description: 'Gespeichert und geschlossen.' })
}
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => { const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
undoStackRef.current.push(featuresRef.current) undoStackRef.current.push(featuresRef.current)
redoStackRef.current = [] redoStackRef.current = []
@@ -798,6 +852,7 @@ export default function AppPage() {
<Topbar <Topbar
project={currentProject} project={currentProject}
onNewProject={handleNewProject} onNewProject={handleNewProject}
onEndProject={handleEndProject}
onSaveProject={handleSaveProject} onSaveProject={handleSaveProject}
onLoadProject={handleProjectLoaded} onLoadProject={handleProjectLoaded}
onDeleteProject={handleDeleteProject} onDeleteProject={handleDeleteProject}

View File

@@ -42,6 +42,7 @@ import {
HelpCircle, HelpCircle,
Lock, Lock,
Unlock, Unlock,
CheckCircle2,
} from 'lucide-react' } from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature, ProjectMode } from '@/types' import type { Project, DrawFeature, ProjectMode } from '@/types'
@@ -51,6 +52,7 @@ import { Logo } from '@/components/ui/logo'
interface TopbarProps { interface TopbarProps {
project: Project | null project: Project | null
onNewProject: (mode?: ProjectMode) => void onNewProject: (mode?: ProjectMode) => void
onEndProject?: () => void
onSaveProject: () => void onSaveProject: () => void
onLoadProject: (project: Project, features: DrawFeature[]) => void onLoadProject: (project: Project, features: DrawFeature[]) => void
onDeleteProject?: (projectId: string) => void onDeleteProject?: (projectId: string) => void
@@ -76,6 +78,7 @@ interface TopbarProps {
export function Topbar({ export function Topbar({
project, project,
onNewProject, onNewProject,
onEndProject,
onSaveProject, onSaveProject,
onLoadProject, onLoadProject,
onDeleteProject, onDeleteProject,
@@ -285,19 +288,17 @@ export function Topbar({
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />} {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</Button> </Button>
<Button {project && onEndProject && (
variant={isAuditOpen ? 'default' : 'outline'} <Button
className="hidden md:flex h-9 px-2 relative" variant="outline"
onClick={onToggleAudit} className="hidden md:flex h-9 px-2 md:px-3 border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950/40"
title="Audit Trail" onClick={onEndProject}
> title="Einsatz speichern und schliessen"
<ClipboardList className="w-4 h-4" /> >
{auditLog.length > 0 && ( <CheckCircle2 className="w-4 h-4 lg:mr-1" />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-primary-foreground text-[10px] rounded-full flex items-center justify-center font-bold"> <span className="hidden lg:inline">Einsatz beenden</span>
{auditLog.length > 99 ? '99' : auditLog.length} </Button>
</span> )}
)}
</Button>
{/* Desktop: User menu dropdown */} {/* Desktop: User menu dropdown */}
{userName && onLogout && ( {userName && onLogout && (
@@ -366,10 +367,6 @@ export function Topbar({
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Einstellungen Einstellungen
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onToggleAudit}>
<ClipboardList className="w-4 h-4 mr-2" />
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
</DropdownMenuItem>
{onLogout && ( {onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive"> <DropdownMenuItem onClick={onLogout} className="text-destructive">
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />

View File

@@ -103,18 +103,18 @@ export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelPr
<div className="grid grid-cols-[1fr_auto] gap-2"> <div className="grid grid-cols-[1fr_auto] gap-2">
<Select value={selectedHoseName} onValueChange={setSelectedHoseName}> <Select value={selectedHoseName} onValueChange={setSelectedHoseName}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger> <SelectTrigger className="h-8 text-xs min-w-0"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
<SelectContent> <SelectContent className="z-[2000]">
{hoseTypes.map(h => ( {hoseTypes.map(h => (
<SelectItem key={h.name} value={h.name}> <SelectItem key={h.name} value={h.name}>
{h.name} · {h.diameterMm}mm · {h.flowRateLpm} l/min{h.isDefault ? ' ★' : ''} {h.name}{h.isDefault ? ' ★' : ''} · {h.flowRateLpm} l/min
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={nozzlePressure} onValueChange={setNozzlePressure}> <Select value={nozzlePressure} onValueChange={setNozzlePressure}>
<SelectTrigger className="h-8 text-xs w-[92px]" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-xs w-[92px] shrink-0" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent className="z-[2000]">
{['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)} {['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -10,6 +10,10 @@ interface UseAutoSaveOptions {
socketRef: React.MutableRefObject<any> socketRef: React.MutableRefObject<any>
isEditingByMe: boolean isEditingByMe: boolean
setSyncQueueCount: (count: number) => void setSyncQueueCount: (count: number) => void
/** Aktuelle Features-Version des Projekts (optimistischer Lock) */
featuresVersionRef: React.MutableRefObject<number>
/** Wird bei einem Speicher-Konflikt (409) mit dem Server-Stand aufgerufen */
onConflict: (serverFeatures: DrawFeature[], serverVersion: number) => void
} }
export function useAutoSave({ export function useAutoSave({
@@ -20,11 +24,15 @@ export function useAutoSave({
socketRef, socketRef,
isEditingByMe, isEditingByMe,
setSyncQueueCount, setSyncQueueCount,
featuresVersionRef,
onConflict,
}: UseAutoSaveOptions) { }: UseAutoSaveOptions) {
// Persist features to localStorage on change (including empty array to reflect deletions) // Persist features to localStorage on change (per project — verhindert, dass Features
// eines Projekts in ein anderes "bluten"). Inkl. leerem Array (spiegelt Löschungen).
useEffect(() => { useEffect(() => {
localStorage.setItem('lageplan-features', JSON.stringify(features)) if (!currentProject?.id) return
}, [features]) localStorage.setItem(`lageplan-features-${currentProject.id}`, JSON.stringify(features))
}, [features, currentProject?.id])
// Auto-save to API — debounced 2s after every feature change + fallback interval // Auto-save to API — debounced 2s after every feature change + fallback interval
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -32,7 +40,7 @@ export function useAutoSave({
if (!currentProject?.id) return if (!currentProject?.id) return
const url = `/api/projects/${currentProject.id}/features` const url = `/api/projects/${currentProject.id}/features`
const mapInstance = mapRef.current const mapInstance = mapRef.current
const body: any = { features: featuresRef.current } const body: any = { features: featuresRef.current, baseVersion: featuresVersionRef.current }
if (mapInstance) { if (mapInstance) {
const c = mapInstance.getCenter() const c = mapInstance.getCenter()
body.mapCenter = { lng: c.lng, lat: c.lat } body.mapCenter = { lng: c.lng, lat: c.lat }
@@ -54,11 +62,22 @@ export function useAutoSave({
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json().catch(() => null)
if (data && typeof data.featuresVersion === 'number') {
featuresVersionRef.current = data.featuresVersion
}
console.log('[Auto-Save] Features gespeichert') console.log('[Auto-Save] Features gespeichert')
socketRef.current?.emit('features-updated', { socketRef.current?.emit('features-updated', {
projectId: currentProject.id, projectId: currentProject.id,
features: featuresRef.current, features: featuresRef.current,
}) })
} else if (res.status === 409) {
// Konflikt: jemand anderes hat zwischenzeitlich gespeichert → Server-Stand übernehmen
const data = await res.json().catch(() => null)
if (data?.conflict) {
console.warn('[Auto-Save] Versionskonflikt — Server-Stand übernehmen')
onConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
}
} else if (res.status === 404) { } else if (res.status === 404) {
console.warn('[Auto-Save] Projekt nicht in DB') console.warn('[Auto-Save] Projekt nicht in DB')
} }
@@ -68,7 +87,7 @@ export function useAutoSave({
setSyncQueueCount(getSyncQueue().length) setSyncQueueCount(getSyncQueue().length)
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e) console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
} }
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount]) }, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount, featuresVersionRef, onConflict])
// Debounced save on every feature change (2s delay) // Debounced save on every feature change (2s delay)
useEffect(() => { useEffect(() => {
@@ -81,8 +100,10 @@ export function useAutoSave({
// Also save on page unload / tab switch // Also save on page unload / tab switch
useEffect(() => { useEffect(() => {
const handleBeforeUnload = () => { const handleBeforeUnload = () => {
if (currentProject?.id && featuresRef.current.length > 0) { // Nur der aktive Bearbeiter persistiert beim Schliessen — auch eine leere Liste,
const payload = JSON.stringify({ features: featuresRef.current }) // damit "alles gelöscht + Tab zu" nicht die alten Elemente in der DB stehen lässt.
if (currentProject?.id && isEditingByMe) {
const payload = JSON.stringify({ features: featuresRef.current, baseVersion: featuresVersionRef.current })
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' })) navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
} }
} }

View File

@@ -58,8 +58,12 @@ export async function flushSyncQueue(): Promise<{ success: number; failed: numbe
}) })
if (res.ok) { if (res.ok) {
success++ success++
} else if (res.status >= 400 && res.status < 500) {
// Client-Fehler (403/404/...) = dauerhaft. Retry bringt nichts → verwerfen,
// damit die Queue nicht verstopft und nicht ewig "Sync-Fehler" meldet.
failed++
} else { } else {
// Server error — keep in queue for retry // Server-Fehler (5xx) — später erneut versuchen
remaining.push(item) remaining.push(item)
failed++ failed++
} }

View File

@@ -12,6 +12,7 @@ export interface Project {
mapCenter: { lng: number; lat: number } mapCenter: { lng: number; lat: number }
mapZoom: number mapZoom: number
isLocked: boolean isLocked: boolean
featuresVersion?: number
editingById?: string | null editingById?: string | null
editingUserName?: string | null editingUserName?: string | null
editingStartedAt?: string | null editingStartedAt?: string | null