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

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