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