From aceef65002a75d8edc6c0bc1519efc3e6a5a710c Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:44:15 +0100 Subject: [PATCH] feat(web): optimistic UI updates with debounced server sync Ship and weapon updates now apply locally immediately so stepper clicks feel instant. Server sends are debounced (300ms) and batched so rapid clicks produce a single network call. Co-Authored-By: Claude Opus 4.6 --- web/src/pages/ShipDashboardPage.tsx | 21 +++------------ web/src/store/use-ship-store.ts | 42 ++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/web/src/pages/ShipDashboardPage.tsx b/web/src/pages/ShipDashboardPage.tsx index 8e63598..13e3d55 100644 --- a/web/src/pages/ShipDashboardPage.tsx +++ b/web/src/pages/ShipDashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import TopBar from '../components/layout/TopBar'; import PageContainer from '../components/layout/PageContainer'; @@ -7,29 +7,16 @@ import MobilitySection from '../components/dashboard/MobilitySection'; import WeaponsSection from '../components/dashboard/WeaponsSection'; import NotesSection from '../components/dashboard/NotesSection'; import { useShipStore } from '../store/use-ship-store'; -import type { Ship } from '../types/ship'; export default function ShipDashboardPage() { const { id } = useParams<{ id: string }>(); const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore(); - const debounceRef = useRef | null>(null); useEffect(() => { if (id) joinShip(id); return () => leaveShip(); }, [id, joinShip, leaveShip]); - const handleUpdate = useCallback( - (patch: Partial) => { - // Debounce rapid changes (e.g. stepper clicks) - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - updateShip(patch); - }, 300); - }, - [updateShip], - ); - if (loading || !ship) { return ( <> @@ -45,15 +32,15 @@ export default function ShipDashboardPage() { <> - - + + - + ); diff --git a/web/src/store/use-ship-store.ts b/web/src/store/use-ship-store.ts index 99fc269..6693bf5 100644 --- a/web/src/store/use-ship-store.ts +++ b/web/src/store/use-ship-store.ts @@ -108,8 +108,24 @@ export const useShipStore = create((set, get) => ({ updateShip: (patch) => { const id = get().currentShipId; - if (!id) return; - socket.emit('ship:update', { shipId: id, patch }); + const ship = get().ship; + if (!id || !ship) return; + + // Optimistic: apply locally immediately + set({ ship: { ...ship, ...patch } }); + + // Accumulate patches and debounce the server send + const pending = (socket as any).__pendingPatch ?? {}; + Object.assign(pending, patch); + (socket as any).__pendingPatch = pending; + + if ((socket as any).__patchTimer) clearTimeout((socket as any).__patchTimer); + (socket as any).__patchTimer = setTimeout(() => { + const merged = (socket as any).__pendingPatch; + delete (socket as any).__pendingPatch; + delete (socket as any).__patchTimer; + if (merged) socket.emit('ship:update', { shipId: id, patch: merged }); + }, 300); }, createWeapon: (weapon) => { @@ -119,7 +135,27 @@ export const useShipStore = create((set, get) => ({ }, updateWeapon: (weaponId, patch) => { - socket.emit('weapon:update', { weaponId, patch }); + // Optimistic: apply locally immediately + set({ + weapons: get().weapons.map((w) => + w.id === weaponId ? { ...w, ...patch } : w, + ), + }); + + // Debounce per-weapon server sends + const timerKey = `__weaponTimer_${weaponId}`; + const patchKey = `__weaponPatch_${weaponId}`; + const pending = (socket as any)[patchKey] ?? {}; + Object.assign(pending, patch); + (socket as any)[patchKey] = pending; + + if ((socket as any)[timerKey]) clearTimeout((socket as any)[timerKey]); + (socket as any)[timerKey] = setTimeout(() => { + const merged = (socket as any)[patchKey]; + delete (socket as any)[patchKey]; + delete (socket as any)[timerKey]; + if (merged) socket.emit('weapon:update', { weaponId, patch: merged }); + }, 300); }, deleteWeapon: (weaponId) => {