diff --git a/cypress/integration/rendering/gantt.spec.js b/cypress/integration/rendering/gantt.spec.js index 72cb6ea29..f250a188f 100644 --- a/cypress/integration/rendering/gantt.spec.js +++ b/cypress/integration/rendering/gantt.spec.js @@ -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 + `, + {} + ); + }); }); diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.js b/packages/mermaid/src/diagrams/gantt/ganttDb.js index 5f328f546..185fdae41 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.js +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.js @@ -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 || diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts index d2d38e86d..48d618938 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts @@ -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); + }); }); diff --git a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js index 6320c4ce4..7a30e0d4a 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js +++ b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js @@ -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; + } } } }