diff --git a/README.md b/README.md index 34ca0b9..ee2eca1 100644 --- a/README.md +++ b/README.md @@ -1,229 +1,227 @@ # Relative Time -Formats Temporal dates to relative time strings (e.g., "3 hours ago") using the -same standards that ship in modern JavaScript engines. +Formats JavaScript dates to localized **relative time strings** (e.g., "3 hours ago", "yesterday", "in 2 weeks"). +It selects a **best‑fit unit** for you (seconds → minutes → hours → days → weeks → months → years) **and** uses the platform’s `Intl.RelativeTimeFormat` for localization. -Pass a [`Temporal.PlainDateTime`](https://tc39.es/proposal-temporal/docs/plaindatetime.html) -when the comparison does not depend on a particular time zone, or a -[`Temporal.ZonedDateTime`](https://tc39.es/proposal-temporal/docs/zoneddatetime.html) -to evaluate the difference within an explicit IANA zone. +- **High‑level**: `RelativeTime` → _resolve unit_ → format +- **Low‑level**: `RelativeTimeResolver` → _resolve only_ the `{ value, unit }` -Built entirely on [Temporal](https://tc39.es/proposal-temporal/) for duration -calculations and the native [Intl.RelativeTimeFormat][] API (ECMA-402) for -localization, so you get spec-compliant results without extra runtime -dependencies. +Built on **Temporal** for precise date math and **Intl.RelativeTimeFormat** for i18n. That means you get the **same CLDR data** your runtime ships with—no locale bundles to download. -The usage examples below assume [Temporal](https://tc39.es/proposal-temporal/) is available (either natively or via the -[@js-temporal/polyfill](https://github.com/js-temporal/temporal-polyfill)). +> **Why not just `Intl.RelativeTimeFormat`?** +> `Intl.RelativeTimeFormat` is intentionally **low level**: you must choose the unit yourself (e.g., day vs week) and then ask it to format the pair `{value, unit}`. This library adds the **unit resolver** layer on top—by design that’s out of scope for the built‑in API. (see more details at [tc39/proposal-intl-relative-time#14](https://github.com/tc39/proposal-intl-relative-time/issues/14)) -[Unicode CLDR]: http://cldr.unicode.org/ -[Intl.RelativeTimeFormat]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat +## Install -## Why +```bash +npm install relative-time +# If your environment doesn't yet support Temporal: +npm install @js-temporal/polyfill +``` -### Built on native web standards +> This library expects **Temporal**. Use it where Temporal is available (or load the official polyfill). -Temporal handles the date math while [Intl.RelativeTimeFormat][] provides the -localized strings, so the library always uses the same Unicode CLDR data that -ships with the runtime. There are no custom heuristics, shimmed tokens, or -vendor-specific formatting rules to maintain. +## Quick start -Standard APIs also mean the examples in this README run anywhere Temporal is -available (including via the official polyfill) and benefit from future engine -improvements automatically. +```js +import RelativeTime from "relative-time"; +// If needed: +// import { Temporal } from "@js-temporal/polyfill"; -### Time zone support +const rt = new RelativeTime(); // locale inferred from the runtime -Temporal.ZonedDateTime inputs preserve the offset and daylight-saving rules for -each region, so the formatter stays accurate even when comparing the same instant -across different cities. The chart below shows how two zones perceive the same -event (`x`) and “now” (`N`). +const threeHoursAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 3 }); +rt.format(threeHoursAgo); +// Output: "3 hours ago" -``` -hr.  | | | | | | | | | | | | | | | | | | | | | | | | -       x . . . . . . . . . . . . . . . . . . | . N -EDT      <------------ Mar 21 -----------> | <-----> (Mar 22) -PDT      <----------------- Mar 21 ----------------> +const pt = new RelativeTime("pt"); +const oneHourAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 1 }); +pt.format(oneHourAgo); +// Output: "há 1 hora" ``` -New York crosses midnight between `x` and `N`, so the event reads as “yesterday”, -but Los Angeles does not, yielding “21 hours ago”. -The relative time between `x` and now `N` is: +### Time‑zone aware -| time zone | relative-time result | -| ------------------- | -------------------- | -| America/New_York | `"yesterday"` | -| America/Los_Angeles | `"21 hours ago"` | +```js +const rt = new RelativeTime(); -### Better results than other libraries +const laDate = Temporal.ZonedDateTime.from( + "2016-04-09T17:00:00-07:00[America/Los_Angeles]" +); +rt.format(laDate); +// Output: "yesterday" +// Assuming now is "2016-04-10T05:00:00-07:00[America/Los_Angeles]" -Other open-source relative time utilities rely on hand-tuned thresholds that can -misreport day or month boundaries. Because this project delegates that logic to -Temporal and CLDR data, it keeps human language aligned with calendar reality. -The comparisons below highlight real cases where Moment.js and similar libraries -fall short. +const berlinDate = Temporal.ZonedDateTime.from( + "2016-04-10T02:00:00+02:00[Europe/Berlin]" +); +rt.format(berlinDate, { now: berlinNow }); +// Output: "12 hours ago" +// Assuming now is "2016-04-10T14:00:00+02:00[Europe/Berlin]" +``` -#### day +Use `Temporal.PlainDateTime` when you want to **ignore time‑zone rules** (local calendar math), and `Temporal.ZonedDateTime` when offset and DST **must** be respected. -``` - Mar 21, 00:00 Mar 22, 00:00 Mar 23, 00:00 Mar 24, 00:00 -hr. | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | -day | b | a | N | - Mar 21 Mar 22 Mar 23 Mar 24 +### Resolver only + +Use the resolver when you need just the unit/value. + +```js +import { RelativeTimeResolver } from "relative-time"; + +const resolver = new RelativeTimeResolver(); +const event = Temporal.Now.plainDateTimeISO().subtract({ minutes: 3 }); +const { value, unit } = resolver.resolve(event); +// Output: { value: -3, unit: "minute" } +// You can format this yourself or with Intl.RelativeTimeFormat +new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(value, unit); +// Output: "3 minutes ago" ``` -Let's assume now (`N`) is _Mar 23, 9 AM_. +## API -| | relative-time | moment.js | -| --------------------- | -------------- | ---------------- | -| _Mar 22, 11 AM_ (`a`) | `"yesterday"` | `"a day ago"` | -| _Mar 21, 8 PM_ (`b`) | `"2 days ago"` | `"a day ago"` ❓ | +### `class RelativeTime` (default export) -Note `relative-time` checks for the actual day change instead of counting on approximate number of hours to turn the unit. +High‑level formatter that **resolves the unit** and **formats** the string. -#### month +#### Constructor -``` - Feb 1 Mar 1 Apr 1 -day | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | -mo. | d c|b a N| +```ts +new RelativeTime( + locales?: string | string[], + options?: Intl.RelativeTimeFormatOptions +) ``` -Let's assume now (`N`) is _Mar 31_. +- `locales` — same semantics as other `Intl` constructors (e.g., `"en"`, `["fr", "en"]`). +- `options` — forwarded to `Intl.RelativeTimeFormat` (e.g., `{ style: "short", numeric: "auto" }`). + Tip: `numeric: "auto"` yields strings like “yesterday/tomorrow”; `numeric: "always"` gives “1 day ago / in 1 day”. -| | relative-time | moment.js | -| -------------- | --------------- | ------------------- | -| _Mar 5_ (`a`) | `"26 days ago"` | `"a month ago"` ❓ | -| _Mar 1_ (`b`) | `"30 days ago"` | `"a month ago"` ❓ | -| _Feb 28_ (`c`) | `"last month"` | `"a month ago"` | -| _Feb 9_ (`d`) | `"last month"` | `"2 months ago"` ❓ | +#### `format(date, options?) => string` -Note `relative-time` checks for the actual month change instead of counting on approximate number of days to turn the unit. +```ts +format( + date: Temporal.PlainDateTime | Temporal.ZonedDateTime, + options?: { + now?: Temporal.PlainDateTime | Temporal.ZonedDateTime, + unit?: "best-fit" | "second" | "minute" | "hour" | "day" | "week" | "month" | "year" + } +): string +``` -## Usage +- `date` — the target moment. +- `options.now` — reference moment (defaults to “now”, matched to `date`’s type). +- `options.unit` — force a specific unit or let the library decide with `"best-fit"` (default). - npm install --save relative-time +**How “best‑fit” works (conceptually)** -```js -import RelativeTime, { RelativeTimeResolver } from "relative-time"; +The resolver promotes units using _calendar boundaries_ and _configurable step-up thresholds_ (e.g., sec → min → hr → day → month). If the difference crosses a calendar day, you'll get _"yesterday"_ (instead of "20 hours ago"). But if it crosses midnight _without_ clearing the threshold, it stays in hours, e.g., _"3 hours ago"_ (instead of "yesterday"). -const relativeTime = new RelativeTime(); -const threeHoursAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 3 }); -console.log(relativeTime.format(threeHoursAgo)); -// > 3 hours ago +--- -const relativeTimeInPortuguese = new RelativeTime("pt"); -const oneHourAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 1 }); -console.log(relativeTimeInPortuguese.format(oneHourAgo)); -// > há 1 hora +### `class RelativeTimeResolver` (named export) -// Use the resolver when you need just the unit/value -const resolver = new RelativeTimeResolver(); -const event = Temporal.Now.plainDateTimeISO().subtract({ minutes: 3 }); -const { value, unit } = resolver.resolve(event); // { value: -3, unit: "minute" } -// You can format this yourself or with Intl.RelativeTimeFormat -new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(value, unit); -// > 3 minutes ago -``` +Low‑level **unit chooser**. Use it if you want the `{ value, unit }` pair to feed into your own formatter (including `Intl.RelativeTimeFormat` directly). -### Time zone support +#### Constructor -When you need to evaluate relative time in a different IANA time zone, create a -`Temporal.ZonedDateTime` in that zone before calling `format`. +```ts +new RelativeTimeResolver(); +``` -| | America/Los_Angeles | Europe/Berlin | -| ---- | ------------------------------- | -------------------------------------------------------- | -| date | 2016-04-09 17:00:00 GMT-7 (PDT) | 2016-04-10 02:00:00 GMT+2 (Central European Summer Time) | -| now | 2016-04-10 05:00:00 GMT-7 (PDT) | 2016-04-10 14:00:00 GMT+2 (Central European Summer Time) | +#### `resolve(date, options?) => { value: number, unit: RTFUnit }` -```js -const losAngelesDate = Temporal.ZonedDateTime.from( - "2016-04-09T17:00:00-07:00[America/Los_Angeles]" -); -relativeTime.format(losAngelesDate, { now: losAngelesNow }); -// > "yesterday" +```ts +type RTFUnit = "second" | "minute" | "hour" | "day" | "week" | "month" | "year"; -const berlinDate = Temporal.ZonedDateTime.from( - "2016-04-10T02:00:00+02:00[Europe/Berlin]" -); -relativeTime.format(berlinDate, { now: berlinNow }); -// > "12 hours ago" +resolve( + date: Temporal.PlainDateTime | Temporal.ZonedDateTime, + options?: { + now?: Temporal.PlainDateTime | Temporal.ZonedDateTime, + unit?: "best-fit" | RTFUnit // "best-fit" (default) or force a unit + } +): { value: number; unit: RTFUnit } ``` -## API +**Example** -### RelativeTime (default export) +```js +import { RelativeTimeResolver } from "relative-time"; -#### `format(date{, options})` +const r = new RelativeTimeResolver(); +const target = Temporal.Now.plainDateTimeISO().add({ days: 6, hours: 5 }); -### date +const { value, unit } = r.resolve(target); // e.g., { value: 1, unit: "week" } -A [Temporal.PlainDateTime](https://tc39.es/proposal-temporal/docs/plaindatetime.html) -or [Temporal.ZonedDateTime](https://tc39.es/proposal-temporal/docs/zoneddatetime.html) -representing the target moment. Use a plain date-time when the relative distance -should ignore time zone rules (for example, comparing two local calendar events) -and a zoned date-time when offset and daylight-saving changes matter. +new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(value, unit); +// "next week" +``` -#### options.unit (optional) +## Using `Intl.RelativeTimeFormat` options + +You can customize style and numeric behavior at construction: + +```js +const rtShort = new RelativeTime("en", { style: "short", numeric: "always" }); +rtShort.format(Temporal.Now.plainDateTimeISO().subtract({ day: 1 })); +// "1 day ago" +``` -Unit for formatting. If the unit is not provided, `"best-fit"` is used. +> `RelativeTime` is literally a thin wrapper over `Intl.RelativeTimeFormat` once a `{ value, unit }` has been resolved. You get the same pluralization and grammar your engine provides. -- `"best-fit"` (default) -- `"second"` -- `"minute"` -- `"hour"` -- `"day"` -- `"month"` -- `"year"` +## When to use which -#### The `"best-fit"` unit +| You need… | Use | Why | +| --------------------------------- | ---------------------------------- | ------------------------------------------------------------- | +| “Just give me a localized string” | `RelativeTime` | Picks a sensible unit and formats it | +| `{ value, unit }` only | `RelativeTimeResolver` | Feed another formatter, build custom UIs, analytics, etc. | +| Full control over unit and i18n | `Intl.RelativeTimeFormat` directly | Low‑level by design; no unit resolution provided by the spec. | -It automatically picks a unit based on the relative time scale using thresholds. In short: +## Examples -- If `absDiff.year > 0 && absDiff.month > threshold.month` → `"year"` -- If `absDiff.month > 0 && absDiff.day > threshold.day` → `"month"` -- If `absDiff.day > 0 && absDiff.hour > threshold.hour` → `"day"` -- If `absDiff.hour > 0 && absDiff.minute > threshold.minute` → `"hour"` -- If `absDiff.minute > 0 && absDiff.second > threshold.second` → `"minute"` -- Otherwise → `"second"` +**Force a unit** -#### options.now (optional) +```js +const rt = new RelativeTime("en"); +rt.format(Temporal.Now.plainDateTimeISO().subtract({ hours: 27 }), { + unit: "hour", +}); +// "27 hours ago" +``` -A [Temporal.PlainDateTime](https://tc39.es/proposal-temporal/docs/plaindatetime.html) -or [Temporal.ZonedDateTime](https://tc39.es/proposal-temporal/docs/zoneddatetime.html) -representing the reference point used to compute the relative difference. When -omitted, the current moment is retrieved with -[`Temporal.Now`](https://tc39.es/proposal-temporal/docs/now.html) and evaluated as -either a plain or zoned date-time to match the type of `date`. Passing any other -type throws a `TypeError`. +**Different locales** -#### Return +```js +new RelativeTime(["fr", "en"]).format( + Temporal.Now.plainDateTimeISO().add({ minutes: 5 }) +); +// "dans 5 minutes" +``` -Returns the formatted relative time string given `date` and `options`. +## Notes on accuracy -### RelativeTimeResolver (named export) +This project uses **Temporal** for differences (calendar‑aware) and **`Intl.RelativeTimeFormat`** for localization. That combination prevents common errors around DST, month lengths, and week boundaries that “approximate threshold” libraries can exhibit. -Resolves the relative difference without formatting, returning `{ value, unit }`. +## Browser / runtime support -#### Constructor +- Works wherever **Temporal** is available; otherwise include **`@js-temporal/polyfill`**. +- Uses your runtime’s **`Intl.RelativeTimeFormat`**, which has wide support in modern environments. -`new RelativeTimeResolver(options?)` +## FAQ -- `options.threshold` — override the thresholds used by best-fit. -- `options.units` — override the units considered by the resolver. +**Does this support quarters?** +No—units are: `second`, `minute`, `hour`, `day`, `week`, `month`, `year`. -#### `resolve(date, { now, unit = "best-fit" } = {})` +**Why do I sometimes see “yesterday” instead of “1 day ago”?** +That comes from `numeric: "auto"` in `Intl.RelativeTimeFormat`. Switch to `numeric: "always"` if you prefer numbers only. -- `date` — `Temporal.PlainDateTime` or `Temporal.ZonedDateTime`. -- `now` — must match the `date` type; for zoned dates, the time zone must match. If omitted, `Temporal.Now` is used accordingly. -- `unit` — - - `"best-fit"` (default): chooses a unit using thresholds and returns `{ unit, value }`. - - Any supported unit (`second`, `minute`, `hour`, `day`, `month`, `year`): returns `{ unit, value }` using that exact unit (signed, truncated difference). The hour edge-case is handled so very recent past returns `-1` hour instead of `0` hours. +**How is this different from the TC39 proposal?** +This library’s resolver is the high‑level part **omitted on purpose** from the `Intl.RelativeTimeFormat` spec; the built‑in API only formats a supplied `{ value, unit }`. See the historical discussion around “best‑fit” in the proposal repo. -#### Return +## Contributing -An object `{ value, unit }` with the signed difference and chosen unit. +PRs and issues welcome! ## Appendix @@ -355,4 +353,4 @@ Note that (although not shown by the above ruler), the years distances doesn't m ## License -MIT © [Rafael Xavier de Souza](http://rafael.xavier.blog.br) +MIT © Rafael Xavier de Souza