UNPKG

@naturalcycles/js-lib

Version:

Standard library for universal (browser + Node.js) javascript

721 lines (720 loc) 22.3 kB
import { _assert } from '../error/assert.js'; import { Iterable2 } from '../iter/iterable2.js'; import { localTime, VALID_DAYS_OF_WEEK } from './localTime.js'; 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)$/; /** * LocalDate represents a date without time. * It is timezone-independent. */ export class LocalDate { year; month; day; constructor(year, month, day) { this.year = year; this.month = month; this.day = day; } get(unit) { return unit === 'year' ? this.year : unit === 'month' ? this.month : this.day; } set(unit, v, opt = {}) { 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) { return this.set('year', v); } setMonth(v) { return this.set('month', v); } setDay(v) { return this.set('day', v); } get dayOfWeek() { return (this.toDate().getDay() || 7); } /** * 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) { _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) { _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) { d = localDate.fromInput(d); return this.day === d.day && this.month === d.month && this.year === d.year; } isBefore(d, inclusive = false) { const r = this.compare(d); return r === -1 || (r === 0 && inclusive); } isSameOrBefore(d) { return this.compare(d) <= 0; } isAfter(d, inclusive = false) { const r = this.compare(d); return r === 1 || (r === 0 && inclusive); } isSameOrAfter(d) { return this.compare(d) >= 0; } isBetween(min, max, incl) { 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, unit, today) { 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, unit, today) { 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, unit, today) { 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, unit, today) { return this.isSameOrAfter(localDate.fromInput(today || new Date()).plus(-n, unit)); } isToday() { return this.isSame(localDate.today()); } isAfterToday() { return this.isAfter(localDate.today()); } isSameOrAfterToday() { return this.isSameOrAfter(localDate.today()); } isBeforeToday() { return this.isBefore(localDate.today()); } isSameOrBeforeToday() { return this.isSameOrBefore(localDate.today()); } getAgeInYears(today) { return this.getAgeIn('year', today); } getAgeInMonths(today) { return this.getAgeIn('month', today); } getAgeInDays(today) { return this.getAgeIn('day', today); } getAgeIn(unit, today) { 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) { 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, unit) { 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, unit) { 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) { return this.plus(num, 'day'); } plusWeeks(num) { return this.plus(num, 'week'); } plusMonths(num) { return this.plus(num, 'month'); } plusYears(num) { return this.plus(num, 'year'); } minusDays(num) { return this.plus(-num, 'day'); } minusWeeks(num) { return this.plus(-num, 'week'); } minusMonths(num) { return this.plus(-num, 'month'); } minusYears(num) { return this.plus(-num, 'year'); } plus(num, unit, opt = {}) { 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, unit, opt = {}) { return this.plus(-num, unit, opt); } startOf(unit) { 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) { 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() { return localDate.getMonthLength(this.year, this.month); } clone() { 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() { 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() { return new Date(this.toISODateTimeInUTC()); } toDateObject() { 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() { return localTime.fromDate(this.toDate()); } /** * Returns e.g: `1984-06-21` */ toISODate() { return [ String(this.year).padStart(4, '0'), String(this.month).padStart(2, '0'), String(this.day).padStart(2, '0'), ].join('-'); } /** * Returns e.g: `1984-06` */ toISOMonth() { return this.toISODate().slice(0, 7); } /** * Returns e.g: `1984-06-21T00:00:00` * Hours, minutes and seconds are 0. */ toISODateTime() { return (this.toISODate() + 'T00:00:00'); } /** * Returns e.g: `1984-06-21T00:00:00Z` (notice the Z at the end, which indicates UTC). * Hours, minutes and seconds are 0. */ toISODateTimeInUTC() { return (this.toISODateTime() + 'Z'); } toString() { return this.toISODate(); } /** * Returns e.g: `19840621` */ toStringCompact() { 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() { return Math.floor(this.toDate().valueOf() / 1000); } /** * Same as .unix(), but in milliseconds. */ get unixMillis() { return this.toDate().valueOf(); } toJSON() { return this.toISODate(); } format(fmt) { 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) { return d ? this.fromInput(d) : undefined; } /** * Creates a LocalDate from the input, unless it's falsy - then returns localDate.today. */ orToday(d) { return d ? this.fromInput(d) : this.today(); } /** * Creates LocalDate that represents `today` (in local timezone). */ today() { return this.fromDate(new Date()); } /** * Creates LocalDate that represents `today` in UTC. */ todayInUTC() { return this.fromDateInUTC(new Date()); } /** Convenience function to return current today's IsoDate representation, e.g `2024-06-21` */ todayString() { 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) { 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) { 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) { 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) { 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) { return this.parseToLocalDate(DATE_REGEX, s); } /** * Parses "compact iso8601 format", e.g `19840621` into LocalDate. * Throws if it fails to do so. */ fromCompactString(s) { return this.parseToLocalDate(COMPACT_DATE_REGEX, s); } /** * Throws if it fails to parse the input string via Regex and YMD validation. */ parseToLocalDate(regex, s) { 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. */ parseToLocalDateOrUndefined(regex, s) { if (!s || typeof s !== '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. */ validateDateObject(o) { _assert(this.isDateObjectValid(o), `Cannot construct LocalDate from: ${o.year}-${o.month}-${o.day}`); } isDateObjectValid({ year, month, day }) { 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) { _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) { _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) { 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, opt = {}) { 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) { let min; 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) { 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) { let max; 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) { 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, max, incl, step = 1, stepUnit = 'day') { 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, max, incl, step = 1, stepUnit = 'day') { 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) { return this.isLeapYear(year) ? 366 : 365; } getMonthLength(year, month) { if (month === 2) return this.isLeapYear(year) ? 29 : 28; return MDAYS[month]; } isLeapYear(year) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } } const localDateFactory = new LocalDateFactory(); export const localDate = localDateFactory.fromInput.bind(localDateFactory); // The line below is the blackest of black magic I have ever written in 2024. // And probably 2023 as well. Object.setPrototypeOf(localDate, localDateFactory);