diff --git a/frontend/src/helpers/ganttRelationArrows.spec.ts b/frontend/src/helpers/ganttRelationArrows.spec.ts new file mode 100644 index 000000000..2603e8060 --- /dev/null +++ b/frontend/src/helpers/ganttRelationArrows.spec.ts @@ -0,0 +1,117 @@ +import {describe, expect, it} from 'vitest' +import {buildRelationArrows, type GanttArrow, type GanttBarPosition} from './ganttRelationArrows' +import type {ITask} from '@/modelTypes/ITask' + +function makeTask(id: number, overrides: Partial = {}): ITask { + return { + id, + title: `Task ${id}`, + relatedTasks: {}, + ...overrides, + } as ITask +} + +describe('buildRelationArrows', () => { + it('returns empty array when no dependency relations exist', () => { + const tasks = new Map([ + [1, makeTask(1)], + [2, makeTask(2)], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + [2, {x: 50, y: 60, width: 100, rowIndex: 1}], + ]) + + const result = buildRelationArrows(tasks, positions, new Map()) + expect(result).toHaveLength(0) + }) + + it('creates arrow for blocking relation', () => { + const tasks = new Map([ + [1, makeTask(1, {relatedTasks: {blocking: [makeTask(2)]}})], + [2, makeTask(2, {relatedTasks: {blocked: [makeTask(1)]}})], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + [2, {x: 150, y: 60, width: 100, rowIndex: 1}], + ]) + + const result = buildRelationArrows(tasks, positions, new Map()) + + expect(result).toHaveLength(1) + expect(result[0].fromTaskId).toBe(1) + expect(result[0].toTaskId).toBe(2) + expect(result[0].startX).toBe(100) // x + width + expect(result[0].endX).toBe(150) // target x + expect(result[0].color).toBe('var(--danger)') + expect(result[0].relationKind).toBe('blocking') + }) + + it('creates arrow for precedes relation', () => { + const tasks = new Map([ + [1, makeTask(1, {relatedTasks: {precedes: [makeTask(2)]}})], + [2, makeTask(2, {relatedTasks: {follows: [makeTask(1)]}})], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + [2, {x: 150, y: 60, width: 100, rowIndex: 1}], + ]) + + const result = buildRelationArrows(tasks, positions, new Map()) + + expect(result).toHaveLength(1) + expect(result[0].relationKind).toBe('precedes') + expect(result[0].color).toBe('var(--grey-500)') + }) + + it('skips arrows when target task is not visible', () => { + const tasks = new Map([ + [1, makeTask(1, {relatedTasks: {blocking: [makeTask(99)]}})], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + ]) + + const result = buildRelationArrows(tasks, positions, new Map()) + expect(result).toHaveLength(0) + }) + + it('re-routes arrows to parent when child is collapsed', () => { + const tasks = new Map([ + [1, makeTask(1, {relatedTasks: {blocking: [makeTask(3)]}})], + [2, makeTask(2)], // parent of task 3 + [3, makeTask(3, {relatedTasks: {blocked: [makeTask(1)]}})], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + [2, {x: 50, y: 60, width: 200, rowIndex: 1}], + // task 3 has no position (collapsed) + ]) + const hiddenToAncestor = new Map([ + [3, 2], + ]) + + const result = buildRelationArrows(tasks, positions, hiddenToAncestor) + + expect(result).toHaveLength(1) + expect(result[0].toTaskId).toBe(2) // re-routed to parent + expect(result[0].endX).toBe(50) // parent's x + }) + + it('deduplicates bidirectional relations', () => { + const tasks = new Map([ + [1, makeTask(1, {relatedTasks: {blocking: [makeTask(2)]}})], + [2, makeTask(2, {relatedTasks: {blocked: [makeTask(1)]}})], + ]) + const positions = new Map([ + [1, {x: 0, y: 20, width: 100, rowIndex: 0}], + [2, {x: 150, y: 60, width: 100, rowIndex: 1}], + ]) + + const result = buildRelationArrows(tasks, positions, new Map()) + + // Only one arrow, not two + expect(result).toHaveLength(1) + }) +}) + diff --git a/frontend/src/helpers/ganttRelationArrows.ts b/frontend/src/helpers/ganttRelationArrows.ts new file mode 100644 index 000000000..02b9427d2 --- /dev/null +++ b/frontend/src/helpers/ganttRelationArrows.ts @@ -0,0 +1,86 @@ +import type {ITask} from '@/modelTypes/ITask' + +export interface GanttBarPosition { + x: number // left edge x position + y: number // vertical center y position + width: number // bar width in pixels + rowIndex: number +} + +export interface GanttArrow { + fromTaskId: number + toTaskId: number + startX: number + startY: number + endX: number + endY: number + color: string + relationKind: 'blocking' | 'precedes' +} + +const ARROW_COLORS: Record = { + blocking: 'var(--danger)', + precedes: 'var(--grey-500)', +} + +/** + * Builds arrow data for dependency relations between visible Gantt tasks. + * Only processes `blocking` and `precedes` directions to avoid duplicates. + */ +export function buildRelationArrows( + tasks: Map, + positions: Map, + hiddenToAncestor: Map, +): GanttArrow[] { + const arrows: GanttArrow[] = [] + const seen = new Set() + + for (const [taskId, task] of tasks) { + const sourceKinds = ['blocking', 'precedes'] as const + + for (const kind of sourceKinds) { + const relatedTasks = task.relatedTasks?.[kind] ?? [] + + for (const related of relatedTasks) { + let fromId = taskId + let toId = related.id + + // Re-route hidden tasks to their visible ancestor + if (hiddenToAncestor.has(fromId)) { + fromId = hiddenToAncestor.get(fromId)! + } + if (hiddenToAncestor.has(toId)) { + toId = hiddenToAncestor.get(toId)! + } + + // Skip if either end is not visible + if (!positions.has(fromId) || !positions.has(toId)) continue + + // Skip self-arrows (can happen after re-routing) + if (fromId === toId) continue + + // Deduplicate + const key = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}-${kind}` + if (seen.has(key)) continue + seen.add(key) + + const fromPos = positions.get(fromId)! + const toPos = positions.get(toId)! + + arrows.push({ + fromTaskId: fromId, + toTaskId: toId, + startX: fromPos.x + fromPos.width, + startY: fromPos.y, + endX: toPos.x, + endY: toPos.y, + color: ARROW_COLORS[kind], + relationKind: kind, + }) + } + } + } + + return arrows +} +