Skip to content

Commit

Permalink
lib: Reimplement timeformat.distanceToNow() with standard browser API
Browse files Browse the repository at this point in the history
The only remaining usage of date-fns is formatting relative times. Its
main advantage is proper locales support. However, by now
`Intl.RelativeTimeFormat` [1] is available on all browsers. It does not provide
the "pick appropriate unit" functionality, but that's easy enough to implement
ourselves.

However, keep special-casing distances of less than a minute like date-fns
does: APIs/UI delays, and the fact that we don't re-probe things like uptime or
snapshot creation times every second make it impractical to render precise
seconds, and in most cases it's also uninteresting.

Drop the `addSuffix` parameter. This mostly correlated with whether the
distance is positive or negative (i.e. rendering a future or past time),
and `RelativeTimeFormat()` does the distinction between "in ..." and
"... ago" automatically. There is currently no portable "format duration"
API in the browser [2], so we have to adjust our UI to get along with
the grammar change. The only remaining caller with `addSuffix=false` is
the Storage page's `format_delay()` for the JobsPanel, which is a future
time and thus "in ... minutes" is fine.

Note: The only other external consumers of this (c-ostree and c-machines) use
`addSuffix: true`, and thus the new behaviour is API compatible -- the extra
`true` will simply be ignored, and the result continues to be "... ago".

This was the last consumer of date-fns. This reduces the bundle size of
packagekit, sosreport, storage, and systemd by 128 kB each in production mode,
and 3 MB each in development mode.

