diff --git a/README.md b/README.md index 98bea64d..6b1f37f1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Playground for TypeScript - [x] `@kitsuyui/hello` ... simple hello world package - [x] `@kitsuyui/string` ... simple string package - [x] `@kitsuyui/mymath` ... simple math package +- [x] `@kitsuyui/luxon-ext` ... extension for [luxon](https://moment.github.io/luxon/) - [x] `@kitsuyu/standalone` ... make a standalone binary from TypeScript - [x] Binary application - [x] NPM package diff --git a/packages/luxon-ext/README.md b/packages/luxon-ext/README.md new file mode 100644 index 00000000..e6530d0b --- /dev/null +++ b/packages/luxon-ext/README.md @@ -0,0 +1,20 @@ +# @kitsuyui/luxon-ext + +## Usage + +```typescript +import { Duration } from 'luxon' +import { toHumanDurationExtended, toHumanDurationWithTemporal, toHumanDurationWithDiff } from '@kitsuyui/luxon-ext' + +const duration = Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }) +toHumanDurationExtended(duration)) // => '1 hour, 24 minutes' +toHumanDurationWithTemporal(duration, 'past') // => '1 hour, 24 minutes ago' +toHumanDurationWithTemporal(duration, 'future') // => 'in 1 hour, 24 minutes' +const date1 = DateTime.fromISO('2024-01-01T00:00:00Z') +const date2 = DateTime.fromISO('2024-01-01T01:23:45Z') +toHumanDurationWithDiff(date1, date2) // => 'in 1 hour, 24 minutes' +``` + +## License + +MIT diff --git a/packages/luxon-ext/package.json b/packages/luxon-ext/package.json new file mode 100644 index 00000000..1a1969e5 --- /dev/null +++ b/packages/luxon-ext/package.json @@ -0,0 +1,35 @@ +{ + "name": "@kitsuyui/luxon-ext", + "version": "0.0.0", + "license": "MIT", + "author": "Yui Kitsu ", + "description": "The extension of Luxon", + "scripts": { + "build": "tsup src/index.ts --clean", + "dev": "pnpm build --watch" + }, + "bin": { + "ts-playground-main": "./dist/main.js" + }, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "package.json" + ], + "devDependencies": { + "@types/luxon": "^3.4.2", + "luxon": "^3.4.4" + }, + "peerDependencies": { + "luxon": "^3" + } +} \ No newline at end of file diff --git a/packages/luxon-ext/src/constants.ts b/packages/luxon-ext/src/constants.ts new file mode 100644 index 00000000..8138e8c8 --- /dev/null +++ b/packages/luxon-ext/src/constants.ts @@ -0,0 +1,19 @@ +const MILLIS = 1 +const SECONDS = MILLIS * 1000 +const MINUTES = SECONDS * 60 +const HOURS = MINUTES * 60 +const DAYS = HOURS * 24 +const WEEKS = DAYS * 7 +const YEARS = DAYS * 365.25 +const MONTHS = YEARS / 12 + +export const HALF_OF_TIME_UNITS = { + years: 0.5 * YEARS, + months: 0.5 * MONTHS, + weeks: 0.5 * WEEKS, + days: 0.5 * DAYS, + hours: 0.5 * HOURS, + minutes: 0.5 * MINUTES, + seconds: 0.5 * SECONDS, + milliseconds: 0.5 * MILLIS, +} as const diff --git a/packages/luxon-ext/src/index.spec.ts b/packages/luxon-ext/src/index.spec.ts new file mode 100644 index 00000000..da5d715d --- /dev/null +++ b/packages/luxon-ext/src/index.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, jest } from '@jest/globals' + +import luxon, { Duration, DateTime } from 'luxon' +import { toHumanDurationExtended, toHumanDurationWithTemporal, toHumanDurationWithDiff } from '.' + + +describe('toHumanDurationExtended', () => { + it('should return the human duration', () => { + expect(toHumanDurationExtended(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'en' }))).toBe('1 hour, 24 minutes') + expect(toHumanDurationExtended(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'en' }), { + human: { + unitDisplay: 'narrow', + unit: 'short', + }, + rounding: { + numOfUnits: 2, + minUnit: 'minutes', + roundingMethod: 'round' + } + })).toBe('1h, 24m') + + expect(toHumanDurationExtended(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'ja' }))).toBe('1 時間、24 分') + }) +}) + +describe('toHumanDurationWithTemporal', () => { + it('should return the human duration', () => { + expect(toHumanDurationWithTemporal(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'en' }), 'past')).toBe('1 hour, 24 minutes ago') + expect(toHumanDurationWithTemporal(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'en' }), 'future')).toBe('in 1 hour, 24 minutes') + + const formatter = (baseText: string, temporal: 'past' | 'future'): string => { + if (temporal === 'future') { + return `あと ${baseText}` + } + return `${baseText} 前` + } + + expect(toHumanDurationWithTemporal(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'ja' }), 'past', { + formatter: formatter + })).toBe('1 時間、24 分 前') + expect(toHumanDurationWithTemporal(Duration.fromObject({ hours: 1, minutes: 23, seconds: 45 }).reconfigure({ locale: 'ja' }), 'future', { + formatter: formatter + })).toBe('あと 1 時間、24 分') + }) +}) + +describe('toHumanDurationWithDiff', () => { + it('should return the human duration', () => { + const begin = DateTime.fromISO('2022-01-01T00:00:00Z').reconfigure({ locale: 'en' }) + const end = DateTime.fromISO('2022-01-01T01:23:00Z').reconfigure({ locale: 'en' }) + expect(toHumanDurationWithDiff(begin, end)).toBe('in 1 hour, 23 minutes') + expect(toHumanDurationWithDiff(end, begin)).toBe('1 hour, 23 minutes ago') + }) +}) \ No newline at end of file diff --git a/packages/luxon-ext/src/index.ts b/packages/luxon-ext/src/index.ts new file mode 100644 index 00000000..41cec02f --- /dev/null +++ b/packages/luxon-ext/src/index.ts @@ -0,0 +1,71 @@ +// This is a workaround for the issue #1134 +// c.f. https://github.com/moment/luxon/issues/1134 + +import type { DateTime, Duration, ToHumanDurationOptions } from 'luxon' +import { cleanDuration, roundDuration, type PartialRoundingOptions } from './rounding' + +interface ExtendedToHumanDurationOptions { + rounding?: PartialRoundingOptions + human?: ToHumanDurationOptions +} + +interface TemporalToHumanDurationOptions extends ExtendedToHumanDurationOptions { + formatter: Formatter +} + +type Temporal = 'past' | 'future' + +type Formatter = (baseText: string, temporal: Temporal) => string + +export const toHumanDurationExtended = ( + duration: Duration, + opts?: ExtendedToHumanDurationOptions, +): string => { + const locale = duration.locale ?? undefined + const cleaned = cleanDuration(duration) + return roundDuration( + cleaned, + opts?.rounding, + ).reconfigure({ locale }).toHuman(opts?.human) +} + +export const toHumanDurationWithTemporal = ( + duration: Duration, + temporal: Temporal, + opts?: TemporalToHumanDurationOptions, +): string => { + const formatter = opts?.formatter ?? defaultFormatter + const human = toHumanDurationExtended(duration, opts) + return formatter(human, temporal) +} + +/** + * Convert the duration between two DateTimes to a human readable format + * @param start + * @param end + * @param opts + * @returns + */ +export const toHumanDurationWithDiff = ( + begin: DateTime, + end: DateTime, + opts?: TemporalToHumanDurationOptions, +): string => { + const temporal = end > begin ? 'future' : 'past' + const locale = end.locale ?? undefined + const duration = begin.diff(end).reconfigure({ locale }) + return toHumanDurationWithTemporal(duration, temporal, opts) +} + +/** + * Default formatter + * @param baseText + * @param temporal + * @returns + */ +const defaultFormatter: Formatter = (baseText: string, temporal: Temporal): string => { + if (temporal === 'future') { + return `in ${baseText}` + } + return `${baseText} ago` +} diff --git a/packages/luxon-ext/src/rounding/index.spec.ts b/packages/luxon-ext/src/rounding/index.spec.ts new file mode 100644 index 00000000..bb7778df --- /dev/null +++ b/packages/luxon-ext/src/rounding/index.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, jest } from '@jest/globals' + +import { Duration } from 'luxon' + +import { roundDuration, cleanDuration } from '.' + +describe('cleanDuration', () => { + it('should clean the duration', () => { + expect(cleanDuration(Duration.fromObject({ hours: 1 })).toMillis()).toEqual(3600000) + expect(cleanDuration(Duration.fromObject({ hours: -1 })).toMillis()).toEqual(3600000) + expect(cleanDuration(Duration.fromObject({ hours: 1, minutes: 1 })).toMillis()).toEqual(3660000) + expect(cleanDuration(Duration.fromObject({ hours: -1, minutes: -1 })).toMillis()).toEqual(3660000) + expect(cleanDuration(Duration.fromObject({ hours: 1, minutes: 1, seconds: 1 })).toMillis()).toEqual(3661000) + expect(cleanDuration(Duration.fromObject({ hours: -1, minutes: -1, seconds: -1 })).toMillis()).toEqual(3661000) + }) +}) + +describe('roundDuration', () => { + it('should round the duration', () => { + expect(roundDuration(Duration.fromObject({ hours: 1 }), { numOfUnits: 1, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ hours: 1 }) + expect(roundDuration(Duration.fromObject({ hours: 1 }), { numOfUnits: 1, minUnit: 'minutes', roundingMethod: 'ceil' }).toObject()).toEqual({ hours: 1 }) + expect(roundDuration(Duration.fromObject({ hours: 1, minutes: 1 }), { numOfUnits: 1, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ hours: 1 }) + expect(roundDuration(Duration.fromObject({ years: 1, months: 1, days: 1, hours: 1, minutes: 1, seconds: 1 }), { numOfUnits: 1, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ years: 1 }) + expect(roundDuration(Duration.fromObject({ years: 1, months: 1, days: 1, hours: 1, minutes: 1, seconds: 1 }), { numOfUnits: 2, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ years: 1, months: 1 }) + expect(roundDuration(Duration.fromObject({ years: 1, months: 0, days: 1, hours: 1, minutes: 1, seconds: 1 }), { numOfUnits: 2, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ years: 1 }) + expect(roundDuration(Duration.fromObject({ years: 1, months: 7, days: 1, hours: 1, minutes: 1, seconds: 1 }), { numOfUnits: 2, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ years: 1, months: 7 }) + expect(roundDuration(Duration.fromObject({ years: 1, months: 7, days: 1, hours: 1, minutes: 1, seconds: 1 }), { numOfUnits: 1, minUnit: 'minutes', roundingMethod: 'round' }).toObject()).toEqual({ years: 2 }) + expect(roundDuration(Duration.fromObject({ years: 1, seconds: 1 }), { numOfUnits: 1, roundingMethod: 'ceil' }).toObject()).toEqual({ years: 2 }) + expect(roundDuration(Duration.fromObject({ }), { numOfUnits: 1, roundingMethod: 'floor' }).toObject()).toEqual({ seconds: 0 }) + }) +}) diff --git a/packages/luxon-ext/src/rounding/index.ts b/packages/luxon-ext/src/rounding/index.ts new file mode 100644 index 00000000..45806b04 --- /dev/null +++ b/packages/luxon-ext/src/rounding/index.ts @@ -0,0 +1,91 @@ +import { Duration, type DurationObjectUnits } from 'luxon' +import { HALF_OF_TIME_UNITS } from '../constants' +import { type TimeUnit, computeTopUnit, computeUseUnits } from '../units' + +type RoundingMethod = 'floor' | 'ceil' | 'round' + +interface RoundingOptions { + numOfUnits: number + minUnit: TimeUnit + roundingMethod: RoundingMethod +} +export type PartialRoundingOptions = Partial +/** + * Convert partial rounding options to full rounding options + * @param opts + * @returns + */ +const roundingOptionsFromPartial = ( + opts?: PartialRoundingOptions, +): RoundingOptions => { + const { + numOfUnits = 2, + minUnit = 'seconds', + roundingMethod = 'round', + } = opts ?? {} + return { numOfUnits, minUnit, roundingMethod } +} + +/** + * Clean the duration (shift and remove the negative sign) + * @param duration + * @returns + */ +export const cleanDuration = (duration: Duration): Duration => { + const cleaned = duration.shiftToAll().toMillis() + const abs = Math.abs(cleaned) + return Duration.fromMillis(abs) +} + +/** + * Round the duration + * @param duration duration must be clean + * @param opts + * @param opts.numOfUnits the number of units to round + * @param opts.minUnit the minimum unit to round + * @param opts.roundingMethod the rounding type + * @returns rounded duration + */ +export const roundDuration = ( + duration: Duration, // required to be clean + opts?: PartialRoundingOptions, +): Duration => { + const { numOfUnits, minUnit, roundingMethod } = roundingOptionsFromPartial( + opts ?? {}, + ) + const base = duration.shiftToAll().toObject() + const rounded: DurationObjectUnits = {} + const remain: DurationObjectUnits = { ...base } + const topUnit = computeTopUnit(duration) + const useUnits = computeUseUnits({ + top: topUnit, + nums: numOfUnits, + min: minUnit, + }) + const roundingHigherUnit = useUnits[useUnits.length - 2] + const roundingLowerUnit = useUnits[useUnits.length - 1] + + for (const unit of useUnits.slice(0, -1)) { + const value = remain[unit] ?? 0 + if (value === 0) continue + rounded[unit] = value + delete remain[unit] + } + + const remainMillis = Duration.fromObject(remain).toMillis() + if (roundingHigherUnit && roundingLowerUnit) { + const shouldCarry = + (roundingMethod === 'ceil' && remainMillis > 0) || + (roundingMethod === 'round' && + remainMillis >= HALF_OF_TIME_UNITS[roundingHigherUnit]) + if (shouldCarry) { + rounded[roundingHigherUnit] = (rounded[roundingHigherUnit] ?? 0) + 1 + } + } + + if (Object.keys(rounded).length === 0) { + rounded[minUnit] = 0 + } + + return Duration.fromObject(rounded) +} diff --git a/packages/luxon-ext/src/units/index.spec.ts b/packages/luxon-ext/src/units/index.spec.ts new file mode 100644 index 00000000..b95fcc9a --- /dev/null +++ b/packages/luxon-ext/src/units/index.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, jest } from '@jest/globals' + +import { Duration } from 'luxon' +import { computeTopUnit, computeUseUnits } from '.' + +describe('computeTopUnit', () => { + it('should return the most significant unit', () => { + expect(computeTopUnit(Duration.fromObject({ hours: 1 }))).toBe('hours') + expect(computeTopUnit(Duration.fromObject({ hours: 1, minutes: 1 }))).toBe('hours') + expect(computeTopUnit(Duration.fromObject({ hours: 1, minutes: 1, seconds: 1 }))).toBe('hours') + }) +}) + +describe('computeUseUnits', () => { + it('should return the units to use', () => { + expect(computeUseUnits({ top: 'hours', nums: 1, min: 'minutes' })).toEqual(['hours', 'minutes']) + expect(computeUseUnits({ top: 'hours', nums: 2, min: 'minutes' })).toEqual(['hours', 'minutes', 'seconds']) + expect(computeUseUnits({ top: 'hours', nums: 3, min: 'minutes' })).toEqual(['hours', 'minutes', 'seconds']) + expect(computeUseUnits({ top: 'hours', nums: 1, min: 'hours' })).toEqual(['hours', 'minutes']) + expect(computeUseUnits({ top: 'hours', nums: 2, min: 'seconds' })).toEqual(['hours', 'minutes', 'seconds']) + expect(computeUseUnits({ top: 'hours', nums: 3, min: 'seconds' })).toEqual(['hours', 'minutes', 'seconds', 'milliseconds']) + }) +}) diff --git a/packages/luxon-ext/src/units/index.ts b/packages/luxon-ext/src/units/index.ts new file mode 100644 index 00000000..af567abd --- /dev/null +++ b/packages/luxon-ext/src/units/index.ts @@ -0,0 +1,53 @@ +import type { Duration } from 'luxon' + +const TIME_UNITS = [ + 'years', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', + 'milliseconds', +] as const + +// type TimeUnit != keyof DurationObjectUnits, quarters are not supported +export type TimeUnit = (typeof TIME_UNITS)[number] + +/** + * Compute the most significant unit + * @param duration duration must be clean + * @returns the most significant unit + */ +export const computeTopUnit = (duration: Duration): TimeUnit => { + const cleaned = duration.shiftToAll().toObject() + for (const unit of TIME_UNITS) { + if (cleaned[unit] !== 0) { + return unit + } + } + return 'milliseconds' +} + +/** + * Compute the units to use (include +1 unit for rounding) + * @param args + * @param args.top the most significant unit + * @param args.nums the number of units to use + * @param args.min the minimum unit to use + * @returns + */ +export const computeUseUnits = (args: { + top: TimeUnit + nums: number + min: TimeUnit +}) => { + const { top, min, nums } = args + const indexOfTopUnit = TIME_UNITS.indexOf(top) + const baseUnits = TIME_UNITS.slice(indexOfTopUnit, indexOfTopUnit + nums + 1) + if (baseUnits.includes(min)) { + const indexOfMinUnit = baseUnits.indexOf(min) + return baseUnits.slice(0, indexOfMinUnit + 2).slice(0, nums + 1) + } + return baseUnits +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a60e82d..7562efb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,15 @@ importers: packages/hello: {} + packages/luxon-ext: + devDependencies: + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + luxon: + specifier: ^3.4.4 + version: 3.4.4 + packages/mymath: {} packages/standalone: @@ -49,6 +58,8 @@ importers: specifier: ^5.8.1 version: 5.8.1 + packages/string: {} + packages: /@ampproject/remapping@2.3.0: @@ -1336,6 +1347,10 @@ packages: '@types/istanbul-lib-report': 3.0.3 dev: true + /@types/luxon@3.4.2: + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + dev: true + /@types/node@20.11.30: resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==} dependencies: @@ -2855,6 +2870,11 @@ packages: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} dev: true + /luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + dev: true + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'}