feat(gantt): rebuild the gantt chart (#1001)

This commit is contained in:
kolaente
2025-08-12 16:33:50 +02:00
committed by GitHub
parent 7033405c9e
commit 5fc255cb36
19 changed files with 1520 additions and 359 deletions

View File

@@ -13,7 +13,7 @@ describe('Project View Gantt', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
cy.get('.gantt-rows')
.should('not.contain', tasks[0].title)
})
@@ -27,9 +27,9 @@ describe('Project View Gantt', () => {
cy.visit('/projects/1/2')
cy.get('.g-timeunits-container')
.should('contain', dayjs(now).format('MMMM'))
.should('contain', dayjs(nextMonth).format('MMMM'))
cy.get('.gantt-timeline-months')
.should('contain', dayjs(now).format('MMMM YYYY'))
.should('contain', dayjs(nextMonth).format('MMMM YYYY'))
})
it('Shows tasks with dates', () => {
@@ -40,7 +40,7 @@ describe('Project View Gantt', () => {
})
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
cy.get('.gantt-rows')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
@@ -56,7 +56,7 @@ describe('Project View Gantt', () => {
.contains('Show tasks without dates')
.click()
cy.get('.g-gantt-rows-container')
cy.get('.gantt-rows')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
@@ -71,11 +71,40 @@ describe('Project View Gantt', () => {
})
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
cy.get('.gantt-rows .gantt-row-bars .gantt-bar')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
.then($bar => {
// Get the current position of the bar
const rect = $bar[0].getBoundingClientRect()
const startX = rect.left + rect.width / 2
const startY = rect.top + rect.height / 2
// Trigger pointer events with proper coordinates and delays
cy.wrap($bar)
.trigger('pointerdown', {
clientX: startX,
clientY: startY,
pointerId: 1,
which: 1
})
.wait(100) // Wait to ensure double-click detection doesn't interfere
.trigger('pointermove', {
clientX: startX + 10, // Small initial movement to trigger drag
clientY: startY,
pointerId: 1
})
.trigger('pointermove', {
clientX: startX + 150, // Move 150px to the right (about 5 days)
clientY: startY,
pointerId: 1
})
.trigger('pointerup', {
clientX: startX + 150,
clientY: startY,
pointerId: 1,
force: true
})
})
cy.wait('@taskUpdate')
})
@@ -101,7 +130,7 @@ describe('Project View Gantt', () => {
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
cy.get('.gantt-timeline-months')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
@@ -117,7 +146,7 @@ describe('Project View Gantt', () => {
})
cy.visit('/projects/1/2')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
cy.get('.gantt-container .gantt-row-bars .gantt-bar')
.dblclick()
cy.url()

View File

@@ -56,7 +56,6 @@
"@fortawesome/free-solid-svg-icons": "7.0.0",
"@fortawesome/vue-fontawesome": "3.1.1",
"@github/hotkey": "3.1.1",
"@infectoone/vue-ganttastic": "2.3.2",
"@intlify/unplugin-vue-i18n": "6.0.8",
"@kyvg/vue3-notification": "3.4.1",
"@sentry/tracing": "7.120.4",

View File

@@ -34,9 +34,6 @@ importers:
'@github/hotkey':
specifier: 3.1.1
version: 3.1.1(patch_hash=145ab3233cbcd3bc934b4961cd8710e2b15e4ae5dd20862a8d1d6621d7f9d4a8)
'@infectoone/vue-ganttastic':
specifier: 2.3.2
version: 2.3.2(dayjs@1.11.13)(vue@3.5.18(typescript@5.9.2))
'@intlify/unplugin-vue-i18n':
specifier: 6.0.8
version: 6.0.8(@vue/compiler-dom@3.5.18)(eslint@9.33.0(jiti@2.4.2))(rollup@4.46.2)(typescript@5.9.2)(vue-i18n@11.1.11(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))
@@ -1512,12 +1509,6 @@ packages:
resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
engines: {node: '>=18.18'}
'@infectoone/vue-ganttastic@2.3.2':
resolution: {integrity: sha512-krxHdlZvo4cdS4axQ99qb756RzwieI7LcyY2vAIehJ5Sxd/jz5Pu/vTplTC0Rxqj8T4v1knYPK9uvTMkQYWYng==}
peerDependencies:
dayjs: ^1.11.5
vue: ^3.2.40
'@intlify/bundle-utils@10.0.1':
resolution: {integrity: sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==}
engines: {node: '>= 18'}
@@ -2335,9 +2326,6 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -2660,15 +2648,9 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/metadata@13.6.0':
resolution: {integrity: sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/router@13.6.0':
resolution: {integrity: sha512-iXRwR4K7nz4PReW0QudhnM9NtYGvN4KrskFgF9G7NouM43big3bpSNRRocJKFWK7iu97ww5y82B3QA2zz3S/vw==}
peerDependencies:
@@ -2680,9 +2662,6 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -8368,14 +8347,6 @@ snapshots:
'@humanwhocodes/retry@0.4.2': {}
'@infectoone/vue-ganttastic@2.3.2(dayjs@1.11.13)(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@vueuse/core': 9.13.0(vue@3.5.18(typescript@5.9.2))
dayjs: 1.11.13
vue: 3.5.18(typescript@5.9.2)
transitivePeerDependencies:
- '@vue/composition-api'
'@intlify/bundle-utils@10.0.1(vue-i18n@11.1.11(vue@3.5.18(typescript@5.9.2)))':
dependencies:
'@intlify/message-compiler': 11.1.5
@@ -9173,8 +9144,6 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.16': {}
'@types/web-bluetooth@0.0.21': {}
'@types/whatwg-mimetype@3.0.2': {}
@@ -9656,20 +9625,8 @@ snapshots:
'@vueuse/shared': 13.6.0(vue@3.5.18(typescript@5.9.2))
vue: 3.5.18(typescript@5.9.2)
'@vueuse/core@9.13.0(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.5.18(typescript@5.9.2))
vue-demi: 0.14.10(vue@3.5.18(typescript@5.9.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@13.6.0': {}
'@vueuse/metadata@9.13.0': {}
'@vueuse/router@13.6.0(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))':
dependencies:
'@vueuse/shared': 13.6.0(vue@3.5.18(typescript@5.9.2))
@@ -9680,13 +9637,6 @@ snapshots:
dependencies:
vue: 3.5.18(typescript@5.9.2)
'@vueuse/shared@9.13.0(vue@3.5.18(typescript@5.9.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.18(typescript@5.9.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
abbrev@2.0.0: {}
acorn-jsx@5.3.2(acorn@8.15.0):

View File

@@ -0,0 +1,507 @@
<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">
<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"
:focused-cell="focusedCell"
:row-id="rowId"
@barPointerDown="handleBarPointerDown"
@startResize="startResize"
@updateTask="updateGanttTask"
/>
</div>
</GanttRow>
</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 type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import type {GanttBarModel} 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 Loading from '@/components/misc/Loading.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
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[]>>({})
function transformTaskToGanttBar(t: ITask): GanttBarModel {
const startDate = t.startDate
? new Date(t.startDate)
: new Date(props.defaultTaskStartDate)
const endDate = t.endDate
? new Date(t.endDate)
: new Date(props.defaultTaskEndDate)
const taskColor = getHexColor(t.hexColor)
const bar = {
id: String(t.id),
start: startDate,
end: endDate,
meta: {
label: t.title,
task: t,
color: taskColor,
hasActualDates: Boolean(t.startDate && t.endDate),
isDone: t.done,
},
}
return bar
}
watch(
[tasks, filters],
() => {
const bars: GanttBarModel[] = []
const rows: string[] = []
const cells: Record<string, string[]> = {}
const filteredTasks = Array.from(tasks.value.values()).filter(task => {
if (!filters.value.showTasksWithoutDates && (!task.startDate || !task.endDate)) {
return false
}
const taskStart = task.startDate
? new Date(task.startDate)
: new Date(props.defaultTaskStartDate)
const taskEnd = task.endDate
? new Date(task.endDate)
: new Date(props.defaultTaskEndDate)
// Task is visible if it overlaps with the current date range
return taskStart <= dateToDate.value
&& taskEnd >= 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) => {
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},
)
function updateGanttTask(id: string, newStart: Date, newEnd: Date) {
emit('update:task', {
id: Number(id),
startDate: dayjs(newStart).startOf('day').toDate(),
endDate: dayjs(newEnd).endOf('day').toDate(),
})
}
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 {
position: relative;
z-index: 2;
}
.gantt-row-content {
position: relative;
min-block-size: 40px;
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<GanttChartPrimitive
ref="primitiveRef"
:rows="rows"
:cells-by-row="cellsByRow"
@update:focused="$emit('update:focused', $event)"
@enterPressed="$emit('enterPressed', $event)"
>
<template #default="{ focusedRow, focusedCell }">
<slot
:focused-row="focusedRow"
:focused-cell="focusedCell"
/>
</template>
</GanttChartPrimitive>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import GanttChartPrimitive from '@/components/gantt/primitives/GanttChartPrimitive.vue'
defineProps<{
rows: string[]
cellsByRow: Record<string, string[]>
}>()
defineEmits<{
'update:focused': [payload: { row: string | null; cell: number | null }],
'enterPressed': [payload: { row: string; cell: number }],
}>()
const primitiveRef = ref<InstanceType<typeof GanttChartPrimitive> | null>(null)
function setFocus(rowId: string, cellIndex: number = 0) {
primitiveRef.value?.setFocus(rowId, cellIndex)
}
defineExpose({
setFocus,
})
</script>

View File

@@ -0,0 +1,40 @@
<template>
<GanttRowPrimitive
:id="id"
@focus="onFocus"
@select="onSelect"
>
<div
class="w-full flex items-center"
:class="index % 2 ? 'bg-row-alt' : 'bg-row'"
role="presentation"
>
<slot />
</div>
</GanttRowPrimitive>
</template>
<script setup lang="ts">
import GanttRowPrimitive from '@/components/gantt/primitives/GanttRowPrimitive.vue'
const props = defineProps<{
id: string
index: number
}>()
const emit = defineEmits<{
(e: 'focus', id: string): void
(e: 'select', id: string): void
}>()
const onFocus = () => emit('focus', props.id)
const onSelect = () => emit('select', props.id)
</script>
<style scoped>
.bg-row {
background: hsla(var(--white-h), var(--white-s), var(--white-l), .15);
}
.bg-row-alt {
background: hsla(var(--grey-100-hsl), .5);
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<svg
class="gantt-row-bars"
:width="totalWidth"
height="40"
xmlns="http://www.w3.org/2000/svg"
role="img"
:aria-label="$t('project.gantt.taskBarsForRow', { rowId })"
:data-row-id="rowId"
>
<GanttBarPrimitive
v-for="bar in bars"
:key="bar.id"
:model="bar"
:timeline-start="dateFromDate"
:timeline-end="dateToDate"
:on-update="(id, start, end) => emit('updateTask', id, start, end)"
>
<!-- Main bar -->
<rect
:x="getBarX(bar)"
:y="4"
:width="getBarWidth(bar)"
:height="32"
:rx="4"
:fill="getBarFill(bar)"
:stroke="getBarStroke(bar)"
:stroke-width="getBarStrokeWidth(bar)"
:stroke-dasharray="!bar.meta?.hasActualDates ? '5,5' : 'none'"
class="gantt-bar"
role="button"
:aria-label="$t('project.gantt.taskBarLabel', {
task: bar.meta?.label || bar.id,
startDate: bar.start.toLocaleDateString(),
endDate: bar.end.toLocaleDateString(),
dateType: bar.meta?.hasActualDates ? $t('project.gantt.scheduledDates') : $t('project.gantt.estimatedDates')
})"
:aria-pressed="isRowFocused"
@pointerdown="handleBarPointerDown(bar, $event)"
/>
<!-- Left resize handle -->
<rect
:x="getBarX(bar) - RESIZE_HANDLE_OFFSET"
:y="4"
:width="6"
:height="32"
:rx="3"
fill="var(--white)"
stroke="var(--primary)"
stroke-width="1"
class="gantt-resize-handle gantt-resize-left"
role="button"
:aria-label="$t('project.gantt.resizeStartDate', { task: bar.meta?.label || bar.id })"
@pointerdown="startResize(bar, 'start', $event)"
/>
<!-- Right resize handle -->
<rect
:x="getBarX(bar) + getBarWidth(bar) - RESIZE_HANDLE_OFFSET"
:y="4"
:width="6"
:height="32"
:rx="3"
fill="var(--white)"
stroke="var(--primary)"
stroke-width="1"
class="gantt-resize-handle gantt-resize-right"
role="button"
:aria-label="$t('project.gantt.resizeEndDate', { task: bar.meta?.label || bar.id })"
@pointerdown="startResize(bar, 'end', $event)"
/>
<!-- Task label with clipping -->
<defs>
<clipPath :id="`clip-${bar.id}`">
<rect
:x="getBarX(bar) + 2"
:y="4"
:width="getBarWidth(bar) - 4"
:height="32"
:rx="4"
/>
</clipPath>
</defs>
<text
:x="getBarTextX(bar)"
:y="24"
class="gantt-bar-text"
:fill="getBarTextColor(bar)"
:clip-path="`url(#clip-${bar.id})`"
aria-hidden="true"
>
{{ bar.meta?.label || bar.id }}
</text>
</GanttBarPrimitive>
</svg>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import dayjs from 'dayjs'
import type {GanttBarModel} from '@/composables/useGanttBar'
import {getTextColor, LIGHT} from '@/helpers/color/getTextColor'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import GanttBarPrimitive from './primitives/GanttBarPrimitive.vue'
const props = defineProps<{
bars: GanttBarModel[]
totalWidth: number
dateFromDate: Date
dateToDate: Date
dayWidthPixels: number
isDragging: boolean
isResizing: boolean
dragState: {
barId: string
startX: number
originalStart: Date
originalEnd: Date
currentDays: number
edge?: 'start' | 'end'
} | null
focusedRow: string | null
focusedCell: number | null
rowId: string
}>()
const emit = defineEmits<{
(e: 'barPointerDown', bar: GanttBarModel, event: PointerEvent): void
(e: 'startResize', bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent): void
(e: 'updateTask', id: string, newStart: Date, newEnd: Date): void
}>()
const RESIZE_HANDLE_OFFSET = 3
function addDays(dateOrValue: Date | string | number, days: number): Date {
const date = new Date(dateOrValue)
const newDate = new Date(date)
newDate.setDate(newDate.getDate() + days)
return newDate
}
const isRowFocused = computed(() => props.focusedRow === props.rowId)
function computeBarX(startDate: Date) {
const daysDiff = dayjs(startDate).diff(dayjs(props.dateFromDate), 'day')
const x = daysDiff * props.dayWidthPixels
return x
}
function getDaysDifference(startDate: Date, endDate: Date): number {
return Math.floor((endDate.getTime() - startDate.getTime()) / MILLISECONDS_A_DAY)
}
function computeBarWidth(bar: GanttBarModel) {
const diff = getDaysDifference(bar.start, bar.end)
const width = diff * props.dayWidthPixels
return width
}
const originalEndX = computed(() => props.dragState?.originalEnd
? computeBarX(props.dragState.originalEnd)
: 0)
const originalStartX = computed(() => props.dragState?.originalStart
? computeBarX(props.dragState.originalStart)
: 0)
const getBarX = computed(() => (bar: GanttBarModel) => {
if (props.isDragging && props.dragState?.barId === bar.id) {
const offset = props.dragState.currentDays * props.dayWidthPixels
return originalStartX.value + offset
}
if (props.isResizing && props.dragState?.barId === bar.id && props.dragState.edge === 'start') {
const newStart = addDays(props.dragState.originalStart, props.dragState.currentDays)
return computeBarX(newStart)
}
return computeBarX(bar.start)
})
const getBarWidth = computed(() => (bar: GanttBarModel) => {
if (props.isResizing && props.dragState?.barId === bar.id) {
if (props.dragState.edge === 'start') {
const newStart = addDays(props.dragState.originalStart, props.dragState.currentDays)
const newStartX = computeBarX(newStart)
return Math.max(0, originalEndX.value - newStartX)
} else {
const newEnd = addDays(props.dragState.originalEnd, props.dragState.currentDays)
const newEndX = computeBarX(newEnd)
return Math.max(0, newEndX - originalStartX.value)
}
}
return computeBarWidth(bar)
})
const getBarTextX = computed(() => (bar: GanttBarModel) => {
return getBarX.value(bar) + 8
})
function getBarFill(bar: GanttBarModel) {
if (bar.meta?.hasActualDates) {
if (bar.meta?.color) {
return bar.meta.color
}
return 'var(--primary)'
}
return 'var(--grey-100)'
}
function getBarStroke(bar: GanttBarModel) {
if (!bar.meta?.hasActualDates) {
return 'var(--grey-300)' // Gray for dashed border
}
return 'none'
}
function getBarStrokeWidth(bar: GanttBarModel) {
if (!bar.meta?.hasActualDates) {
return '2'
}
return '0'
}
function getBarTextColor(bar: GanttBarModel) {
if (!bar.meta?.hasActualDates) {
return 'var(--grey-800)'
}
if (bar.meta?.color) {
return getTextColor(bar.meta.color)
}
return LIGHT
}
function handleBarPointerDown(bar: GanttBarModel, event: PointerEvent) {
emit('barPointerDown', bar, event)
}
function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent) {
emit('startResize', bar, edge, event)
}
</script>
<style scoped lang="scss">
.gantt-row-bars {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
pointer-events: none;
z-index: 4;
.gantt-bar {
cursor: grab;
pointer-events: all;
&:hover {
opacity: 0.8;
}
&:active {
cursor: grabbing;
}
}
:deep(text) {
pointer-events: none;
user-select: none;
}
}
.gantt-bar-text {
font-size: .85rem;
pointer-events: none;
user-select: none;
}
:deep(.gantt-resize-handle) {
cursor: col-resize !important;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: all; // Ensure they receive pointer events
}
// Show resize handles on bar hover
:deep(g:hover) .gantt-resize-handle {
opacity: 0.8;
&:hover {
opacity: 1;
cursor: inherit; // Use the specific cursor defined above
}
}
// Focus styles for task bars
:deep(g[role="slider"]:focus) {
outline: none; // Remove default browser outline
.gantt-bar {
stroke: var(--primary) !important;
stroke-width: 3 !important;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div
class="gantt-timeline"
role="columnheader"
:aria-label="$t('project.gantt.timelineHeader')"
>
<!-- Upper timeunit for months -->
<div
class="gantt-timeline-months"
role="row"
:aria-label="$t('project.gantt.monthsRow')"
>
<div
v-for="monthGroup in monthGroups"
:key="monthGroup.key"
class="timeunit-month"
:style="{ width: `${monthGroup.width}px` }"
role="columnheader"
:aria-label="$t('project.gantt.monthLabel', {month: monthGroup.label})"
>
{{ monthGroup.label }}
</div>
</div>
<!-- Lower timeunit for days -->
<div
class="gantt-timeline-days"
role="row"
:aria-label="$t('project.gantt.daysRow')"
>
<div
v-for="date in timelineData"
:key="date.toISOString()"
class="timeunit"
:style="{ width: `${dayWidthPixels}px` }"
role="columnheader"
:aria-label="dateIsToday(date)
? $t('project.gantt.dayLabelToday', {
date: date.toLocaleDateString(),
weekday: weekDayFromDate(date)
})
: $t('project.gantt.dayLabel', {
date: date.toLocaleDateString(),
weekday: weekDayFromDate(date)
})"
>
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ date.getDate() }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useGlobalNow} from '@/composables/useGlobalNow'
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
import dayjs from 'dayjs'
const props = defineProps<{
timelineData: Date[]
dayWidthPixels: number
}>()
const weekDayFromDate = useWeekDayFromDate()
const { now: today } = useGlobalNow()
const dateIsToday = computed(() => {
const todayStr = today.value.toDateString()
return (date: Date) => date.toDateString() === todayStr
})
const monthGroups = computed(() => {
const groups = props.timelineData.reduce(
(groups, date) => {
const month = date.getMonth()
const year = date.getFullYear()
const key = `${year}-${month}`
const lastGroup = groups[groups.length - 1]
if (lastGroup?.key === key) {
lastGroup.width += props.dayWidthPixels
} else {
groups.push({
key,
label: dayjs(date).format('MMMM YYYY'),
width: props.dayWidthPixels,
})
}
return groups
},
[] as Array<{key: string; label: string; width: number}>,
)
return groups
})
</script>
<style scoped lang="scss">
.gantt-timeline {
background: var(--white);
border-block-end: 1px solid var(--grey-200);
position: sticky;
inset-block-start: 0;
z-index: 10;
}
.gantt-timeline-months {
display: flex;
.timeunit-month {
background: var(--white);
font-family: $vikunja-font;
font-weight: bold;
border-inline-end: 1px solid var(--grey-200);
padding: 0.5rem 0;
text-align: center;
font-size: 1rem;
color: var(--grey-800);
}
}
.gantt-timeline-days {
display: flex;
.timeunit {
.timeunit-wrapper {
padding: 0.5rem 0;
font-size: 1rem;
display: flex;
flex-direction: column;
align-items: center;
inline-size: 100%;
font-family: $vikunja-font;
&.today {
background: var(--primary);
color: var(--white);
border-radius: 5px 5px 0 0;
font-weight: bold;
}
.weekday {
font-size: 0.8rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="gantt-grid-lines">
<svg
class="gantt-vertical-lines"
:width="totalWidth"
:height="height"
xmlns="http://www.w3.org/2000/svg"
>
<line
v-for="(date, index) in timelineData"
:key="date.toISOString()"
:x1="index * dayWidthPixels"
:y1="0"
:x2="index * dayWidthPixels"
:y2="height"
stroke="var(--grey-400)"
stroke-width="0.5"
opacity="0.6"
/>
</svg>
</div>
</template>
<script setup lang="ts">
defineProps<{
timelineData: Date[]
totalWidth: number
height: number
dayWidthPixels: number
}>()
</script>
<style scoped lang="scss">
.gantt-grid-lines {
position: absolute;
inset-inline-start: 0;
z-index: 1;
pointer-events: none;
}
.gantt-vertical-lines {
position: absolute;
inset: 0;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<component
:is="as"
role="slider"
tabindex="0"
:aria-valuemin="ariaMin"
:aria-valuemax="ariaMax"
:aria-valuenow="ariaNow"
:aria-valuetext="ariaValueText"
:aria-label="ariaLabel"
:data-state="dataState"
v-bind="attrs"
@dblclick="() => props.onDoubleClick?.(props.model)"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeyDown"
>
<slot
:dragging="dragging"
:selected="selected"
:focused="focused"
/>
</component>
</template>
<script setup lang="ts">
import {computed, useAttrs} from 'vue'
import {useI18n} from 'vue-i18n'
import {useGanttBar, type GanttBarModel} from '@/composables/useGanttBar'
const props = withDefaults(
defineProps<{
model: GanttBarModel
timelineStart: Date
timelineEnd: Date
onDoubleClick?: (model: GanttBarModel) => void
onUpdate?: (id: string, newStart: Date, newEnd: Date) => void
as?: string
}>(),
{
as: 'g',
onDoubleClick: undefined,
onUpdate: undefined,
},
)
const attrs = useAttrs()
const {t} = useI18n({useScope: 'global'})
const {
dragging,
selected,
focused,
onFocus,
onBlur,
onKeyDown,
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
} = useGanttBar({
model: props.model,
timelineStart: props.timelineStart,
timelineEnd: props.timelineEnd,
onUpdate: props.onUpdate,
})
const ariaMin = computed(() => props.timelineStart.valueOf())
const ariaMax = computed(() => props.timelineEnd.valueOf())
const ariaNow = computed(() => props.model.start.valueOf())
const ariaValueText = computed(() => `${props.model.start.toLocaleString()} ${props.model.end.toLocaleString()}`)
const ariaLabel = computed(() =>
props.model.meta?.label
? t('project.gantt.taskAriaLabel', { task: props.model.meta.label })
: t('project.gantt.taskAriaLabelById', { id: props.model.id }),
)
const dataState = computed(() =>
[
dragging.value && 'dragging',
selected.value && 'selected',
focused.value && 'focused',
]
.filter(Boolean)
.join(' '),
)
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div
ref="chartRef"
role="grid"
tabindex="0"
:aria-rowcount="rows.length"
:aria-colcount="cellsCount"
@keydown="onKeyDown"
@click="initializeFocus"
>
<slot
:focused-row="focusedRow"
:focused-cell="focusedCellIndex"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
rows: string[]
cellsByRow: Record<string, string[]>
}>()
const emit = defineEmits<{
(e: 'update:focused', payload: { row: string | null; cell: number | null }): void
(e: 'enterPressed', payload: { row: string; cell: number }): void
}>()
const chartRef = ref<HTMLElement | null>(null)
const focusedRowIndex = ref<number | null>(null)
const focusedCellIndex = ref<number | null>(null)
const focusedRow = computed(() => focusedRowIndex.value === null
? null
: props.rows[focusedRowIndex.value])
const cellsCount = computed(() => props.rows.length
? props.cellsByRow[props.rows[0]].length
: 0)
onClickOutside(chartRef, () => {
focusedRowIndex.value = null
focusedCellIndex.value = null
emit('update:focused', { row: null, cell: null })
})
function onKeyDown(e: KeyboardEvent) {
if (focusedRowIndex.value === null || focusedCellIndex.value === null) return
if (e.key === 'Enter') {
e.preventDefault()
emit('enterPressed', { row: focusedRow.value!, cell: focusedCellIndex.value })
return
}
}
function initializeFocus() {
// Only initialize focus if not already set and there are rows
if (focusedRowIndex.value === null && props.rows.length > 0) {
focusedRowIndex.value = 0
focusedCellIndex.value = 0
emit('update:focused', { row: focusedRow.value, cell: focusedCellIndex.value })
}
}
function setFocus(rowId: string, cellIndex: number = 0) {
const rowIndex = props.rows.indexOf(rowId)
if (rowIndex !== -1) {
focusedRowIndex.value = rowIndex
focusedCellIndex.value = Math.max(0, Math.min(cellIndex, cellsCount.value - 1))
emit('update:focused', { row: focusedRow.value, cell: focusedCellIndex.value })
}
}
// Expose methods for parent components
defineExpose({
setFocus,
initializeFocus,
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div
role="row"
:aria-selected="selected"
tabindex="-1"
:data-state="selected ? 'selected' : null"
@click="onSelect"
@focus="onFocus"
@keydown="onKeyDown"
>
<slot :selected="selected" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
id: string
}>()
const emit = defineEmits<{
select: [id: string]
focus: [id: string]
}>()
const selected = ref(false)
function onSelect() {
emit('select', props.id)
}
function onFocus() {
emit('focus', props.id)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect()
}
}
</script>

View File

@@ -114,6 +114,36 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
},
],
},
{
title: 'keyboardShortcuts.gantt.title',
available: (route) => route.name === 'project.view',
shortcuts: [
{
title: 'keyboardShortcuts.gantt.moveTaskLeft',
keys: ['←'],
},
{
title: 'keyboardShortcuts.gantt.moveTaskRight',
keys: ['→'],
},
{
title: 'keyboardShortcuts.gantt.expandTaskLeft',
keys: ['shift', '←'],
},
{
title: 'keyboardShortcuts.gantt.expandTaskRight',
keys: ['shift', '→'],
},
{
title: 'keyboardShortcuts.gantt.shrinkTaskLeft',
keys: [ctrl, '←'],
},
{
title: 'keyboardShortcuts.gantt.shrinkTaskRight',
keys: [ctrl, '→'],
},
],
},
{
title: 'keyboardShortcuts.task.title',
available: (route) => route.name === 'task.detail',

View File

@@ -85,7 +85,7 @@ import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
import GanttChart from '@/components/gantt/GanttChart.vue'
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
import {RIGHTS} from '@/constants/rights'
@@ -101,7 +101,6 @@ const props = defineProps<{
viewId: IProjectView['id']
}>()
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
const baseStore = useBaseStore()
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)

View File

@@ -1,288 +0,0 @@
<template>
<Loading
v-if="isLoading && !ganttBars.length || dayjsLanguageLoading"
class="gantt-container"
/>
<div
v-else
ref="ganttContainer"
class="gantt-container"
>
<GGanttChart
:date-format="DAYJS_ISO_DATE_FORMAT"
:chart-start="isoToKebabDate(filters.dateFrom)"
:chart-end="isoToKebabDate(filters.dateTo)"
precision="day"
bar-start="startDate"
bar-end="endDate"
:grid="true"
:width="ganttChartWidth"
:color-scheme="GANTT_COLOR_SCHEME"
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{date}">
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ date.getDate() }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>
</div>
</template>
<GGanttRow
v-for="(bar, k) in ganttBars"
:key="k"
label=""
:bars="bar"
/>
</GGanttChart>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs} from 'vue'
import {useRouter} from 'vue-router'
import { useGlobalNow } from '@/composables/useGlobalNow'
import {getHexColor} from '@/models/task'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import {
extendDayjs,
GGanttChart,
GGanttRow,
type GanttBarObject, type ColorScheme,
} from '@infectoone/vue-ganttastic'
import Loading from '@/components/misc/Loading.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
export interface GanttChartProps {
isLoading: boolean,
filters: GanttFilters,
tasks: Map<ITask['id'], ITask>,
defaultTaskStartDate: DateISO
defaultTaskEndDate: DateISO
}
const props = defineProps<GanttChartProps>()
const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
const {tasks, filters} = toRefs(props)
// setup dayjs for vue-ganttastic
// const dayjsLanguageLoading = ref(false)
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
extendDayjs()
const ganttContainer = ref(null)
const router = useRouter()
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
const DAY_WIDTH_PIXELS = 30
const ganttChartWidth = computed(() => {
const ganttContainerReference = ganttContainer?.value
const ganttContainerWidth = ganttContainerReference ? (ganttContainerReference['clientWidth'] ?? 0) : 0
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
const calculatedWidth = dateDiff * DAY_WIDTH_PIXELS
return (calculatedWidth > ganttContainerWidth) ? calculatedWidth + 'px' : '100%'
})
const ganttBars = ref<GanttBarObject[][]>([])
const GANTT_COLOR_SCHEME: ColorScheme = {
primary: 'var(--grey-100)',
secondary: 'var(--grey-300)',
ternary: 'var(--grey-500)',
quartenary: 'var(--grey-600)',
hoverHighlight: 'var(--grey-700)',
text: 'var(--grey-800)',
background: 'var(--white)',
} as const
/**
* Update ganttBars when tasks change
*/
watch(
tasks,
() => {
ganttBars.value = []
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
},
{deep: true, immediate: true},
)
function transformTaskToGanttBar(t: ITask) {
const black = 'var(--grey-800)'
const taskColor = getHexColor(t.hexColor)
let textColor = black
let backgroundColor = 'var(--grey-100)'
if(t.startDate) {
backgroundColor = taskColor ?? ''
if(typeof taskColor === 'undefined') {
textColor = 'white'
backgroundColor = 'var(--primary)'
} else if(colorIsDark(taskColor)) {
textColor = black
} else {
textColor = 'white'
}
}
return [{
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
ganttBarConfig: {
id: String(t.id),
label: t.title,
hasHandles: true,
style: {
color: textColor,
backgroundColor,
border: t.startDate ? '' : '2px dashed var(--grey-300)',
'text-decoration': t.done ? 'line-through' : null,
},
},
} as GanttBarObject]
}
async function updateGanttTask(e: {
bar: GanttBarObject;
e: MouseEvent;
datetime?: string | undefined;
}) {
emit('update:task', {
id: Number(e.bar.ganttBarConfig.id),
startDate: new Date((new Date(e.bar.startDate)).setHours(0,0,0,0)),
endDate: new Date((new Date(e.bar.endDate)).setHours(23,59,0,0)),
})
}
function openTask(e: {
bar: GanttBarObject;
e: MouseEvent;
datetime?: string | undefined;
}) {
router.push({
name: 'task.detail',
params: {id: e.bar.ganttBarConfig.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const weekDayFromDate = useWeekDayFromDate()
const {now: today} = useGlobalNow()
const dateIsToday = computed(() => (date: Date) => {
return (
date.getDate() === today.value.getDate() &&
date.getMonth() === today.value.getMonth() &&
date.getFullYear() === today.value.getFullYear()
)
})
</script>
<style scoped lang="scss">
.gantt-container {
overflow-x: auto;
}
</style>
<style lang="scss">
// Not scoped because we need to style the elements inside the gantt chart component
.g-gantt-chart {
inline-size: max-content;
}
.g-gantt-row-label {
display: none !important;
}
.g-upper-timeunit, .g-timeunit {
background: var(--white) !important;
font-family: $vikunja-font;
}
.g-upper-timeunit {
font-weight: bold;
border-inline-end: 1px solid var(--grey-200);
padding: .5rem 0;
}
.g-timeunit .timeunit-wrapper {
padding: 0.5rem 0;
font-size: 1rem !important;
display: flex;
flex-direction: column;
align-items: center;
inline-size: 100%;
&.today {
background: var(--primary);
color: var(--white);
border-radius: 5px 5px 0 0;
font-weight: bold;
}
.weekday {
font-size: 0.8rem;
}
}
.g-timeaxis {
block-size: auto !important;
box-shadow: none !important;
}
.g-gantt-row > .g-gantt-row-bars-container {
border-inline-end: none !important;
border-block-start: none !important;
}
.g-gantt-row:nth-child(odd) {
background: hsla(var(--grey-100-hsl), .5);
}
.g-gantt-bar {
border-radius: $radius * 1.5;
overflow: visible;
font-size: .85rem;
&-handle-left,
&-handle-right {
inline-size: 6px !important;
block-size: 75% !important;
opacity: .75 !important;
border-radius: $radius !important;
margin-block-start: 4px;
}
}
</style>

View File

@@ -0,0 +1,108 @@
import { ref } from 'vue'
export interface GanttBarModel {
id: string
start: Date
end: Date
meta?: {
label?: string
color?: string
hasActualDates?: boolean
isDone?: boolean
task?: unknown
}
}
export interface UseGanttBarOptions {
model: GanttBarModel
timelineStart: Date
timelineEnd: Date
onUpdate?: (id: string, newStart: Date, newEnd: Date) => void
}
export function useGanttBar(options: UseGanttBarOptions) {
const dragging = ref(false)
const selected = ref(false)
const focused = ref(false)
function onFocus() {
focused.value = true
}
function onBlur() {
focused.value = false
}
function changeSize(direction: 'left' | 'right', modifier: -1 | 1) {
const newStart = new Date(options.model.start)
const newEnd = new Date(options.model.end)
if (direction === 'left') {
// Shift+Left: Expand task to the left (move start date earlier)
newStart.setDate(newStart.getDate() - 1 * modifier)
} else {
// Shift+Right: Expand task to the right (move end date later)
newEnd.setDate(newEnd.getDate() + 1 * modifier)
}
// Validate that start is before end (maintain minimum 1 day duration)
if (newStart < newEnd) {
options.model.start = newStart
options.model.end = newEnd
if (options.onUpdate) {
options.onUpdate(options.model.id, newStart, newEnd)
}
}
}
function onKeyDown(e: KeyboardEvent) {
// task expanding
if (e.shiftKey) {
if (e.key === 'ArrowLeft') {
e.preventDefault()
changeSize('left', 1)
}
if (e.key === 'ArrowRight') {
e.preventDefault()
changeSize('right', 1)
}
}
// task shrinking
else if (e.ctrlKey) {
if (e.key === 'ArrowLeft') {
e.preventDefault()
changeSize('left', -1)
}
if (e.key === 'ArrowRight') {
e.preventDefault()
changeSize('right', -1)
}
}
// task movement
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const dir = e.key === 'ArrowRight' ? 1 : -1
const newStart = new Date(options.model.start)
newStart.setDate(newStart.getDate() + dir)
const newEnd = new Date(options.model.end)
newEnd.setDate(newEnd.getDate() + dir)
options.model.start = newStart
options.model.end = newEnd
if (options.onUpdate) {
options.onUpdate(options.model.id, newStart, newEnd)
}
}
}
return {
dragging,
selected,
focused,
onFocus,
onBlur,
onKeyDown,
}
}

View File

@@ -0,0 +1,11 @@
import { colorIsDark } from './colorIsDark'
export const LIGHT = 'hsl(220, 13%, 91%)' // grey-200
export const DARK = 'hsl(215, 27.9%, 16.9%)' // grey-800
export function getTextColor(backgroundColor: string) {
return colorIsDark(backgroundColor)
// Fixed colors to avoid flipping in dark mode
? DARK
: LIGHT
}

View File

@@ -366,7 +366,21 @@
"day": "Day",
"hour": "Hour",
"range": "Date Range",
"noDates": "This task has no dates set."
"chartLabel": "Project Gantt Chart",
"taskBarsForRow": "Task bars for row {rowId}",
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
"scheduledDates": "Scheduled dates",
"estimatedDates": "Estimated dates",
"resizeStartDate": "Resize start date for task {task}",
"resizeEndDate": "Resize end date for task {task}",
"timelineHeader": "Timeline header with months and days",
"monthsRow": "Months row",
"daysRow": "Days row",
"monthLabel": "Month: {month}",
"dayLabel": "Day: {date}, {weekday}",
"dayLabelToday": "Today: {date}, {weekday}",
"taskAriaLabel": "Task: {task}",
"taskAriaLabelById": "Task {id}"
},
"table": {
"title": "Table",
@@ -1101,6 +1115,15 @@
"navigateDown": "Highlight next task",
"navigateUp": "Highlight previous task",
"open": "Open highlighted task"
},
"gantt": {
"title": "Gantt Chart",
"moveTaskLeft": "Move task to earlier date",
"moveTaskRight": "Move task to later date",
"expandTaskLeft": "Expand task start date earlier",
"expandTaskRight": "Expand task end date later",
"shrinkTaskLeft": "Shrink task from start date",
"shrinkTaskRight": "Shrink task from end date"
}
},
"update": {

View File

@@ -4,7 +4,7 @@ import UserModel from './user'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {getTextColor} from '@/helpers/color/getTextColor'
export default class LabelModel extends AbstractModel<ILabel> implements ILabel {
id = 0
@@ -29,10 +29,7 @@ export default class LabelModel extends AbstractModel<ILabel> implements ILabel
}
if (this.hexColor !== '') {
this.textColor = colorIsDark(this.hexColor)
// Fixed colors to avoid flipping in dark mode
? 'hsl(215, 27.9%, 16.9%)' // grey-800
: 'hsl(220, 13%, 91%)' // grey-200
this.textColor = getTextColor(this.hexColor)
}
this.createdBy = new UserModel(this.createdBy)