trails/apps/planner/app/lib/use-yjs.ts
Ullrich Schäfer 55806fc84a
Fix waypoint drag, host election, segment routing, debug panel
Waypoint drag:
- Use observeDeep instead of observe to detect Y.Map property changes

Host election:
- Extracted to host-election.ts with 8 unit tests
- Simple rule: lowest real client ID is always host, deterministic
- Always sets role (host or participant), no missing roles
- Filters server docs (empty awareness state) from election

BRouter routing:
- Route segment-by-segment like bikerouter.de (one request per
  waypoint pair), guarantees route passes through every waypoint
- Merge GeoJSON segments with accumulated stats
- 8 unit tests for segment merging and waypoint splitting

Awareness cleanup:
- Track client IDs per WebSocket connection
- Immediately remove awareness on WebSocket close

Debug panel:
- Visibility + expanded state persisted in localStorage
- Hotkey (Shift+Cmd+Option+T) toggles visibility
- Labels server docs as (server)
- All logic self-contained in YjsDebugPanel.tsx

User identity:
- Color and name persisted in localStorage across reloads

Tests: 32 total (16 existing + 8 host election + 8 routing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:52:52 +01:00

85 lines
2.4 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const COLORS = [
"#ef4444", "#f97316", "#eab308", "#22c55e",
"#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
];
const NAMES = [
"Hiker", "Cyclist", "Runner", "Explorer",
"Wanderer", "Pathfinder", "Trailblazer", "Navigator",
];
function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function getOrCreateUserIdentity(): { color: string; name: string } {
try {
const stored = localStorage.getItem("trails:user");
if (stored) return JSON.parse(stored);
} catch { /* localStorage unavailable */ }
const identity = { color: randomItem(COLORS), name: randomItem(NAMES) };
try { localStorage.setItem("trails:user", JSON.stringify(identity)); } catch { /* localStorage unavailable */ }
return identity;
}
export interface YjsState {
doc: Y.Doc;
provider: WebsocketProvider;
waypoints: Y.Array<Y.Map<unknown>>;
routeData: Y.Map<unknown>;
awareness: WebsocketProvider["awareness"];
connected: boolean;
}
export function useYjs(sessionId: string): YjsState | null {
const [state, setState] = useState<YjsState | null>(null);
const providerRef = useRef<WebsocketProvider | null>(null);
const docRef = useRef<Y.Doc | null>(null);
useEffect(() => {
const doc = new Y.Doc();
docRef.current = doc;
const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync`;
const provider = new WebsocketProvider(wsUrl, sessionId, doc);
providerRef.current = provider;
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
const routeData = doc.getMap("routeData");
// Use persistent identity
const { color, name } = getOrCreateUserIdentity();
provider.awareness.setLocalStateField("user", { color, name });
const updateState = (connected: boolean) => {
setState({
doc,
provider,
waypoints,
routeData,
awareness: provider.awareness,
connected,
});
};
provider.on("status", ({ status }: { status: string }) => {
updateState(status === "connected");
});
updateState(false);
return () => {
provider.destroy();
doc.destroy();
providerRef.current = null;
docRef.current = null;
};
}, [sessionId]);
return state;
}