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

Add date and time utils to use in downstream projects #1726

Merged
merged 1 commit into from
Oct 2, 2024
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
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type {
} from './hooks/use-toast-messages';

// Utils
export {
nextFuzzyUpdate,
decayingInterval,
formatRelativeDate,
formatDateTime,
} from './util/date-and-time';
export { confirm } from './util/prompts';

// Components
Expand Down Expand Up @@ -146,4 +152,9 @@ export type {
TabListProps,
} from './components/navigation/';

export type {
Breakpoint,
DateFormatter,
FormatDateTimeOptions,
} from './util/date-and-time';
export type { ConfirmModalProps } from './util/prompts';
278 changes: 278 additions & 0 deletions src/util/date-and-time.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module is called time.ts in client and date.ts in LMS, so I decided to call it date-and-time.ts.

Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;

/**
* Map of stringified `DateTimeFormatOptions` to cached `DateTimeFormat` instances.
*/
let formatters = new Map<string, Intl.DateTimeFormat>();

/**
* Clears the cache of formatters.
*/
export function clearFormatters() {
formatters = new Map<string, Intl.DateTimeFormat>();
}

type IntlType = typeof window.Intl;

/**
* Calculate time delta in milliseconds between two `Date` objects
*/
function delta(date: Date, now: Date) {
// @ts-ignore
return now - date;
}

/**
* Return date string formatted with `options`.
*
* This is a caching wrapper for `Intl.DateTimeFormat.format`, useful because
* constructing a `DateTimeFormat` is expensive.
*
* @param Intl - Test seam. JS `Intl` API implementation.
*/
function format(
date: Date,
options: Intl.DateTimeFormatOptions,
/* istanbul ignore next */
Intl: IntlType = window.Intl,
): string {
const key = JSON.stringify(options);
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat(undefined, options);
formatters.set(key, formatter);
}
return formatter.format(date);
}

/**
* @return formatted date
*/
export type DateFormatter = (date: Date, now: Date, intl?: IntlType) => string;

const nSec: DateFormatter = (date, now) => {
const n = Math.floor(delta(date, now) / SECOND);
return `${n} secs ago`;
};

const nMin: DateFormatter = (date, now) => {
const n = Math.floor(delta(date, now) / MINUTE);
const plural = n > 1 ? 's' : '';
return `${n} min${plural} ago`;
};

const nHr: DateFormatter = (date, now) => {
const n = Math.floor(delta(date, now) / HOUR);
const plural = n > 1 ? 's' : '';
return `${n} hr${plural} ago`;
};

const dayAndMonth: DateFormatter = (date, now, Intl) => {
return format(date, { month: 'short', day: 'numeric' }, Intl);
};

const dayAndMonthAndYear: DateFormatter = (date, now, Intl) => {
return format(
date,
{ day: 'numeric', month: 'short', year: 'numeric' },
Intl,
);
};

export type Breakpoint = {
test: (date: Date, now: Date) => boolean;
formatter: DateFormatter;
nextUpdate: number | null;
};

const BREAKPOINTS: Breakpoint[] = [
{
// Less than 30 seconds
test: (date, now) => delta(date, now) < 30 * SECOND,
formatter: () => 'Just now',
nextUpdate: 1 * SECOND,
},
{
// Less than 1 minute
test: (date, now) => delta(date, now) < 1 * MINUTE,
formatter: nSec,
nextUpdate: 1 * SECOND,
},
{
// Less than one hour
test: (date, now) => delta(date, now) < 1 * HOUR,
formatter: nMin,
nextUpdate: 1 * MINUTE,
},
{
// Less than one day
test: (date, now) => delta(date, now) < 24 * HOUR,
formatter: nHr,
nextUpdate: 1 * HOUR,
},
{
// This year
test: (date, now) => date.getFullYear() === now.getFullYear(),
formatter: dayAndMonth,
nextUpdate: null,
},
];

const DEFAULT_BREAKPOINT: Breakpoint = {
test: /* istanbul ignore next */ () => true,
formatter: dayAndMonthAndYear,
nextUpdate: null,
};

