refactor(team): extract member activity meta helpers
This commit is contained in:
parent
993982311d
commit
e723a62ed0
3 changed files with 265 additions and 57 deletions
|
|
@ -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[],
|
||||
|
|
|
|||
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal file
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal 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;
|
||||
}
|
||||
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal file
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue