refactor(team): extract member activity meta helpers

This commit is contained in:
777genius 2026-05-22 11:16:28 +03:00
parent 993982311d
commit e723a62ed0
3 changed files with 265 additions and 57 deletions

View file

@ -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<string, MemberActivityMetaEntry> | undefined,
next: Record<string, MemberActivityMetaEntry>
): Record<string, MemberActivityMetaEntry> {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
const previousKeys = Object.keys(previous);
let changed = nextKeys.length !== previousKeys.length;
const shared: Record<string, MemberActivityMetaEntry> = {};
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<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
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[],

View file

@ -0,0 +1,64 @@
import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache';
import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
export interface TeamMemberActivityMetaState extends TeamMessagesCacheState {
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
}
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<string, MemberActivityMetaEntry> | undefined,
next: Record<string, MemberActivityMetaEntry>
): Record<string, MemberActivityMetaEntry> {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
const previousKeys = Object.keys(previous);
let changed = nextKeys.length !== previousKeys.length;
const shared: Record<string, MemberActivityMetaEntry> = {};
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;
}

View file

@ -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> = {}): MemberActivityMetaEntry {
return {
memberName: 'alice',
lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z',
messageCountExact: 3,
latestAuthoredMessageSignalsTermination: false,
...overrides,
};
}
function createMeta(overrides: Partial<TeamMemberActivityMeta> = {}): TeamMemberActivityMeta {
return {
teamName: 'my-team',
computedAt: '2026-05-22T10:00:00.000Z',
members: {
alice: createEntry(),
},
feedRevision: 'feed-1',
...overrides,
};
}
function createMessagesEntry(
overrides: Partial<TeamMessagesCacheEntry> = {}
): 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);
});
});