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: "Understanding Relative Time",
    publishedAt: new Date("2024-11-15T10:30:00Z"),
    updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
  };

  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        Published <RelativeTime date={post.publishedAt} />
      </p>
      <p>
        Updated <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: "Product Launch",
    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>
  );
}

このフックは、同じフォーマットロジックを関数形式で提供し、属性、計算値、またはコンポーネントをレンダリングできない場所で相対時間文字列を使用できるようにします。