# calendar-date

A calendar date is a date without time information, e.g. "2020-01-01".
This library provides an immutable object to represent and work with a calendar date.

[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![npm version](https://badge.fury.io/js/calendar-date.svg)](https://badge.fury.io/js/calendar-date)
[![Coverage Status](https://coveralls.io/repos/github/gastromatic/calendar-date/badge.svg?branch=main)](https://coveralls.io/github/gastromatic/calendar-date?branch=main)

## Why another date library?

There are a lot of date libraries in the javascript world, but there is currently no adequate support for representing a calendar date.
Most SQL Databases like MySQL and PostgreSQL have a datatype for representing a date without time information.

Using the built-in date object for a calendar date poses a number of disadvantages:
- If you are using typescript you can not tell if the object is representing a timestamp, or a calendar date by looking at the type definition.
- If you want to compare two date objects it is harder to do it without knowing if the time and timezone information are the same.
- Overhead by including time and timezone information in the representation.
- Error prone when working with different timezones and converting from and to Strings.
  For example `new Date(2020, 0, 1)` and `new Date('2020-01-01')` return different Date objects depending on your local timezone.
  If you call `toISOString()` on both date objects based on a timezone of UTC+1 it would return `2019-12-31T23:00:00.000Z` for the first and `2020-01-01T00:00:00.000Z` for the second.
  
## Features

- CalendarDate and CalendarDateRange
- Immutable API
- Written completely in typescript
- No external dependencies
- Tiny package size
- Fast: the core date arithmetic uses integers instead of `Date` objects

## Getting Started

Install with bun, npm or yarn:

```sh
bun add calendar-date
```

or

```sh
npm install calendar-date
```

or

```sh
yarn add calendar-date
```

## Documentation

### CalendarDate

You can construct a CalendarDate from a String `YYYY-MM-DD` according to ISO 8601 or the year, month and day values.
The following constructor calls return the same calendar date.

```typescript
new CalendarDate('2020-01-01');
new CalendarDate(2020, 1, 1);
```

The constructor throws an Error for invalid inputs: strings that don't follow the pattern `YYYY-MM-DD`,
year, month or day values that are not integers, years outside of 1 to 9999 and days that don't exist in the month.

You can also get the current UTC CalendarDate, the current local CalendarDate based on the timezone of your local environment or a CalendarDate for a specific time zone with the static methods `nowUTC`, `nowLocal` and `nowTimeZone`.

```typescript
CalendarDate.nowUTC();
CalendarDate.nowLocal();
CalendarDate.nowTimeZone('Europe/Berlin');
```

If you want to construct a CalendarDate from an existing Date object instead of the current Time you can use the static methods `fromDateUTC`, `fromDateLocal` and `fromDateWithTimeZone`.
```typescript
const date = new Date();
CalendarDate.fromDateUTC(date);
CalendarDate.fromDateLocal(date);
CalendarDate.fromDateWithTimeZone(date, 'Europe/Berlin');
```

The year, month, day and unix timestamp can be accessed as read-only properties on the object.
Since version 2 of calendar-date the month value is now in the range of 1 to 12 and not of 0 to 11 anymore.

To compare to CalendarDate objects you can use the `equals`, `isBefore`, `isAfter`, `isBeforeOrEqual` and `isAfterOrEqual` 
function or`>=`, `<=`, `>` and `<` operators.
The comparison works based on the unix timestamp.
In performance-sensitive code prefer the methods: the operators make JavaScript convert both objects
to numbers on every comparison, which is several times slower than `isBefore` and the other methods.

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-01-01');
const date3 = new CalendarDate('2020-02-02');

date1.equals(date2)   // true
date1 === date2       // false
date1.equals(date3)   // false
date1.isBefore(date3) // true
date1.isAfter(date3)  // false
date1.isBeforeOrEqual(date2) // true
date1.isAfterOrEqual(date2)  // false
date1 >= date2        // true
date1 > date2         // false
date1 >= date3        // false
date1 <= date3        // true
```

#### isSameYear
Returns true if the CalendarDate is in the same year as the other CalendarDate.

```typescript
new CalendarDate('2020-01-01').isSameYear(new CalendarDate('2020-12-31')); // true
new CalendarDate('2020-01-01').isSameYear(new CalendarDate('2021-01-01')); // false
```

#### isSameMonth
Returns true if the CalendarDate is in the same month and year as the other CalendarDate.

```typescript
new CalendarDate('2020-01-01').isSameMonth(new CalendarDate('2020-01-15')); // true
new CalendarDate('2020-01-01').isSameMonth(new CalendarDate('2020-02-01')); // false
new CalendarDate('2020-01-01').isSameMonth(new CalendarDate('2021-01-01')); // false
```

#### addMonths, addDays
Returns a new CalendarDate with the specified amount of months or days added. The amount must be an integer, otherwise an Error is thrown.

If the day doesn't exist in the new month, `addMonths` returns the last day of that month. Pass `{ overflow: true }` to let the remaining days overflow into the next month instead.

```typescript
new CalendarDate('2020-01-01').addMonths(3);                       // 2020-04-01
new CalendarDate('2020-01-31').addMonths(1);                       // 2020-02-29
new CalendarDate('2020-01-31').addMonths(1, { overflow: true });   // 2020-03-02
new CalendarDate('2020-01-01').addDays(15);                        // 2020-01-16
```

#### addYears, addWeeks
Returns a new CalendarDate with the specified amount of years or weeks added. The amount must be an integer, otherwise an Error is thrown.
Adding years behaves like adding the equivalent amount of months, so the `overflow` option is supported as well.

```typescript
new CalendarDate('2020-06-15').addYears(2);                       // 2022-06-15
new CalendarDate('2020-02-29').addYears(1);                       // 2021-02-28
new CalendarDate('2020-02-29').addYears(1, { overflow: true });   // 2021-03-01
new CalendarDate('2020-01-01').addWeeks(2);                       // 2020-01-15
```

#### getFirstDayOfMonth, getLastDayOfMonth
Returns a new CalendarDate with the first or last day of the month.

```typescript
new CalendarDate('2020-01-15').getFirstDayOfMonth();    // 2020-01-01
new CalendarDate('2020-01-15').getLastDayOfMonth();     // 2020-01-31
```

#### isFirstDayOfMonth, isLastDayOfMonth
Returns true if the CalendarDate is the first day of the month / last day of the month;

```typescript
new CalendarDate('2020-01-15').isFirstDayOfMonth();   // false
new CalendarDate('2020-01-01').isFirstDayOfMonth();   // true
new CalendarDate('2020-01-30').isLastDayOfMonth();    // false
new CalendarDate('2020-01-31').isLastDayOfMonth();    // true
```

#### getFirstDayOfYear, getLastDayOfYear
Returns a new CalendarDate with the first or last day of the year.

```typescript
new CalendarDate('2020-06-15').getFirstDayOfYear();    // 2020-01-01
new CalendarDate('2020-06-15').getLastDayOfYear();     // 2020-12-31
```

#### getFirstDayOfQuarter, getLastDayOfQuarter
Returns a new CalendarDate with the first or last day of the quarter.

```typescript
new CalendarDate('2020-05-15').getFirstDayOfQuarter();    // 2020-04-01
new CalendarDate('2020-05-15').getLastDayOfQuarter();     // 2020-06-30
```

#### isFirstDayOfYear, isLastDayOfYear
Returns true if the CalendarDate is the first day of the year / last day of the year.

```typescript
new CalendarDate('2020-01-01').isFirstDayOfYear();   // true
new CalendarDate('2020-01-02').isFirstDayOfYear();   // false
new CalendarDate('2020-12-31').isLastDayOfYear();    // true
new CalendarDate('2020-12-30').isLastDayOfYear();    // false
```

#### isFirstDayOfQuarter, isLastDayOfQuarter
Returns true if the CalendarDate is the first day of the quarter / last day of the quarter.

```typescript
new CalendarDate('2020-04-01').isFirstDayOfQuarter();   // true
new CalendarDate('2020-04-02').isFirstDayOfQuarter();   // false
new CalendarDate('2020-06-30').isLastDayOfQuarter();    // true
new CalendarDate('2020-06-29').isLastDayOfQuarter();    // false
```

#### getDifferenceInDays
Returns the difference in days between to CalendarDate objects.
It will subtract the input date from the base date. With `{ absolute: true }` it never returns a negative value.

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-02-01');
date1.getDifferenceInDays(date2);                       // -31
date1.getDifferenceInDays(date2, { absolute: true });   // 31
```

#### max, min
Returns the max/min CalendarDate for an array of CalendarDates.

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-06-10');
const date3 = new CalendarDate('2021-03-15');
const maxDate = CalendarDate.max(date1, date2, date3); // 2021-03-15
const minDate = CalendarDate.min(date1, date2, date3); // 2020-01-01
```

#### MIN_VALUE, MAX_VALUE
Static constants for the minimum (`0001-01-01`) and maximum (`9999-12-31`) CalendarDate.
They are intended as sentinel values instead of null, for example to model a date range with an open start or open end.
The constants are the minimum and maximum representable CalendarDate values, and both are safe to store in common SQL databases: `0001-01-01` is the minimum DATE in SQL Server and well within PostgreSQL's supported range, which has no year zero.

```typescript
// Model a validity period with an open end without nullable fields
const validity = new CalendarDateRange(new CalendarDate('2024-01-01'), CalendarDate.MAX_VALUE);
validity.includes(CalendarDate.nowUTC());   // true for any date from 2024-01-01 on

// Model a validity period with an open start
new CalendarDateRange(CalendarDate.MIN_VALUE, new CalendarDate('2023-12-31'));
```

#### sortAscending, sortDescending
Returns a sorted copy of an array of CalendarDates.

```typescript
const sortedArrayAscending = CalendarDate.sortAscending([new CalendarDate('2020-01-01'), new CalendarDate('2020-06-10'), new CalendarDate('2021-03-15')]);
const sortedArrayDescending = CalendarDate.sortDescending([new CalendarDate('2020-01-01'), new CalendarDate('2020-06-10'), new CalendarDate('2021-03-15')]);
```

#### toFormat
Returns the formatted string based on a provided pattern.

```typescript
const date = new CalendarDate('2020-06-01');
date.toFormat('dd.MM.yy');     // 01.06.20
date.toFormat('d-M-yy');       // 1-6-20
date.toFormat('yyyy/dd/MM');   // 2020/01/06
```

Text in single quotes is kept as is, so letters like `d` or `y` can be used in literal text. Two single quotes stand for one, inside or outside of quoted text. Quoted text without a closing quote throws an Error.

```typescript
date.toFormat("dd 'de' MM");            // 01 de 06
date.toFormat("'day' d 'of' yyyy");     // day 1 of 2020
date.toFormat("dd.MM.''yy");            // 01.06.'20
date.toFormat("'o''clock' d");          // o'clock 1
```

You can also supply a locale and options object from the [Intl DateTimeFormat Api](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat).
    
```typescript
const date = new CalendarDate('2020-06-01');
date.toFormat('en', { year: 'numeric', month: 'numeric', day: 'numeric' });     // 6/1/2020
date.toFormat('en', { weekday: 'long', month: 'short', day: 'numeric' });     // Monday, Jun 1
```

#### WeekOfYear (ISO-8601)
You can access the week of year according to ISO 8601 as a read only property on the calendar date object.
```typescript
new CalendarDate('2023-01-01').week;    // 52 (of year 2022)
new CalendarDate('2023-01-02').week;    // 1 (of year 2023)
```

#### WeekYear (ISO-8601)
You can access the week-numbering year according to ISO 8601 as a read only property on the calendar date object.
Around the turn of the year it can differ from the calendar year, because the first and last days of a year can belong to a week of the previous or next year.
```typescript
new CalendarDate('2023-01-01').weekYear;    // 2022 (week 52 of 2022)
new CalendarDate('2025-12-29').weekYear;    // 2026 (week 1 of 2026)
new CalendarDate('2020-06-15').weekYear;    // 2020 (mid-year dates always match the calendar year)
```

#### DayOfTheWeek
You can access the weekday according to ISO 8601 as a read only property on the calendar date object. The values are in the range from 1 to 7 starting from Monday,
typed as the `DayOfTheWeek` enum.
```typescript
new CalendarDate('2020-01-13').weekday;    // 1 (DayOfTheWeek.MONDAY)
new CalendarDate('2020-01-19').weekday;    // 7 (DayOfTheWeek.SUNDAY)
new CalendarDate('2020-01-19').weekday === DayOfTheWeek.SUNDAY; // true
```

#### getFirstDayOfWeek, getLastDayOfWeek
Returns a new CalendarDate with the first or last day of the week.

```typescript
new CalendarDate('2020-01-15').getFirstDayOfWeek();    // 2020-01-13 (Monday)
new CalendarDate('2020-01-15').getLastDayOfWeek();     // 2020-01-19 (Sunday)
```

#### isFirstDayOfWeek, isLastDayOfWeek
Returns true if the CalendarDate is the first day of the week / last day of the week;

### CalendarDateRange
You need two CalendarDate objects to construct a new CalendarDateRange.
The first CalendarDate needs to be before or equal to the second CalendarDate.
With `{ autoArrange: true }` the constructor puts the passed CalendarDates in the correct order.

```typescript
const start = new CalendarDate('2020-01-01');
const end = new CalendarDate('2020-12-31');
new CalendarDateRange(start, end);

// OR

new CalendarDateRange(end, start, { autoArrange: true });

// This will throw an error
new CalendarDateRange(end, start)
```

You can also parse a CalendarDateRange from a String representation with the format `YYYY-MM-DD/YYYY-MM-DD`:
```typescript
CalendarDateRange.parse('2020-01-01/2020-12-31');
```
Start and end CalendarDates can be accessed as read-only properties on the object.

To compare a CalendarDateRange you can use the `equals` method.
It will return true if the start and end date represent the same CalendarDate for both CalendarDateRange objects.

#### includes
Can be used to check if a CalendarDateRange or a CalendarDate is included in the Interval defined by the base CalendarDateRange.
By default, start and end CalendarDates are included in the calculation, but you can omit them with the options parameter:

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-02-01');
const date3 = new CalendarDate('2020-03-01');

const dateRange = new CalendarDateRange(date1, date3);

dateRange.includes(date1);                          // true
dateRange.includes(date1, { excludeStart: true});   // false
dateRange.includes(date2);                          // true
dateRange.includes(date2, { excludeEnd: true});     // false
dateRange.includes(date3);                          // true
```

#### getTotalDays
Returns the total amount of days in the CalendarDateRange, including the start and end date.

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-01-02');
new CalendarDateRange(date1, date2).getTotalDays();  // 2
```

#### getDifferenceInDays
Returns the difference in days between start and end of the CalendarDateRange.

```typescript
const date1 = new CalendarDate('2020-01-01');
const date2 = new CalendarDate('2020-02-01');
new CalendarDateRange(date1, date2).getDifferenceInDays();  // 31
```

#### getDifferenceInMonths
Returns the difference in months between start and end of the CalendarDateRange as an integer, ignoring the day values.

```typescript
const date1 = new CalendarDate('2020-05-15');
const date2 = new CalendarDate('2022-02-01');
new CalendarDateRange(date1, date2).getDifferenceInMonths();  // 21
```

#### hasOverlap
For an array of CalendarDateRanges it returns true if any two ranges overlap.
The ranges are sorted by start date and each range is compared with the one before it, which finds every overlap. Ranges that cover no days because of `excludeStart` or `excludeEnd`, e.g. a single-day range with `excludeEnd`, can't overlap and are ignored. The input array is not modified.

```typescript
import { CalendarDateRange } from 'calendar-date';

const dateRange1 = new CalendarDateRange(new CalendarDate('2020-01-01'), new CalendarDate('2020-12-31'));
const dateRange2 = new CalendarDateRange(new CalendarDate('2020-06-01'), new CalendarDate('2021-06-01'));
const dateRange3 = new CalendarDateRange(new CalendarDate('2021-01-01'), new CalendarDate('2021-12-31'));
CalendarDateRange.hasOverlap([dateRange1, dateRange2]);  // true
CalendarDateRange.hasOverlap([dateRange1, dateRange3]);  // false
```

#### hasGaps
For an array of CalendarDateRanges it returns true if there is a gap between two neighbouring ranges.
The ranges are sorted by start date, and each range is compared only with the one before it. A range inside a longer range is still a neighbour, so a gap after it counts even if the longer range covers those days. The input array is not modified.

```typescript
import { CalendarDateRange } from 'calendar-date';

const dateRange1 = new CalendarDateRange(new CalendarDate('2020-01-01'), new CalendarDate('2020-12-31'));
const dateRange2 = new CalendarDateRange(new CalendarDate('2020-06-01'), new CalendarDate('2021-06-01'));
const dateRange3 = new CalendarDateRange(new CalendarDate('2021-01-01'), new CalendarDate('2021-12-31'));
const dateRange4 = new CalendarDateRange(new CalendarDate('2021-01-02'), new CalendarDate('2021-12-31'));
CalendarDateRange.hasGaps([dateRange1, dateRange2]);  // false
CalendarDateRange.hasGaps([dateRange1, dateRange3]);  // false
CalendarDateRange.hasGaps([dateRange1, dateRange4]);  // true

// The gap between February and March counts, although the whole year covers it.
const year = new CalendarDateRange(new CalendarDate('2020-01-01'), new CalendarDate('2020-12-31'));
const february = new CalendarDateRange(new CalendarDate('2020-02-01'), new CalendarDate('2020-02-05'));
const march = new CalendarDateRange(new CalendarDate('2020-03-01'), new CalendarDate('2020-03-05'));
CalendarDateRange.hasGaps([year, february, march]);  // true
```

### Iteration Methods

#### iterateDatesInRange

Creates an iterator that yields each `CalendarDate` in the range from start to end.

```typescript
import { CalendarDateRange } from 'calendar-date';

const range = new CalendarDateRange(new CalendarDate('2023-01-01'), new CalendarDate('2023-01-03'));

// Iterate through all dates (inclusive)
for (const date of range.iterateDatesInRange()) {
  console.log(date.toString()); // 2023-01-01, 2023-01-02, 2023-01-03
}

// Exclude start date
for (const date of range.iterateDatesInRange({ excludeStart: true })) {
  console.log(date.toString()); // 2023-01-02, 2023-01-03
}

// Exclude end date
for (const date of range.iterateDatesInRange({ excludeEnd: true })) {
  console.log(date.toString()); // 2023-01-01, 2023-01-02
}

// Exclude both start and end dates
for (const date of range.iterateDatesInRange({ excludeStart: true, excludeEnd: true })) {
  console.log(date.toString()); // 2023-01-02
}
```

The range itself is iterable as well and yields the same dates as `iterateDatesInRange()` without options:

```typescript
for (const date of range) {
  console.log(date.toString()); // 2023-01-01, 2023-01-02, 2023-01-03
}

const dates = [...range]; // same as range.toDatesArrayInRange()
```

#### toDatesArrayInRange

Returns an array of all `CalendarDate` objects in the range.

```typescript
import { CalendarDateRange } from 'calendar-date';

const range = new CalendarDateRange(new CalendarDate('2023-01-01'), new CalendarDate('2023-01-03'));

// Get all dates as array (inclusive)
const allDates = range.toDatesArrayInRange();
console.log(allDates.length); // 3

// Get dates excluding start
const datesExcludingStart = range.toDatesArrayInRange({ excludeStart: true });
console.log(datesExcludingStart.length); // 2

// Get dates excluding end
const datesExcludingEnd = range.toDatesArrayInRange({ excludeEnd: true });
console.log(datesExcludingEnd.length); // 2

// Get dates excluding both boundaries
const innerDates = range.toDatesArrayInRange({ excludeStart: true, excludeEnd: true });
console.log(innerDates.length); // 1
```

### Immutability
All methods return new instances instead of changing the existing one, and the properties are `readonly` in TypeScript.
Since version 3 the instances are no longer frozen with `Object.freeze`, which made creating them about twice as expensive.

## Migration from v2 to v3
- The constructor throws an Error if year, month or day is not an integer. Before, such inputs created an instance with a `NaN` timestamp. This also applies to `fromDateUTC` and `fromDateLocal` with an invalid `Date`, e.g. `new Date(NaN)`.
- `addDays`, `addWeeks`, `addMonths` and `addYears` throw an Error if the amount is not an integer.
- The deprecated `toDate` method was removed, use `toDateUTC` instead.
- `toFormat(locale, options)` formats the date in UTC, so the output no longer depends on the process time zone. A `timeZone` in the options, which the TypeScript type doesn't allow, is ignored. Before, it was applied to local midnight and could print the previous or next day.
- `toFormat(pattern)` treats single quotes as quoted literal text, see [toFormat](#toformat). Escape a literal single quote as `''`. Before, quotes were printed as they were and letters inside of text were replaced by date values.
- `addMonths` and `addYears` return the last day of the month if the day doesn't exist in the new month, e.g. `2020-01-31` plus one month is `2020-02-29`. Before, the remaining days overflowed into the next month (`2020-03-02`). Pass `{ overflow: true }` to keep the old behaviour.
- The boolean parameters became options objects, so calls say what they do: `addMonths(n, true)` and `addYears(n, true)` are now the default, and `addMonths(n, false)` becomes `addMonths(n, { overflow: true })`. `getDifferenceInDays(date, true)` becomes `getDifferenceInDays(date, { absolute: true })` and `new CalendarDateRange(start, end, true)` becomes `new CalendarDateRange(start, end, { autoArrange: true })`. In plain JavaScript a boolean is ignored, so check these calls when you upgrade.
- `weekday` is typed as `DayOfTheWeek` instead of `number`. Its values don't change, but TypeScript now rejects comparisons with numbers outside of 1 to 7.
- CalendarDate and CalendarDateRange instances are no longer frozen with `Object.freeze`. Assigning to a property doesn't throw anymore, but TypeScript still rejects it because the properties are `readonly`.

## Migration from v1 to v2
If you want to upgrade from v1 to v2 of calendar-date you need to check if you access the read only month property on the calendar date object or use the number constructor anywhere and change the code according to the change (Range of month changed from 0-11 to 1-12). All other functions and properties work the same as before.

## License

This project is licensed under the [MIT License](LICENSE).
