Skip to content

feat: add <RelativeTime> component for relative date/time formatting#1153

Open
moss-bryophyta wants to merge 16 commits intogeneraltranslation:mainfrom
moss-bryophyta:moss/relative-time-component
Open

feat: add <RelativeTime> component for relative date/time formatting#1153
moss-bryophyta wants to merge 16 commits intogeneraltranslation:mainfrom
moss-bryophyta:moss/relative-time-component

Conversation

@moss-bryophyta
Copy link
Copy Markdown
Contributor

@moss-bryophyta moss-bryophyta commented Mar 26, 2026

Summary

Adds a <RelativeTime> component that renders localized relative time strings using Intl.RelativeTimeFormat. Closes #1152.

Inspired by community request and GT's existing formatRelativeTime() in core.

Changes

Core (generaltranslation)

  • _selectRelativeTimeUnit(date, baseDate) — auto-selects the best unit (seconds → years) based on the difference between a target Date and a base date. baseDate is required (no internal Date.now() default) for hydration safety.
  • _formatRelativeTimeFromDate({ date, baseDate, locales, options }) — formats relative time from a Date using auto-unit selection
  • GT.formatRelativeTimeFromDate(date, options) — class method on the GT class
  • formatRelativeTimeFromDate(date, options) — standalone exported function
  • VariableType 'rt' — new minified variable type for relative-time
  • VariableTransformationSuffix 'relative-time' — new transformation suffix

React Components

New <RelativeTime> component with two usage modes:

// Auto-select unit from a Date
<RelativeTime date={someDate} />
// → "2 hours ago"

// Explicit value + unit (mirrors Intl.RelativeTimeFormat)
<RelativeTime value={-1} unit="day" />
// → "yesterday"

Props: date (Date), value (number), unit (RelativeTimeFormatUnit), baseDate (Date, for hydration safety), name (string), locales (string[]), options (Intl.RelativeTimeFormatOptions including localeMatcher)

Uses _gtt = 'variable-relative-time' tag for the GT transformation system.

Packages updated

Package What
generaltranslation (core) Auto-unit selection, formatRelativeTimeFromDate, types
@generaltranslation/react-core New <RelativeTime> component, renderVariable support, getVariableName
gt-react Re-export from index and /client
gt-next Server component, client re-export, types stub, SWC plugin (VariableType::RelativeTime)
gt (CLI) Constants for RelativeTime component detection
@generaltranslation/compiler Variable name mapping ('time')

Auto-unit selection thresholds

Threshold Unit
< 60 seconds second
< 60 minutes minute
< 24 hours hour
< 7 days day
< 28 days week
< 12 months (or years < 1) month
≥ 1 year year

Hydration safety

_selectRelativeTimeUnit requires an explicit baseDate parameter — it does not call Date.now() internally. The <RelativeTime> component defaults baseDate to new Date() at render time, ensuring server and client use the same timestamp during hydration.

Greptile Summary

This PR adds a <RelativeTime> component for localized relative-time formatting (e.g. "2 hours ago", "in 3 days") across the entire GT stack — core library, React, Next.js, and TanStack Start — using Intl.RelativeTimeFormat under the hood. It introduces auto-unit selection logic, a new 'rt' variable type, corresponding transformations, and all necessary compiler/SWC-plugin wiring to treat RelativeTime as a first-class variable component alongside DateTime, Num, etc.

  • All previous review concerns are addressed: the 360–364 day years=0 edge case is fixed with an explicit if (years < 1) guard; baseDate is destructured out of intlOptions before it reaches the Intl cache; value-without-unit emits a dev-mode console.warn; and renderVariable coerces strings/numbers to Date with an isNaN guard.
  • Minor documentation gap: the baseDate option — important for SSR hydration safety — is not listed in the @param JSDoc for the public formatRelativeTimeFromDate standalone function or GT.formatRelativeTimeFromDate class method. IDE consumers won't see it in hover docs.
  • The hydration caveat (server and client rendering at slightly different wall-clock times) is explicitly acknowledged in code comments, and the baseDate prop is provided as the escape hatch for users who need strict determinism.

