# Plan: Timeline Interaction Stability — P1 continuation Stand: 2026-04-02 --- # Workstream C: Drag + Selection Interaction Conflicts ## Anforderungsanalyse Code-Review von `useTimelineDrag.ts` ergibt zwei tatsächliche Konflikte: 1. **`onProjectBarMouseDown`** (Zeile 488): Startet einen Projekt-Bar-Drag **ohne** das committed `multiSelectState` zurückzusetzen. → `FloatingActionBar` bleibt während des Drags sichtbar (da `selectedAllocationIds.length > 0` aus dem committed Multi-Select). 2. **`onAllocMouseDown` single-drag path** (Zeile 611, nach dem `if (isMultiSelected)` Block): Wenn eine Allocation per Mousedown angedraggt wird die NICHT in der Multi-Selektion ist, wird ebenfalls kein Reset ausgeführt. → `FloatingActionBar` bleibt sichtbar. 3. **`onCanvasRightMouseDown`**: Startet neue Multi-Select-Session mit `selectedAllocationIds: []` — resettet Selektion korrekt. Kein Fix nötig. 4. **ESC-Taste**: Bereits in `TimelineView.tsx` über `clearMultiSelect` abgehandelt. Kein Fix nötig. **Fix-Semantik**: Beide Handler brauchen ein **volles Reset** (`INITIAL_MULTI_SELECT`) — im Gegensatz zu `cancelTransientMultiSelectState`, das committed Selektionen bewahrt. Ein neuer, unabhängiger Drag soll die Selektion vollständig verwerfen. ## Betroffene Pakete & Dateien | Paket | Datei | Art | |-------|-------|-----| | `apps/web` | `src/hooks/useTimelineDrag.ts` | edit | | `apps/web` | `src/components/timeline/FloatingActionBar.test.tsx` | create | ## Task-Liste - [ ] **C-1:** `useTimelineDrag.ts` — Multi-Select bei Drag-Start zurücksetzen. - In `onProjectBarMouseDown` (nach `if (e.button !== 0) return;`, vor `createProjectDragState`): ```ts multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); ``` - In `onAllocMouseDown` im Single-Drag-Zweig (nach dem `if (isMultiSelected) { ... return; }` Block, vor `createAllocationDragState`): ```ts multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); ``` - `setMultiSelectState` ist bereits von `useState` (stabil) — kein Eintrag in dep-Array nötig (Konvention konsistent mit bestehender `onAllocMouseDown`-Implementierung, die `setMultiSelectState` ebenfalls ohne Dep-Eintrag nutzt). - Ref-Reset ist nötig damit nachfolgende synchrone Ref-Lesungen im selben Event-Cycle den gecleansten State sehen. - → Datei: `apps/web/src/hooks/useTimelineDrag.ts` - [ ] **C-2:** `FloatingActionBar.test.tsx` erstellen — Regressions-Coverage. - Test 1: `selectedAllocationCount=0, selectedResourceCount=0` → rendert `null` (Bar ist ausgeblendet) - Test 2: `selectedAllocationCount=2, selectedResourceCount=0` → Bar sichtbar, Text "2 allocations selected" - Test 3: `selectedAllocationCount=0, selectedResourceCount=1` → Bar sichtbar, Text "1 resource selected" - Test 4: `selectedAllocationCount=1, selectedResourceCount=1` → Bar sichtbar, Text enthält "1 allocation" und "1 resource" - Sichert den `totalCount === 0 → null`-Guard: wenn Multi-Select gecleart wird, verschwindet die Bar. - → Datei: `apps/web/src/components/timeline/FloatingActionBar.test.tsx` ## Abhängigkeiten - C-1 und C-2 sind **unabhängig** voneinander (C-2 ist reiner Render-Test der Komponente) ## Akzeptanzkriterien - [ ] `pnpm test:unit` läuft grün - [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen TS-Errors - [ ] Projekt-Bar-Drag startet mit aktiver Selektion: `FloatingActionBar` wird sofort ausgeblendet - [ ] Single-Alloc-Drag auf nicht-selektierter Allocation: `FloatingActionBar` wird ausgeblendet - [ ] Rechtsklick-Drag auf Canvas: Selektion wird korrekt überschrieben (bereits korrekt) ## Risiken & offene Fragen - **Ref-Sync**: `multiSelectRef.current` wird bereits in `useTimelineDrag` auf Zeile 260 bei jedem Render mit `multiSelectState` synchronisiert. Der explizite Ref-Reset im Handler ist aber nötig, damit nachfolgende synchrone Zugriffe (z.B. innerhalb desselben Event-Handlers) nicht den alten Ref-Wert lesen. Kein Risiko. - **Dep-Array**: `INITIAL_MULTI_SELECT` ist eine Modul-Konstante (referenz-stabil), `setMultiSelectState` ist stabil aus `useState`. Beide müssen nicht in die `useCallback` dep-Arrays — das ist konsistent mit der bestehenden Nutzung in `onAllocMouseDown`. --- # Workstream D: SSE Reconnect — fehlende Edge-Case-Tests ## Anforderungsanalyse Vollständige Analyse von `useTimelineSSE.ts` und 8 bestehenden Tests in `useTimelineSSE.test.ts`. **Bereits abgedeckt:** - Malformed payloads → kein invalidate - Ping → kein invalidate, backoff-Reset - Initial open → kein resync - Error → reconnect → open → resync (genau einmal) - Double-open nach reconnect → resync nur einmal - Dispose während pending Timer - Hidden on mount, dann visible → connect ohne resync - Hide während aktiver Connection, dann visible → resync **Fehlende Edge Cases:** 1. **Tab versteckt während pending reconnect-Timer** (kein aktives `es`, aber `reconnectTimeout.current` gesetzt): `handleVisibilityChange` prüft `if (es || reconnectTimeout.current)` → setzt `shouldResyncOnOpen = true`, löscht Timer via `clearReconnectTimer()`. Bei visible wieder: `reconnectAttempts = 0`, `connect()` → neue Connection → open → resync. **Nicht getestet.** 2. **Allererste Verbindung schlägt fehl** (kein `onopen` je gerufen, direkt `onerror`): `shouldResyncOnOpen = true` nach dem ersten Fehler ohne vorangehendes open. Nach Retry → open → resync. Dokumentiert: auch wenn die initiale Verbindung nie erfolgreich war, wird beim ersten erfolgreichen open korrekt resynct. **Nicht explizit getestet.** 3. **Mehrere konsekutive Errors ohne open** (implizit bereits abgedeckt im "resets reconnect backoff"-Test über `emitError(); emitError();`). Kein zusätzlicher Test nötig. ## Betroffene Pakete & Dateien | Paket | Datei | Art | |-------|-------|-----| | `apps/web` | `src/hooks/useTimelineSSE.test.ts` | edit | ## Task-Liste - [ ] **D-1:** `useTimelineSSE.test.ts` — zwei fehlende Edge-Case-Tests hinzufügen. - **Test 9**: `"hides during pending reconnect timer — clears timer and resyncs on next open"` - Sequence: connect → open → error (shouldResyncOnOpen=true, Timer 2s) → hide (Timer gecleart, shouldResyncOnOpen bleibt true, es=null) → show (reconnectAttempts=0, connect() → neue EventSource) → emitOpen() → resync - Verifikation: `MockEventSource.instances` hat Länge 2; `invalidateQueries` wurde genau `getTimelineSseResyncKeys().length` mal mit den richtigen Keys aufgerufen - Unterschied zu bestehendem hide-Test: dort wird `es` aktiv geschlossen; hier ist `es = null` und nur der Timer ist pending - **Test 10**: `"resyncs after the first-ever connection attempt fails before any open"` - Sequence: connect → emitError() (kein emitOpen davor, shouldResyncOnOpen=true) → advance timer 2000ms → neue EventSource → emitOpen() → resync - Verifikation: `MockEventSource.instances` hat Länge 2; `invalidateQueries` aufgerufen mit resync-keys - Dokumentiert: `shouldResyncOnOpen` wird auch beim allerersten Fehler gesetzt — nicht nur nach erfolgreicher erster Verbindung - Kein Produktionscode geändert — reine Test-Ergänzung. - → Datei: `apps/web/src/hooks/useTimelineSSE.test.ts` ## Abhängigkeiten - D-1 ist vollständig unabhängig (nur Tests, kein Source-Code) ## Akzeptanzkriterien - [ ] `pnpm test:unit` läuft grün (alle 10 SSE-Tests grün) - [ ] Beide neuen Tests decken bisher ungetestetem Verhalten ab (kein Produktionscode nötig) --- # Parallele Ausführung **Workstream C** und **Workstream D** sind vollständig unabhängig: - **Agent 1** → C-1 + C-2 (sequenziell: C-1 zuerst, C-2 kann parallel) - **Agent 2** → D-1 (eigenständig) Keine gemeinsamen Dateien, kein Merge-Konflikt-Risiko.