Как форматировать относительное время в TanStack Start v1

Форматируйте временные метки как фразы вида '2 дня назад'

Проблема

Отображение временных меток в виде относительных временных фраз, таких как "2 дня назад" или "через 3 часа", делает временную информацию более интуитивно понятной для пользователей. Однако эти фразы подчиняются сложным грамматическим правилам, которые различаются в зависимости от языка. В английском языке "ago" (назад) ставится после прошедших количеств, а "in" (через) перед будущими, но в других языках может использоваться другой порядок слов, склонение единиц времени или совершенно другие грамматические структуры. Ручное создание таких фраз с помощью конкатенации строк приводит к некорректному результату на всех языках, кроме того, который вы закодировали вручную.

Базовый API форматирования требует числового значения и единицы времени, но временные метки поступают в виде объектов Date или значений в миллисекундах. Преобразование временной метки в соответствующую единицу и значение требует вычисления разницы с текущим моментом и выбора наиболее естественной единицы для выражения этой разницы.

Решение

Вычислите разницу во времени между временной меткой и текущим моментом, выберите наиболее подходящую единицу для этой разницы, а затем отформатируйте результат с использованием локализованного форматирования относительного времени. Это преобразует сырые временные метки в грамматически правильные фразы, которые адаптируются к языку пользователя.

Используйте форматирование относительного времени из react-intl с вспомогательной функцией, которая определяет лучшую единицу. Вспомогательная функция сравнивает разницу во времени с пороговыми значениями для каждой единицы и возвращает как числовое значение, так и название единицы. Форматтер затем применяет правильные грамматические правила для активной локали.

Шаги

1. Создайте вспомогательную функцию для выбора единицы

Создайте функцию, которая вычисляет разницу во времени и выбирает наиболее естественную единицу для её выражения.

type RelativeTimeUnit =
  | "second"
  | "minute"
  | "hour"
  | "day"
  | "week"
  | "month"
  | "year";

interface RelativeTimeValue {
  value: number;
  unit: RelativeTimeUnit;
}

export function selectRelativeTimeUnit(
  timestamp: Date | number,
  baseTime: Date | number = Date.now(),
): RelativeTimeValue {
  const date = typeof timestamp === "number" ? timestamp : timestamp.getTime();
  const base = typeof baseTime === "number" ? baseTime : baseTime.getTime();
  const diffMs = date - base;
  const absDiff = Math.abs(diffMs);

  const minute = 60 * 1000;
  const hour = 60 * minute;
  const day = 24 * hour;
  const week = 7 * day;
  const month = 30 * day;
  const year = 365 * day;

  if (absDiff < minute) {
    return { value: Math.round(diffMs / 1000), unit: "second" };
  }
  if (absDiff < hour) {
    return { value: Math.round(diffMs / minute), unit: "minute" };
  }
  if (absDiff < day) {
    return { value: Math.round(diffMs / hour), unit: "hour" };
  }
  if (absDiff < week) {
    return { value: Math.round(diffMs / day), unit: "day" };
  }
  if (absDiff < month) {
    return { value: Math.round(diffMs / week), unit: "week" };
  }
  if (absDiff < year) {
    return { value: Math.round(diffMs / month), unit: "month" };
  }
  return { value: Math.round(diffMs / year), unit: "year" };
}

Эта функция преобразует временную метку в относительное значение и единицу, сравнивая разницу во времени с фиксированными порогами. Она возвращает отрицательные значения для прошедшего времени и положительные для будущего, которые форматтер интерпретирует корректно.

2. Создайте компонент для относительного времени

Создайте компонент, который объединяет помощник выбора единицы измерения с форматированием react-intl.

import { FormattedRelativeTime } from "react-intl";
import { selectRelativeTimeUnit } from "./selectRelativeTimeUnit";

interface RelativeTimeProps {
  date: Date | number;
  numeric?: "always" | "auto";
  style?: "long" | "short" | "narrow";
}

export function RelativeTime({
  date,
  numeric = "auto",
  style = "long",
}: RelativeTimeProps) {
  const { value, unit } = selectRelativeTimeUnit(date);

  return (
    <FormattedRelativeTime
      value={value}
      unit={unit}
      numeric={numeric}
      style={style}
    />
  );
}

Этот компонент принимает временную метку и параметры форматирования, вычисляет относительное значение и единицу измерения, а затем передает их компоненту react-intl для рендеринга с учетом локали. Опция numeric="auto" позволяет использовать фразы, такие как "вчера", вместо "1 день назад", когда это уместно.

3. Используйте компонент в ваших маршрутах

Импортируйте и рендерьте компонент там, где нужно отображать относительные временные метки.

import { createFileRoute } from "@tanstack/react-router";
import { RelativeTime } from "../components/RelativeTime";

export const Route = createFileRoute("/posts/$postId")({
  component: PostPage,
});

function PostPage() {
  const post = {
    title: "Понимание относительного времени",
    publishedAt: new Date("2024-11-15T10:30:00Z"),
    updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
  };

  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        Опубликовано <RelativeTime date={post.publishedAt} />
      </p>
      <p>
        Обновлено <RelativeTime date={post.updatedAt} style="short" />
      </p>
    </article>
  );
}

Компонент работает как в серверном, так и в клиентском рендеринге. На сервере он создает начальную фразу для относительного времени, а на клиенте отображает ту же фразу с учетом локали пользователя из IntlProvider.

4. Добавьте опцию императивного форматирования

Для случаев, когда вам нужна отформатированная строка напрямую, создайте помощник на основе хука.

import { useIntl } from "react-intl";
import { selectRelativeTimeUnit } from "./selectRelativeTimeUnit";

export function useRelativeTime() {
  const intl = useIntl();

  return (
    date: Date | number,
    options?: {
      numeric?: "always" | "auto";
      style?: "long" | "short" | "narrow";
    },
  ) => {
    const { value, unit } = selectRelativeTimeUnit(date);
    return intl.formatRelativeTime(value, unit, options);
  };
}

Этот хук возвращает функцию, которая форматирует временные метки императивно, что полезно для установки текстовых атрибутов или вычисления значений вне JSX.

5. Используйте хук для контекстов вне компонентов

Вызывайте хук в компонентах, где вам нужны отформатированные строки для атрибутов или логики.

import { createFileRoute } from "@tanstack/react-router";
import { useRelativeTime } from "../hooks/useRelativeTime";

export const Route = createFileRoute("/events/$eventId")({
  component: EventPage,
});

function EventPage() {
  const formatRelativeTime = useRelativeTime();
  const event = {
    name: "Запуск продукта",
    startTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
  };

  const timeUntil = formatRelativeTime(event.startTime);

  return (
    <div>
      <h1>{event.name}</h1>
      <time dateTime={event.startTime.toISOString()} title={timeUntil}>
        {timeUntil}
      </time>
    </div>
  );
}

Хук предоставляет ту же логику форматирования в функциональной форме, позволяя использовать строки относительного времени в атрибутах, вычисляемых значениях или в любом месте, где компонент не может быть отрисован.