diff --git a/docs/graphs-tables.md b/docs/graphs-tables.md index eacc1a4f..f42a2c66 100644 --- a/docs/graphs-tables.md +++ b/docs/graphs-tables.md @@ -14,7 +14,7 @@ The Overview tab provides a high-level summary of all test runs across projects. | Section | Description | |---------|-------------| -| **Latest Runs** | Displays the most recent run for each project as a card. Each card shows pass/fail/skip counts and duration, color-coded to indicate performance relative to previous runs. Clicking a project card filters the Overview to that project. | +| **Latest Runs** | Displays the most recent run for each project as a card. Each card shows pass/fail/skip counts, duration (color-coded to indicate performance relative to previous runs) and how long ago the run was executed (hover it to see the exact run timestamp). Clicking a project card filters the Overview to that project. | | **Total Stats** | Shows aggregate statistics across all runs grouped by project: total passed, failed, skipped runs, average duration, and average pass rate. | ### Graphs diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index dfda06ba..ddec1228 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -423,6 +423,22 @@ height: 200px; } +/* relative run time, pinned to the bottom right corner of a run card without + affecting the layout of the stats/duration columns next to the donut */ +.overview-card .card-body { + position: relative; +} + +.run-card-run-time { + position: absolute; + right: 1rem; + bottom: 0.55rem; + color: var(--color-text-muted); + cursor: help; + font-size: 0.85em; + white-space: nowrap; +} + .overview-card { cursor: pointer; min-width: 300px; diff --git a/robotframework_dashboard/js/common.js b/robotframework_dashboard/js/common.js index ef0f3d85..485dbaaf 100644 --- a/robotframework_dashboard/js/common.js +++ b/robotframework_dashboard/js/common.js @@ -73,6 +73,68 @@ function format_date_to_string(date) { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } +// function to parse a run_start string ("YYYY-MM-DD HH:MM:SS[.ffffff][±HH:MM]") into a Date +// timestamps without a timezone offset are interpreted as local time, returns null when unparsable +function parse_run_start(run_start) { + if (!run_start) return null; + let value = String(run_start).trim(); + let timezone = ""; + const suffix = value.slice(-6); + if (/^[+-]\d{2}:\d{2}$/.test(suffix)) { + timezone = suffix; + value = value.slice(0, -6); + } else if (value.endsWith("Z")) { + timezone = "Z"; + value = value.slice(0, -1); + } + // normalize to ISO-8601 with at most milliseconds, browsers handle microseconds inconsistently + value = value.replace(" ", "T").replace(/(\.\d{3})\d+$/, "$1"); + const date = new Date(`${value}${timezone}`); + return isNaN(date.getTime()) ? null : date; +} + +// function to format how long ago a run was executed, showing the 2 largest relevant units +// e.g. "15 minutes ago", "3 hours 20 minutes ago", "4 days 2 hours ago" +function format_relative_time(run_start, now = new Date()) { + const date = parse_run_start(run_start); + if (!date) return ""; + const pluralize = (amount, unit) => `${amount} ${unit}${amount === 1 ? "" : "s"}`; + const totalSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + if (totalSeconds < 0) return "just now"; // future timestamps (clock skew/timezone mismatch) + if (totalSeconds < 60) return `${pluralize(totalSeconds, "second")} ago`; + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) return `${pluralize(totalMinutes, "minute")} ago`; + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) { + const minutes = totalMinutes % 60; + return minutes + ? `${pluralize(totalHours, "hour")} ${pluralize(minutes, "minute")} ago` + : `${pluralize(totalHours, "hour")} ago`; + } + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return hours + ? `${pluralize(days, "day")} ${pluralize(hours, "hour")} ago` + : `${pluralize(days, "day")} ago`; +} + +// function to format a run_start into a readable absolute timestamp (used for tooltips) +// keeps the stored timezone offset when present and drops sub-second precision +function format_run_start_exact(run_start) { + if (!run_start) return ""; + let value = String(run_start).trim(); + let timezone = ""; + const suffix = value.slice(-6); + if (/^[+-]\d{2}:\d{2}$/.test(suffix)) { + timezone = ` ${suffix}`; + value = value.slice(0, -6); + } else if (value.endsWith("Z")) { + timezone = " +00:00"; + value = value.slice(0, -1); + } + return `${value.replace("T", " ").replace(/\.\d+$/, "")}${timezone}`; +} + // function to transform an output.xml path to a log.html path function transform_file_path(filePath) { const normalizedPath = filePath.replace(/\\/g, "/"); @@ -271,6 +333,9 @@ export { space_to_camelcase, underscore_to_camelcase, format_date_to_string, + parse_run_start, + format_relative_time, + format_run_start_exact, transform_file_path, combine_paths, add_alert, diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index dc0d6a84..7f5cea24 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -3,6 +3,8 @@ import { compare_to_average, transform_file_path, format_duration, + format_relative_time, + format_run_start_exact, debounce, show_loading_overlay, hide_loading_overlay, @@ -134,6 +136,7 @@ function generate_overview_card_html( isForOverview = false, isTotalStats = false, sectionPrefix = 'overview', + runStart = null, ) { const normalizedProjectVersion = projectVersion ?? "None"; // ensure overview stats and project bar card ids unique @@ -195,6 +198,11 @@ function generate_overview_card_html( const totalStatsHeader = isTotalStats ? `
Run Stats
` : ''; const totalStatsAverage = isTotalStats ? `
Average Run Duration
` : ''; const logLinkHtml = log_name ? `${log_name}` : ''; + // relative run time (e.g. "3 hours 20 minutes ago") with the exact datetime as tooltip + const relativeRunTime = isTotalStats ? '' : format_relative_time(runStart); + const runTimeHtml = relativeRunTime + ? `
${relativeRunTime}
` + : ''; return `
@@ -234,6 +242,7 @@ function generate_overview_card_html(
+ ${runTimeHtml} `; @@ -724,6 +733,7 @@ function create_project_run_card(run, projectName, runIndex, runNumber, passRate isForOverview, isTotalStats, sectionPrefix, + run.run_start, ) const existingRunCard = document.getElementById(`${projectNameForId}Card${runIndex}`); if (existingRunCard) { diff --git a/tests/javascript/common.test.js b/tests/javascript/common.test.js index aceab441..771bc3c0 100644 --- a/tests/javascript/common.test.js +++ b/tests/javascript/common.test.js @@ -10,6 +10,9 @@ import { underscore_to_camelcase, camelcase_to_underscore, format_date_to_string, + parse_run_start, + format_relative_time, + format_run_start_exact, transform_file_path, combine_paths, debounce, @@ -181,6 +184,126 @@ describe('format_date_to_string', () => { }); +describe('parse_run_start', () => { + it('parses a timestamp without timezone as local time', () => { + const date = parse_run_start('2025-01-15 09:05:03'); + expect(date.getTime()).toBe(new Date(2025, 0, 15, 9, 5, 3).getTime()); + }); + + it('parses microsecond precision by truncating to milliseconds', () => { + const date = parse_run_start('2025-01-15 09:05:03.123456'); + expect(date.getTime()).toBe(new Date(2025, 0, 15, 9, 5, 3, 123).getTime()); + }); + + it('honours a +HH:MM timezone offset', () => { + const date = parse_run_start('2025-01-15 09:05:03+02:00'); + expect(date.toISOString()).toBe('2025-01-15T07:05:03.000Z'); + }); + + it('honours a -HH:MM timezone offset', () => { + const date = parse_run_start('2025-01-15 09:05:03-05:00'); + expect(date.toISOString()).toBe('2025-01-15T14:05:03.000Z'); + }); + + it('honours a Z suffix', () => { + const date = parse_run_start('2025-01-15T09:05:03Z'); + expect(date.toISOString()).toBe('2025-01-15T09:05:03.000Z'); + }); + + it('returns null for empty or invalid input', () => { + expect(parse_run_start('')).toBeNull(); + expect(parse_run_start(null)).toBeNull(); + expect(parse_run_start(undefined)).toBeNull(); + expect(parse_run_start('not a date')).toBeNull(); + }); +}); + + +describe('format_relative_time', () => { + const now = new Date(2025, 0, 15, 12, 0, 0); // Jan 15, 2025 12:00:00 local time + + it('formats seconds', () => { + expect(format_relative_time('2025-01-15 11:59:15', now)).toBe('45 seconds ago'); + }); + + it('formats a single second', () => { + expect(format_relative_time('2025-01-15 11:59:59', now)).toBe('1 second ago'); + }); + + it('formats minutes', () => { + expect(format_relative_time('2025-01-15 11:45:00', now)).toBe('15 minutes ago'); + }); + + it('formats a single minute', () => { + expect(format_relative_time('2025-01-15 11:59:00', now)).toBe('1 minute ago'); + }); + + it('formats hours and minutes', () => { + expect(format_relative_time('2025-01-15 08:40:00', now)).toBe('3 hours 20 minutes ago'); + }); + + it('omits minutes on an exact hour boundary', () => { + expect(format_relative_time('2025-01-15 09:00:00', now)).toBe('3 hours ago'); + }); + + it('formats a single hour and minute', () => { + expect(format_relative_time('2025-01-15 10:59:00', now)).toBe('1 hour 1 minute ago'); + }); + + it('formats days and hours', () => { + expect(format_relative_time('2025-01-11 10:00:00', now)).toBe('4 days 2 hours ago'); + }); + + it('omits hours on an exact day boundary', () => { + expect(format_relative_time('2025-01-14 12:00:00', now)).toBe('1 day ago'); + }); + + it('rounds down to whole units', () => { + // 14 minutes and 59.877 seconds ago + expect(format_relative_time('2025-01-15 11:45:00.123456', now)).toBe('14 minutes ago'); + }); + + it('takes the timezone offset into account', () => { + const utcNow = new Date('2025-01-15T12:00:00Z'); + expect(format_relative_time('2025-01-15 14:45:00+03:00', utcNow)).toBe('15 minutes ago'); + }); + + it('returns "just now" for future timestamps', () => { + expect(format_relative_time('2025-01-15 12:30:00', now)).toBe('just now'); + }); + + it('returns an empty string for missing or invalid input', () => { + expect(format_relative_time('', now)).toBe(''); + expect(format_relative_time(null, now)).toBe(''); + expect(format_relative_time('not a date', now)).toBe(''); + }); +}); + + +describe('format_run_start_exact', () => { + it('drops microseconds', () => { + expect(format_run_start_exact('2025-01-15 09:05:03.123456')).toBe('2025-01-15 09:05:03'); + }); + + it('keeps the timezone offset', () => { + expect(format_run_start_exact('2025-01-15 09:05:03.123456+02:00')).toBe('2025-01-15 09:05:03 +02:00'); + }); + + it('normalizes the ISO T separator', () => { + expect(format_run_start_exact('2025-01-15T09:05:03')).toBe('2025-01-15 09:05:03'); + }); + + it('converts a Z suffix into an offset', () => { + expect(format_run_start_exact('2025-01-15T09:05:03Z')).toBe('2025-01-15 09:05:03 +00:00'); + }); + + it('returns an empty string for missing input', () => { + expect(format_run_start_exact('')).toBe(''); + expect(format_run_start_exact(null)).toBe(''); + }); +}); + + describe('transform_file_path', () => { it('transforms output.xml to log.html with forward slashes', () => { expect(transform_file_path('/path/to/output.xml')).toBe('/path/to/log.html'); diff --git a/tests/robot/resources/keywords/dashboard-keywords.resource b/tests/robot/resources/keywords/dashboard-keywords.resource index de2171fc..77ffafba 100644 --- a/tests/robot/resources/keywords/dashboard-keywords.resource +++ b/tests/robot/resources/keywords/dashboard-keywords.resource @@ -24,6 +24,9 @@ Open Dashboard New Context colorScheme=dark viewport={"width": 1920, "height": 1600} New Page url=file:${ROOT_FOLDER}/robotdashboard_${DASHBOARD_INDEX}.html Wait For Elements State selector=id=loading state=hidden + # the relative run times on the overview cards ("2 hours ago") change as time passes, + # hide them so the screenshot comparisons stay deterministic + Add Style Tag content=.run-card-run-time { visibility: hidden; } Click selector=id=settings Fill Text selector=id=toggleAnimationDuration txt=0 Click selector=id=closeSettings