@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
856 lines (732 loc) • 22.8 kB
text/typescript
import { _assert } from '../error/assert.js'
import { Iterable2 } from '../iter/iterable2.js'
import type {
Inclusiveness,
IsoDate,
IsoDateTime,
IsoMonth,
MutateOptions,
SortOptions,
UnixTimestamp,
UnixTimestampMillis,
} from '../types.js'
import type { DateObject, ISODayOfWeek, LocalTime } from './localTime.js'
import { localTime, VALID_DAYS_OF_WEEK } from './localTime.js'
export type LocalDateUnit = LocalDateUnitStrict | 'week'
export type LocalDateUnitStrict = 'year' | 'month' | 'day'
const MDAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
/**
* Regex is open-ended (no $ at the end) to support e.g Date+Time string to be parsed (time part will be dropped)
*/
const DATE_REGEX = /^(\d\d\d\d)-(\d\d)-(\d\d)/
const COMPACT_DATE_REGEX = /^(\d\d\d\d)(\d\d)(\d\d)$/
export type LocalDateInput = LocalDate | Date | IsoDate
export type LocalDateInputNullable = LocalDateInput | null | undefined
export type LocalDateFormatter = (ld: LocalDate) => string
/**
* LocalDate represents a date without time.
* It is timezone-independent.
*/
export class LocalDate {
constructor(
public year: number,
public month: number,
public day: number,
) {}
get(unit: LocalDateUnitStrict): number {
return unit === 'year' ? this.year : unit === 'month' ? this.month : this.day
}
set(unit: LocalDateUnitStrict, v: number, opt: MutateOptions = {}): LocalDate {
const t = opt.mutate ? this : this.clone()
if (unit === 'year') {
t.year = v
} else if (unit === 'month') {
t.month = v
} else {
t.day = v
}
return t
}
setYear(v: number): LocalDate {
return this.set('year', v)
}
setMonth(v: number): LocalDate {
return this.set('month', v)
}
setDay(v: number): LocalDate {
return this.set('day', v)
}
get dayOfWeek(): ISODayOfWeek {
return (this.toDate().getDay() || 7) as ISODayOfWeek
}
/**
* Returns LocalDate for the given DayOfWeek (e.g Monday), that is in the same week as this.
* It may move the time into the future, or the past, depending on how the desired DayOfWeek is in
* relation to `this`.
*/
setDayOfWeek(dow: ISODayOfWeek): LocalDate {
_assert(VALID_DAYS_OF_WEEK.has(dow), `Invalid dayOfWeek: ${dow}`)
const delta = dow - this.dayOfWeek
return this.plus(delta, 'day')
}
/**
* Returns LocalDate for the given DayOfWeek (e.g Monday), that is in the future,
* in relation to this.
* If this LocalDate is Monday, and desired DoW is also Monday - `this` is returned.
*/
setNextDayOfWeek(dow: ISODayOfWeek): LocalDate {
_assert(VALID_DAYS_OF_WEEK.has(dow), `Invalid dayOfWeek: ${dow}`)
let delta = dow - this.dayOfWeek
if (delta < 0) delta += 7
return this.plus(delta, 'day')
}
isSame(d: LocalDateInput): boolean {
d = localDate.fromInput(d)
return this.day === d.day && this.month === d.month && this.year === d.year
}
isBefore(d: LocalDateInput, inclusive = false): boolean {
const r = this.compare(d)
return r === -1 || (r === 0 && inclusive)
}
isSameOrBefore(d: LocalDateInput): boolean {
return this.compare(d) <= 0
}
isAfter(d: LocalDateInput, inclusive = false): boolean {
const r = this.compare(d)
return r === 1 || (r === 0 && inclusive)
}
isSameOrAfter(d: LocalDateInput): boolean {
return this.compare(d) >= 0
}
isBetween(min: LocalDateInput, max: LocalDateInput, incl: Inclusiveness): boolean {
let r = this.compare(min)
if (r < 0) return false
r = this.compare(max)
if (r > 0 || (r === 0 && incl[1] === ')')) return false
return true
}
/**
* Checks if this localDate is older (<) than "today" by X units.
*
* Example:
*
* localDate(expirationDate).isOlderThan(5, 'day')
*
* Third argument allows to override "today".
*/
isOlderThan(n: number, unit: LocalDateUnit, today?: LocalDateInput): boolean {
return this.isBefore(localDate.fromInput(today || new Date()).plus(-n, unit))
}
/**
* Checks if this localDate is same or older (<=) than "today" by X units.
*/
isSameOrOlderThan(n: number, unit: LocalDateUnit, today?: LocalDateInput): boolean {
return this.isSameOrBefore(localDate.fromInput(today || new Date()).plus(-n, unit))
}
/**
* Checks if this localDate is younger (>) than "today" by X units.
*
* Example:
*
* localDate(expirationDate).isYoungerThan(5, 'day')
*
* Third argument allows to override "today".
*/
isYoungerThan(n: number, unit: LocalDateUnit, today?: LocalDateInput): boolean {
return this.isAfter(localDate.fromInput(today || new Date()).plus(-n, unit))
}
/**
* Checks if this localDate is same or younger (>=) than "today" by X units.
*/
isSameOrYoungerThan(n: number, unit: LocalDateUnit, today?: LocalDateInput): boolean {
return this.isSameOrAfter(localDate.fromInput(today || new Date()).plus(-n, unit))
}
isToday(): boolean {
return this.isSame(localDate.today())
}
isAfterToday(): boolean {
return this.isAfter(localDate.today())
}
isSameOrAfterToday(): boolean {
return this.isSameOrAfter(localDate.today())
}
isBeforeToday(): boolean {
return this.isBefore(localDate.today())
}
isSameOrBeforeToday(): boolean {
return this.isSameOrBefore(localDate.today())
}
getAgeInYears(today?: LocalDateInput): number {
return this.getAgeIn('year', today)
}
getAgeInMonths(today?: LocalDateInput): number {
return this.getAgeIn('month', today)
}
getAgeInDays(today?: LocalDateInput): number {
return this.getAgeIn('day', today)
}
getAgeIn(unit: LocalDateUnit, today?: LocalDateInput): number {
return localDate.fromInput(today || new Date()).diff(this, unit)
}
/**
* Returns 1 if this > d
* returns 0 if they are equal
* returns -1 if this < d
*/
compare(d: LocalDateInput): -1 | 0 | 1 {
d = localDate.fromInput(d)
if (this.year < d.year) return -1
if (this.year > d.year) return 1
if (this.month < d.month) return -1
if (this.month > d.month) return 1
if (this.day < d.day) return -1
if (this.day > d.day) return 1
return 0
}
/**
* Same as Math.abs( diff )
*/
absDiff(d: LocalDateInput, unit: LocalDateUnit): number {
return Math.abs(this.diff(d, unit))
}
/**
* Returns the number of **full** units difference (aka `Math.floor`).
*
* a.diff(b) means "a minus b"
*/
diff(d: LocalDateInput, unit: LocalDateUnit): number {
d = localDate.fromInput(d)
const sign = this.compare(d)
if (!sign) return 0
// Put items in descending order: "big minus small"
const [big, small] = sign === 1 ? [this, d] : [d, this]
if (unit === 'year') {
let years = big.year - small.year
if (
big.month < small.month ||
(big.month === small.month &&
big.day < small.day &&
!(
big.day === localDate.getMonthLength(big.year, big.month) &&
small.day === localDate.getMonthLength(small.year, small.month)
))
) {
years--
}
return years * sign || 0
}
if (unit === 'month') {
let months = (big.year - small.year) * 12 + (big.month - small.month)
if (big.day < small.day) {
const bigMonthLen = localDate.getMonthLength(big.year, big.month)
if (big.day !== bigMonthLen || small.day < bigMonthLen) {
months--
}
}
return months * sign || 0
}
// unit is 'day' or 'week'
let days = big.day - small.day
// If small date is after 1st of March - next year's "leapness" should be used
const offsetYear = small.month >= 3 ? 1 : 0
for (let year = small.year; year < big.year; year++) {
days += localDate.getYearLength(year + offsetYear)
}
if (small.month < big.month) {
for (let month = small.month; month < big.month; month++) {
days += localDate.getMonthLength(big.year, month)
}
} else if (big.month < small.month) {
for (let month = big.month; month < small.month; month++) {
days -= localDate.getMonthLength(big.year, month)
}
}
if (unit === 'week') {
return Math.trunc(days / 7) * sign || 0
}
return days * sign || 0
}
plusDays(num: number): LocalDate {
return this.plus(num, 'day')
}
plusWeeks(num: number): LocalDate {
return this.plus(num, 'week')
}
plusMonths(num: number): LocalDate {
return this.plus(num, 'month')
}
plusYears(num: number): LocalDate {
return this.plus(num, 'year')
}
minusDays(num: number): LocalDate {
return this.plus(-num, 'day')
}
minusWeeks(num: number): LocalDate {
return this.plus(-num, 'week')
}
minusMonths(num: number): LocalDate {
return this.plus(-num, 'month')
}
minusYears(num: number): LocalDate {
return this.plus(-num, 'year')
}
plus(num: number, unit: LocalDateUnit, opt: MutateOptions = {}): LocalDate {
num = Math.floor(num) // if a fractional number like 0.5 is passed - it will be floored, as LocalDate only deals with "whole days" as minimal unit
let { day, month, year } = this
if (unit === 'week') {
num *= 7
unit = 'day'
}
if (unit === 'day') {
day += num
} else if (unit === 'month') {
month += num
} else if (unit === 'year') {
year += num
}
// check month overflow
while (month > 12) {
year += 1
month -= 12
}
while (month < 1) {
year -= 1
month += 12
}
// check day overflow
// Applies not only for 'day' unit, but also e.g 2022-05-31 plus 1 month should be 2022-06-30 (not 31!)
if (day < 1) {
while (day < 1) {
month -= 1
if (month < 1) {
year -= 1
month += 12
}
day += localDate.getMonthLength(year, month)
}
} else {
let monLen = localDate.getMonthLength(year, month)
if (unit !== 'day') {
if (day > monLen) {
// Case of 2022-05-31 plus 1 month should be 2022-06-30, not 31
day = monLen
}
} else {
while (day > monLen) {
day -= monLen
month += 1
if (month > 12) {
year += 1
month -= 12
}
monLen = localDate.getMonthLength(year, month)
}
}
}
if (opt.mutate) {
this.year = year
this.month = month
this.day = day
return this
}
return new LocalDate(year, month, day)
}
minus(num: number, unit: LocalDateUnit, opt: MutateOptions = {}): LocalDate {
return this.plus(-num, unit, opt)
}
startOf(unit: LocalDateUnitStrict): LocalDate {
if (unit === 'day') return this
if (unit === 'month') return new LocalDate(this.year, this.month, 1)
// year
return new LocalDate(this.year, 1, 1)
}
endOf(unit: LocalDateUnitStrict): LocalDate {
if (unit === 'day') return this
if (unit === 'month') {
return new LocalDate(this.year, this.month, localDate.getMonthLength(this.year, this.month))
}
// year
return new LocalDate(this.year, 12, 31)
}
/**
* Returns how many days are in the current month.
* E.g 31 for January.
*/
get daysInMonth(): number {
return localDate.getMonthLength(this.year, this.month)
}
clone(): LocalDate {
return new LocalDate(this.year, this.month, this.day)
}
/**
* Converts LocalDate into instance of Date.
* Year, month and day will match.
* Hour, minute, second, ms will be 0.
* Timezone will match local timezone.
*/
toDate(): Date {
return new Date(this.year, this.month - 1, this.day)
}
/**
* Converts LocalDate to Date in UTC timezone.
* Unlike normal `.toDate` that uses browser's timezone by default.
*/
toDateInUTC(): Date {
return new Date(this.toISODateTimeInUTC())
}
toDateObject(): DateObject {
return {
year: this.year,
month: this.month,
day: this.day,
}
}
/**
* Converts LocalDate to LocalTime with 0 hours, 0 minutes, 0 seconds.
* LocalTime's Date will be in local timezone.
*/
toLocalTime(): LocalTime {
return localTime.fromDate(this.toDate())
}
/**
* Returns e.g: `1984-06-21`
*/
toISODate(): IsoDate {
return [
String(this.year).padStart(4, '0'),
String(this.month).padStart(2, '0'),
String(this.day).padStart(2, '0'),
].join('-') as IsoDate
}
/**
* Returns e.g: `1984-06`
*/
toISOMonth(): IsoMonth {
return this.toISODate().slice(0, 7) as IsoMonth
}
/**
* Returns e.g: `1984-06-21T00:00:00`
* Hours, minutes and seconds are 0.
*/
toISODateTime(): IsoDateTime {
return (this.toISODate() + 'T00:00:00') as IsoDateTime
}
/**
* Returns e.g: `1984-06-21T00:00:00Z` (notice the Z at the end, which indicates UTC).
* Hours, minutes and seconds are 0.
*/
toISODateTimeInUTC(): IsoDateTime {
return (this.toISODateTime() + 'Z') as IsoDateTime
}
toString(): IsoDate {
return this.toISODate()
}
/**
* Returns e.g: `19840621`
*/
toStringCompact(): string {
return [
String(this.year).padStart(4, '0'),
String(this.month).padStart(2, '0'),
String(this.day).padStart(2, '0'),
].join('')
}
/**
* Returns unix timestamp of 00:00:00 of that date (in UTC, because unix timestamp always reflects UTC).
*/
get unix(): UnixTimestamp {
return Math.floor(this.toDate().valueOf() / 1000) as UnixTimestamp
}
/**
* Same as .unix(), but in milliseconds.
*/
get unixMillis(): UnixTimestampMillis {
return this.toDate().valueOf() as UnixTimestampMillis
}
toJSON(): IsoDate {
return this.toISODate()
}
format(fmt: Intl.DateTimeFormat | LocalDateFormatter): string {
if (fmt instanceof Intl.DateTimeFormat) {
return fmt.format(this.toDate())
}
return fmt(this)
}
}
class LocalDateFactory {
/**
* Creates a LocalDate from the input, unless it's falsy - then returns undefined.
*
* Similar to `localDate.orToday`, but that will instead return Today on falsy input.
*/
orUndefined(d: LocalDateInputNullable): LocalDate | undefined {
return d ? this.fromInput(d) : undefined
}
/**
* Creates a LocalDate from the input, unless it's falsy - then returns localDate.today.
*/
orToday(d: LocalDateInputNullable): LocalDate {
return d ? this.fromInput(d) : this.today()
}
/**
* Creates LocalDate that represents `today` (in local timezone).
*/
today(): LocalDate {
return this.fromDate(new Date())
}
/**
* Creates LocalDate that represents `today` in UTC.
*/
todayInUTC(): LocalDate {
return this.fromDateInUTC(new Date())
}
/**
Convenience function to return current today's IsoDate representation, e.g `2024-06-21`
*/
todayString(): IsoDate {
return this.fromDate(new Date()).toISODate()
}
/**
* Create LocalDate from LocalDateInput.
* Input can already be a LocalDate - it is returned as-is in that case.
* String - will be parsed as yyyy-mm-dd.
* Date - will be converted to LocalDate (as-is, in whatever timezone it is - local or UTC).
* No other formats are supported.
*
* Will throw if it fails to parse/construct LocalDate.
*/
fromInput(input: LocalDateInput): LocalDate {
if (input instanceof LocalDate) return input
if (input instanceof Date) {
return this.fromDate(input)
}
// It means it's a string
return this.fromString(input)
}
/**
* Returns true if input is valid to create LocalDate.
*/
isValid(input: LocalDateInputNullable): boolean {
if (!input) return false
if (input instanceof LocalDate) return true
if (input instanceof Date) return !Number.isNaN(input.getDate())
return this.isValidString(input)
}
/**
* Returns true if isoString is a valid iso8601 string like `yyyy-mm-dd`.
*/
isValidString(isoString: string | undefined | null): boolean {
return !!this.parseToLocalDateOrUndefined(DATE_REGEX, isoString)
}
/**
* Tries to convert/parse the input into LocalDate.
* Uses LOOSE parsing.
* If invalid - doesn't throw, but returns undefined instead.
*/
try(input: LocalDateInputNullable): LocalDate | undefined {
if (!input) return
if (input instanceof LocalDate) return input
if (input instanceof Date) {
if (Number.isNaN(input.getDate())) return
return new LocalDate(input.getFullYear(), input.getMonth() + 1, input.getDate())
}
return this.parseToLocalDateOrUndefined(DATE_REGEX, input)
}
/**
* Performs STRICT parsing.
* Only allows IsoDate input, nothing else.
*/
fromString(s: IsoDate): LocalDate {
return this.parseToLocalDate(DATE_REGEX, s)
}
/**
* Parses "compact iso8601 format", e.g `19840621` into LocalDate.
* Throws if it fails to do so.
*/
fromCompactString(s: string): LocalDate {
return this.parseToLocalDate(COMPACT_DATE_REGEX, s)
}
/**
* Throws if it fails to parse the input string via Regex and YMD validation.
*/
private parseToLocalDate(regex: RegExp, s: string): LocalDate {
const ld = this.parseToLocalDateOrUndefined(regex, s)
_assert(ld, `Cannot parse "${s}" into LocalDate`)
return ld
}
/**
* Tries to parse the input string, returns undefined if input is invalid.
*/
private parseToLocalDateOrUndefined(
regex: RegExp,
s: string | undefined | null,
): LocalDate | undefined {
if (!s || typeof (s as any) !== 'string') return
const m = regex.exec(s)
if (!m) return
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
if (!this.isDateObjectValid({ year, month, day })) return
return new LocalDate(year, month, day)
}
/**
* Throws on invalid value.
*/
private validateDateObject(o: DateObject): void {
_assert(
this.isDateObjectValid(o),
`Cannot construct LocalDate from: ${o.year}-${o.month}-${o.day}`,
)
}
isDateObjectValid({ year, month, day }: DateObject): boolean {
return (
!!year && month >= 1 && month <= 12 && day >= 1 && day <= this.getMonthLength(year, month)
)
}
/**
* Constructs LocalDate from Date.
* Takes Date as-is, in its timezone - local or UTC.
*/
fromDate(d: Date): LocalDate {
_assert(
!Number.isNaN(d.getDate()),
'localDate.fromDate is called on Date object that is invalid',
)
return new LocalDate(d.getFullYear(), d.getMonth() + 1, d.getDate())
}
/**
* Constructs LocalDate from Date.
* Takes Date's year/month/day components in UTC, using getUTCFullYear, getUTCMonth, getUTCDate.
*/
fromDateInUTC(d: Date): LocalDate {
_assert(
!Number.isNaN(d.getDate()),
'localDate.fromDateInUTC is called on Date object that is invalid',
)
return new LocalDate(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())
}
fromDateObject(o: DateObject): LocalDate {
this.validateDateObject(o)
return new LocalDate(o.year, o.month, o.day)
}
/**
* Sorts an array of LocalDates in `dir` order (ascending by default).
*/
sort(items: LocalDate[], opt: SortOptions = {}): LocalDate[] {
const mod = opt.dir === 'desc' ? -1 : 1
return (opt.mutate ? items : [...items]).sort((a, b) => a.compare(b) * mod)
}
/**
* Returns the earliest (min) LocalDate from the array, or undefined if the array is empty.
*/
minOrUndefined(items: LocalDateInputNullable[]): LocalDate | undefined {
let min: LocalDate | undefined
for (const item of items) {
if (!item) continue
const ld = this.fromInput(item)
if (!min || ld.isBefore(min)) {
min = ld
}
}
return min
}
/**
* Returns the earliest LocalDate from the array.
* Throws if the array is empty.
*/
min(items: LocalDateInputNullable[]): LocalDate {
const min = this.minOrUndefined(items)
_assert(min, 'localDate.min called on empty array')
return min
}
/**
* Returns the latest (max) LocalDate from the array, or undefined if the array is empty.
*/
maxOrUndefined(items: LocalDateInputNullable[]): LocalDate | undefined {
let max: LocalDate | undefined
for (const item of items) {
if (!item) continue
const ld = this.fromInput(item)
if (!max || ld.isAfter(max)) {
max = ld
}
}
return max
}
/**
* Returns the latest LocalDate from the array.
* Throws if the array is empty.
*/
max(items: LocalDateInputNullable[]): LocalDate {
const max = this.maxOrUndefined(items)
_assert(max, 'localDate.max called on empty array')
return max
}
/**
* Returns the range (array) of LocalDates between min and max.
* By default, min is included, max is excluded.
*/
range(
min: LocalDateInput,
max: LocalDateInput,
incl: Inclusiveness,
step = 1,
stepUnit: LocalDateUnit = 'day',
): LocalDate[] {
return this.rangeIterable(min, max, incl, step, stepUnit).toArray()
}
/**
* Returns the Iterable2 of LocalDates between min and max.
* By default, min is included, max is excluded.
*/
rangeIterable(
min: LocalDateInput,
max: LocalDateInput,
incl: Inclusiveness,
step = 1,
stepUnit: LocalDateUnit = 'day',
): Iterable2<LocalDate> {
if (stepUnit === 'week') {
step *= 7
stepUnit = 'day'
}
const $min = this.fromInput(min).startOf(stepUnit)
const $max = this.fromInput(max).startOf(stepUnit)
let value = $min
if (value.isSameOrAfter($min)) {
// ok
} else {
value.plus(1, stepUnit, { mutate: true })
}
const rightInclusive = incl[1] === ']'
return Iterable2.of({
*[Symbol.iterator]() {
while (value.isBefore($max, rightInclusive)) {
yield value
// We don't mutate, because we already returned `current`
// in the previous iteration
value = value.plus(step, stepUnit)
}
},
})
}
getYearLength(year: number): number {
return this.isLeapYear(year) ? 366 : 365
}
getMonthLength(year: number, month: number): number {
if (month === 2) return this.isLeapYear(year) ? 29 : 28
return MDAYS[month]!
}
isLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
}
}
interface LocalDateFn extends LocalDateFactory {
(d: LocalDateInput): LocalDate
}
const localDateFactory = new LocalDateFactory()
export const localDate = localDateFactory.fromInput.bind(localDateFactory) as LocalDateFn
// The line below is the blackest of black magic I have ever written in 2024.
// And probably 2023 as well.
Object.setPrototypeOf(localDate, localDateFactory)