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'); + }); +});