Skip to content

Commit

Permalink
Implement: @kitsuyui/luxon-ext
Browse files Browse the repository at this point in the history
This package provides some extensions for Luxon.

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

You can pass options same as luxon's `toHuman` method.
  • Loading branch information
kitsuyui committed Mar 30, 2024
1 parent ea3731f commit a835cb5
Show file tree
Hide file tree
Showing 11 changed files with 418 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/luxon-ext/README.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions packages/luxon-ext/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@kitsuyui/luxon-ext",
"version": "0.0.0",
"license": "MIT",
"author": "Yui Kitsu <kitsuyui@kitsuyui.com>",
"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"
}
}
19 changes: 19 additions & 0 deletions packages/luxon-ext/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions packages/luxon-ext/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
71 changes: 71 additions & 0 deletions packages/luxon-ext/src/index.ts
Original file line number Diff line number Diff line change
@@ -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`
}
31 changes: 31 additions & 0 deletions packages/luxon-ext/src/rounding/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
91 changes: 91 additions & 0 deletions packages/luxon-ext/src/rounding/index.ts
Original file line number Diff line number Diff line change
@@ -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<RoundingOptions>
/**
* 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)
}
23 changes: 23 additions & 0 deletions packages/luxon-ext/src/units/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading

0 comments on commit a835cb5

Please sign in to comment.