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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import TopBar from '../components/layout/TopBar';
|
import TopBar from '../components/layout/TopBar';
|
||||||
import PageContainer from '../components/layout/PageContainer';
|
import PageContainer from '../components/layout/PageContainer';
|
||||||
@@ -7,29 +7,16 @@ import MobilitySection from '../components/dashboard/MobilitySection';
|
|||||||
import WeaponsSection from '../components/dashboard/WeaponsSection';
|
import WeaponsSection from '../components/dashboard/WeaponsSection';
|
||||||
import NotesSection from '../components/dashboard/NotesSection';
|
import NotesSection from '../components/dashboard/NotesSection';
|
||||||
import { useShipStore } from '../store/use-ship-store';
|
import { useShipStore } from '../store/use-ship-store';
|
||||||
import type { Ship } from '../types/ship';
|
|
||||||
|
|
||||||
export default function ShipDashboardPage() {
|
export default function ShipDashboardPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore();
|
const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore();
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) joinShip(id);
|
if (id) joinShip(id);
|
||||||
return () => leaveShip();
|
return () => leaveShip();
|
||||||
}, [id, joinShip, leaveShip]);
|
}, [id, joinShip, leaveShip]);
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
|
||||||
(patch: Partial<Ship>) => {
|
|
||||||
// Debounce rapid changes (e.g. stepper clicks)
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
updateShip(patch);
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
[updateShip],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading || !ship) {
|
if (loading || !ship) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -45,15 +32,15 @@ export default function ShipDashboardPage() {
|
|||||||
<>
|
<>
|
||||||
<TopBar title={ship.name} />
|
<TopBar title={ship.name} />
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<VitalsSection ship={ship} onUpdate={handleUpdate} />
|
<VitalsSection ship={ship} onUpdate={updateShip} />
|
||||||
<MobilitySection ship={ship} onUpdate={handleUpdate} />
|
<MobilitySection ship={ship} onUpdate={updateShip} />
|
||||||
<WeaponsSection
|
<WeaponsSection
|
||||||
weapons={weapons}
|
weapons={weapons}
|
||||||
onCreateWeapon={createWeapon}
|
onCreateWeapon={createWeapon}
|
||||||
onUpdateWeapon={updateWeapon}
|
onUpdateWeapon={updateWeapon}
|
||||||
onDeleteWeapon={deleteWeapon}
|
onDeleteWeapon={deleteWeapon}
|
||||||
/>
|
/>
|
||||||
<NotesSection notes={ship.notes} onUpdate={handleUpdate} />
|
<NotesSection notes={ship.notes} onUpdate={updateShip} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,8 +108,24 @@ export const useShipStore = create<ShipStore>((set, get) => ({
|
|||||||
|
|
||||||
updateShip: (patch) => {
|
updateShip: (patch) => {
|
||||||
const id = get().currentShipId;
|
const id = get().currentShipId;
|
||||||
if (!id) return;
|
const ship = get().ship;
|
||||||
socket.emit('ship:update', { shipId: id, patch });
|
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) => {
|
createWeapon: (weapon) => {
|
||||||
@@ -119,7 +135,27 @@ export const useShipStore = create<ShipStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateWeapon: (weaponId, patch) => {
|
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) => {
|
deleteWeapon: (weaponId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user