diff --git a/frontend/src/components/gantt/GanttChart.vue b/frontend/src/components/gantt/GanttChart.vue index daeb28de2..6941e6be4 100644 --- a/frontend/src/components/gantt/GanttChart.vue +++ b/frontend/src/components/gantt/GanttChart.vue @@ -71,6 +71,7 @@ import dayjs from 'dayjs' import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync' import {getHexColor} from '@/models/task' +import {buildGanttTaskTree, type GanttTaskTreeNode} from '@/helpers/ganttTaskTree' import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask' import type {DateISO} from '@/types/DateISO' @@ -150,43 +151,102 @@ const ganttBars = ref([]) const ganttRows = ref([]) const cellsByRow = ref>({}) +// Hierarchy state +const collapsedTaskIds = ref(new Set()) +const allNodes = ref([]) + +const visibleNodes = computed(() => { + const result: GanttTaskTreeNode[] = [] + const hiddenParents = new Set() + + for (const node of allNodes.value) { + const parents = node.task.relatedTasks?.parenttask ?? [] + const isHidden = parents.some(p => + collapsedTaskIds.value.has(p.id) || hiddenParents.has(p.id), + ) + + if (isHidden) { + hiddenParents.add(node.task.id) + continue + } + + result.push(node) + } + + return result +}) + +// Used in Task 8 for arrow re-routing when children are collapsed +const _hiddenToAncestor = computed(() => { + const map = new Map() + const hiddenParents = new Set() + + for (const node of allNodes.value) { + const parents = node.task.relatedTasks?.parenttask ?? [] + const collapsedParent = parents.find(p => + collapsedTaskIds.value.has(p.id), + ) + + if (collapsedParent && tasks.value.has(collapsedParent.id)) { + map.set(node.task.id, collapsedParent.id) + hiddenParents.add(node.task.id) + } else { + const hiddenAncestor = parents.find(p => hiddenParents.has(p.id)) + if (hiddenAncestor) { + const ancestorTarget = map.get(hiddenAncestor.id) ?? hiddenAncestor.id + map.set(node.task.id, ancestorTarget) + hiddenParents.add(node.task.id) + } + } + } + + return map +}) + +// Used in Task 5 for collapse/expand toggle +function _toggleCollapse(taskId: number) { + const newSet = new Set(collapsedTaskIds.value) + if (newSet.has(taskId)) { + newSet.delete(taskId) + } else { + newSet.add(taskId) + } + collapsedTaskIds.value = newSet +} + function getRoundedDate(value: string | Date | undefined, fallback: Date | string, isStart: boolean) { return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart) } -function transformTaskToGanttBar(t: ITask): GanttBarModel { +function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel { + const t = node.task const DEFAULT_SPAN_DAYS = 7 - // Determine the effective start and end dates - // If only dueDate is set (no startDate or endDate), treat dueDate as endDate - const effectiveEndDate = t.endDate || t.dueDate - const effectiveStartDate = t.startDate + // Use derived dates for dateless parents + const effectiveEndDate = t.endDate || t.dueDate || (node.hasDerivedDates ? node.derivedEndDate : null) + const effectiveStartDate = t.startDate || (node.hasDerivedDates ? node.derivedStartDate : null) let startDate: Date let endDate: Date let dateType: GanttBarDateType if (effectiveStartDate && effectiveEndDate) { - // Both dates available startDate = getRoundedDate(effectiveStartDate, effectiveStartDate, true) endDate = getRoundedDate(effectiveEndDate, effectiveEndDate, false) dateType = 'both' } else if (effectiveStartDate && !effectiveEndDate) { - // Only start date — extend forward by DEFAULT_SPAN_DAYS startDate = getRoundedDate(effectiveStartDate, effectiveStartDate, true) const defaultEnd = new Date(startDate) defaultEnd.setDate(defaultEnd.getDate() + DEFAULT_SPAN_DAYS) endDate = getRoundedDate(defaultEnd, defaultEnd, false) dateType = 'startOnly' } else if (!effectiveStartDate && effectiveEndDate) { - // Only end date (or only due date) — extend backward by DEFAULT_SPAN_DAYS endDate = getRoundedDate(effectiveEndDate, effectiveEndDate, false) const defaultStart = new Date(endDate) defaultStart.setDate(defaultStart.getDate() - DEFAULT_SPAN_DAYS) startDate = getRoundedDate(defaultStart, defaultStart, true) dateType = 'endOnly' } else { - // No dates at all — use defaults (existing behavior) startDate = getRoundedDate(undefined, props.defaultTaskStartDate, true) endDate = getRoundedDate(undefined, props.defaultTaskEndDate, false) dateType = 'both' @@ -205,49 +265,57 @@ function transformTaskToGanttBar(t: ITask): GanttBarModel { hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)), dateType, isDone: t.done, + isParent: node.isParent, + hasDerivedDates: node.hasDerivedDates, + indentLevel: node.indentLevel, }, } } +// Build the task tree when tasks change watch( [tasks, filters], + () => { + allNodes.value = buildGanttTaskTree(tasks.value) + }, + {deep: true, immediate: true}, +) + +// Derive bars, rows, and cells from visible nodes +watch( + [visibleNodes, filters], () => { const bars: GanttBarModel[] = [] const rows: string[] = [] const cells: Record = {} - const filteredTasks = Array.from(tasks.value.values()).filter(task => { - const hasAnyDate = Boolean(task.startDate || task.endDate || task.dueDate) + visibleNodes.value.forEach((node, index) => { + const bar = transformTaskToGanttBar(node) + // Check if task is visible in the current date range + const hasAnyDate = Boolean(node.task.startDate || node.task.endDate || node.task.dueDate || node.hasDerivedDates) if (!filters.value.showTasksWithoutDates && !hasAnyDate) { - return false + return + } + if (bar.start > dateToDate.value || bar.end < dateFromDate.value) { + return } - const bar = transformTaskToGanttBar(task) - - // Task is visible if it overlaps with the current date range - return bar.start <= dateToDate.value && bar.end >= dateFromDate.value - }) - - filteredTasks.forEach((t, index) => { - const bar = transformTaskToGanttBar(t) bars.push(bar) - + const rowId = `row-${index}` rows.push(rowId) - + const rowCells: string[] = [] - timelineData.value.forEach((date, dayIndex) => { + timelineData.value.forEach((_, dayIndex) => { rowCells.push(`${rowId}-cell-${dayIndex}`) }) cells[rowId] = rowCells }) - - // Group bars by rows (one bar per row for now) + ganttBars.value = bars.map(bar => [bar]) ganttRows.value = rows cellsByRow.value = cells - }, {deep: true, immediate: true}, ) diff --git a/frontend/src/composables/useGanttBar.ts b/frontend/src/composables/useGanttBar.ts index 6d863c8e0..dcfadcc54 100644 --- a/frontend/src/composables/useGanttBar.ts +++ b/frontend/src/composables/useGanttBar.ts @@ -13,6 +13,9 @@ export interface GanttBarModel { dateType?: GanttBarDateType isDone?: boolean task?: unknown + isParent?: boolean + hasDerivedDates?: boolean + indentLevel?: number } } export interface UseGanttBarOptions { diff --git a/frontend/src/helpers/ganttRelationArrows.spec.ts b/frontend/src/helpers/ganttRelationArrows.spec.ts index 2603e8060..d7edd581f 100644 --- a/frontend/src/helpers/ganttRelationArrows.spec.ts +++ b/frontend/src/helpers/ganttRelationArrows.spec.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest' -import {buildRelationArrows, type GanttArrow, type GanttBarPosition} from './ganttRelationArrows' +import {buildRelationArrows, type GanttBarPosition} from './ganttRelationArrows' import type {ITask} from '@/modelTypes/ITask' function makeTask(id: number, overrides: Partial = {}): ITask { diff --git a/frontend/src/helpers/ganttTaskTree.spec.ts b/frontend/src/helpers/ganttTaskTree.spec.ts index 8f4d8da6e..b94c31bc9 100644 --- a/frontend/src/helpers/ganttTaskTree.spec.ts +++ b/frontend/src/helpers/ganttTaskTree.spec.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest' -import {buildGanttTaskTree, type GanttTaskTreeNode} from './ganttTaskTree' +import {buildGanttTaskTree} from './ganttTaskTree' import type {ITask} from '@/modelTypes/ITask' function makeTask(id: number, overrides: Partial = {}): ITask {