/**
* Returns a dict that describes how to format the date based on the delta
* between date and now.
*
* @param date - The date to consider as the timestamp to format.
* @param now - The date to consider as the current time.
* @return An object that describes how to format the date.
*/
function getBreakpoint(date: Date, now: Date): Breakpoint {
for (const breakpoint of BREAKPOINTS) {
if (breakpoint.test(date, now)) {
return breakpoint;
}
}
return DEFAULT_BREAKPOINT;
}

/**
* Determines if provided date represents a specific instant of time.
* See https://262.ecma-international.org/6.0/#sec-time-values-and-time-range
acelaya marked this conversation as resolved.
Show resolved Hide resolved
*/
function isDateValid(date: Date): boolean {
return !isNaN(date.valueOf());
}

/**
* Return the number of milliseconds until the next update for a given date
* should be handled, based on the delta between `date` and `now`.
*
* @return ms until next update or `null` if no update should occur
*/
export function nextFuzzyUpdate(date: Date | null, now: Date): number | null {
if (!date || !isDateValid(date) || !isDateValid(now)) {
return null;
}

let nextUpdate = getBreakpoint(date, now).nextUpdate;

if (nextUpdate === null) {
return null;
}

// We don't want to refresh anything more often than 5 seconds
nextUpdate = Math.max(nextUpdate, 5 * SECOND);

// setTimeout limit is MAX_INT32=(2^31-1) (in ms),
// which is about 24.8 days. So we don't set up any timeouts
// longer than 24 days, that is, 2073600 seconds.
nextUpdate = Math.min(nextUpdate, 2073600 * SECOND);

return nextUpdate;
}

/**
* Start an interval whose frequency depends on the age of a timestamp.
*
* This is useful for refreshing UI components displaying timestamps generated
* by `formatRelativeDate`, since the output changes less often for older timestamps.
*
* @param date - Date string to use to determine the interval frequency
* @param callback - Interval callback
* @return A function that cancels the interval
*/
export function decayingInterval(
date: string,
callback: () => void,
): () => void {
let timer: number | undefined;
const timestamp = new Date(date);

const update = () => {
const fuzzyUpdate = nextFuzzyUpdate(timestamp, new Date());
if (fuzzyUpdate === null) {
return;
}
const nextUpdate = fuzzyUpdate + 500;
timer = setTimeout(() => {
callback();
update();
}, nextUpdate);
};

update();

return () => clearTimeout(timer);
}

/**
* Formats a date as a short approximate string relative to the current date.
*
* The level of precision is proportional to how recent the date is.
*
* For example:
*
* - "Just now"
* - "5 minutes ago"
* - "25 Oct 2018"
*
* @param date - The date to consider as the timestamp to format.
* @param now - The date to consider as the current time.
* @param Intl - Test seam. JS `Intl` API implementation.
* @return A 'fuzzy' string describing the relative age of the date.
*/
export function formatRelativeDate(
date: Date | null,
now: Date,
Intl?: IntlType,
): string {
if (!date) {
return '';
}
return getBreakpoint(date, now).formatter(date, now, Intl);
}

export type FormatDateTimeOptions = {
/**
* Whether the formatted date should include the week day or not.
* Defaults to `false`.
*/
includeWeekday?: boolean;

/** Test seam. JS `Intl` API implementation. */
Intl?: IntlType;
};

/**
* Formats a date as an absolute string in a human-readable format.
*
* The exact format will vary depending on the locale, but the verbosity will
* be consistent across locales. In en-US for example this will look like:
*
* "Dec 17, 2017, 10:00 AM"
*/
export function formatDateTime(
date: Date | string,
options?: FormatDateTimeOptions,
): string {
return format(
typeof date === 'string' ? new Date(date) : date,
{
year: 'numeric',
month: 'short',
day: '2-digit',
weekday: options?.includeWeekday ? 'long' : undefined,
hour: '2-digit',
minute: '2-digit',
},
options?.Intl,
);
}
Comment on lines +262 to +278
Copy link
Contributor Author

@acelaya acelaya Oct 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function consolidates formatDate from client, which includes the weekday, and formatDateTime from LMS, which returns the same format but without the weekday.

The formatDateTime name seems more correct, and the dynamic inclusion of the weekday is now handled by an optional config option.

Loading