- useTimelineDrag: onProjectBarMouseDown and single-alloc drag path now reset multiSelectRef + multiSelectState before starting a new drag, so the FloatingActionBar is dismissed immediately when an unrelated drag begins - FloatingActionBar.test.tsx: 4 regression tests for the null-render guard (count=0) and all three label variants - useTimelineSSE.test.ts: 2 new tests — tab hides during pending reconnect timer (clears timer, resyncs on next open) and first-ever connection fails before any open (retry open still resyncs correctly) - assistant-tools-user-admin-inventory-read.test.ts: add isActive to expected findMany select shape (already in production, test was stale) Co-Authored-By: claude-flow <ruv@ruv.net>
7.7 KiB
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:
-
onProjectBarMouseDown(Zeile 488): Startet einen Projekt-Bar-Drag ohne das committedmultiSelectStatezurückzusetzen. →FloatingActionBarbleibt während des Drags sichtbar (daselectedAllocationIds.length > 0aus dem committed Multi-Select). -
onAllocMouseDownsingle-drag path (Zeile 611, nach demif (isMultiSelected)Block): Wenn eine Allocation per Mousedown angedraggt wird die NICHT in der Multi-Selektion ist, wird ebenfalls kein Reset ausgeführt. →FloatingActionBarbleibt sichtbar. -
onCanvasRightMouseDown: Startet neue Multi-Select-Session mitselectedAllocationIds: []— resettet Selektion korrekt. Kein Fix nötig. -
ESC-Taste: Bereits in
TimelineView.tsxüberclearMultiSelectabgehandelt. 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(nachif (e.button !== 0) return;, vorcreateProjectDragState):multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); - In
onAllocMouseDownim Single-Drag-Zweig (nach demif (isMultiSelected) { ... return; }Block, vorcreateAllocationDragState):multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); setMultiSelectStateist bereits vonuseState(stabil) — kein Eintrag in dep-Array nötig (Konvention konsistent mit bestehenderonAllocMouseDown-Implementierung, diesetMultiSelectStateebenfalls 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
- In
-
C-2:
FloatingActionBar.test.tsxerstellen — Regressions-Coverage.- Test 1:
selectedAllocationCount=0, selectedResourceCount=0→ rendertnull(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
- Test 1:
Abhängigkeiten
- C-1 und C-2 sind unabhängig voneinander (C-2 ist reiner Render-Test der Komponente)
Akzeptanzkriterien
pnpm test:unitläuft grünpnpm --filter @capakraken/web exec tsc --noEmit— keine neuen TS-Errors- Projekt-Bar-Drag startet mit aktiver Selektion:
FloatingActionBarwird sofort ausgeblendet - Single-Alloc-Drag auf nicht-selektierter Allocation:
FloatingActionBarwird ausgeblendet - Rechtsklick-Drag auf Canvas: Selektion wird korrekt überschrieben (bereits korrekt)
Risiken & offene Fragen
- Ref-Sync:
multiSelectRef.currentwird bereits inuseTimelineDragauf Zeile 260 bei jedem Render mitmultiSelectStatesynchronisiert. 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_SELECTist eine Modul-Konstante (referenz-stabil),setMultiSelectStateist stabil aususeState. Beide müssen nicht in dieuseCallbackdep-Arrays — das ist konsistent mit der bestehenden Nutzung inonAllocMouseDown.
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:
-
Tab versteckt während pending reconnect-Timer (kein aktives
es, aberreconnectTimeout.currentgesetzt):handleVisibilityChangeprüftif (es || reconnectTimeout.current)→ setztshouldResyncOnOpen = true, löscht Timer viaclearReconnectTimer(). Bei visible wieder:reconnectAttempts = 0,connect()→ neue Connection → open → resync. Nicht getestet. -
Allererste Verbindung schlägt fehl (kein
onopenje gerufen, direktonerror):shouldResyncOnOpen = truenach 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. -
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.instanceshat Länge 2;invalidateQuerieswurde genaugetTimelineSseResyncKeys().lengthmal mit den richtigen Keys aufgerufen - Unterschied zu bestehendem hide-Test: dort wird
esaktiv geschlossen; hier istes = nullund 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.instanceshat Länge 2;invalidateQueriesaufgerufen mit resync-keys - Dokumentiert:
shouldResyncOnOpenwird 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
- Test 9:
Abhängigkeiten
- D-1 ist vollständig unabhängig (nur Tests, kein Source-Code)
Akzeptanzkriterien
pnpm test:unitlä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.