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 {
const taskStateById = new Map<
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 taskIdsByCanonicalReference = new Map<string, Set<string>>();
const taskIdsByDisplayReference = new Map<string, Set<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) {
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<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;
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

View file

@ -90,11 +90,47 @@ function buildBaseItem(
};
}
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): 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<string, Set<MemberWorkSyncTaskLike>>;
display: Map<string, Set<MemberWorkSyncTaskLike>>;
}
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(
@ -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)) {

View file

@ -32,22 +32,65 @@ function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt);
}
function taskMatchesId(task: TeamTask, taskId: string): boolean {
const normalized = taskId.trim().replace(/^#/, '');
interface TaskReferenceIndex {
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 (
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<TeamTask, 'id' | 'displayId'>): 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);

View file

@ -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<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(
task: TeamTaskBlockerLike,
taskStateById: ReadonlyMap<string, TeamTaskStateLike>
@ -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));
});
}

View file

@ -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',

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 () => {
const tasks: TeamTask[] = [
{

View file

@ -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', () => {

View file

@ -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(