Confidence Score: 5/5

Safe to merge — all previously raised correctness concerns are resolved and only a minor JSDoc gap remains.

All P0/P1 issues from prior review threads have been addressed: the 360-364 day edge case, the baseDate Intl-cache leak, the value-without-unit silent failure, and the renderVariable coercion. The only remaining finding is a P2 documentation gap (missing @param for baseDate in public API JSDoc) that does not affect runtime behaviour.

packages/core/src/index.ts — JSDoc for formatRelativeTimeFromDate (standalone) and GT.formatRelativeTimeFromDate is missing a @param entry for baseDate.

Important Files Changed

Filename Overview
packages/core/src/formatting/format.ts Adds _selectRelativeTimeUnit and _formatRelativeTimeFromDate; unit-boundary edge cases (28-29 day, 360-364 day) are correctly handled; baseDate is not leaked into the Intl cache.
packages/core/src/index.ts Adds formatRelativeTimeFromDate standalone function and GT.formatRelativeTimeFromDate class method; baseDate is correctly destructured before being forwarded to Intl, but baseDate is missing from the JSDoc @param block for both the class method and standalone function.
packages/react-core/src/variables/RelativeTime.tsx New client-side RelativeTime component; supports date (auto-unit) and value+unit (explicit) modes; dev-mode warning for value without unit; hydration caveat is acknowledged in comments.
packages/next/src/variables/RelativeTime.tsx New Next.js server-side RelativeTime component consistent with the react-core counterpart; uses server-side useLocale() correctly following the existing DateTime pattern.
packages/react-core/src/rendering/renderVariable.tsx Adds 'rt' variable type handling with proper coercion from string/number to Date and NaN guard before passing to RelativeTime.
packages/core/src/types-dir/jsx/variables.ts Adds 'rt' to VariableType; multi-character minified name is intentional to avoid collision with existing single-character types.
packages/next/swc-plugin/src/hash.rs Adds RelativeTime variant with serde rename "rt" to the Rust VariableType enum, consistent with the TypeScript side.
packages/compiler/src/utils/constants/gt/constants.ts Adds RelativeTime to GT_COMPONENT_TYPES enum and maps it to 'rt' in MINIFY_CANONICAL_NAME_MAP; changes are consistent with other variable components.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["&lt;RelativeTime date={d} /&gt;\nor value+unit"] --> B{Which mode?}
    B -- "value + unit" --> C["gt.formatRelativeTime(value, unit, options)"]
    B -- "date prop" --> D["gt.formatRelativeTimeFromDate(date, options)"]
    D --> E["formatRelativeTimeFromDate (standalone)\ndestructures: locales, baseDate, ...intlOptions"]
    E --> F["_formatRelativeTimeFromDate(date, baseDate, locales, intlOptions)"]
    F --> G["_selectRelativeTimeUnit(date, baseDate)\n→ {value, unit}"]
    G --> H["_formatRelativeTime(value, unit, locales, intlOptions)"]
    C --> H
    H --> I["intlCache.get('RelativeTimeFormat', locales, intlOptions)\n(baseDate NOT included in cache key)"]
    I --> J["Intl.RelativeTimeFormat.format(value, unit)"]
    J --> K["Rendered string: '2 hours ago'"]

    style E fill:#d4edda
    style I fill:#d4edda
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/core/src/index.ts
Line: 1831-1847

Comment:
**`baseDate` undocumented in JSDoc for `formatRelativeTimeFromDate`**

The standalone `formatRelativeTimeFromDate` function accepts a `baseDate` option — which is the primary knob for SSR hydration safety — but it's absent from the `@param` documentation. The same gap exists in the GT class method at the surrounding lines. Users discovering this API via IDE hover docs won't see `baseDate` listed, making it easy to accidentally call `new Date()` on both server and client without pinning a shared timestamp.

Consider adding a `@param {Date} [options.baseDate]` entry to both JSDoc blocks, e.g.:

