diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9b210a7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +# Build artifacts +/dist/ + +# Package manager directories +/node_modules/ + +# Generated lock files +package-lock.json + +# Misc +coverage/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7197da8 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/README.md b/README.md index 10fd966..34ca0b9 100644 --- a/README.md +++ b/README.md @@ -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 }); @@ -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 @@ -145,7 +153,9 @@ relativeTime.format(berlinDate, { now: berlinNow }); ## API -### `format(date{, options})` +### RelativeTime (default export) + +#### `format(date{, options})` ### date @@ -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. @@ -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) @@ -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 diff --git a/src/relative-time.js b/src/relative-time.js index 0574165..4779d48 100644 --- a/src/relative-time.js +++ b/src/relative-time.js @@ -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; @@ -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, @@ -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; diff --git a/test/relative-time.js b/test/relative-time.js index 0c7bbc4..b434488 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -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) { @@ -13,6 +13,7 @@ describe("relative-time", function () { let relativeTime; let originalTemporal; let baseNow; + let resolver; before(function () { originalTemporal = global.Temporal; @@ -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(