Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor timeAgo to make it more performant #850

Merged
merged 1 commit into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-keys-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@guardian/libs': patch
---

Small performance improvements and internal refactors to `timeAgo`
153 changes: 72 additions & 81 deletions libs/@guardian/libs/src/datetime/timeAgo.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
type Unit = 's' | 'm' | 'h' | 'd';
const units = {
second: 1_000,
minute: 60_000,
hour: 3_600_000,
day: 86_400_000,
} as const satisfies Record<string, number>;
mxdvl marked this conversation as resolved.
Show resolved Hide resolved

const pad = (n: number): number | string => n.toString().padStart(2, '0');

const isWithin24Hours = (date: Date): boolean => {
const today = new Date();
return date.getTime() > today.getTime() - 24 * 60 * 60 * 1000;
export const duration = ({
then,
now,
}: {
then: number;
now: number;
}): { length: number; unit: keyof typeof units } => {
const difference = now - then;
if (difference < units.minute) {
return { length: difference / units.second, unit: 'second' };
}
if (difference < units.hour) {
return { length: difference / units.minute, unit: 'minute' };
}
if (difference < units.day) {
return { length: difference / units.hour, unit: 'hour' };
}
return { length: difference / units.day, unit: 'day' };
};

const isYesterday = (relative: Date): boolean => {
const today = new Date();
const yesterday = new Date();
const isYesterday = (then: number, now: number): boolean => {
const today = new Date(now);
const yesterday = new Date(now);
yesterday.setDate(today.getDate() - 1);
return relative.toDateString() === yesterday.toDateString();
};

const getSuffix = (type: Unit, value: number, verbose?: boolean): string => {
const shouldPluralise = value !== 1;
switch (type) {
case 's': {
// Always pluralised, as less than 15 seconds returns “now”
if (verbose) return ' seconds ago';
return 's ago';
}
case 'm': {
if (verbose && shouldPluralise) return ' minutes ago';
if (verbose) return ' minute ago';
return 'm ago';
}
case 'h': {
if (verbose && shouldPluralise) return ' hours ago';
if (verbose) return ' hour ago';
return 'h ago';
}
case 'd': {
// Always pluralised, as less than 2 days returns “Yesterday HH.MM”
if (verbose) return ' days ago';
return 'd ago';
}
}
return new Date(then).toDateString() === yesterday.toDateString();
};

const withTime = (date: Date): string =>
` ${date.getHours()}.${pad(date.getMinutes())}`;
`${date.getHours()}.${date.getMinutes().toString().padStart(2, '0')}`;

/**
* Takes an absolute date in [epoch format] and returns a string representing
Expand All @@ -64,52 +56,51 @@ export const timeAgo = (
now?: number;
},
): false | string => {
const then = new Date(epoch);
const now = options?.now ? new Date(options.now) : new Date();
const then = epoch;
const now = options?.now ?? Date.now();

const verbose = options?.verbose ?? false;

const { length: rawLength, unit } = duration({ then, now });
const length = Math.round(rawLength);

const verbose = options?.verbose;
const daysUntilAbsolute = options?.daysUntilAbsolute ?? 7;
// Dates in the future are not supported
if (length < 0) return false;

const secondsAgo = Math.floor((now.getTime() - then.getTime()) / 1000);
const veryClose = secondsAgo < 15;
const within55Seconds = secondsAgo < 55;
const withinTheHour = secondsAgo < 55 * 60;
const within24hrs = isWithin24Hours(then);
const wasYesterday = isYesterday(then);
const withinAbsoluteCutoff = secondsAgo < daysUntilAbsolute * 24 * 60 * 60;
switch (unit) {
case 'second': {
if (length > 55) return verbose ? '1 minute ago' : '1m ago';
if (length < 15) return 'now';
if (!verbose) return `${length}s ago`;
return `${length} seconds ago`;
}
case 'minute': {
if (length > 55) return verbose ? '1 hour ago' : '1h ago';
if (!verbose) return `${length}m ago`;
if (length == 1) return '1 minute ago';
return `${length} minutes ago`;
}
case 'hour': {
if (!verbose) return `${length}h ago`;
if (length == 1) return '1 hour ago';
return `${length} hours ago`;
}
case 'day': {
if (rawLength < (options?.daysUntilAbsolute ?? 7)) {
if (!verbose) return `${length}d ago`;
if (isYesterday(then, now)) {
return `Yesterday ${withTime(new Date(then))}`;
}
if (length == 1) return '1 day ago';
return `${length} days ago`;
}

if (secondsAgo < 0) {
// Dates in the future are not supported
return false;
} else if (veryClose) {
// Now
return 'now';
} else if (within55Seconds) {
// Seconds
return `${secondsAgo}${getSuffix('s', secondsAgo, verbose)}`;
} else if (withinTheHour) {
// Minutes
const minutes = Math.round(secondsAgo / 60);
return `${minutes}${getSuffix('m', minutes, verbose)}`;
} else if (within24hrs) {
// Hours
const hours = Math.round(secondsAgo / 3600);
return `${hours}${getSuffix('h', hours, verbose)}`;
} else if (wasYesterday && verbose) {
// Yesterday
return `Yesterday${withTime(then)}`;
} else if (withinAbsoluteCutoff) {
// Days
const days = Math.round(secondsAgo / 3600 / 24);
return `${days}${getSuffix('d', days, verbose)}`;
} else {
// Simple date - "9 Nov 2019"
return [
then.getDate(),
verbose
? then.toLocaleString('en-GB', { month: 'long' })
: then.toLocaleString('en-GB', { month: 'short' }),
then.getFullYear(),
].join(' ');
// Simple date - "9 Nov 2019"
return new Date(then).toLocaleString('en-GB', {
day: 'numeric',
month: verbose ? 'long' : 'short',
year: 'numeric',
});
}
}
};