fix: enhance Gantt chart handling for invalid date formats and tick intervals

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
omkarht
2025-11-27 20:42:59 +05:30
parent 88e8ad6f5b
commit 73b8626ab0
4 changed files with 155 additions and 76 deletions

View File

@@ -833,4 +833,34 @@ describe('Gantt diagram', () => {
{}
);
});
it('should handle seconds-only format with tickInterval (issue #5496)', () => {
imgSnapshotTest(
`
gantt
tickInterval 1second
dateFormat ss
axisFormat %s
section Network Request
RTT : rtt, 0, 20
`,
{}
);
});
it('should handle dates with year typo like 202 instead of 2024 (issue #5496)', () => {
imgSnapshotTest(
`
gantt
title Schedule
dateFormat YYYY-MM-DD
tickInterval 1week
axisFormat %m-%d
section Vacation
London : 2024-12-01, 7d
London : 202-12-01, 7d
`,
{}
);
});
});

View File

@@ -268,7 +268,15 @@ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, include
const getStartDate = function (prevTime, dateFormat, str) {
str = str.trim();
if ((dateFormat.trim() === 'x' || dateFormat.trim() === 'X') && /^\d+$/.test(str)) {
// Helper function to check if format is a timestamp format (x or X)
const isTimestampFormat = (format) => {
const trimmedFormat = format.trim();
return trimmedFormat === 'x' || trimmedFormat === 'X';
};
// Handle timestamp formats (x, X) with numeric strings
if (isTimestampFormat(dateFormat) && /^\d+$/.test(str)) {
return new Date(Number(str));
}
// Test for after
@@ -293,7 +301,7 @@ const getStartDate = function (prevTime, dateFormat, str) {
return today;
}
// Check for actual date set
// Check for actual date set using dayjs strict parsing
let mDate = dayjs(str, dateFormat.trim(), true);
if (mDate.isValid()) {
return mDate.toDate();
@@ -301,15 +309,13 @@ const getStartDate = function (prevTime, dateFormat, str) {
log.debug('Invalid date:' + str);
log.debug('With date format:' + dateFormat.trim());
// Only allow fallback for formats that are simple timestamps (x, X)
// which represent Unix timestamps. For all other formats, if dayjs
// strict parsing fails - throws an error.
const isTimestampFormat = dateFormat.trim() === 'x' || dateFormat.trim() === 'X';
if (!isTimestampFormat) {
// Only allow fallback to new Date() for timestamp formats (x, X)
// For all other formats, if dayjs strict parsing fails, throw an error
if (!isTimestampFormat(dateFormat)) {
log.debug(`Invalid date: "${str}" does not match format "${dateFormat.trim()}".`);
}
// Timestamp formats can fall back to new Date()
const d = new Date(str);
if (
d === undefined ||

View File

@@ -505,4 +505,27 @@ describe('when using the ganttDb', function () {
ganttDb.addTask('test1', 'id1,202304,1d');
expect(() => ganttDb.getTasks()).toThrowError('Invalid date:202304');
});
it('should handle seconds-only format with valid numeric values (issue #5496)', function () {
ganttDb.setDateFormat('ss');
ganttDb.addSection('Network Request');
ganttDb.addTask('RTT', 'rtt, 0, 20');
const tasks = ganttDb.getTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].task).toBe('RTT');
expect(tasks[0].id).toBe('rtt');
});
it('should handle dates with year typo like 202 instead of 2024 (issue #5496)', function () {
ganttDb.setDateFormat('YYYY-MM-DD');
ganttDb.addSection('Vacation');
ganttDb.addTask('London Trip 1', '2024-12-01, 7d');
ganttDb.addTask('London Trip 2', '202-12-01, 7d');
const tasks = ganttDb.getTasks();
expect(tasks).toHaveLength(2);
// First task should be in year 2024
expect(tasks[0].startTime.getFullYear()).toBe(2024);
// Second task will be parsed as year 202 (fallback to new Date())
expect(tasks[1].startTime.getFullYear()).toBe(202);
});
});

View File

@@ -608,7 +608,7 @@ export const draw = function (text, id, version, diagObj) {
/**
* Calculates the estimated number of ticks based on the time domain and tick interval.
* Returns the count or Infinity if there would be too many ticks.
* Returns the estimated number of ticks as a number.
* @param {Date} minTime - The minimum time in the domain
* @param {Date} maxTime - The maximum time in the domain
* @param {number} every - The interval count (e.g., 1 for "1second")
@@ -616,8 +616,14 @@ export const draw = function (text, id, version, diagObj) {
* @returns {number} The estimated number of ticks
*/
function getEstimatedTickCount(minTime, maxTime, every, interval) {
if (every <= 0 || minTime > maxTime) {
return Infinity;
}
const timeDiffMs = maxTime - minTime;
const intervalMs = dayjs.duration({ [interval ?? 'day']: every }).asMilliseconds();
if (intervalMs <= 0) {
return Infinity;
}
return Math.ceil(timeDiffMs / intervalMs);
}
@@ -650,45 +656,52 @@ export const draw = function (text, id, version, diagObj) {
if (resultTickInterval !== null) {
const every = parseInt(resultTickInterval[1], 10);
const interval = resultTickInterval[2];
const weekday = diagObj.db.getWeekday() || conf.weekday;
// Get the time domain to check tick count
const domain = timeScale.domain();
const minTime = domain[0];
const maxTime = domain[1];
const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval);
if (estimatedTicks > MAX_TICK_COUNT) {
if (isNaN(every) || every <= 0) {
log.warn(
`The tick interval "${every}${interval}" would generate ${estimatedTicks} ticks, ` +
`which exceeds the maximum allowed (${MAX_TICK_COUNT}). ` +
`This may indicate an invalid date or time range. Skipping custom tick interval.`
`Invalid tick interval value: "${resultTickInterval[1]}". Skipping custom tick interval.`
);
// D3 using its default automatic tick generation
// Skip applying custom ticks
} else {
switch (interval) {
case 'millisecond':
bottomXAxis.ticks(timeMillisecond.every(every));
break;
case 'second':
bottomXAxis.ticks(timeSecond.every(every));
break;
case 'minute':
bottomXAxis.ticks(timeMinute.every(every));
break;
case 'hour':
bottomXAxis.ticks(timeHour.every(every));
break;
case 'day':
bottomXAxis.ticks(timeDay.every(every));
break;
case 'week':
bottomXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every));
break;
case 'month':
bottomXAxis.ticks(timeMonth.every(every));
break;
const interval = resultTickInterval[2];
const weekday = diagObj.db.getWeekday() || conf.weekday;
// Get the time domain to check tick count
const domain = timeScale.domain();
const minTime = domain[0];
const maxTime = domain[1];
const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval);
if (estimatedTicks > MAX_TICK_COUNT) {
log.warn(
`The tick interval "${every}${interval}" would generate ${estimatedTicks} ticks, ` +
`which exceeds the maximum allowed (${MAX_TICK_COUNT}). ` +
`This may indicate an invalid date or time range. Skipping custom tick interval.`
);
// D3 will use its default automatic tick generation
} else {
switch (interval) {
case 'millisecond':
bottomXAxis.ticks(timeMillisecond.every(every));
break;
case 'second':
bottomXAxis.ticks(timeSecond.every(every));
break;
case 'minute':
bottomXAxis.ticks(timeMinute.every(every));
break;
case 'hour':
bottomXAxis.ticks(timeHour.every(every));
break;
case 'day':
bottomXAxis.ticks(timeDay.every(every));
break;
case 'week':
bottomXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every));
break;
case 'month':
bottomXAxis.ticks(timeMonth.every(every));
break;
}
}
}
}
@@ -712,39 +725,46 @@ export const draw = function (text, id, version, diagObj) {
if (resultTickInterval !== null) {
const every = parseInt(resultTickInterval[1], 10);
const interval = resultTickInterval[2];
const weekday = diagObj.db.getWeekday() || conf.weekday;
if (isNaN(every) || every <= 0) {
log.warn(
`Invalid tick interval value: "${resultTickInterval[1]}". Skipping custom tick interval.`
);
// Skip applying custom ticks
} else {
const interval = resultTickInterval[2];
const weekday = diagObj.db.getWeekday() || conf.weekday;
// Get the time domain to check tick count
const domain = timeScale.domain();
const minTime = domain[0];
const maxTime = domain[1];
const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval);
// Get the time domain to check tick count
const domain = timeScale.domain();
const minTime = domain[0];
const maxTime = domain[1];
const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval);
// Only apply custom ticks if the count is reasonable
if (estimatedTicks <= MAX_TICK_COUNT) {
switch (interval) {
case 'millisecond':
topXAxis.ticks(timeMillisecond.every(every));
break;
case 'second':
topXAxis.ticks(timeSecond.every(every));
break;
case 'minute':
topXAxis.ticks(timeMinute.every(every));
break;
case 'hour':
topXAxis.ticks(timeHour.every(every));
break;
case 'day':
topXAxis.ticks(timeDay.every(every));
break;
case 'week':
topXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every));
break;
case 'month':
topXAxis.ticks(timeMonth.every(every));
break;
// Only apply custom ticks if the count is reasonable
if (estimatedTicks <= MAX_TICK_COUNT) {
switch (interval) {
case 'millisecond':
topXAxis.ticks(timeMillisecond.every(every));
break;
case 'second':
topXAxis.ticks(timeSecond.every(every));
break;
case 'minute':
topXAxis.ticks(timeMinute.every(every));
break;
case 'hour':
topXAxis.ticks(timeHour.every(every));
break;
case 'day':
topXAxis.ticks(timeDay.every(every));
break;
case 'week':
topXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every));
break;
case 'month':
topXAxis.ticks(timeMonth.every(every));
break;
}
}
}
}