diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index ce88d46c..d805172b 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -677,19 +677,60 @@ export class TeamGraphAdapter { ): void { const taskStateById = new Map< string, - Pick + Pick< + TeamGraphData['tasks'][number], + 'id' | 'displayId' | 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt' + > >(); const taskDisplayIds = new Map(); + const taskIdsByCanonicalReference = new Map>(); + const taskIdsByDisplayReference = new Map>(); const memberColorByName = new Map(); + const addTaskReference = ( + references: Map>, + reference: string | undefined, + taskId: string + ): void => { + const normalized = reference?.trim().replace(/^#/, ''); + if (!normalized) return; + const taskIds = references.get(normalized) ?? new Set(); + taskIds.add(taskId); + references.set(normalized, taskIds); + }; + const resolveTaskReference = (reference: string): string | null => { + const normalized = reference.trim().replace(/^#/, ''); + if (!normalized) return null; + const canonicalTaskIds = taskIdsByCanonicalReference.get(normalized); + if (canonicalTaskIds?.size === 1) { + return [...canonicalTaskIds][0]!; + } + if (canonicalTaskIds && canonicalTaskIds.size > 1) { + return null; + } + const displayTaskIds = taskIdsByDisplayReference.get(normalized); + return displayTaskIds?.size === 1 ? [...displayTaskIds][0]! : null; + }; + const formatTaskReference = (reference: string): string => { + const taskId = resolveTaskReference(reference); + if (taskId) { + return taskDisplayIds.get(taskId) ?? `#${taskId.slice(0, 6)}`; + } + const trimmed = reference.trim(); + return trimmed.startsWith('#') ? trimmed : `#${trimmed.slice(0, 6)}`; + }; for (const t of data.tasks) { taskStateById.set(t.id, { + id: t.id, + ...(t.displayId ? { displayId: t.displayId } : {}), status: t.status, ...(t.reviewState ? { reviewState: t.reviewState } : {}), ...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}), ...(t.deletedAt ? { deletedAt: t.deletedAt } : {}), }); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); + addTaskReference(taskIdsByCanonicalReference, t.id, t.id); + addTaskReference(taskIdsByDisplayReference, t.displayId, t.id); } for (const member of data.members) { if (member.color) { @@ -726,10 +767,10 @@ export class TeamGraphAdapter { : TeamGraphAdapter.#mapReviewState(task.reviewState); const blockedByDisplayIds = task.blockedBy?.length - ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + ? task.blockedBy.map(formatTaskReference) : undefined; const blocksDisplayIds = task.blocks?.length - ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + ? task.blocks.map(formatTaskReference) : undefined; const totalCommentCount = task.comments?.length ?? 0; @@ -799,7 +840,10 @@ export class TeamGraphAdapter { targetTaskIds: Set; } >(); - const addBlockingRelation = (blockerId: string, blockedId: string): void => { + const addBlockingRelation = (blockerRef: string, blockedRef: string): void => { + const blockerId = resolveTaskReference(blockerRef); + const blockedId = resolveTaskReference(blockedRef); + if (!blockerId || !blockedId) return; if (blockerId === blockedId) return; const rawRelationKey = `${blockerId}->${blockedId}`; if (seenBlockingRelations.has(rawRelationKey)) return; @@ -842,7 +886,9 @@ export class TeamGraphAdapter { if (!visibleTaskIds.has(task.id)) continue; - for (const relatedId of task.related ?? []) { + for (const relatedRef of task.related ?? []) { + const relatedId = resolveTaskReference(relatedRef); + if (!relatedId) continue; if (!visibleTaskIds.has(relatedId)) continue; const key = task.id.localeCompare(relatedId) <= 0 diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index dd457aef..9bc2f2e7 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -90,11 +90,47 @@ function buildBaseItem( }; } -function taskReferenceKeys(task: Pick): string[] { - const keys = [task.id, task.displayId] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; +interface TaskReferenceIndex { + canonical: Map>; + display: Map>; +} + +function addTaskReference( + index: Map>, + reference: string | undefined, + task: MemberWorkSyncTaskLike +): void { + const normalized = reference?.trim().replace(/^#/, ''); + if (!normalized) return; + const matches = index.get(normalized) ?? new Set(); + matches.add(task); + index.set(normalized, matches); +} + +function buildTaskReferenceIndex(tasks: MemberWorkSyncTaskLike[]): TaskReferenceIndex { + const index = new Map>(); + const display = new Map>(); + for (const task of tasks) { + addTaskReference(index, task.id, task); + addTaskReference(display, task.displayId, task); + } + return { canonical: index, display }; +} + +function findUniqueReferencedTask( + tasksByReference: TaskReferenceIndex, + reference: string +): MemberWorkSyncTaskLike | null { + const normalized = reference.trim().replace(/^#/, ''); + const canonicalMatches = tasksByReference.canonical.get(normalized); + if (canonicalMatches?.size === 1) { + return [...canonicalMatches][0]!; + } + if (canonicalMatches && canonicalMatches.size > 1) { + return null; + } + const matches = tasksByReference.display.get(normalized); + return matches?.size === 1 ? [...matches][0]! : null; } export function buildActionableWorkAgenda( @@ -104,9 +140,7 @@ export function buildActionableWorkAgenda( const diagnostics: string[] = []; const activeMemberNames = getActiveMemberNames(input.members); const activeLeadName = getActiveLeadName(input.members); - const tasksByReference = new Map( - input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const)) - ); + const tasksByReference = buildTaskReferenceIndex(input.tasks); if (!memberName || isReservedMemberName(memberName)) { diagnostics.push('member_invalid_or_reserved'); @@ -135,7 +169,7 @@ export function buildActionableWorkAgenda( const brokenDependencyIds: string[] = []; const waitingDependencyIds: string[] = []; for (const dependencyId of blockedBy) { - const dependency = tasksByReference.get(dependencyId) ?? null; + const dependency = findUniqueReferencedTask(tasksByReference, dependencyId); if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) { brokenDependencyIds.push(dependencyId); } else if (!isTeamTaskFinishedForDependency(dependency)) { diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index b6f0fae7..b0d74322 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -32,22 +32,65 @@ function isDeletedTask(task: Pick): boolean { return task.status === 'deleted' || Boolean(task.deletedAt); } -function taskMatchesId(task: TeamTask, taskId: string): boolean { - const normalized = taskId.trim().replace(/^#/, ''); +interface TaskReferenceIndex { + canonical: Map>; + display: Map>; +} + +function addTaskReference( + index: Map>, + reference: string | undefined, + task: TeamTask +): void { + const normalized = reference?.trim().replace(/^#/, ''); + if (!normalized) return; + const matches = index.get(normalized) ?? new Set(); + matches.add(task); + index.set(normalized, matches); +} + +function buildTaskReferenceIndex(tasks: TeamTask[]): TaskReferenceIndex { + const canonical = new Map>(); + const display = new Map>(); + for (const task of tasks) { + addTaskReference(canonical, task.id, task); + addTaskReference(display, task.displayId, task); + } + return { canonical, display }; +} + +function getTaskReferenceMatches( + tasksByReference: TaskReferenceIndex, + reference: string +): ReadonlySet | null { + const normalized = reference.trim().replace(/^#/, ''); return ( - task.id === taskId || - task.id === normalized || - task.displayId === taskId || - task.displayId === normalized || - task.displayId === `#${normalized}` + tasksByReference.canonical.get(normalized) ?? tasksByReference.display.get(normalized) ?? null ); } -function taskReferenceKeys(task: Pick): string[] { - const keys = [task.id, task.displayId] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; +function taskReferenceIncludesTask( + tasksByReference: TaskReferenceIndex, + reference: string, + task: TeamTask +): boolean { + return getTaskReferenceMatches(tasksByReference, reference)?.has(task) === true; +} + +function taskReferenceIsMissingOrDeleted( + tasksByReference: TaskReferenceIndex, + reference: string +): boolean { + const matches = getTaskReferenceMatches(tasksByReference, reference); + if (!matches || matches.size === 0) { + return true; + } + return [...matches].every(isDeletedTask); +} + +function findTasksByReference(tasksByReference: TaskReferenceIndex, reference: string): TeamTask[] { + const matches = getTaskReferenceMatches(tasksByReference, reference); + return matches ? [...matches] : []; } function normalizedTaskReferences(values: readonly string[] | undefined): string[] { @@ -124,14 +167,23 @@ export class MemberWorkSyncTaskImpactResolver { } }; - const task = tasks.find((candidate) => taskMatchesId(candidate, taskId)); - if (!task) { + const tasksByReference = buildTaskReferenceIndex(tasks); + const matchingTasks = findTasksByReference(tasksByReference, taskId); + if (matchingTasks.length === 0) { return { memberNames: [], fallbackTeamWide: true, diagnostics: ['task_not_found'], }; } + if (matchingTasks.length > 1) { + return { + memberNames: [], + fallbackTeamWide: true, + diagnostics: ['task_reference_ambiguous'], + }; + } + const task = matchingTasks[0]!; addMember(task.owner); @@ -178,14 +230,8 @@ export class MemberWorkSyncTaskImpactResolver { addLead(); } - const tasksByReference = new Map( - tasks.flatMap((candidate) => - taskReferenceKeys(candidate).map((key) => [key, candidate] as const) - ) - ); const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => { - const dependency = tasksByReference.get(dependencyId); - return !dependency || isDeletedTask(dependency); + return taskReferenceIsMissingOrDeleted(tasksByReference, dependencyId); }); if (brokenDependencies.length > 0) { addLead(); @@ -204,8 +250,8 @@ export class MemberWorkSyncTaskImpactResolver { continue; } if ( - normalizedTaskReferences(candidate.blockedBy).some( - (dependencyId) => tasksByReference.get(dependencyId) === task + normalizedTaskReferences(candidate.blockedBy).some((dependencyId) => + taskReferenceIncludesTask(tasksByReference, dependencyId, task) ) ) { addMember(candidate.owner); diff --git a/src/shared/utils/teamTaskState.ts b/src/shared/utils/teamTaskState.ts index aa47133f..8fd42254 100644 --- a/src/shared/utils/teamTaskState.ts +++ b/src/shared/utils/teamTaskState.ts @@ -1,4 +1,6 @@ export interface TeamTaskStateLike { + id?: string | null; + displayId?: string | null; status: string; reviewState?: string | null; kanbanColumn?: string | null; @@ -126,6 +128,55 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea return getCachedTeamTaskState(task).finishedForDependency; } +function normalizeTaskReference(value: unknown): string { + return typeof value === 'string' ? value.trim().replace(/^#/, '') : ''; +} + +function findTaskStateByReference( + taskStateById: ReadonlyMap, + taskId: string +): TeamTaskStateLike | null { + const normalized = normalizeTaskReference(taskId); + if (!normalized) { + return null; + } + + const direct = + taskStateById.get(taskId) ?? + taskStateById.get(normalized) ?? + taskStateById.get(`#${normalized}`); + if (direct && (!direct.id || normalizeTaskReference(direct.id) === normalized)) { + return direct; + } + + let idMatched: TeamTaskStateLike | null = null; + for (const task of taskStateById.values()) { + if (normalizeTaskReference(task.id) === normalized) { + if (idMatched && idMatched !== task) { + return null; + } + idMatched = task; + } + } + if (idMatched) { + return idMatched; + } + + let displayMatched: TeamTaskStateLike | null = null; + for (const [key, task] of taskStateById) { + if ( + normalizeTaskReference(key) === normalized || + normalizeTaskReference(task.displayId) === normalized + ) { + if (displayMatched && displayMatched !== task) { + return null; + } + displayMatched = task; + } + } + return displayMatched; +} + export function isTeamTaskBlockedByUnfinishedDependency( task: TeamTaskBlockerLike, taskStateById: ReadonlyMap @@ -137,7 +188,7 @@ export function isTeamTaskBlockedByUnfinishedDependency( } return blockedBy.some((taskId) => { - const blocker = taskStateById.get(taskId); + const blocker = findTaskStateByReference(taskStateById, taskId); return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker)); }); } diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index 1423f424..f4ad5e28 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -709,6 +709,78 @@ describe('buildActionableWorkAgenda', () => { expect(agenda.items).toEqual([]); }); + it('does not unblock owner work when a display-id dependency is ambiguous', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-active-dep', + displayId: '#33333333', + subject: 'Active dependency', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-completed-dep', + displayId: '#33333333', + subject: 'Duplicate completed dependency', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-dependent', + subject: 'Ambiguous dependency', + status: 'in_progress', + owner: 'bob', + blockedBy: ['33333333'], + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('prefers canonical task ids over colliding display ids for dependencies', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-dep', + displayId: '#33333333', + subject: 'Completed dependency', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-collision', + displayId: '#task-dep', + subject: 'Display collision', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-dependent', + subject: 'Canonical dependency', + status: 'in_progress', + owner: 'bob', + blockedBy: ['task-dep'], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([ + ['task-dependent', 'owned_in_progress_task'], + ]); + }); + it('projects lead-owned oversight for lead clarification and broken dependencies', () => { const agenda = buildActionableWorkAgenda({ teamName: 'team-a', diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts index 31b7319b..7075fff1 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -129,6 +129,116 @@ describe('MemberWorkSyncTaskImpactResolver', () => { }); }); + it('targets dependent owners when a display-id dependency is ambiguous but includes the changed task', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + displayId: '#11111111', + subject: 'Changed dependency', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-duplicate', + displayId: '#11111111', + subject: 'Duplicate display id', + status: 'completed', + owner: 'zoe', + }, + { + id: 'task-b', + subject: 'Depends on ambiguous display id', + status: 'pending', + owner: 'tom', + blockedBy: [' 11111111 '], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom', 'zoe']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({ + memberNames: ['alice', 'tom'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); + + it('falls back team-wide when the changed task reference is ambiguous', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + displayId: '#11111111', + subject: 'First duplicate', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-b', + displayId: '#11111111', + subject: 'Second duplicate', + status: 'pending', + owner: 'tom', + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: '#11111111' })).resolves.toEqual({ + memberNames: [], + fallbackTeamWide: true, + diagnostics: ['task_reference_ambiguous'], + }); + }); + + it('prefers canonical task ids over colliding display ids', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + displayId: '#11111111', + subject: 'Changed dependency', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-collision', + displayId: '#task-a', + subject: 'Display collision', + status: 'in_progress', + owner: 'zoe', + }, + { + id: 'task-b', + subject: 'Depends on canonical id', + status: 'pending', + owner: 'tom', + blockedBy: ['task-a'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom', 'zoe']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({ + memberNames: ['alice', 'tom'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); + it('does not target owners of already approved dependent tasks', async () => { const tasks: TeamTask[] = [ { diff --git a/test/main/services/team/teamTaskActiveState.test.ts b/test/main/services/team/teamTaskActiveState.test.ts index e9752045..d7cd5157 100644 --- a/test/main/services/team/teamTaskActiveState.test.ts +++ b/test/main/services/team/teamTaskActiveState.test.ts @@ -142,6 +142,121 @@ describe('isTeamTaskBlockedByUnfinishedDependency', () => { isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById) ).toBe(true); }); + + it('resolves blocker references by display id and #display id', () => { + const taskStateById = new Map([ + [ + 'task-completed', + { + id: 'task-completed', + displayId: 'abc12345', + status: 'completed', + }, + ], + [ + 'task-approved', + { + id: 'task-approved', + displayId: 'def67890', + status: 'in_progress', + kanbanColumn: 'approved', + }, + ], + [ + 'task-active', + { + id: 'task-active', + displayId: 'fedcba98', + status: 'in_progress', + }, + ], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency( + { blockedBy: ['abc12345', '#def67890'] }, + taskStateById + ) + ).toBe(false); + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['#fedcba98'] }, taskStateById) + ).toBe(true); + }); + + it('fails closed for ambiguous display id blocker references', () => { + const taskStateById = new Map([ + [ + 'task-completed', + { + id: 'task-completed', + displayId: 'abc12345', + status: 'completed', + }, + ], + [ + 'task-active', + { + id: 'task-active', + displayId: 'abc12345', + status: 'in_progress', + }, + ], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['#abc12345'] }, taskStateById) + ).toBe(true); + }); + + it('fails closed when a direct map key match is an ambiguous display id', () => { + const taskStateById = new Map([ + [ + 'abc12345', + { + id: 'task-completed', + displayId: 'abc12345', + status: 'completed', + }, + ], + [ + 'task-active', + { + id: 'task-active', + displayId: 'abc12345', + status: 'in_progress', + }, + ], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['abc12345'] }, taskStateById) + ).toBe(true); + }); + + it('prefers canonical id matches over colliding display ids', () => { + const taskStateById = new Map([ + [ + 'task-completed', + { + id: 'task-completed', + displayId: 'abc12345', + status: 'completed', + }, + ], + [ + 'task-active', + { + id: 'task-active', + displayId: 'task-completed', + status: 'in_progress', + }, + ], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['task-completed'] }, taskStateById) + ).toBe(false); + }); }); describe('getTeamTaskWorkflowColumn', () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 99fd1cf8..fa27fd3b 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -2112,6 +2112,91 @@ describe('TeamGraphAdapter particles', () => { expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); }); + it('resolves blocking edges and labels from display id references', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-a', + displayId: '#A1', + subject: 'Blocker', + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'task-b', + displayId: '#B1', + subject: 'Blocked task', + owner: 'bob', + status: 'pending', + blockedBy: ['#A1'], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-b')).toMatchObject({ + isBlocked: true, + blockedByDisplayIds: ['#A1'], + }); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source: 'task:my-team:task-a', + target: 'task:my-team:task-b', + type: 'blocking', + }) + ); + }); + + it('prefers canonical task ids over colliding display id references', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-a', + displayId: '#A1', + subject: 'Canonical blocker', + owner: 'alice', + status: 'completed', + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'task-collision', + displayId: '#task-a', + subject: 'Display id collision', + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'task-b', + displayId: '#B1', + subject: 'Blocked task', + owner: 'bob', + status: 'pending', + blockedBy: ['task-a'], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-b')?.isBlocked).toBe(false); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source: 'task:my-team:task-a', + target: 'task:my-team:task-b', + type: 'blocking', + }) + ); + }); + it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => { const adapter = TeamGraphAdapter.create(); const graph = adapter.adapt(