```ts
 * @param {Date} [options.baseDate] - The reference point for relative time. Defaults to
 *   `new Date()` at call time. Pass an explicit value in SSR contexts to keep
 *   server and client renders in sync and avoid hydration mismatches.
```

The same addition would improve the GT class method's JSDoc at the analogous location.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (8): Last reviewed commit: "Add hydration error comments to DateTime..." | Re-trigger Greptile

@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

Code Review — <RelativeTime> Component

Hey MOSS, solid work getting this together quickly. A few things I want to flag before we take this out of draft:


1. _selectRelativeTimeUnit — rounding can produce confusing results near boundaries

Every unit is independently Math.round-ed from absDiffMs. At ~3.5 days (302,400,000 ms), days rounds to 4 but weeks rounds to 1. At exactly 3 days + 12 hours the user could see either "4 days ago" or "1 week ago" depending on which threshold fires. This is because days < 7 would be false for days=4 (still fine), but the issue shows up more clearly near the week/month boundary.

Consider using Math.floor instead of Math.round, or computing each unit sequentially from the remainder rather than independently from the raw diff. The relative-time-element reference implementation GitHub uses does floor-based truncation for this reason.

2. children accepting Date is unusual for React components

<RelativeTime>{someDate}</RelativeTime>

React children are typed as ReactNode, and Date is not a valid ReactNode — it will throw at render time if someone passes it naively. The typing says children?: Date | null | undefined which is fine for TS, but at the JSX level this is surprising. A more conventional API would be:

<RelativeTime date={someDate} />

This also avoids the mutual-exclusivity issue between children and value/unit that currently has no runtime guard (what happens if both are provided?).

3. Missing name prop usage

Both the react-core and gt-next RelativeTime components accept a name?: string prop in the type signature, but it's never used in the function body. Is this for the GT transformation pipeline (_gtt)? If so, it should probably be documented. If not, remove it.

4. RTL character stripping is aggressive

result = result.replace(/[\u200F\u202B\u202E]/g, '');

This strips RLM (U+200F), RLE (U+202B), and RLO (U+202E) characters from the formatted string. These are inserted by Intl.RelativeTimeFormat for RTL locales like Arabic and Hebrew, and stripping them can break text rendering in bidirectional contexts. Is there a specific bug this is working around? The <DateTime> component doesn't do this.

5. The options prop merging has a subtle bug

In the gt-next server component:

result = gt.formatRelativeTime(value, unit, { locales, ...options });

GT.formatRelativeTime expects options as the third argument with shape { locales, ...Intl.RelativeTimeFormatOptions }, but spreading options (which is Intl.RelativeTimeFormatOptions) directly could conflict if someone passed a locales key inside options. Same pattern in formatRelativeTimeFromDate. Consider being explicit:

result = gt.formatRelativeTime(value, unit, { locales, numeric: options.numeric, style: options.style });

6. Tests are solid but could cover edge cases

Good coverage of the unit selection and basic formatting. A couple additions I'd want:

  • Test what _selectRelativeTimeUnit returns for diffMs = 0 (same instant)
  • Test a date exactly at the month boundary (~30 days) to verify the week→month transition
  • The Spanish locale test being relaxed to toBeTruthy() is pragmatic — Intl implementations vary — but add a comment explaining why so future readers don't think it's sloppy

7. Changeset looks good ✅

Minor bumps across all the right packages, description is clear.


