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,
|
invalidateTeamLocalStateEpoch,
|
||||||
isTeamLocalStateEpochCurrent,
|
isTeamLocalStateEpochCurrent,
|
||||||
} from '../team/teamLocalStateEpoch';
|
} from '../team/teamLocalStateEpoch';
|
||||||
|
import {
|
||||||
|
isMemberActivityMetaStale,
|
||||||
|
structurallyShareMemberActivityFacts,
|
||||||
|
} from '../team/teamMemberActivityMeta';
|
||||||
import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality';
|
import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality';
|
||||||
import {
|
import {
|
||||||
clearAllMemberSpawnStatusesIpcBackoffs,
|
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<
|
type ResolvedMemberSelectorState = Pick<
|
||||||
TeamSlice,
|
TeamSlice,
|
||||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||||
|
|
@ -1793,21 +1755,6 @@ export function selectResolvedMemberForTeamName(
|
||||||
return result;
|
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(
|
function seedStableSlotAssignmentsForMembers(
|
||||||
assignments: TeamGraphSlotAssignments,
|
assignments: TeamGraphSlotAssignments,
|
||||||
members: readonly TeamGraphMemberSeedInput[],
|
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