Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Build artifacts
/dist/

# Package manager directories
/node_modules/

# Generated lock files
package-lock.json

# Misc
coverage/
4 changes: 4 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": false,
"trailingComma": "es5"
}
58 changes: 45 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Note `relative-time` checks for the actual month change instead of counting on a
npm install --save relative-time

```js
import RelativeTime from "relative-time";
import RelativeTime, { RelativeTimeResolver } from "relative-time";

const relativeTime = new RelativeTime();
const threeHoursAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 3 });
Expand All @@ -117,6 +117,14 @@ const relativeTimeInPortuguese = new RelativeTime("pt");
const oneHourAgo = Temporal.Now.plainDateTimeISO().subtract({ hours: 1 });
console.log(relativeTimeInPortuguese.format(oneHourAgo));
// > há 1 hora

// 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
```

### Time zone support
Expand Down Expand Up @@ -145,7 +153,9 @@ relativeTime.format(berlinDate, { now: berlinNow });

## API

### `format(date{, options})`
### RelativeTime (default export)

#### `format(date{, options})`

### date

Expand All @@ -155,7 +165,7 @@ 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.

### options.unit (optional)
#### options.unit (optional)

Unit for formatting. If the unit is not provided, `"best-fit"` is used.

Expand All @@ -169,17 +179,16 @@ Unit for formatting. If the unit is not provided, `"best-fit"` is used.

#### The `"best-fit"` unit

It automatically picks a unit based on the relative time scale. Basically, it looks like this:
It automatically picks a unit based on the relative time scale using thresholds. In short:

- If `absDiffYears > 0 && absDiffMonths > threshold.month`, return `"year"`.
- If `absDiffMonths > 0 && absDiffWeeks > threshold.week`, return `"month"`.
- If `absDiffWeeks > 0 && absDiffDays > threshold.day`, return `"week"`.
- If `absDiffDays > 0 && absDiffHours > threshold.hour`, return `"day"`.
- If `absDiffHours > 0 && absDiffMinutes > threshold.minute`, return `"hour"`.
- If `absDiffMinutes > 0 && absDiffSeconds > threshold.second`, return `"minutes"`.
- Return `"second"`.
- 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"`

### options.now (optional)
#### options.now (optional)

