From 55806fc84ac2e37ac309f4efd1a600d258244a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 07:30:00 +0100 Subject: [PATCH] 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) --- apps/planner/app/components/PlannerMap.tsx | 4 +- apps/planner/app/components/SessionView.tsx | 2 + .../app/components/WaypointSidebar.tsx | 4 +- apps/planner/app/components/YjsDebugPanel.tsx | 255 ++++++++++++++++++ apps/planner/app/lib/brouter.test.ts | 167 ++++++++++++ apps/planner/app/lib/brouter.ts | 92 ++++++- apps/planner/app/lib/host-election.test.ts | 94 +++++++ apps/planner/app/lib/host-election.ts | 26 ++ apps/planner/app/lib/use-routing.ts | 13 +- apps/planner/app/lib/use-yjs.ts | 16 +- apps/planner/app/lib/vite-yjs-plugin.ts | 32 ++- 11 files changed, 673 insertions(+), 32 deletions(-) create mode 100644 apps/planner/app/components/YjsDebugPanel.tsx create mode 100644 apps/planner/app/lib/brouter.test.ts create mode 100644 apps/planner/app/lib/host-election.test.ts create mode 100644 apps/planner/app/lib/host-election.ts diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index eff4d01..b70e16d 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -127,11 +127,11 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa } }; - yjs.waypoints.observe(update); + yjs.waypoints.observeDeep(update); update(); return () => { - yjs.waypoints.unobserve(update); + yjs.waypoints.unobserveDeep(update); }; }, [yjs.waypoints, onRouteRequest]); diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index abe0e4c..17bee15 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -3,6 +3,7 @@ import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; +import { YjsDebugPanel } from "~/components/YjsDebugPanel"; const PlannerMap = lazy(() => import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })), @@ -83,6 +84,7 @@ export function SessionView({ sessionId }: { sessionId: string }) { + ); } diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index 7d11285..a041581 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -30,9 +30,9 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { useEffect(() => { const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints)); - yjs.waypoints.observe(update); + yjs.waypoints.observeDeep(update); update(); - return () => yjs.waypoints.unobserve(update); + return () => yjs.waypoints.unobserveDeep(update); }, [yjs.waypoints]); const deleteWaypoint = useCallback( diff --git a/apps/planner/app/components/YjsDebugPanel.tsx b/apps/planner/app/components/YjsDebugPanel.tsx new file mode 100644 index 0000000..98a7b06 --- /dev/null +++ b/apps/planner/app/components/YjsDebugPanel.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState, useCallback } from "react"; +import * as Y from "yjs"; +import * as awarenessProtocol from "y-protocols/awareness"; +import type { YjsState } from "~/lib/use-yjs"; + +// --- localStorage helpers --- + +function loadBool(key: string, fallback: boolean): boolean { + try { return localStorage.getItem(key) === "1"; } catch { return fallback; } +} + +function saveBool(key: string, value: boolean) { + try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* localStorage unavailable */ } +} + +// --- Debug state --- + +interface DebugState { + waypoints: Array>; + routeData: Record; + awareness: Array<{ clientId: number; state: Record }>; + docSize: number; +} + +function serializeYMap(yMap: Y.Map): Record { + const obj: Record = {}; + yMap.forEach((value, key) => { + if (value instanceof Y.Map) { + obj[key] = serializeYMap(value); + } else if (value instanceof Y.Array) { + obj[key] = `Y.Array(${value.length})`; + } else if (typeof value === "string" && value.length > 200) { + obj[key] = value.slice(0, 200) + `... (${value.length} chars)`; + } else { + obj[key] = value; + } + }); + return obj; +} + +function getDebugState(yjs: YjsState): DebugState { + const waypoints = yjs.waypoints.toArray().map((yMap) => serializeYMap(yMap)); + + const routeData: Record = {}; + yjs.routeData.forEach((value, key) => { + if (typeof value === "string" && value.length > 100) { + routeData[key] = `string(${value.length} chars)`; + } else { + routeData[key] = value; + } + }); + + const awareness: DebugState["awareness"] = []; + yjs.awareness.getStates().forEach((state, clientId) => { + awareness.push({ clientId, state: state as Record }); + }); + + const docSize = Y.encodeStateAsUpdate(yjs.doc).byteLength; + + return { waypoints, routeData, awareness, docSize }; +} + +// --- Styles --- + +const btnClass = + "rounded bg-gray-700 px-2 py-0.5 text-[10px] hover:bg-gray-600 active:bg-gray-500 transition-colors"; +const dangerBtnClass = + "rounded bg-red-900 px-2 py-0.5 text-[10px] text-red-200 hover:bg-red-800 active:bg-red-700 transition-colors"; + +// --- Component --- + +/** + * Yjs debug panel with: + * - Visibility: shown in dev by default, toggle with Shift+Cmd+Option+T anywhere + * - Expanded state: persisted in localStorage + * - Actions: reset session, refetch route, become host, kick users + * - State inspector: awareness, waypoints, route data, doc stats + */ +export function YjsDebugPanel({ yjs }: { yjs: YjsState }) { + const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV)); + const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false)); + const [state, setState] = useState(null); + + // Hotkey: Shift+Cmd+Option+T + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.shiftKey && e.metaKey && e.altKey && e.code === "KeyT") { + e.preventDefault(); + setVisible((v) => { + const next = !v; + saveBool("trails:debug", next); + return next; + }); + } + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, []); + + // Sync Yjs state + useEffect(() => { + if (!visible) return; + + const update = () => setState(getDebugState(yjs)); + + yjs.waypoints.observe(update); + yjs.routeData.observe(update); + yjs.awareness.on("change", update); + update(); + + const interval = setInterval(update, 2000); + + return () => { + yjs.waypoints.unobserve(update); + yjs.routeData.unobserve(update); + yjs.awareness.off("change", update); + clearInterval(interval); + }; + }, [yjs, visible]); + + // Actions + + const resetSession = useCallback(() => { + yjs.doc.transact(() => { + while (yjs.waypoints.length > 0) { + yjs.waypoints.delete(0, 1); + } + yjs.routeData.delete("geojson"); + yjs.routeData.delete("profile"); + }); + }, [yjs]); + + const refetchRoute = useCallback(async () => { + const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ + lat: yMap.get("lat") as number, + lon: yMap.get("lon") as number, + })); + + if (waypoints.length < 2) return; + + const profile = (yjs.routeData.get("profile") as string) ?? "trekking"; + const response = await fetch("/api/route", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ waypoints, profile }), + }); + + if (response.ok) { + const geojson = await response.json(); + yjs.routeData.set("geojson", JSON.stringify(geojson)); + } + }, [yjs]); + + const kickUser = useCallback( + (clientId: number) => { + if (clientId === yjs.awareness.clientID) return; + awarenessProtocol.removeAwarenessStates(yjs.awareness, [clientId], "kicked"); + }, + [yjs], + ); + + const toggleExpanded = useCallback(() => { + setExpanded((v) => { + const next = !v; + saveBool("trails:debug:expanded", next); + return next; + }); + }, []); + + // Render + + if (!visible || !state) return null; + + return ( +
+ + + {expanded && ( +
+
+
+ + +
+
+ +
+ {state.awareness.map((a) => ( +
+
+ client:{a.clientId} + {a.clientId === yjs.awareness.clientID && ( + (you) + )} + {Object.keys(a.state).length === 0 && ( + (server) + )} + {a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && ( + + )} +
+
{JSON.stringify(a.state, null, 1)}
+
+ ))} +
+ +
+ {state.waypoints.map((wp, i) => ( +
+ [{i}] {JSON.stringify(wp)} +
+ ))} + {state.waypoints.length === 0 && empty} +
+ +
+
{JSON.stringify(state.routeData, null, 1)}
+
+ +
+
+ Size: {(state.docSize / 1024).toFixed(1)} KB +
+
+
+ )} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} diff --git a/apps/planner/app/lib/brouter.test.ts b/apps/planner/app/lib/brouter.test.ts new file mode 100644 index 0000000..4ddcf3f --- /dev/null +++ b/apps/planner/app/lib/brouter.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; + +// Test the mergeGeoJsonSegments logic extracted from brouter.ts + +interface GeoJsonFeature { + type: string; + properties: Record; + geometry: { type: string; coordinates: number[][] }; +} + +interface GeoJsonCollection { + type: string; + features: GeoJsonFeature[]; +} + +function mergeGeoJsonSegments(segments: GeoJsonCollection[]): GeoJsonCollection { + const allCoords: number[][] = []; + let totalLength = 0; + let totalAscend = 0; + let totalTime = 0; + + for (let i = 0; i < segments.length; i++) { + const feature = segments[i]!.features?.[0]; + if (!feature) continue; + + const coords = feature.geometry.coordinates; + const startIdx = i === 0 ? 0 : 1; + for (let j = startIdx; j < coords.length; j++) { + allCoords.push(coords[j]!); + } + + const props = feature.properties; + totalLength += parseInt(String(props["track-length"] ?? "0")); + totalAscend += parseInt(String(props["filtered ascend"] ?? "0")); + totalTime += parseInt(String(props["total-time"] ?? "0")); + } + + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + "track-length": String(totalLength), + "filtered ascend": String(totalAscend), + "total-time": String(totalTime), + creator: "trails.cool (BRouter segments)", + }, + geometry: { + type: "LineString", + coordinates: allCoords, + }, + }, + ], + }; +} + +function makeSegment(coords: number[][], length: number, ascend: number): GeoJsonCollection { + return { + type: "FeatureCollection", + features: [{ + type: "Feature", + properties: { + "track-length": String(length), + "filtered ascend": String(ascend), + "total-time": "100", + }, + geometry: { type: "LineString", coordinates: coords }, + }], + }; +} + +describe("mergeGeoJsonSegments", () => { + it("merges two segments", () => { + const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10); + const seg2 = makeSegment([[13.2, 52.2], [13.3, 52.3], [13.4, 52.4]], 1500, 20); + + const result = mergeGeoJsonSegments([seg1, seg2]); + const coords = result.features[0]!.geometry.coordinates; + + // Should have 5 points (first segment 3 + second segment 2, skipping duplicate) + expect(coords).toHaveLength(5); + expect(coords[0]).toEqual([13.0, 52.0]); + expect(coords[2]).toEqual([13.2, 52.2]); // shared point + expect(coords[4]).toEqual([13.4, 52.4]); + }); + + it("skips duplicate point at segment boundaries", () => { + const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0); + const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0); + const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0); + + const result = mergeGeoJsonSegments([seg1, seg2, seg3]); + const coords = result.features[0]!.geometry.coordinates; + + expect(coords).toHaveLength(4); + expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]); + }); + + it("accumulates stats across segments", () => { + const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50); + const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30); + + const result = mergeGeoJsonSegments([seg1, seg2]); + const props = result.features[0]!.properties; + + expect(props["track-length"]).toBe("3000"); + expect(props["filtered ascend"]).toBe("80"); + expect(props["total-time"]).toBe("200"); + }); + + it("handles single segment", () => { + const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10); + const result = mergeGeoJsonSegments([seg]); + + expect(result.features[0]!.geometry.coordinates).toHaveLength(3); + expect(result.features[0]!.properties["track-length"]).toBe("500"); + }); + + it("handles empty segment gracefully", () => { + const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0); + const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] }; + + const result = mergeGeoJsonSegments([seg1, empty]); + expect(result.features[0]!.geometry.coordinates).toHaveLength(2); + }); + + it("preserves elevation data in coordinates", () => { + const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50); + const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50); + + const result = mergeGeoJsonSegments([seg1, seg2]); + const coords = result.features[0]!.geometry.coordinates; + + expect(coords).toHaveLength(3); + expect(coords[0]).toEqual([13.0, 52.0, 100]); + expect(coords[1]).toEqual([13.1, 52.1, 150]); + expect(coords[2]).toEqual([13.2, 52.2, 200]); + }); +}); + +describe("waypoint to BRouter segments", () => { + it("splits N waypoints into N-1 pairs", () => { + const waypoints = [ + { lat: 52.0, lon: 13.0 }, + { lat: 52.1, lon: 13.1 }, + { lat: 52.2, lon: 13.2 }, + { lat: 52.3, lon: 13.3 }, + ]; + + const pairs: Array<[typeof waypoints[0], typeof waypoints[0]]> = []; + for (let i = 0; i < waypoints.length - 1; i++) { + pairs.push([waypoints[i]!, waypoints[i + 1]!]); + } + + expect(pairs).toHaveLength(3); + expect(pairs[0]).toEqual([waypoints[0], waypoints[1]]); + expect(pairs[1]).toEqual([waypoints[1], waypoints[2]]); + expect(pairs[2]).toEqual([waypoints[2], waypoints[3]]); + }); + + it("formats lon,lat correctly for BRouter", () => { + const wp = { lat: 52.5277, lon: 13.4033 }; + const lonlat = `${wp.lon},${wp.lat}`; + expect(lonlat).toBe("13.4033,52.5277"); + }); +}); diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index d9c02d1..e709b86 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -7,29 +7,101 @@ export interface RouteRequest { format?: string; } +/** + * Compute a route segment-by-segment between consecutive waypoints. + * This matches bikerouter.de's behavior and guarantees the route + * passes through every waypoint. + */ export async function computeRoute(request: RouteRequest): Promise { if (request.waypoints.length < 2) { throw new Error("At least 2 waypoints are required"); } - const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|"); + const profile = request.profile ?? "trekking"; + const format = request.format ?? "geojson"; - const params = new URLSearchParams({ - lonlats, - profile: request.profile ?? "trekking", - alternativeidx: String(request.alternativeIdx ?? 0), - format: request.format ?? "geojson", - }); + // Route each segment independently + const segments = await Promise.all( + request.waypoints.slice(0, -1).map((wp, i) => { + const next = request.waypoints[i + 1]!; + const lonlats = `${wp.lon},${wp.lat}|${next.lon},${next.lat}`; + const params = new URLSearchParams({ + lonlats, + profile, + alternativeidx: "0", + format, + }); + return fetchSegment(`${BROUTER_URL}/brouter?${params}`); + }), + ); - const url = `${BROUTER_URL}/brouter?${params}`; + // Merge GeoJSON segments into a single FeatureCollection + return mergeGeoJsonSegments(segments); +} + +async function fetchSegment(url: string): Promise> { const response = await fetch(url); - if (!response.ok) { const body = await response.text(); throw new Error(`BRouter error (${response.status}): ${body}`); } + return response.json() as Promise>; +} - return response.json(); +interface GeoJsonFeature { + type: string; + properties: Record; + geometry: { type: string; coordinates: number[][] }; +} + +interface GeoJsonCollection { + type: string; + features: GeoJsonFeature[]; +} + +function mergeGeoJsonSegments(segments: Record[]): GeoJsonCollection { + const allCoords: number[][] = []; + let totalLength = 0; + let totalAscend = 0; + let totalTime = 0; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] as unknown as GeoJsonCollection; + const feature = segment.features?.[0]; + if (!feature) continue; + + const coords = feature.geometry.coordinates; + // Skip first point of subsequent segments to avoid duplicates + const startIdx = i === 0 ? 0 : 1; + for (let j = startIdx; j < coords.length; j++) { + allCoords.push(coords[j]!); + } + + // Accumulate stats + const props = feature.properties; + totalLength += parseInt(String(props["track-length"] ?? "0")); + totalAscend += parseInt(String(props["filtered ascend"] ?? "0")); + totalTime += parseInt(String(props["total-time"] ?? "0")); + } + + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + "track-length": String(totalLength), + "filtered ascend": String(totalAscend), + "total-time": String(totalTime), + creator: "trails.cool (BRouter segments)", + }, + geometry: { + type: "LineString", + coordinates: allCoords, + }, + }, + ], + }; } export function getBRouterUrl(): string { diff --git a/apps/planner/app/lib/host-election.test.ts b/apps/planner/app/lib/host-election.test.ts new file mode 100644 index 0000000..3467f04 --- /dev/null +++ b/apps/planner/app/lib/host-election.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { electHost } from "./host-election"; + +function makeStates( + entries: Array<{ id: number; user?: boolean; role?: string }>, +): Map> { + const map = new Map>(); + for (const e of entries) { + const state: Record = {}; + if (e.user) state.user = { color: "#000", name: "Test" }; + if (e.role) state.role = e.role; + map.set(e.id, state); + } + return map; +} + +describe("electHost", () => { + it("elects the only real client as host", () => { + const states = makeStates([ + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + }); + + it("elects lowest ID among two real clients", () => { + const states = makeStates([ + { id: 200, user: true }, + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("ignores server docs (no user state)", () => { + const states = makeStates([ + { id: 50 }, // server doc — lowest ID but no user + { id: 200, user: true }, + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("always assigns a role (never undefined)", () => { + const states = makeStates([ + { id: 300, user: true }, + { id: 100, user: true }, + { id: 200, user: true }, + ]); + const result = electHost(states, 200); + expect(result.role).toBeDefined(); + expect(result.role).toBe("participant"); + }); + + it("handles single client with server doc", () => { + const states = makeStates([ + { id: 999 }, // server + { id: 500, user: true }, + ]); + expect(electHost(states, 500)).toEqual({ isHost: true, role: "host" }); + }); + + it("handles no real clients (defensive)", () => { + const states = makeStates([ + { id: 10 }, // server only + ]); + // localId not in states — still returns host as fallback + expect(electHost(states, 42)).toEqual({ isHost: true, role: "host" }); + }); + + it("ignores existing role claims — always uses lowest ID", () => { + const states = makeStates([ + { id: 200, user: true, role: "host" }, + { id: 100, user: true, role: "participant" }, + ]); + // ID 100 should be host regardless of current role claims + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("works with many clients", () => { + const states = makeStates([ + { id: 999, user: true }, + { id: 500, user: true }, + { id: 42 }, // server + { id: 200, user: true }, + { id: 100, user: true }, + { id: 800, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 500)).toEqual({ isHost: false, role: "participant" }); + expect(electHost(states, 999)).toEqual({ isHost: false, role: "participant" }); + }); +}); diff --git a/apps/planner/app/lib/host-election.ts b/apps/planner/app/lib/host-election.ts new file mode 100644 index 0000000..24b7d46 --- /dev/null +++ b/apps/planner/app/lib/host-election.ts @@ -0,0 +1,26 @@ +/** + * Determines which client should be the routing host. + * + * Simple rule: the real client (has user state) with the lowest + * client ID is always the host. Deterministic, no race conditions. + */ +export function electHost( + states: Map>, + localId: number, +): { isHost: boolean; role: "host" | "participant" } { + let lowestRealId = Infinity; + + states.forEach((state, clientId) => { + if (state.user) { + if (clientId < lowestRealId) lowestRealId = clientId; + } + }); + + // No real clients found (shouldn't happen, but defensive) + if (lowestRealId === Infinity) { + return { isHost: true, role: "host" }; + } + + const isHost = localId === lowestRealId; + return { isHost, role: isHost ? "host" : "participant" }; +} diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index fb9ba17..227c967 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import type { YjsState } from "./use-yjs"; +import { electHost } from "./host-election"; interface RouteStats { distance?: number; @@ -31,17 +32,11 @@ export function useRouting(yjs: YjsState | null) { if (!yjs) return; const checkHost = () => { - const states = yjs.awareness.getStates(); + const states = yjs.awareness.getStates() as Map>; const localId = yjs.awareness.clientID; - - let lowestId = Infinity; - states.forEach((_state, clientId) => { - if (clientId < lowestId) lowestId = clientId; - }); - - const amHost = localId === lowestId; + const { isHost: amHost, role } = electHost(states, localId); setIsHost(amHost); - yjs.awareness.setLocalStateField("role", amHost ? "host" : "participant"); + yjs.awareness.setLocalStateField("role", role); }; yjs.awareness.on("change", checkHost); diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 7170b87..066c7fb 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -16,6 +16,17 @@ function randomItem(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; @@ -41,8 +52,8 @@ export function useYjs(sessionId: string): YjsState | null { const waypoints = doc.getArray>("waypoints"); const routeData = doc.getMap("routeData"); - const color = randomItem(COLORS); - const name = randomItem(NAMES); + // Use persistent identity + const { color, name } = getOrCreateUserIdentity(); provider.awareness.setLocalStateField("user", { color, name }); const updateState = (connected: boolean) => { @@ -60,7 +71,6 @@ export function useYjs(sessionId: string): YjsState | null { updateState(status === "connected"); }); - // Set initial state (even before connection) updateState(false); return () => { diff --git a/apps/planner/app/lib/vite-yjs-plugin.ts b/apps/planner/app/lib/vite-yjs-plugin.ts index 219a6ef..13383ec 100644 --- a/apps/planner/app/lib/vite-yjs-plugin.ts +++ b/apps/planner/app/lib/vite-yjs-plugin.ts @@ -16,7 +16,8 @@ const messageAwareness = 1; export function yjsDevPlugin(): Plugin { const docs = new Map(); const awarenessMap = new Map(); - const conns = new Map }>(); + // Map each WebSocket to its doc name and the awareness client IDs it owns + const conns = new Map }>(); function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { let doc = docs.get(docName); @@ -35,7 +36,7 @@ export function yjsDevPlugin(): Plugin { const message = encoding.toUint8Array(encoder); for (const [ws, meta] of conns) { - if (meta.docName === docName) { + if (meta.docName === docName && ws.readyState === ws.OPEN) { ws.send(message); } } @@ -63,6 +64,20 @@ export function yjsDevPlugin(): Plugin { case messageAwareness: { const update = decoding.readVarUint8Array(decoder); awarenessProtocol.applyAwarenessUpdate(awareness, update, ws); + + // Track which client IDs this WebSocket is responsible for + // by checking what was added/updated in this awareness update + const meta = conns.get(ws); + if (meta) { + awareness.getStates().forEach((_state, clientId) => { + // The awareness meta stores which "origin" (ws) last updated each client + const awarenessClientMeta = awareness.meta.get(clientId); + if (awarenessClientMeta) { + // If this client ID was just updated from this connection, track it + meta.clientIds.add(clientId); + } + }); + } break; } } @@ -81,7 +96,7 @@ export function yjsDevPlugin(): Plugin { wss.handleUpgrade(request, socket, head, (ws) => { const { doc, awareness } = getDoc(docName); - conns.set(ws, { docName, awarenessIds: new Set() }); + conns.set(ws, { docName, clientIds: new Set() }); // Send sync step 1 const encoder = encoding.createEncoder(); @@ -89,7 +104,7 @@ export function yjsDevPlugin(): Plugin { syncProtocol.writeSyncStep1(encoder, doc); ws.send(encoding.toUint8Array(encoder)); - // Send awareness states + // Send current awareness states const awarenessStates = awareness.getStates(); if (awarenessStates.size > 0) { const awarenessEncoder = encoding.createEncoder(); @@ -107,8 +122,13 @@ export function yjsDevPlugin(): Plugin { ws.on("close", () => { const meta = conns.get(ws); - if (meta) { - awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null); + if (meta && meta.clientIds.size > 0) { + // Immediately remove awareness states for all clients on this connection + awarenessProtocol.removeAwarenessStates( + awareness, + Array.from(meta.clientIds), + null, + ); } conns.delete(ws); });