From 88f3a5fc26f74b31e581541ec50ff0d966af57bd Mon Sep 17 00:00:00 2001 From: MatheusAssisNeves Date: Wed, 9 Sep 2026 11:22:25 -0300 Subject: [PATCH] feat: show relative run time on overview run cards Each run card on the Overview page now shows how long ago the run was executed, e.g. "15 minutes ago", "3 hours 20 minutes ago" or "4 days 2 hours ago", in the bottom right corner of the card. Hovering the text reveals the exact run timestamp, including the stored timezone offset. - add parse_run_start, format_relative_time and format_run_start_exact helpers to common.js - render the relative time on the Latest Runs cards and on the project bar cards; Total Statistics cards aggregate many runs and are left unchanged - position the text absolutely so the existing card layout, and with it the visual test references, stay untouched - hide the relative time in the robot visual tests, since the text changes as time passes and would make screenshot comparisons flaky - add unit tests for the new helpers and document the behaviour Closes #318 --- docs/graphs-tables.md | 2 +- robotframework_dashboard/css/components.css | 16 +++ robotframework_dashboard/js/common.js | 65 +++++++++ .../js/graph_creation/overview.js | 10 ++ tests/javascript/common.test.js | 123 ++++++++++++++++++ .../keywords/dashboard-keywords.resource | 3 + 6 files changed, 218 insertions(+), 1 deletion(-) 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