-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #225 from kitsuyui/luxon-ext
Implement: @kitsuyui/luxon-ext
- Loading branch information
Showing
11 changed files
with
418 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 <[email protected]>", | ||
"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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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']) | ||
}) | ||
}) |
Oops, something went wrong.