fix(team-tasks): resolve display task references

This commit is contained in:
777genius 2026-06-01 23:38:20 +03:00
parent cb17bcfdb5
commit 5b1fccdbed
8 changed files with 597 additions and 38 deletions

View file

@ -677,19 +677,60 @@ export class TeamGraphAdapter {
): void { ): void {
const taskStateById = new Map< const taskStateById = new Map<
string, string,
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'> Pick<
TeamGraphData['tasks'][number],
'id' | 'displayId' | 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
>
>(); >();
const taskDisplayIds = new Map<string, string>(); const taskDisplayIds = new Map<string, string>();
const taskIdsByCanonicalReference = new Map<string, Set<string>>();
const taskIdsByDisplayReference = new Map<string, Set<string>>();
const memberColorByName = new Map<string, string>(); const memberColorByName = new Map<string, string>();
const addTaskReference = (
references: Map<string, Set<string>>,
reference: string | undefined,
taskId: string
): void => {
const normalized = reference?.trim().replace(/^#/, '');
if (!normalized) return;
const taskIds = references.get(normalized) ?? new Set<string>();
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) { for (const t of data.tasks) {
taskStateById.set(t.id, { taskStateById.set(t.id, {
id: t.id,
...(t.displayId ? { displayId: t.displayId } : {}),
status: t.status, status: t.status,
...(t.reviewState ? { reviewState: t.reviewState } : {}), ...(t.reviewState ? { reviewState: t.reviewState } : {}),
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}), ...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}), ...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
}); });
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); 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) { for (const member of data.members) {
if (member.color) { if (member.color) {
@ -726,10 +767,10 @@ export class TeamGraphAdapter {
: TeamGraphAdapter.#mapReviewState(task.reviewState); : TeamGraphAdapter.#mapReviewState(task.reviewState);
const blockedByDisplayIds = task.blockedBy?.length const blockedByDisplayIds = task.blockedBy?.length
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) ? task.blockedBy.map(formatTaskReference)
: undefined; : undefined;
const blocksDisplayIds = task.blocks?.length const blocksDisplayIds = task.blocks?.length
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) ? task.blocks.map(formatTaskReference)
: undefined; : undefined;
const totalCommentCount = task.comments?.length ?? 0; const totalCommentCount = task.comments?.length ?? 0;
@ -799,7 +840,10 @@ export class TeamGraphAdapter {
targetTaskIds: Set<string>; targetTaskIds: Set<string>;
} }
>(); >();
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; if (blockerId === blockedId) return;
const rawRelationKey = `${blockerId}->${blockedId}`; const rawRelationKey = `${blockerId}->${blockedId}`;
if (seenBlockingRelations.has(rawRelationKey)) return; if (seenBlockingRelations.has(rawRelationKey)) return;
@ -842,7 +886,9 @@ export class TeamGraphAdapter {
if (!visibleTaskIds.has(task.id)) continue; 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; if (!visibleTaskIds.has(relatedId)) continue;
const key = const key =
task.id.localeCompare(relatedId) <= 0 task.id.localeCompare(relatedId) <= 0

View file

@ -90,11 +90,47 @@ function buildBaseItem(
}; };
} }
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): string[] { interface TaskReferenceIndex {
const keys = [task.id, task.displayId] canonical: Map<string, Set<MemberWorkSyncTaskLike>>;
.map((value) => value?.trim()) display: Map<string, Set<MemberWorkSyncTaskLike>>;
.filter((value): value is string => Boolean(value)); }
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
function addTaskReference(
index: Map<string, Set<MemberWorkSyncTaskLike>>,
reference: string | undefined,
task: MemberWorkSyncTaskLike
): void {
const normalized = reference?.trim().replace(/^#/, '');
if (!normalized) return;
const matches = index.get(normalized) ?? new Set<MemberWorkSyncTaskLike>();
matches.add(task);
index.set(normalized, matches);
}
function buildTaskReferenceIndex(tasks: MemberWorkSyncTaskLike[]): TaskReferenceIndex {
const index = new Map<string, Set<MemberWorkSyncTaskLike>>();
const display = new Map<string, Set<MemberWorkSyncTaskLike>>();
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( export function buildActionableWorkAgenda(
@ -104,9 +140,7 @@ export function buildActionableWorkAgenda(
const diagnostics: string[] = []; const diagnostics: string[] = [];
const activeMemberNames = getActiveMemberNames(input.members); const activeMemberNames = getActiveMemberNames(input.members);
const activeLeadName = getActiveLeadName(input.members); const activeLeadName = getActiveLeadName(input.members);
const tasksByReference = new Map( const tasksByReference = buildTaskReferenceIndex(input.tasks);
input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const))
);
if (!memberName || isReservedMemberName(memberName)) { if (!memberName || isReservedMemberName(memberName)) {
diagnostics.push('member_invalid_or_reserved'); diagnostics.push('member_invalid_or_reserved');
@ -135,7 +169,7 @@ export function buildActionableWorkAgenda(
const brokenDependencyIds: string[] = []; const brokenDependencyIds: string[] = [];
const waitingDependencyIds: string[] = []; const waitingDependencyIds: string[] = [];
for (const dependencyId of blockedBy) { for (const dependencyId of blockedBy) {
const dependency = tasksByReference.get(dependencyId) ?? null; const dependency = findUniqueReferencedTask(tasksByReference, dependencyId);
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) { if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
brokenDependencyIds.push(dependencyId); brokenDependencyIds.push(dependencyId);
} else if (!isTeamTaskFinishedForDependency(dependency)) { } else if (!isTeamTaskFinishedForDependency(dependency)) {

View file

@ -32,22 +32,65 @@ function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt); return task.status === 'deleted' || Boolean(task.deletedAt);
} }
function taskMatchesId(task: TeamTask, taskId: string): boolean { interface TaskReferenceIndex {
const normalized = taskId.trim().replace(/^#/, ''); canonical: Map<string, Set<TeamTask>>;
display: Map<string, Set<TeamTask>>;
}
function addTaskReference(
index: Map<string, Set<TeamTask>>,
reference: string | undefined,
task: TeamTask
): void {
const normalized = reference?.trim().replace(/^#/, '');
if (!normalized) return;
const matches = index.get(normalized) ?? new Set<TeamTask>();
matches.add(task);
index.set(normalized, matches);
}
function buildTaskReferenceIndex(tasks: TeamTask[]): TaskReferenceIndex {
const canonical = new Map<string, Set<TeamTask>>();
const display = new Map<string, Set<TeamTask>>();
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<TeamTask> | null {
const normalized = reference.trim().replace(/^#/, '');
return ( return (
task.id === taskId || tasksByReference.canonical.get(normalized) ?? tasksByReference.display.get(normalized) ?? null
task.id === normalized ||
task.displayId === taskId ||
task.displayId === normalized ||
task.displayId === `#${normalized}`
); );
} }
function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] { function taskReferenceIncludesTask(
const keys = [task.id, task.displayId] tasksByReference: TaskReferenceIndex,
.map((value) => value?.trim()) reference: string,
.filter((value): value is string => Boolean(value)); task: TeamTask
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; ): 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[] { function normalizedTaskReferences(values: readonly string[] | undefined): string[] {
@ -124,14 +167,23 @@ export class MemberWorkSyncTaskImpactResolver {
} }
}; };
const task = tasks.find((candidate) => taskMatchesId(candidate, taskId)); const tasksByReference = buildTaskReferenceIndex(tasks);
if (!task) { const matchingTasks = findTasksByReference(tasksByReference, taskId);
if (matchingTasks.length === 0) {
return { return {
memberNames: [], memberNames: [],
fallbackTeamWide: true, fallbackTeamWide: true,
diagnostics: ['task_not_found'], diagnostics: ['task_not_found'],
}; };
} }
if (matchingTasks.length > 1) {
return {
memberNames: [],
fallbackTeamWide: true,
diagnostics: ['task_reference_ambiguous'],
};
}
const task = matchingTasks[0]!;
addMember(task.owner); addMember(task.owner);
@ -178,14 +230,8 @@ export class MemberWorkSyncTaskImpactResolver {
addLead(); addLead();
} }
const tasksByReference = new Map(
tasks.flatMap((candidate) =>
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
)
);
const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => { const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => {
const dependency = tasksByReference.get(dependencyId); return taskReferenceIsMissingOrDeleted(tasksByReference, dependencyId);
return !dependency || isDeletedTask(dependency);
}); });
if (brokenDependencies.length > 0) { if (brokenDependencies.length > 0) {
addLead(); addLead();
@ -204,8 +250,8 @@ export class MemberWorkSyncTaskImpactResolver {
continue; continue;
} }
if ( if (
normalizedTaskReferences(candidate.blockedBy).some( normalizedTaskReferences(candidate.blockedBy).some((dependencyId) =>
(dependencyId) => tasksByReference.get(dependencyId) === task taskReferenceIncludesTask(tasksByReference, dependencyId, task)
) )
) { ) {
addMember(candidate.owner); addMember(candidate.owner);

View file

@ -1,4 +1,6 @@
export interface TeamTaskStateLike { export interface TeamTaskStateLike {
id?: string | null;
displayId?: string | null;
status: string; status: string;
reviewState?: string | null; reviewState?: string | null;
kanbanColumn?: string | null; kanbanColumn?: string | null;
@ -126,6 +128,55 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea
return getCachedTeamTaskState(task).finishedForDependency; return getCachedTeamTaskState(task).finishedForDependency;
} }
function normalizeTaskReference(value: unknown): string {
return typeof value === 'string' ? value.trim().replace(/^#/, '') : '';
}
function findTaskStateByReference(
taskStateById: ReadonlyMap<string, TeamTaskStateLike>,
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( export function isTeamTaskBlockedByUnfinishedDependency(
task: TeamTaskBlockerLike, task: TeamTaskBlockerLike,
taskStateById: ReadonlyMap<string, TeamTaskStateLike> taskStateById: ReadonlyMap<string, TeamTaskStateLike>
@ -137,7 +188,7 @@ export function isTeamTaskBlockedByUnfinishedDependency(
} }
return blockedBy.some((taskId) => { return blockedBy.some((taskId) => {
const blocker = taskStateById.get(taskId); const blocker = findTaskStateByReference(taskStateById, taskId);
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker)); return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
}); });
} }

View file

@ -709,6 +709,78 @@ describe('buildActionableWorkAgenda', () => {
expect(agenda.items).toEqual([]); 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', () => { it('projects lead-owned oversight for lead clarification and broken dependencies', () => {
const agenda = buildActionableWorkAgenda({ const agenda = buildActionableWorkAgenda({
teamName: 'team-a', teamName: 'team-a',

View file

@ -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 () => { it('does not target owners of already approved dependent tasks', async () => {
const tasks: TeamTask[] = [ const tasks: TeamTask[] = [
{ {

View file

@ -142,6 +142,121 @@ describe('isTeamTaskBlockedByUnfinishedDependency', () => {
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById) isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById)
).toBe(true); ).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', () => { describe('getTeamTaskWorkflowColumn', () => {

View file

@ -2112,6 +2112,91 @@ describe('TeamGraphAdapter particles', () => {
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); 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', () => { it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt( const graph = adapter.adapt(