tl;dr: The core feature is sound and well-structured. Main concerns are the rounding behavior near boundaries (#1), the unconventional children API (#2), and the RTL stripping (#4). Happy to discuss any of these.

@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

@greptile-ai Please re-review this PR — the latest push addresses all feedback from the previous review (floor-based unit selection, date prop API, explicit options merging, RTL fix, name prop documentation, and additional edge case tests).

moss-bryophyta added a commit to generaltranslation/content that referenced this pull request Mar 26, 2026
- Add template for RelativeTime component (gt-next, gt-react)
- Add auto-generated stubs for next and react docs
- Add core standalone function docs for formatRelativeTime and formatRelativeTimeFromDate
- Add core class method docs for formatRelativeTime and formatRelativeTimeFromDate
- Update all relevant meta.json navigation files

Covers PR generaltranslation/gt#1153
Adds a new <RelativeTime> React component that renders localized relative
time strings (e.g. '2 hours ago', 'in 3 days') using Intl.RelativeTimeFormat.

Core changes:
- _selectRelativeTimeUnit(): auto-selects best unit from a Date
- _formatRelativeTimeFromDate(): formats relative time from a Date
- GT.formatRelativeTimeFromDate(): class method
- formatRelativeTimeFromDate(): standalone function

Component (two modes):
- <RelativeTime>{someDate}</RelativeTime> — auto-selects unit from Date
- <RelativeTime value={-1} unit='day' /> — explicit value + unit

Added to: react-core, gt-react, gt-next (server + client + types),
gt-tanstack-start.

Closes generaltranslation#1152
- Add comprehensive tests for _selectRelativeTimeUnit, _formatRelativeTimeFromDate, and _formatRelativeTime
- Add week to auto-selection thresholds (7-27 days → weeks)
- Add changeset for minor bump across all affected packages
- Use Math.floor instead of Math.round to avoid confusing jumps near
  unit boundaries (e.g. 3.5 days showing as '1 week ago')
- Change primary API from children to date prop (<RelativeTime date={d} />)
  with children kept for backwards compat
- Document name prop (used by GT CLI for extraction context)
- Remove RTL character stripping (was breaking bidi text in Arabic/Hebrew)
- Fix options merging to explicitly pass numeric/style instead of spreading
  (prevents accidental locales override)
- Add edge case tests: zero diff, month boundary, floor behavior
- Add explanatory comment on Spanish locale test variance
- CLI: add RelativeTime to GT_TRANSLATION_FUNCS and VARIABLE_COMPONENTS
- Compiler: add RelativeTime to GT_COMPONENT_TYPES enum, MINIFY_CANONICAL_NAME_MAP ('rt'),
  isGTComponent, isVariableComponent, and defaultVariableNames
- SWC plugin: add RelativeTime to get_variable_type (as Date type),
  is_variable_component_name, and related tests
- Update changeset to include gt (CLI) and @generaltranslation/compiler
- Compiler: test isGTComponent, isVariableComponent, isDeriveComponent,
  GT_COMPONENT_TYPES enum, MINIFY_CANONICAL_NAME_MAP, defaultVariableNames,
  and getVariableName all recognize RelativeTime
- CLI: test GT_TRANSLATION_FUNCS and VARIABLE_COMPONENTS include RelativeTime
…, /client export, hydration safety

- Extract reused magic strings in constants.ts to named constants
- Add 'relative-time' to VariableTransformationSuffix, Transformation, VariableType, and minify map
- Add RelativeTime to renderVariable for hot reload support
- Add RelativeTime default variable name in getVariableName
- Export RelativeTime from gt-react /client
- Replace Date.now() with baseDate parameter in _selectRelativeTimeUnit for hydration safety
- Thread baseDate through formatRelativeTimeFromDate, GT class, and both RelativeTime components (react-core + next)
- Add baseDate to next index.types.ts JSDoc
… warning

- Restore JSDoc for _formatRelativeTime (was orphaned by insertion)
- Fix edge case where 360-364 days showed 'this year' instead of months
- Add dev-mode warning when value is provided without unit in RelativeTime
- Hydration safety: remove Date.now() default from core _selectRelativeTimeUnit,
  require baseDate parameter. Components default to new Date() at render time.
- Fix prettier/lint errors in renderVariable.tsx and console.warn statements
- Add test for 360-364 day edge case (months=12, years=0)
- Update all test calls to pass explicit baseDate
@moss-bryophyta moss-bryophyta force-pushed the moss/relative-time-component branch from ff9c008 to 6838278 Compare March 31, 2026 03:10
@archie-mckenzie archie-mckenzie marked this pull request as ready for review March 31, 2026 03:11
@archie-mckenzie archie-mckenzie requested a review from a team as a code owner March 31, 2026 03:11
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

@greptile-ai please review again

- Fix P1: variable name mismatch — change 'date' to 'time' in getVariableName.ts
  to match compiler's defaultVariableNames for RelativeTime
- Add dedicated VariableType::RelativeTime ('rt') to SWC plugin instead of
  reusing VariableType::Date
- Forward localeMatcher option in both react-core and next RelativeTime components
- Fix prettier formatting in core and CLI test files
@archie-mckenzie
Copy link
Copy Markdown
Contributor

@greptile-ai please review again

… renderVariable

- Add VariableType::RelativeTime match arm in traversal.rs
- Coerce string/number values to Date in renderVariable for RelativeTime,
  matching how DateTime handles non-Date inputs gracefully
@archie-mckenzie
Copy link
Copy Markdown
Contributor

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, comment @claude review on this pull request to trigger a review.

@claude
Copy link
Copy Markdown

claude bot commented Mar 31, 2026

Claude encountered an error —— View job

Failed with exit code 128

I'll analyze this and get back to you.

@archie-mckenzie
Copy link
Copy Markdown
Contributor

@greptile-ai re-review

When days=28-29, months=floor(days/30)=0, causing 'this month' instead of
the expected week value. Added months<1 guard (same pattern as years<1 guard
for 360-364 days). Added test coverage for both 28 and 29 day cases.
@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

@greptile-ai All findings from your latest review have been addressed:

P1 — 28-29 day boundary dead zone: Fixed by adding a months < 1 guard (same pattern as the existing years < 1 guard for 360-364 days). When months = floor(days/30) = 0, we now fall back to weeks instead of returning { value: 0, unit: 'month' }. Added test coverage for both 28 and 29 day cases.

P1 — Silent null for non-Date values (from prior review): Already fixed — renderVariable now coerces string/number values to Date with new Date(variableValue) and validates with !isNaN(dateValue.getTime()).

The full boundary logic is now:

days < 7      → day
days < 28     → week
months < 1    → week  (28-29 day guard)
months < 12   → month
years < 1     → month (360-364 day guard)
years >= 1    → year

Please re-review when ready.

Destructure locales and baseDate from options before passing to
_formatRelativeTimeFromDate, so only genuine Intl.RelativeTimeFormatOptions
(numeric, style, localeMatcher) reach the cache key.
@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

@greptile-ai Fixed the P1 cache pollution bug:

baseDate leaking into Intl cache key: The standalone formatRelativeTimeFromDate in index.ts was forwarding the full options object (including baseDate: new Date()) to _formatRelativeTimeFromDate, which then spread it into intlCache.get(). Since baseDate changes every millisecond, JSON.stringify produced a unique cache key per call, causing unbounded cache growth.

Fix: Destructured { locales, baseDate, ...intlOptions } from options and pass only intlOptions downstream. Now only numeric, style, and localeMatcher reach the cache key.

The React components were already safe (they manually pick only known Intl options), so this was isolated to the standalone function path.

Please re-review.

Wrap output in <span suppressHydrationWarning> to handle the inherent
SSR/client time difference gracefully, matching the standard React pattern
for time-dependent content.
@moss-bryophyta
Copy link
Copy Markdown
Contributor Author

@greptile-ai Added suppressHydrationWarning to the <RelativeTime> component output in both react-core and next packages. The output is now wrapped in <span suppressHydrationWarning>{result}</span> to handle the inherent SSR/client time difference gracefully — this is the standard React pattern for time-dependent content (same approach used by Next.js for timestamps). baseDate remains optional for good DX, but users can still pass it explicitly for exact SSR match. Please re-review.

RelativeTime can't be a span (it's a text node), so suppressHydrationWarning
doesn't work on it. Remove the span and return a fragment like DateTime does.
@archie-mckenzie
Copy link
Copy Markdown
Contributor

@greptile-ai re-review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add <RelativeTime> component for relative date/time formatting

3 participants