[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat
[2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat

Fixes #20653
  • Loading branch information
martinpitt committed Jun 29, 2024
1 parent 51ec0e7 commit cb251b6
Show file tree
Hide file tree
Showing 8 changed files with 76 additions and 63 deletions.
70 changes: 32 additions & 38 deletions pkg/base1/test-timeformat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,60 +61,54 @@ const DAY = 24 * HOUR;

QUnit.test("relative formatter, English", assert => {
const now = Date.now();
const seconds = now - 4.5 * SEC;
const minutes = now - 4 * MIN - 5 * SEC;
const halfhour = now - 32 * MIN;
const hours = now - 3 * HOUR - 57 * MIN;
const yesterday = now - 25 * HOUR;
const days4short = now - 4 * DAY + 2 * HOUR;
const days4long = now - 4 * DAY - 2 * HOUR;
const week3short = now - 20 * DAY + 2 * HOUR;
const week3long = now - 21 * DAY - 2 * HOUR;
const month2short = now - 2 * 30 * DAY + 5 * DAY;
const month2long = now - 2 * 30 * DAY - 5 * DAY;

cockpit.language = "en";
assert.equal(timeformat.distanceToNow(seconds), "less than a minute");
assert.equal(timeformat.distanceToNow(seconds, false), "less than a minute");
assert.equal(timeformat.distanceToNow(seconds, true), "less than a minute ago");
assert.equal(timeformat.distanceToNow(now + 4.5 * SEC), "in less than a minute");
assert.equal(timeformat.distanceToNow(now - 4.5 * SEC), "less than a minute ago");

assert.equal(timeformat.distanceToNow(minutes), "4 minutes");
assert.equal(timeformat.distanceToNow(minutes, true), "4 minutes ago");
assert.equal(timeformat.distanceToNow(now + 4 * MIN - 5 * SEC), "in 4 minutes");
assert.equal(timeformat.distanceToNow(now - 4 * MIN - 5 * SEC), "4 minutes ago");
assert.equal(timeformat.distanceToNow(now - 4 * MIN + 5 * SEC), "4 minutes ago");

assert.equal(timeformat.distanceToNow(halfhour), "32 minutes");
assert.equal(timeformat.distanceToNow(halfhour, true), "32 minutes ago");
assert.equal(timeformat.distanceToNow(now - 32 * MIN), "32 minutes ago");
assert.equal(timeformat.distanceToNow(now + 32 * MIN), "in 32 minutes");

assert.equal(timeformat.distanceToNow(hours), "about 4 hours");
assert.equal(timeformat.distanceToNow(hours, true), "about 4 hours ago");
assert.equal(timeformat.distanceToNow(now + 3 * HOUR + 57 * MIN), "in 4 hours");
assert.equal(timeformat.distanceToNow(now - 3 * HOUR - 57 * MIN), "4 hours ago");

assert.equal(timeformat.distanceToNow(yesterday), "1 day");
assert.equal(timeformat.distanceToNow(yesterday, true), "1 day ago");
assert.equal(timeformat.distanceToNow(now + 25 * HOUR), "tomorrow");
assert.equal(timeformat.distanceToNow(now - 25 * HOUR), "yesterday");

assert.equal(timeformat.distanceToNow(days4short), "4 days");
assert.equal(timeformat.distanceToNow(days4long), "4 days");
assert.equal(timeformat.distanceToNow(days4short, true), "4 days ago");
assert.equal(timeformat.distanceToNow(now + 4 * DAY - 2 * HOUR), "in 4 days");
assert.equal(timeformat.distanceToNow(now + 4 * DAY + 2 * HOUR), "in 4 days");
assert.equal(timeformat.distanceToNow(now - 4 * DAY - 2 * HOUR), "4 days ago");
assert.equal(timeformat.distanceToNow(now - 4 * DAY + 2 * HOUR), "4 days ago");

assert.equal(timeformat.distanceToNow(week3short), "20 days");
assert.equal(timeformat.distanceToNow(week3long), "21 days");
assert.equal(timeformat.distanceToNow(week3long, true), "21 days ago");
assert.equal(timeformat.distanceToNow(now + 20 * DAY), "in 3 weeks");
assert.equal(timeformat.distanceToNow(now + 21 * DAY), "in 3 weeks");
assert.equal(timeformat.distanceToNow(now - 21 * DAY), "3 weeks ago");

assert.equal(timeformat.distanceToNow(month2short), "about 2 months");
assert.equal(timeformat.distanceToNow(month2long), "2 months");
assert.equal(timeformat.distanceToNow(now + 60 * DAY), "in 2 months");
assert.equal(timeformat.distanceToNow(now - 60 * DAY), "2 months ago");

assert.equal(timeformat.distanceToNow(now + 1200 * DAY), "in 3 years");
assert.equal(timeformat.distanceToNow(now - 1200 * DAY), "3 years ago");
});

QUnit.test("relative formatter, German", assert => {
const now = Date.now();
const seconds = now - 4.5 * SEC;

// no need to be as thorough as with English, just spot check that it's translated
cockpit.language = "de";
assert.equal(timeformat.distanceToNow(seconds), "weniger als 1 Minute");
assert.equal(timeformat.distanceToNow(seconds, true), "vor weniger als 1 Minute");

assert.equal(timeformat.distanceToNow(now - 25 * HOUR), "1 Tag");
assert.equal(timeformat.distanceToNow(now - 4 * DAY), "4 Tage");
assert.equal(timeformat.distanceToNow(now - 21 * DAY), "21 Tage");
assert.equal(timeformat.distanceToNow(now - 62 * DAY), "2 Monate");
/* TODO: this first needs to be translated in po/de.po
assert.equal(timeformat.distanceToNow(now + 4.5 * SEC), "in weniger als 1 Minute");
assert.equal(timeformat.distanceToNow(now - 4.5 * SEC), "vor weniger als 1 Minute");
*/

assert.equal(timeformat.distanceToNow(now + 25 * HOUR), "morgen");
assert.equal(timeformat.distanceToNow(now - 25 * HOUR), "gestern");
assert.equal(timeformat.distanceToNow(now - 4 * DAY), "vor 4 Tagen");
assert.equal(timeformat.distanceToNow(now + 21 * DAY), "in 3 Wochen");
});

QUnit.test("firstDayOfWeek", assert => {
Expand Down
49 changes: 34 additions & 15 deletions pkg/lib/timeformat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,12 @@
* Time stamps are given in milliseconds since the epoch.
*/
import cockpit from "cockpit";
import { formatDistanceToNow } from 'date-fns';
import * as locales from 'date-fns/locale';

const _ = cockpit.gettext;

// this needs to be dynamic, as some pages don't initialize cockpit.language right away
export const dateFormatLang = (): string => cockpit.language.replace('_', '-');

const dateFormatLangDateFns = (): string => {
if (cockpit.language == "en") return "enUS";
else return cockpit.language.replace('_', '');
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dateFnsLocale = (): any => (locales as any)[dateFormatLangDateFns()];

type Time = Date | number;

// general Intl.DateTimeFormat formatter object
Expand All @@ -41,11 +33,38 @@ export const dateTimeNoYear = (t: Time): string => formatter({ month: 'short', d
// Wednesday, June 30, 2021
export const weekdayDate = (t: Time): string => formatter({ dateStyle: "full" }).format(t);

// about 1 hour [ago]
export const distanceToNow = (t: Time, addSuffix?: boolean): string => formatDistanceToNow(t, {
locale: dateFnsLocale(),
addSuffix: addSuffix ?? false
});
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/format
const units: { name: Intl.RelativeTimeFormatUnit, max: number }[] = [
{ name: "second", max: 60 },
{ name: "minute", max: 3600 },
{ name: "hour", max: 86400 },
{ name: "day", max: 86400 * 7 },
{ name: "week", max: 86400 * 30 },
{ name: "month", max: 86400 * 365 },
{ name: "year", max: Infinity },
];

// "1 hour ago" for past times, "in 1 hour" for future times
export function distanceToNow(t: Time): string {
// Calculate the difference in seconds between the given date and the current date
const t_timestamp = t?.valueOf() ?? t;
const secondsDiff = Math.round((t_timestamp - Date.now()) / 1000);

// special case for < 1 minute, like date-fns
// we don't constantly re-render pages and there are delays, so seconds is too precise
if (secondsDiff <= 0 && secondsDiff > -60)
return _("less than a minute ago");
if (secondsDiff > 0 && secondsDiff < 60)
return _("in less than a minute");

// find the appropriate unit based on the seconds difference
const unitIndex = units.findIndex(u => u.max > Math.abs(secondsDiff));
// get the divisor to convert seconds to the appropriate unit
const divisor = unitIndex ? units[unitIndex - 1].max : 1;

const formatter = new Intl.RelativeTimeFormat(dateFormatLang(), { numeric: "auto" });
return formatter.format(Math.round(secondsDiff / divisor), units[unitIndex].name);
}

/***
* sorely missing from Intl: https://github.com/tc39/ecma402/issues/6
Expand Down
2 changes: 1 addition & 1 deletion pkg/packagekit/updates.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ const UpdatesStatus = ({ updates, highestSeverity, timeSinceRefresh, tracerPacka
let lastChecked;
// PackageKit returns G_MAXUINT if the db was never checked.
if (timeSinceRefresh !== null && timeSinceRefresh !== 2 ** 32 - 1)
lastChecked = cockpit.format(_("Last checked: $0"), timeformat.distanceToNow(new Date().valueOf() - timeSinceRefresh * 1000, true));
lastChecked = cockpit.format(_("Last checked: $0"), timeformat.distanceToNow(new Date().valueOf() - timeSinceRefresh * 1000));

const notifications = [];
if (numUpdates > 0) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/sosreport/sosreport.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ const SOSBody = () => {
props: { key: path },
columns: [
report.name,
timeformat.distanceToNow(new Date(report.date * 1000), true),
timeformat.distanceToNow(new Date(report.date * 1000)),
{ title: <LabelGroup>{labels}</LabelGroup> },
{
title: <>{action}{menu}</>,
Expand Down
4 changes: 2 additions & 2 deletions pkg/storaged/dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,7 @@ export const StopProcessesMessage = ({ mount_point, users }) => {
u.pid,
{ title: u.cmd.substr(0, 100), props: { modifier: "breakWord" } },
u.user || "-",
{ title: format_delay(-u.since * 1000, true), props: { modifier: "nowrap" } }
{ title: format_delay(-u.since * 1000), props: { modifier: "nowrap" } }
]
};
});
Expand All @@ -1412,7 +1412,7 @@ export const StopProcessesMessage = ({ mount_point, users }) => {
{ title: u.unit.replace(/\.service$/, ""), props: { modifier: "breakWord" } },
{ title: u.cmd.substr(0, 100), props: { modifier: "breakWord" } },
{ title: u.desc || "", props: { modifier: "breakWord" } },
{ title: format_delay(-u.since * 1000, true), props: { modifier: "nowrap" } }
{ title: format_delay(-u.since * 1000), props: { modifier: "nowrap" } }
]
};
});
Expand Down
6 changes: 3 additions & 3 deletions pkg/storaged/test-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import QUnit, { f } from "qunit-tests";

QUnit.test("format_delay", function (assert) {
const checks = [
[3000, "less than a minute"],
[60000, "1 minute"],
[15550000, "about 4 hours"],
[3000, "in less than a minute"],
[60000, "in 1 minute"],
[15550000, "in 4 hours"],
];

assert.expect(checks.length);
Expand Down
4 changes: 2 additions & 2 deletions pkg/storaged/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ export function format_fsys_usage(used, total) {
return parts[0] + text;
}

export function format_delay(d, addSuffix) {
return timeformat.distanceToNow(new Date().valueOf() + d, addSuffix);
export function format_delay(d) {
return timeformat.distanceToNow(new Date().valueOf() + d);
}

export function format_size_and_text(size, text) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/systemd/overview-cards/systemInformationCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class SystemInformationCard extends React.Component {
.then(content => {
const uptime = parseFloat(content.split(' ')[0]);
const bootTime = new Date().valueOf() - uptime * 1000;
this.setState({ systemUptime: timeformat.distanceToNow(bootTime, true) });
this.setState({ systemUptime: timeformat.distanceToNow(bootTime) });
})
.catch(ex => console.error("Error reading system uptime", ex.toString())); // not-covered: OS error
}
Expand Down

0 comments on commit cb251b6

Please sign in to comment.