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:
Bas van Rossem
2026-02-19 16:44:15 +01:00
parent 275137cdbb
commit aceef65002
2 changed files with 43 additions and 20 deletions

View File

@@ -108,8 +108,24 @@ export const useShipStore = create<ShipStore>((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<ShipStore>((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) => {