feat(gantt): natural day-boundary rounding in Gantt chart (#1476)

This commit is contained in:
kolaente 2025-09-11 17:51:15 +02:00 committed by GitHub
parent 0506b9215a
commit d14443d2f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 23 deletions

View File

@ -85,6 +85,7 @@ import GanttTimelineHeader from '@/components/gantt/GanttTimelineHeader.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,
@ -149,16 +150,16 @@ const ganttBars = ref<GanttBarModel[][]>([])
const ganttRows = ref<string[]>([])
const cellsByRow = ref<Record<string, string[]>>({})
function getRoundedDate(value: string | Date | undefined, fallback: Date, isStart: boolean) {
return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart)
}
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 startDate = getRoundedDate(t.startDate, props.defaultTaskStartDate, true)
const endDate = getRoundedDate(t.endDate, props.defaultTaskEndDate, false)
const taskColor = getHexColor(t.hexColor)
const bar = {
id: String(t.id),
start: startDate,
@ -171,7 +172,7 @@ function transformTaskToGanttBar(t: ITask): GanttBarModel {
isDone: t.done,
},
}
return bar
}
@ -181,22 +182,18 @@ watch(
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)
const taskStart = getRoundedDate(task.startDate, props.defaultTaskStartDate, true)
const taskEnd = getRoundedDate(task.endDate, props.defaultTaskEndDate, false)
// Task is visible if it overlaps with the current date range
return taskStart <= dateToDate.value
&& taskEnd >= dateFromDate.value
return taskStart <= dateToDate.value
&& taskEnd >= dateFromDate.value
})
filteredTasks.forEach((t, index) => {
@ -225,8 +222,8 @@ watch(
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(),
startDate: roundToNaturalDayBoundary(newStart, true),
endDate: roundToNaturalDayBoundary(newEnd),
})
}

View File

@ -104,6 +104,7 @@ 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 {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundary'
import GanttBarPrimitive from './primitives/GanttBarPrimitive.vue'
@ -152,7 +153,10 @@ function computeBarX(startDate: Date) {
}
function getDaysDifference(startDate: Date, endDate: Date): number {
return Math.floor((endDate.getTime() - startDate.getTime()) / MILLISECONDS_A_DAY)
return Math.ceil(
(roundToNaturalDayBoundary(endDate).getTime() - roundToNaturalDayBoundary(startDate, true).getTime()) /
MILLISECONDS_A_DAY,
)
}
function computeBarWidth(bar: GanttBarModel) {

View File

@ -0,0 +1,30 @@
import {describe, it, expect} from 'vitest'
import {roundToNaturalDayBoundary} from './roundToNaturalDayBoundary'
import {MILLISECONDS_A_DAY} from '@/constants/date'
describe('roundToNaturalDayBoundary', () => {
it('rounds start dates to start of day regardless of time', () => {
const morning = new Date('2024-01-01T08:00:00')
const afternoon = new Date('2024-01-01T13:00:00')
expect(roundToNaturalDayBoundary(morning, true)).toEqual(new Date('2024-01-01T00:00:00.000'))
expect(roundToNaturalDayBoundary(afternoon, true)).toEqual(new Date('2024-01-01T00:00:00.000'))
})
it('rounds end dates to natural boundaries', () => {
const morning = new Date('2024-01-01T08:00:00')
const afternoon = new Date('2024-01-01T13:00:00')
expect(roundToNaturalDayBoundary(morning)).toEqual(new Date('2024-01-01T00:00:00.000'))
expect(roundToNaturalDayBoundary(afternoon)).toEqual(new Date('2024-01-01T23:59:59.999'))
})
it('counts inclusive days for same-day evening end', () => {
const start = new Date('2024-01-01T15:00:00')
const end = new Date('2024-01-01T18:00:00')
const diff = Math.ceil(
(roundToNaturalDayBoundary(end).getTime() - roundToNaturalDayBoundary(start, true).getTime()) /
MILLISECONDS_A_DAY,
)
expect(diff).toBe(1)
})
})

View File

@ -0,0 +1,9 @@
export function roundToNaturalDayBoundary(date: Date, isStart = false): Date {
const d = new Date(date)
if (isStart || d.getHours() < 12) {
d.setHours(0, 0, 0, 0)
} else {
d.setHours(23, 59, 59, 999)
}
return d
}