diff --git a/extensions/ql-vscode/src/pure/time.ts b/extensions/ql-vscode/src/pure/time.ts
new file mode 100644
index 00000000000..465b9ef9fd8
--- /dev/null
+++ b/extensions/ql-vscode/src/pure/time.ts
@@ -0,0 +1,83 @@
+/*
+ * Contains an assortment of helper functions for working with time, dates, and durations.
+ */
+
+
+const durationFormatter = new Intl.RelativeTimeFormat('en', {
+ numeric: 'auto',
+});
+
+// Months and years are approximate
+const MINUTE_IN_MILLIS = 1000 * 60;
+const HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
+const DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
+const MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
+const YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
+
+/**
+ * Converts a number of milliseconds into a human-readable string with units, indicating a relative time in the past or future.
+ *
+ * @param relativeTimeMillis The duration in milliseconds. A negative number indicates a duration in the past. And a positive number is
+ * the future.
+ * @returns A humanized duration. For example, "in 2 minutes", "2 minutes ago", "yesterday", or "tomorrow".
+ */
+export function humanizeRelativeTime(relativeTimeMillis?: number) {
+ if (relativeTimeMillis === undefined) {
+ return '';
+ }
+
+ if (Math.abs(relativeTimeMillis) < HOUR_IN_MILLIS) {
+ return durationFormatter.format(Math.floor(relativeTimeMillis / MINUTE_IN_MILLIS), 'minute');
+ } else if (Math.abs(relativeTimeMillis) < DAY_IN_MILLIS) {
+ return durationFormatter.format(Math.floor(relativeTimeMillis / HOUR_IN_MILLIS), 'hour');
+ } else if (Math.abs(relativeTimeMillis) < MONTH_IN_MILLIS) {
+ return durationFormatter.format(Math.floor(relativeTimeMillis / DAY_IN_MILLIS), 'day');
+ } else if (Math.abs(relativeTimeMillis) < YEAR_IN_MILLIS) {
+ return durationFormatter.format(Math.floor(relativeTimeMillis / MONTH_IN_MILLIS), 'month');
+ } else {
+ return durationFormatter.format(Math.floor(relativeTimeMillis / YEAR_IN_MILLIS), 'year');
+ }
+}
+
+/**
+ * Converts a number of milliseconds into a human-readable string with units, indicating an amount of time.
+ * Negative numbers have no meaning and are considered to be "Less than a minute".
+ *
+ * @param millis The number of milliseconds to convert.
+ * @returns A humanized duration. For example, "2 minutes", "2 hours", "2 days", or "2 months".
+ */
+export function humanizeUnit(millis?: number): string {
+ // assume a blank or empty string is a zero
+ // assume anything less than 0 is a zero
+ if (!millis || millis < MINUTE_IN_MILLIS) {
+ return 'Less than a minute';
+ }
+ let unit: string;
+ let unitDiff: number;
+ if (millis < HOUR_IN_MILLIS) {
+ unit = 'minute';
+ unitDiff = Math.floor(millis / MINUTE_IN_MILLIS);
+ } else if (millis < DAY_IN_MILLIS) {
+ unit = 'hour';
+ unitDiff = Math.floor(millis / HOUR_IN_MILLIS);
+ } else if (millis < MONTH_IN_MILLIS) {
+ unit = 'day';
+ unitDiff = Math.floor(millis / DAY_IN_MILLIS);
+ } else if (millis < YEAR_IN_MILLIS) {
+ unit = 'month';
+ unitDiff = Math.floor(millis / MONTH_IN_MILLIS);
+ } else {
+ unit = 'year';
+ unitDiff = Math.floor(millis / YEAR_IN_MILLIS);
+ }
+
+ return createFormatter(unit).format(unitDiff);
+}
+
+function createFormatter(unit: string) {
+ return Intl.NumberFormat('en-US', {
+ style: 'unit',
+ unit,
+ unitDisplay: 'long'
+ });
+}
diff --git a/extensions/ql-vscode/src/remote-queries/gh-actions-api-client.ts b/extensions/ql-vscode/src/remote-queries/gh-actions-api-client.ts
index 64bdbc514eb..b7bee9b5dcc 100644
--- a/extensions/ql-vscode/src/remote-queries/gh-actions-api-client.ts
+++ b/extensions/ql-vscode/src/remote-queries/gh-actions-api-client.ts
@@ -395,7 +395,8 @@ export async function getRepositoriesMetadata(credentials: Credentials, nwos: st
const owner = node.owner.login;
const name = node.name;
const starCount = node.stargazerCount;
- const lastUpdated = Date.now() - new Date(node.updatedAt).getTime();
+ // lastUpdated is always negative since it happened in the past.
+ const lastUpdated = new Date(node.updatedAt).getTime() - Date.now();
metadata[`${owner}/${name}`] = {
starCount, lastUpdated
};
diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts
index f6d0ede24ee..83688d2b8bf 100644
--- a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts
+++ b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts
@@ -27,6 +27,7 @@ import { URLSearchParams } from 'url';
import { SHOW_QUERY_TEXT_MSG } from '../query-history';
import { AnalysesResultsManager } from './analyses-results-manager';
import { AnalysisResults } from './shared/analysis-result';
+import { humanizeUnit } from '../pure/time';
export class RemoteQueriesInterfaceManager {
private panel: WebviewPanel | undefined;
@@ -249,23 +250,7 @@ export class RemoteQueriesInterfaceManager {
private getDuration(startTime: number, endTime: number): string {
const diffInMs = startTime - endTime;
- return this.formatDuration(diffInMs);
- }
-
- private formatDuration(ms: number): string {
- const seconds = ms / 1000;
- const minutes = seconds / 60;
- const hours = minutes / 60;
- const days = hours / 24;
- if (days > 1) {
- return `${days.toFixed(2)} days`;
- } else if (hours > 1) {
- return `${hours.toFixed(2)} hours`;
- } else if (minutes > 1) {
- return `${minutes.toFixed(2)} minutes`;
- } else {
- return `${seconds.toFixed(2)} seconds`;
- }
+ return humanizeUnit(diffInMs);
}
private formatDate = (millis: number): string => {
diff --git a/extensions/ql-vscode/src/remote-queries/view/LastUpdated.tsx b/extensions/ql-vscode/src/remote-queries/view/LastUpdated.tsx
index b03fc779fdf..6d4a83760b1 100644
--- a/extensions/ql-vscode/src/remote-queries/view/LastUpdated.tsx
+++ b/extensions/ql-vscode/src/remote-queries/view/LastUpdated.tsx
@@ -2,6 +2,8 @@ import * as React from 'react';
import { RepoPushIcon } from '@primer/octicons-react';
import styled from 'styled-components';
+import { humanizeRelativeTime } from '../../pure/time';
+
const IconContainer = styled.span`
flex-grow: 0;
text-align: right;
@@ -17,13 +19,15 @@ const Duration = styled.span`
type Props = { lastUpdated?: number };
const LastUpdated = ({ lastUpdated }: Props) => (
+ // lastUpdated will be undefined for older results that were
+ // created before the lastUpdated field was added.
Number.isFinite(lastUpdated) ? (
<>
- {humanizeDuration(lastUpdated)}
+ {humanizeRelativeTime(lastUpdated)}
>
) : (
@@ -32,31 +36,3 @@ const LastUpdated = ({ lastUpdated }: Props) => (
);
export default LastUpdated;
-
-const formatter = new Intl.RelativeTimeFormat('en', {
- numeric: 'auto'
-});
-
-// All these are approximate, specifically months and years
-const MINUTE_IN_MILLIS = 1000 * 60;
-const HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
-const DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
-const MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
-const YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
-
-function humanizeDuration(diff?: number) {
- if (!diff) {
- return '';
- }
- if (diff < HOUR_IN_MILLIS) {
- return formatter.format(- Math.floor(diff / MINUTE_IN_MILLIS), 'minute');
- } else if (diff < DAY_IN_MILLIS) {
- return formatter.format(- Math.floor(diff / HOUR_IN_MILLIS), 'hour');
- } else if (diff < MONTH_IN_MILLIS) {
- return formatter.format(- Math.floor(diff / DAY_IN_MILLIS), 'day');
- } else if (diff < YEAR_IN_MILLIS) {
- return formatter.format(- Math.floor(diff / MONTH_IN_MILLIS), 'month');
- } else {
- return formatter.format(- Math.floor(diff / YEAR_IN_MILLIS), 'year');
- }
-}
diff --git a/extensions/ql-vscode/src/remote-queries/view/RemoteQueries.tsx b/extensions/ql-vscode/src/remote-queries/view/RemoteQueries.tsx
index 8f50fd3a6bb..d1e70e5b8d2 100644
--- a/extensions/ql-vscode/src/remote-queries/view/RemoteQueries.tsx
+++ b/extensions/ql-vscode/src/remote-queries/view/RemoteQueries.tsx
@@ -71,14 +71,18 @@ const openQueryTextVirtualFile = (queryResult: RemoteQueryResult) => {
});
};
+function createResultsDescription(queryResult: RemoteQueryResult) {
+ const reposCount = `${queryResult.totalRepositoryCount} ${queryResult.totalRepositoryCount === 1 ? 'repository' : 'repositories'}`;
+ return `${queryResult.totalResultCount} results from running against ${reposCount} (${queryResult.executionDuration}), ${queryResult.executionTimestamp}`;
+}
+
const sumAnalysesResults = (analysesResults: AnalysisResults[]) =>
analysesResults.reduce((acc, curr) => acc + getAnalysisResultCount(curr), 0);
const QueryInfo = (queryResult: RemoteQueryResult) => (
<>
- {queryResult.totalResultCount} results from running against {queryResult.totalRepositoryCount} repositories
- ({queryResult.executionDuration}), {queryResult.executionTimestamp}
+ {createResultsDescription(queryResult)}
openQueryFile(queryResult)}>
diff --git a/extensions/ql-vscode/test/pure-tests/time.test.ts b/extensions/ql-vscode/test/pure-tests/time.test.ts
new file mode 100644
index 00000000000..43c96955abc
--- /dev/null
+++ b/extensions/ql-vscode/test/pure-tests/time.test.ts
@@ -0,0 +1,73 @@
+import { expect } from 'chai';
+import 'mocha';
+
+import { humanizeRelativeTime, humanizeUnit } from '../../src/pure/time';
+
+describe('Time', () => {
+ it('should return a humanized unit', () => {
+ expect(humanizeUnit(undefined)).to.eq('Less than a minute');
+ expect(humanizeUnit(0)).to.eq('Less than a minute');
+ expect(humanizeUnit(-1)).to.eq('Less than a minute');
+ expect(humanizeUnit(1000 * 60 - 1)).to.eq('Less than a minute');
+ expect(humanizeUnit(1000 * 60)).to.eq('1 minute');
+ expect(humanizeUnit(1000 * 60 * 2 - 1)).to.eq('1 minute');
+ expect(humanizeUnit(1000 * 60 * 2)).to.eq('2 minutes');
+ expect(humanizeUnit(1000 * 60 * 60)).to.eq('1 hour');
+ expect(humanizeUnit(1000 * 60 * 60 * 2)).to.eq('2 hours');
+ expect(humanizeUnit(1000 * 60 * 60 * 24)).to.eq('1 day');
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 2)).to.eq('2 days');
+
+ // assume every month has 30 days
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 30)).to.eq('1 month');
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 30 * 2)).to.eq('2 months');
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 30 * 12)).to.eq('12 months');
+
+ // assume every year has 365 days
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 365)).to.eq('1 year');
+ expect(humanizeUnit(1000 * 60 * 60 * 24 * 365 * 2)).to.eq('2 years');
+ });
+
+ it('should return a humanized duration positive', () => {
+ expect(humanizeRelativeTime(undefined)).to.eq('');
+ expect(humanizeRelativeTime(0)).to.eq('this minute');
+ expect(humanizeRelativeTime(1)).to.eq('this minute');
+ expect(humanizeRelativeTime(1000 * 60 - 1)).to.eq('this minute');
+ expect(humanizeRelativeTime(1000 * 60)).to.eq('in 1 minute');
+ expect(humanizeRelativeTime(1000 * 60 * 2 - 1)).to.eq('in 1 minute');
+ expect(humanizeRelativeTime(1000 * 60 * 2)).to.eq('in 2 minutes');
+ expect(humanizeRelativeTime(1000 * 60 * 60)).to.eq('in 1 hour');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 2)).to.eq('in 2 hours');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24)).to.eq('tomorrow');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 2)).to.eq('in 2 days');
+
+ // assume every month has 30 days
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 30)).to.eq('next month');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 30 * 2)).to.eq('in 2 months');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 30 * 12)).to.eq('in 12 months');
+
+ // assume every year has 365 days
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 365)).to.eq('next year');
+ expect(humanizeRelativeTime(1000 * 60 * 60 * 24 * 365 * 2)).to.eq('in 2 years');
+ });
+
+ it('should return a humanized duration negative', () => {
+ expect(humanizeRelativeTime(-1)).to.eq('1 minute ago');
+ expect(humanizeRelativeTime(-1000 * 60)).to.eq('1 minute ago');
+ expect(humanizeRelativeTime(-1000 * 60 - 1)).to.eq('2 minutes ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 2)).to.eq('2 minutes ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 2 - 1)).to.eq('3 minutes ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 60)).to.eq('1 hour ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 2)).to.eq('2 hours ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24)).to.eq('yesterday');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 2)).to.eq('2 days ago');
+
+ // assume every month has 30 days
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 30)).to.eq('last month');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 30 * 2)).to.eq('2 months ago');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 30 * 12)).to.eq('12 months ago');
+
+ // assume every year has 365 days
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 365)).to.eq('last year');
+ expect(humanizeRelativeTime(-1000 * 60 * 60 * 24 * 365 * 2)).to.eq('2 years ago');
+ });
+});