From 1d2f61ad86183b38d78174591ea4e10fc9416b7d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 00:06:28 +0300 Subject: [PATCH] refactor(team): extract team data selectors --- src/renderer/store/slices/teamSlice.ts | 52 ++-------- src/renderer/store/team/teamDataSelectors.ts | 47 +++++++++ test/renderer/store/teamDataSelectors.test.ts | 95 +++++++++++++++++++ 3 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 src/renderer/store/team/teamDataSelectors.ts create mode 100644 test/renderer/store/teamDataSelectors.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4fc94234..58151dee 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,7 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { selectTeamDataForName } from '../team/teamDataSelectors'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -95,6 +96,12 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +export { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, +} from '../team/teamDataSelectors'; export type { RefreshTeamMessagesHeadResult, TeamMessagesCacheEntry, @@ -1763,9 +1770,6 @@ const resolvedMemberSelectorCache = new Map< result: ResolvedTeamMember | null; } >(); -const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; -const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; - function resolveMemberStatus( snapshot: TeamMemberSnapshot, activity: MemberActivityMetaEntry | undefined @@ -2187,27 +2191,6 @@ function structurallyShareMemberActivityFacts( return changed ? shared : previous; } -type TeamDataSelectorState = Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' ->; - -export function selectTeamDataForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot | null { - if (!teamName) { - return null; - } - if (state.selectedTeamName === teamName && state.selectedTeamData) { - return state.selectedTeamData; - } - return ( - state.teamDataCacheByName[teamName] ?? - (state.selectedTeamName === teamName ? state.selectedTeamData : null) - ); -} - type ResolvedMemberSelectorState = Pick< TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' @@ -2316,27 +2299,6 @@ export function selectResolvedMemberForTeamName( return result; } -export function selectTeamMemberSnapshotsForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['members'] { - return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; -} - -export function selectTeamTasksForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['tasks'] { - return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; -} - -export function selectTeamIsAliveForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): boolean | undefined { - return selectTeamDataForName(state, teamName)?.isAlive; -} - function isMemberActivityMetaStale( state: Pick, teamName: string diff --git a/src/renderer/store/team/teamDataSelectors.ts b/src/renderer/store/team/teamDataSelectors.ts new file mode 100644 index 00000000..aaa23433 --- /dev/null +++ b/src/renderer/store/team/teamDataSelectors.ts @@ -0,0 +1,47 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +export interface TeamDataSelectorState { + teamDataCacheByName: Record; + selectedTeamName: string | null; + selectedTeamData: TeamViewSnapshot | null; +} + +const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = []; +const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; + +export function selectTeamDataForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot | null { + if (!teamName) { + return null; + } + if (state.selectedTeamName === teamName && state.selectedTeamData) { + return state.selectedTeamData; + } + return ( + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null) + ); +} + +export function selectTeamMemberSnapshotsForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['members'] { + return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; +} + +export function selectTeamTasksForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['tasks'] { + return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; +} + +export function selectTeamIsAliveForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): boolean | undefined { + return selectTeamDataForName(state, teamName)?.isAlive; +} diff --git a/test/renderer/store/teamDataSelectors.test.ts b/test/renderer/store/teamDataSelectors.test.ts new file mode 100644 index 00000000..049081cf --- /dev/null +++ b/test/renderer/store/teamDataSelectors.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, + type TeamDataSelectorState, +} from '../../../src/renderer/store/team/teamDataSelectors'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +function createState(overrides: Partial = {}): TeamDataSelectorState { + return { + teamDataCacheByName: {}, + selectedTeamName: null, + selectedTeamData: null, + ...overrides, + }; +} + +describe('teamDataSelectors', () => { + it('returns null when no team name is selected or cached', () => { + const state = createState(); + + expect(selectTeamDataForName(state, null)).toBeNull(); + expect(selectTeamDataForName(state, undefined)).toBeNull(); + expect(selectTeamDataForName(state, 'missing-team')).toBeNull(); + }); + + it('prefers selected team data over cached data for the active team', () => { + const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false }); + const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'my-team', + selectedTeamData: selectedSnapshot, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot); + }); + + it('falls back to cached team data outside the selected snapshot', () => { + const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'other-team', + selectedTeamData: createSnapshot({ teamName: 'other-team' }), + teamDataCacheByName: { + 'cached-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot); + }); + + it('returns stable empty arrays and scalar fields from team snapshots', () => { + const task = { id: 'task-1', subject: 'Build', status: 'pending' as const }; + const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 }; + const state = createState({ + teamDataCacheByName: { + 'my-team': createSnapshot({ + members: [member], + tasks: [task], + isAlive: true, + }), + }, + }); + + expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]); + expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]); + expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true); + + expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe( + selectTeamMemberSnapshotsForName(state, 'missing-team') + ); + expect(selectTeamTasksForName(state, 'missing-team')).toBe( + selectTeamTasksForName(state, 'missing-team') + ); + expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined(); + }); +});