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>
  );
}

이 훅은 동일한 포매팅 로직을 함수 형태로 제공하여 속성, 계산된 값 또는 컴포넌트를 렌더링할 수 없는 모든 곳에서 상대 시간 문자열을 사용할 수 있게 합니다.