vikunja/frontend/src/components/gantt/GanttChart.vue

786 lines
22 KiB
Vue

<template>
<Loading
v-if="(isLoading && !ganttBars.length) || dayjsLanguageLoading"
class="gantt-container"
/>
<div
v-else
ref="ganttContainer"
class="gantt-container"
role="application"
:aria-label="$t('project.gantt.chartLabel')"
>
<div class="gantt-chart-wrapper">
<GanttTimelineHeader
:timeline-data="timelineData"
:day-width-pixels="DAY_WIDTH_PIXELS"
/>
<GanttVerticalGridLines
:timeline-data="timelineData"
:total-width="totalWidth"
:height="ganttRows.length * 40"
:day-width-pixels="DAY_WIDTH_PIXELS"
/>
<GanttChartBody
ref="ganttChartBodyRef"
:rows="ganttRows"
:cells-by-row="cellsByRow"
@update:focused="handleFocusChange"
@enterPressed="handleEnterPressed"
>
<template #default="{ focusedRow, focusedCell }">
<div class="gantt-rows-container">
<!-- Group background bands for parent-child visual grouping -->
<div
v-for="(band, bandIndex) in parentGroupBands"
:key="`band-${bandIndex}`"
class="gantt-group-band"
:style="{
top: `${band.startIndex * ROW_HEIGHT}px`,
height: `${(band.endIndex - band.startIndex + 1) * ROW_HEIGHT}px`,
left: `${band.left}px`,
width: `${band.width}px`,
}"
/>
<div class="gantt-rows">
<GanttRow
v-for="(rowId, index) in ganttRows"
:id="rowId"
:key="rowId"
:index="index"
>
<div class="gantt-row-content">
<GanttRowBars
:bars="ganttBars[index] ?? []"
:total-width="totalWidth"
:date-from-date="dateFromDate"
:date-to-date="dateToDate"
:day-width-pixels="DAY_WIDTH_PIXELS"
:is-dragging="isDragging"
:is-resizing="isResizing"
:drag-state="dragState"
:focused-row="focusedRow ?? null"
:focused-cell="focusedCell"
:row-id="rowId"
:is-parent="ganttBars[index]?.[0]?.meta?.isParent ?? false"
:is-collapsed="collapsedTaskIds.has(Number(ganttBars[index]?.[0]?.id))"
@barPointerDown="handleBarPointerDown"
@startResize="startResize"
@updateTask="updateGanttTask"
@toggleCollapse="toggleCollapse(Number(ganttBars[index]?.[0]?.id))"
/>
</div>
</GanttRow>
</div>
<GanttRelationArrows
v-if="relationArrows.length > 0"
:arrows="relationArrows"
:width="totalWidth"
:height="totalHeight"
:row-height="ROW_HEIGHT"
/>
</div>
</template>
</GanttChartBody>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
import {useRouter} from 'vue-router'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
import {getHexColor} from '@/models/task'
import {buildGanttTaskTree, type GanttTaskTreeNode} from '@/helpers/ganttTaskTree'
import {buildRelationArrows, type GanttBarPosition, type GanttArrow} from '@/helpers/ganttRelationArrows'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar'
import GanttChartBody from '@/components/gantt/GanttChartBody.vue'
import GanttRow from '@/components/gantt/GanttRow.vue'
import GanttRowBars from '@/components/gantt/GanttRowBars.vue'
import GanttVerticalGridLines from '@/components/gantt/GanttVerticalGridLines.vue'
import GanttTimelineHeader from '@/components/gantt/GanttTimelineHeader.vue'
import GanttRelationArrows from '@/components/gantt/GanttRelationArrows.vue'
import Loading from '@/components/misc/Loading.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundary'
const props = defineProps<{
isLoading: boolean,
filters: GanttFilters,
tasks: Map<ITask['id'], ITask>,
defaultTaskStartDate: DateISO
defaultTaskEndDate: DateISO
}>()
const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAY_WIDTH_PIXELS = 30
const {tasks, filters} = toRefs(props)
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
const ganttContainer = ref(null)
const ganttChartBodyRef = ref<InstanceType<typeof GanttChartBody> | null>(null)
const router = useRouter()
const isDragging = ref(false)
const isResizing = ref(false)
const currentFocusedRow = ref<string | null>(null)
const currentFocusedCell = ref<number | null>(null)
const dragState = ref<{
barId: string
startX: number
originalStart: Date
originalEnd: Date
currentDays: number
edge?: 'start' | 'end'
} | null>(null)
let dragMoveHandler: ((e: PointerEvent) => void) | null = null
let dragStopHandler: (() => void) | null = null
const dateFromDate = computed(() => dayjs(filters.value.dateFrom).startOf('day').toDate())
const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDate())
const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
return dateDiff * DAY_WIDTH_PIXELS
})
const timelineData = computed(() => {
const dates: Date[] = []
const currentDate = new Date(dateFromDate.value)
while (currentDate <= dateToDate.value) {
dates.push(new Date(currentDate))
currentDate.setDate(currentDate.getDate() + 1)
}
return dates
})
const ganttBars = ref<GanttBarModel[][]>([])
const ganttRows = ref<string[]>([])
const cellsByRow = ref<Record<string, string[]>>({})
// Hierarchy state
const collapsedTaskIds = ref(new Set<number>())
const allNodes = ref<GanttTaskTreeNode[]>([])
const visibleNodes = computed(() => {
const result: GanttTaskTreeNode[] = []
const hiddenParents = new Set<number>()
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
})
// Map hidden tasks to their visible ancestor for arrow re-routing
const hiddenToAncestor = computed(() => {
const map = new Map<number, number>()
const hiddenParents = new Set<number>()
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
})
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(node: GanttTaskTreeNode): GanttBarModel {
const t = node.task
const DEFAULT_SPAN_DAYS = 7
// 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) {
startDate = getRoundedDate(effectiveStartDate, effectiveStartDate, true)
endDate = getRoundedDate(effectiveEndDate, effectiveEndDate, false)
dateType = 'both'
} else if (effectiveStartDate && !effectiveEndDate) {
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) {
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 {
startDate = getRoundedDate(undefined, props.defaultTaskStartDate, true)
endDate = getRoundedDate(undefined, props.defaultTaskEndDate, false)
dateType = 'both'
}
const taskColor = getHexColor(t.hexColor)
return {
id: String(t.id),
start: startDate,
end: endDate,
meta: {
label: t.title,
task: t,
color: taskColor,
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<string, string[]> = {}
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
}
if (bar.start > dateToDate.value || bar.end < dateFromDate.value) {
return
}
bars.push(bar)
const rowId = `row-${index}`
rows.push(rowId)
const rowCells: string[] = []
timelineData.value.forEach((_, dayIndex) => {
rowCells.push(`${rowId}-cell-${dayIndex}`)
})
cells[rowId] = rowCells
})
ganttBars.value = bars.map(bar => [bar])
ganttRows.value = rows
cellsByRow.value = cells
},
{deep: true, immediate: true},
)
// Compute bar positions for arrow rendering
const ROW_HEIGHT = 40
const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) {
const taskId = Number(bar.id)
let x = computeBarX(bar.start)
let width = computeBarWidth(bar)
const y = rowIndex * ROW_HEIGHT + ROW_HEIGHT / 2
// Apply drag/resize offset for the active bar
if (ds && bar.id === ds.barId && dragPixelOffset !== 0) {
if (isDragging.value) {
x += dragPixelOffset
} else if (isResizing.value) {
if (ds.edge === 'start') {
x += dragPixelOffset
width -= dragPixelOffset
} else {
width += dragPixelOffset
}
}
}
positions.set(taskId, {x, y, width, rowIndex})
}
})
return positions
})
function computeBarX(date: Date): number {
const diff = Math.ceil(
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY,
)
return diff * DAY_WIDTH_PIXELS
}
function computeBarWidth(bar: GanttBarModel): number {
const diff = Math.ceil(
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY,
)
return diff * DAY_WIDTH_PIXELS
}
// Compute relation arrows
const relationArrows = computed<GanttArrow[]>(() => {
return buildRelationArrows(tasks.value, barPositions.value, hiddenToAncestor.value)
})
const totalHeight = computed(() => ganttRows.value.length * ROW_HEIGHT)
// Compute parent-child group bands for visual grouping background
const GROUP_BAND_PADDING = 12
const parentGroupBands = computed(() => {
const bands: Array<{ startIndex: number; endIndex: number; left: number; width: number }> = []
const bars = ganttBars.value
const positions = barPositions.value
for (let i = 0; i < bars.length; i++) {
const bar = bars[i]?.[0]
if (!bar?.meta?.isParent) continue
const parentLevel = bar.meta.indentLevel ?? 0
let endIndex = i
// Find last consecutive child with deeper indent
for (let j = i + 1; j < bars.length; j++) {
const childBar = bars[j]?.[0]
const childLevel = childBar?.meta?.indentLevel ?? 0
if (childLevel <= parentLevel) break
endIndex = j
}
// Only create a band if there are actual children visible
if (endIndex > i) {
// Compute horizontal extent from bar positions
let minX = Infinity
let maxX = -Infinity
for (let k = i; k <= endIndex; k++) {
const taskId = Number(bars[k]?.[0]?.id)
const pos = positions.get(taskId)
if (!pos) continue
minX = Math.min(minX, pos.x)
maxX = Math.max(maxX, pos.x + pos.width)
}
if (minX < Infinity) {
bands.push({
startIndex: i,
endIndex,
left: minX - GROUP_BAND_PADDING,
width: maxX - minX + GROUP_BAND_PADDING * 2,
})
}
}
}
return bands
})
function updateGanttTask(id: string, newStart: Date, newEnd: Date) {
const task = tasks.value.get(Number(id))
if (!task) return
const update: ITaskPartialWithId = {
id: Number(id),
}
const hasStartDate = Boolean(task.startDate)
const hasEndDate = Boolean(task.endDate)
const hasDueDate = Boolean(task.dueDate)
if (hasStartDate && hasEndDate) {
// Both dates exist — update both
update.startDate = roundToNaturalDayBoundary(newStart, true)
update.endDate = roundToNaturalDayBoundary(newEnd)
} else if (hasStartDate && !hasEndDate && hasDueDate) {
// startDate + dueDate (no endDate) — treat as fully dated
update.startDate = roundToNaturalDayBoundary(newStart, true)
update.dueDate = roundToNaturalDayBoundary(newEnd)
} else if (hasStartDate && !hasEndDate) {
// startOnly — only update startDate, don't persist the synthetic end
update.startDate = roundToNaturalDayBoundary(newStart, true)
} else if (!hasStartDate && (hasEndDate || hasDueDate)) {
// endOnly / dueOnly — only update the end side
if (hasEndDate) {
update.endDate = roundToNaturalDayBoundary(newEnd)
}
if (hasDueDate) {
update.dueDate = roundToNaturalDayBoundary(newEnd)
}
} else {
// No dates at all — update both (existing behavior for dateless tasks)
update.startDate = roundToNaturalDayBoundary(newStart, true)
update.endDate = roundToNaturalDayBoundary(newEnd)
}
emit('update:task', update)
}
function openTask(bar: GanttBarModel) {
router.push({
name: 'task.detail',
params: {id: bar.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
// Double-click and drag detection
let lastClickTime = 0
let dragStarted = false
const DOUBLE_CLICK_THRESHOLD_MS = 500
const DRAG_THRESHOLD_PIXELS = 5
function handleBarPointerDown(bar: GanttBarModel, event: PointerEvent) {
event.preventDefault()
const barIndex = ganttBars.value.findIndex(barGroup => barGroup.some(b => b.id === bar.id))
if (barIndex !== -1 && ganttRows.value[barIndex]) {
focusTaskBar(ganttRows.value[barIndex])
}
const currentTime = Date.now()
const timeDiff = currentTime - lastClickTime
if (timeDiff < DOUBLE_CLICK_THRESHOLD_MS) {
openTask(bar)
lastClickTime = 0
return
}
lastClickTime = currentTime
dragStarted = false
const startX = event.clientX
const startY = event.clientY
const handleMove = (e: PointerEvent) => {
const diffX = Math.abs(e.clientX - startX)
const diffY = Math.abs(e.clientY - startY)
// Start drag if mouse moved more than threshhold
if (!dragStarted && (diffX > DRAG_THRESHOLD_PIXELS || diffY > DRAG_THRESHOLD_PIXELS)) {
dragStarted = true
document.removeEventListener('pointermove', handleMove)
document.removeEventListener('pointerup', handleStop)
startDrag(bar, event)
}
}
const handleStop = () => {
document.removeEventListener('pointermove', handleMove)
document.removeEventListener('pointerup', handleStop)
// If no drag was started, this was just a click (do nothing)
}
document.addEventListener('pointermove', handleMove)
document.addEventListener('pointerup', handleStop)
}
function setCursor(cursor: string, barElement?: Element | null) {
document.body.style.setProperty('cursor', cursor, 'important')
if (barElement) {
(barElement as HTMLElement).style.setProperty('cursor', cursor, 'important')
}
}
function clearCursor(barElement?: Element | null) {
document.body.style.removeProperty('cursor')
if (barElement) {
(barElement as HTMLElement).style.removeProperty('cursor')
}
}
function startDrag(bar: GanttBarModel, event: PointerEvent) {
event.preventDefault()
isDragging.value = true
dragState.value = {
barId: bar.id,
startX: event.clientX,
originalStart: new Date(bar.start),
originalEnd: new Date(bar.end),
currentDays: 0,
}
const barGroup = (event.target as Element).closest('g')
const barElement = barGroup?.querySelector('.gantt-bar')
setCursor('grabbing', barElement)
const handleMove = (e: PointerEvent) => {
if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days
}
}
const handleStop = () => {
if (dragMoveHandler) {
document.removeEventListener('pointermove', dragMoveHandler)
dragMoveHandler = null
}
if (dragStopHandler) {
document.removeEventListener('pointerup', dragStopHandler)
dragStopHandler = null
}
clearCursor(barElement)
if (dragState.value && dragState.value.currentDays !== 0) {
const newStart = new Date(dragState.value.originalStart)
newStart.setDate(newStart.getDate() + dragState.value.currentDays)
const newEnd = new Date(dragState.value.originalEnd)
newEnd.setDate(newEnd.getDate() + dragState.value.currentDays)
updateGanttTask(bar.id, newStart, newEnd)
}
isDragging.value = false
dragState.value = null
}
// Store handlers for cleanup
dragMoveHandler = handleMove
dragStopHandler = handleStop
document.addEventListener('pointermove', handleMove)
document.addEventListener('pointerup', handleStop)
}
function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent) {
event.preventDefault()
event.stopPropagation() // Prevent drag from triggering
isResizing.value = true
dragState.value = {
barId: bar.id,
startX: event.clientX,
originalStart: new Date(bar.start),
originalEnd: new Date(bar.end),
currentDays: 0,
edge,
}
const barGroup = (event.target as Element).closest('g')
const barElement = barGroup?.querySelector('.gantt-bar')
setCursor('col-resize', barElement)
const handleMove = (e: PointerEvent) => {
if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart)
newStart.setDate(newStart.getDate() + days)
if (newStart >= dragState.value.originalEnd) return
} else {
const newEnd = new Date(dragState.value.originalEnd)
newEnd.setDate(newEnd.getDate() + days)
if (newEnd <= dragState.value.originalStart) return
}
if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days
}
}
const handleStop = () => {
if (dragMoveHandler) {
document.removeEventListener('pointermove', dragMoveHandler)
dragMoveHandler = null
}
if (dragStopHandler) {
document.removeEventListener('pointerup', dragStopHandler)
dragStopHandler = null
}
clearCursor(barElement)
if (dragState.value && dragState.value.currentDays !== 0) {
if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart)
newStart.setDate(newStart.getDate() + dragState.value.currentDays)
// Ensure start doesn't go past end
if (newStart < dragState.value.originalEnd) {
updateGanttTask(bar.id, newStart, dragState.value.originalEnd)
}
} else {
const newEnd = new Date(dragState.value.originalEnd)
newEnd.setDate(newEnd.getDate() + dragState.value.currentDays)
// Ensure end doesn't go before start
if (newEnd > dragState.value.originalStart) {
updateGanttTask(bar.id, dragState.value.originalStart, newEnd)
}
}
}
isResizing.value = false
dragState.value = null
}
// Store handlers for cleanup
dragMoveHandler = handleMove
dragStopHandler = handleStop
document.addEventListener('pointermove', handleMove)
document.addEventListener('pointerup', handleStop)
}
function handleFocusChange(payload: { row: string | null; cell: number | null }) {
currentFocusedRow.value = payload.row
currentFocusedCell.value = payload.cell
}
function handleEnterPressed(payload: { row: string; cell: number }) {
const rowIndex = ganttRows.value.indexOf(payload.row)
if (rowIndex !== -1 && ganttBars.value[rowIndex]?.[0]) {
const bar = ganttBars.value[rowIndex][0]
openTask(bar)
}
}
function focusTaskBar(rowId: string) {
setTimeout(() => {
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
if (taskBarElement) {
taskBarElement.focus()
}
}, 0)
}
onUnmounted(() => {
if (dragMoveHandler) {
document.removeEventListener('pointermove', dragMoveHandler)
dragMoveHandler = null
}
if (dragStopHandler) {
document.removeEventListener('pointerup', dragStopHandler)
dragStopHandler = null
}
document.body.style.removeProperty('cursor')
})
</script>
<style scoped lang="scss">
.gantt-container {
overflow-x: auto;
}
.gantt-chart-wrapper {
inline-size: max-content;
min-inline-size: 100%;
position: relative;
}
.gantt-rows-container {
position: relative;
}
.gantt-group-band {
position: absolute;
background: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.06);
border: 1px solid hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12);
border-radius: 6px;
pointer-events: none;
z-index: 1;
}
.gantt-rows {
position: relative;
z-index: 2;
}
.gantt-row-content {
position: relative;
min-block-size: 40px;
inline-size: 100%;
}
</style>