A [Temporal.PlainDateTime](https://tc39.es/proposal-temporal/docs/plaindatetime.html)
or [Temporal.ZonedDateTime](https://tc39.es/proposal-temporal/docs/zoneddatetime.html)
Expand All @@ -189,10 +198,33 @@ omitted, the current moment is retrieved with
either a plain or zoned date-time to match the type of `date`. Passing any other
type throws a `TypeError`.

### Return
#### Return

Returns the formatted relative time string given `date` and `options`.

### RelativeTimeResolver (named export)

Resolves the relative difference without formatting, returning `{ value, unit }`.

#### Constructor

`new RelativeTimeResolver(options?)`

- `options.threshold` — override the thresholds used by best-fit.
- `options.units` — override the units considered by the resolver.

#### `resolve(date, { now, unit = "best-fit" } = {})`

- `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.

#### Return

An object `{ value, unit }` with the signed difference and chosen unit.

## Appendix

### Relative time
Expand Down
106 changes: 66 additions & 40 deletions src/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,13 @@ function differenceInUnit(now, target, unit) {
return duration[unit + "s"];
}

export default class RelativeTime {
constructor() {
this.formatters = RelativeTime.initializeFormatters(...arguments);
export class RelativeTimeResolver {
constructor(options = {}) {
this.threshold = options.threshold || this.constructor.threshold;
this.units = options.units || this.constructor.units;
}

format(date, { unit = "best-fit", now } = {}) {
resolve(date, { now, unit = "best-fit" } = {}) {
const Temporal = getTemporal();
let target;
let resolvedNow;
Expand All @@ -166,54 +167,59 @@ export default class RelativeTime {

const diff = Object.create(null);
const absDiff = Object.create(null);
const diffUnits = [
"year",
"month",
/* "week", */ "day",
"hour",
"minute",
"second",
];

diffUnits.forEach(function (currentUnit) {
defineCachedGetter(diff, currentUnit, function () {
const diffUnits = this.units;

diffUnits.forEach((currentUnit) => {
defineCachedGetter(diff, currentUnit, () => {
return differenceInUnit(resolvedNow, target, currentUnit);
});

defineCachedGetter(absDiff, currentUnit, function () {
defineCachedGetter(absDiff, currentUnit, () => {
return Math.abs(diff[currentUnit]);
});
});

if (unit === "best-fit") {
unit = RelativeTime.bestFit(absDiff);
}
const resolvedUnit =
unit === "best-fit"
? this.constructor.bestFit(absDiff, this.threshold)
: unit;

return this.formatters[unit](diff[unit]);
return {
unit: resolvedUnit,
value: diff[resolvedUnit],
};
}
}

RelativeTime.bestFit = function (absDiff) {
const threshold = this.threshold;
switch (true) {
case absDiff.year > 0 && absDiff.month > threshold.month:
return "year";
case absDiff.month > 0 && absDiff.day > threshold.day:
return "month";
// case absDiff.month > 0 && absDiff.week > threshold.week: return "month";
// case absDiff.week > 0 && absDiff.day > threshold.day: return "week";
case absDiff.day > 0 && absDiff.hour > threshold.hour:
return "day";
case absDiff.hour > 0 && absDiff.minute > threshold.minute:
return "hour";
case absDiff.minute > 0 && absDiff.second > threshold.second:
return "minute";
default:
return "second";
static bestFit(absDiff, threshold = this.threshold) {
switch (true) {
case absDiff.year > 0 && absDiff.month > threshold.month:
return "year";
case absDiff.month > 0 && absDiff.day > threshold.day:
return "month";
// case absDiff.month > 0 && absDiff.week > threshold.week: return "month";
// case absDiff.week > 0 && absDiff.day > threshold.day: return "week";
case absDiff.day > 0 && absDiff.hour > threshold.hour:
return "day";
case absDiff.hour > 0 && absDiff.minute > threshold.minute:
return "hour";
case absDiff.minute > 0 && absDiff.second > threshold.second:
return "minute";
default:
return "second";
}
}
};
}

RelativeTimeResolver.units = [
"year",
"month",
/* "week", */ "day",
"hour",
"minute",
"second",
];

RelativeTime.threshold = {
RelativeTimeResolver.threshold = {
month: 2,
// week: 4,
day: 6,
Expand All @@ -222,6 +228,26 @@ RelativeTime.threshold = {
second: 59,
};

export default class RelativeTime {
constructor() {
this.formatters = RelativeTime.initializeFormatters(...arguments);
this.resolver = new RelativeTimeResolver();
}

format(date, options = {}) {
const { unit = "best-fit", now } = options;
const { unit: resolvedUnit, value } = this.resolver.resolve(date, {
unit,
now,
});

return this.formatters[resolvedUnit](value);
}
}

RelativeTime.bestFit = RelativeTimeResolver.bestFit;
RelativeTime.threshold = RelativeTimeResolver.threshold;

RelativeTime.initializeFormatters = function (localesOrFormatter, options) {
let locales = localesOrFormatter;
let formatOptions = options;
Expand Down
23 changes: 22 additions & 1 deletion test/relative-time.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import RelativeTime from "../src/relative-time";
import RelativeTime, { RelativeTimeResolver } from "../src/relative-time";
import { Temporal as TemporalPolyfill } from "@js-temporal/polyfill";

function plain(dateTime) {
Expand All @@ -13,6 +13,7 @@ describe("relative-time", function () {
let relativeTime;
let originalTemporal;
let baseNow;
let resolver;

before(function () {
originalTemporal = global.Temporal;
Expand All @@ -25,9 +26,29 @@ describe("relative-time", function () {

beforeEach(function () {
relativeTime = new RelativeTime();
resolver = new RelativeTimeResolver();
baseNow = plain("2016-04-10T12:00:00");
});

describe("resolver", function () {
it("should resolve best-fit unit and value", function () {
const result = resolver.resolve(plain("2016-04-10T11:59:01"), {
now: baseNow,
});

expect(result).to.deep.equal({ unit: "second", value: -59 });
});

it("should resolve using an explicit unit", function () {
const result = resolver.resolve(plain("2016-04-10T11:01:00"), {
now: baseNow,
unit: "hour",
});

expect(result).to.deep.equal({ unit: "hour", value: -1 });
});
});

describe("bestFit", function () {
it("should format seconds-distant dates", function () {
expect(
Expand Down