@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
721 lines (720 loc) • 22.3 kB
JavaScript
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);