From e723a62ed08756b759d8ae0d196a502cf928bef1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:16:28 +0300 Subject: [PATCH] refactor(team): extract member activity meta helpers --- src/renderer/store/slices/teamSlice.ts | 61 +----- .../store/team/teamMemberActivityMeta.ts | 64 ++++++ .../store/teamMemberActivityMeta.test.ts | 197 ++++++++++++++++++ 3 files changed, 265 insertions(+), 57 deletions(-) create mode 100644 src/renderer/store/team/teamMemberActivityMeta.ts create mode 100644 test/renderer/store/teamMemberActivityMeta.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d0b14d51..b018a59b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -52,6 +52,10 @@ import { invalidateTeamLocalStateEpoch, isTeamLocalStateEpochCurrent, } from '../team/teamLocalStateEpoch'; +import { + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../team/teamMemberActivityMeta'; import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality'; import { clearAllMemberSpawnStatusesIpcBackoffs, @@ -1643,48 +1647,6 @@ function buildResolvedMember( }; } -function areMemberActivityMetaEntriesEqual( - left: MemberActivityMetaEntry | undefined, - right: MemberActivityMetaEntry -): boolean { - if (!left) { - return false; - } - return ( - left.memberName === right.memberName && - left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && - left.messageCountExact === right.messageCountExact && - left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination - ); -} - -function structurallyShareMemberActivityFacts( - previous: Record | undefined, - next: Record -): Record { - if (!previous) { - return next; - } - - const nextKeys = Object.keys(next); - const previousKeys = Object.keys(previous); - let changed = nextKeys.length !== previousKeys.length; - const shared: Record = {}; - - for (const key of nextKeys) { - const nextEntry = next[key]; - const previousEntry = previous[key]; - if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { - changed = true; - shared[key] = nextEntry; - continue; - } - shared[key] = previousEntry; - } - - return changed ? shared : previous; -} - type ResolvedMemberSelectorState = Pick< TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' @@ -1793,21 +1755,6 @@ export function selectResolvedMemberForTeamName( return result; } -function isMemberActivityMetaStale( - state: Pick, - teamName: string -): boolean { - const meta = state.memberActivityMetaByTeam[teamName]; - const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; - if (!meta) { - return true; - } - if (!feedRevision) { - return false; - } - return meta.feedRevision !== feedRevision; -} - function seedStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments, members: readonly TeamGraphMemberSeedInput[], diff --git a/src/renderer/store/team/teamMemberActivityMeta.ts b/src/renderer/store/team/teamMemberActivityMeta.ts new file mode 100644 index 00000000..da1b9b3a --- /dev/null +++ b/src/renderer/store/team/teamMemberActivityMeta.ts @@ -0,0 +1,64 @@ +import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache'; + +import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +export interface TeamMemberActivityMetaState extends TeamMessagesCacheState { + memberActivityMetaByTeam: Record; +} + +export function areMemberActivityMetaEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +export function structurallyShareMemberActivityFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export function isMemberActivityMetaStale( + state: TeamMemberActivityMetaState, + teamName: string +): boolean { + const meta = state.memberActivityMetaByTeam[teamName]; + const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (!meta) { + return true; + } + if (!feedRevision) { + return false; + } + return meta.feedRevision !== feedRevision; +} diff --git a/test/renderer/store/teamMemberActivityMeta.test.ts b/test/renderer/store/teamMemberActivityMeta.test.ts new file mode 100644 index 00000000..32ca24b6 --- /dev/null +++ b/test/renderer/store/teamMemberActivityMeta.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { + areMemberActivityMetaEntriesEqual, + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../../../src/renderer/store/team/teamMemberActivityMeta'; + +import type { TeamMessagesCacheEntry } from '../../../src/renderer/store/team/teamMessagesCache'; +import type { + MemberActivityMetaEntry, + TeamMemberActivityMeta, +} from '../../../src/shared/types'; + +function createEntry(overrides: Partial = {}): MemberActivityMetaEntry { + return { + memberName: 'alice', + lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + ...overrides, + }; +} + +function createMeta(overrides: Partial = {}): TeamMemberActivityMeta { + return { + teamName: 'my-team', + computedAt: '2026-05-22T10:00:00.000Z', + members: { + alice: createEntry(), + }, + feedRevision: 'feed-1', + ...overrides, + }; +} + +function createMessagesEntry( + overrides: Partial = {} +): TeamMessagesCacheEntry { + return { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'feed-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + ...overrides, + }; +} + +describe('teamMemberActivityMeta', () => { + it('compares member activity entries by visible facts', () => { + expect(areMemberActivityMetaEntriesEqual(createEntry(), createEntry())).toBe(true); + expect( + areMemberActivityMetaEntriesEqual(createEntry(), createEntry({ messageCountExact: 4 })) + ).toBe(false); + expect( + areMemberActivityMetaEntriesEqual( + createEntry(), + createEntry({ latestAuthoredMessageSignalsTermination: true }) + ) + ).toBe(false); + expect(areMemberActivityMetaEntriesEqual(undefined, createEntry())).toBe(false); + }); + + it('returns next activity facts when there is no previous record', () => { + const next = { + alice: createEntry(), + }; + + expect(structurallyShareMemberActivityFacts(undefined, next)).toBe(next); + }); + + it('preserves the previous record when all entries are semantically equal', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + const next = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + + expect(structurallyShareMemberActivityFacts(previous, next)).toBe(previous); + }); + + it('shares unchanged entries and replaces changed entries', () => { + const previousAlice = createEntry(); + const previousBob = createEntry({ memberName: 'bob', messageCountExact: 1 }); + const nextBob = createEntry({ memberName: 'bob', messageCountExact: 2 }); + const previous = { + alice: previousAlice, + bob: previousBob, + }; + + const shared = structurallyShareMemberActivityFacts( + previous, + { + alice: createEntry(), + bob: nextBob, + } + ); + + expect(shared).not.toBe(previous); + expect(shared.alice).toBe(previousAlice); + expect(shared.bob).toBe(nextBob); + }); + + it('returns a new record when activity keys are added or removed', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }; + + const removed = structurallyShareMemberActivityFacts(previous, { + alice: createEntry(), + }); + + expect(removed).not.toBe(previous); + expect(removed).toEqual({ + alice: previous.alice, + }); + expect(removed.alice).toBe(previous.alice); + + const singlePrevious = { + alice: createEntry(), + }; + const added = structurallyShareMemberActivityFacts(singlePrevious, { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }); + + expect(added).not.toBe(singlePrevious); + expect(added.alice).toBe(singlePrevious.alice); + expect(added.bob).toEqual(createEntry({ memberName: 'bob' })); + }); + + it('treats missing member activity meta as stale', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: {}, + teamMessagesByName: {}, + }, + 'my-team' + ) + ).toBe(true); + }); + + it('does not require refresh when the message feed has no revision yet', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'old-feed' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: null }), + }, + }, + 'my-team' + ) + ).toBe(false); + }); + + it('compares member activity meta feedRevision against the messages feed revision', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-1' }), + }, + }, + 'my-team' + ) + ).toBe(false); + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-2' }), + }, + }, + 'my-team' + ) + ).toBe(true); + }); +});