relative-time.ts2.6 KBView on GitHub
/**
 * Ages, for a file row's date column.
 *
 * Promoted out of `ConversationFileTree`, where `shortAgo`/`longAgo` were module-private
 * and therefore re-implemented (or simply skipped) by every other file surface.
 *
 * Two forms, because a column and a sentence want different things:
 *  - `shortAgo` for the Modified column — "3m", "5h", "2d", then an absolute date past a
 *    week, which is what Google Drive does and what makes a column of dates scannable.
 *    A file from March reading "24w" tells you nothing you wanted to know.
 *  - `longAgo` for prose inside a tooltip or an activity string — "8h", "3 days", "2 weeks".
 */

function minutesSince(date: Date, now: number): number {
  return Math.max(0, Math.round((now - date.getTime()) / 60000));
}

function toDate(at: Date | string | number | null | undefined): Date | null {
  if (at === null || at === undefined) return null;
  const date = at instanceof Date ? at : new Date(at);
  return Number.isNaN(date.getTime()) ? null : date;
}

/**
 * The Modified column: "now", "42m", "5h", "2d", then "12 Mar" / "12 Mar 2024".
 *
 * `now` is injectable so a test can pin it; every caller in the app omits it.
 */
export function shortAgo(
  at: Date | string | number | null | undefined,
  now: number = Date.now(),
): string {
  const date = toDate(at);
  if (!date) return '—';
  const mins = minutesSince(date, now);
  if (mins < 1) return 'now';
  if (mins < 60) return `${mins}m`;
  const hours = Math.round(mins / 60);
  if (hours < 24) return `${hours}h`;
  const days = Math.round(hours / 24);
  if (days < 7) return `${days}d`;
  // Past a week the age stops being the useful fact and the date starts being it.
  // The year is only shown when it is not the current one — an extra four characters
  // on every row to disambiguate the handful that need it is a bad trade.
  const sameYear = date.getFullYear() === new Date(now).getFullYear();
  return date.toLocaleDateString(undefined, {
    day: 'numeric',
    month: 'short',
    ...(sameYear ? {} : { year: 'numeric' }),
  });
}

/** Spelled-out age for prose: "moments", "42m", "8h", "3 days", "2 weeks". */
export function longAgo(
  at: Date | string | number | null | undefined,
  now: number = Date.now(),
): string {
  const date = toDate(at);
  if (!date) return '—';
  const mins = minutesSince(date, now);
  if (mins < 1) return 'moments';
  if (mins < 60) return `${mins}m`;
  const hours = Math.round(mins / 60);
  if (hours < 24) return `${hours}h`;
  const days = Math.round(hours / 24);
  if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'}`;
  const weeks = Math.round(days / 7);
  return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}`;
}