From 9705304be928038da27625b6adbf55309b0af2f5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:55:45 +0100 Subject: [PATCH] feat(activity): fan out activity updates to all connected instances Switch pushActivities and setShowActivity from wsSend (home-only) to wsSendAll (all instances). Add myActivities cache for re-push on late connections. --- .../web/src/stores/activityFanout.test.ts | 85 +++++++++++++++++++ packages/web/src/stores/activityStore.ts | 12 ++- 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/stores/activityFanout.test.ts diff --git a/packages/web/src/stores/activityFanout.test.ts b/packages/web/src/stores/activityFanout.test.ts new file mode 100644 index 00000000..078e5917 --- /dev/null +++ b/packages/web/src/stores/activityFanout.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock useWebSocket before importing activityStore +const mockWsSend = vi.fn(); +const mockWsSendAll = vi.fn(); +vi.mock('../hooks/useWebSocket', () => ({ + wsSend: (...args: unknown[]) => mockWsSend(...args), + wsSendAll: (...args: unknown[]) => mockWsSendAll(...args), +})); + +import { useActivityStore } from './activityStore'; +import type { Activity } from '@backspace/shared'; + +const GAME_ACTIVITY: Activity = { + type: 'playing', + name: 'Minecraft', + timestamps: { start: Date.now() }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + useActivityStore.getState().reset(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('activityStore fan-out', () => { + it('pushActivities caches myActivities immediately before debounce', () => { + useActivityStore.getState().pushActivities([GAME_ACTIVITY]); + + // myActivities should be set immediately (before the 5s debounce) + expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]); + + // wsSendAll should NOT have been called yet (debounce hasn't fired) + expect(mockWsSendAll).not.toHaveBeenCalled(); + }); + + it('pushActivities calls wsSendAll (not wsSend) after debounce', () => { + useActivityStore.getState().pushActivities([GAME_ACTIVITY]); + vi.advanceTimersByTime(5000); + + expect(mockWsSendAll).toHaveBeenCalledWith({ + type: 'activity_update', + activities: [GAME_ACTIVITY], + }); + expect(mockWsSend).not.toHaveBeenCalled(); + }); + + it('setShowActivity(false) fans out empty activities via wsSendAll and clears myActivities', () => { + // First set some activities + useActivityStore.getState().pushActivities([GAME_ACTIVITY]); + expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]); + + // Now disable + useActivityStore.getState().setShowActivity(false); + + expect(mockWsSendAll).toHaveBeenCalledWith({ + type: 'activity_update', + activities: [], + }); + expect(useActivityStore.getState().myActivities).toBeNull(); + }); + + it('reset clears myActivities', () => { + useActivityStore.getState().pushActivities([GAME_ACTIVITY]); + expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]); + + useActivityStore.getState().reset(); + expect(useActivityStore.getState().myActivities).toBeNull(); + }); + + it('pushActivities does nothing when showActivity is false', () => { + useActivityStore.getState().setShowActivity(false); + vi.clearAllMocks(); + + useActivityStore.getState().pushActivities([GAME_ACTIVITY]); + expect(useActivityStore.getState().myActivities).toBeNull(); + + vi.advanceTimersByTime(5000); + expect(mockWsSendAll).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/stores/activityStore.ts b/packages/web/src/stores/activityStore.ts index d4f927ce..69a8785e 100644 --- a/packages/web/src/stores/activityStore.ts +++ b/packages/web/src/stores/activityStore.ts @@ -1,12 +1,13 @@ import { create } from 'zustand'; import type { Activity } from '@backspace/shared'; -import { wsSend } from '../hooks/useWebSocket'; +import { wsSendAll } from '../hooks/useWebSocket'; let pushTimer: ReturnType | null = null; interface ActivityState { userActivities: Map; showActivity: boolean; + myActivities: Activity[] | null; setUserActivities: (userId: string, activities: Activity[]) => void; clearUserActivities: (userId: string) => void; @@ -19,6 +20,7 @@ interface ActivityState { export const useActivityStore = create((set, get) => ({ userActivities: new Map(), showActivity: true, + myActivities: null, setUserActivities: (userId, activities) => { set((state) => { @@ -58,21 +60,23 @@ export const useActivityStore = create((set, get) => ({ set({ showActivity: show }); if (!show) { if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } - wsSend({ type: 'activity_update', activities: [] }); + wsSendAll({ type: 'activity_update', activities: [] }); + set({ myActivities: null }); } }, pushActivities: (activities) => { if (!get().showActivity) return; + set({ myActivities: activities }); if (pushTimer) clearTimeout(pushTimer); pushTimer = setTimeout(() => { - wsSend({ type: 'activity_update', activities }); + wsSendAll({ type: 'activity_update', activities }); pushTimer = null; }, 5000); }, reset: () => { if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } - set({ userActivities: new Map(), showActivity: true }); + set({ userActivities: new Map(), showActivity: true, myActivities: null }); }, }));