# piso

[![Build](https://github.com/zerodep/piso/actions/workflows/build.yaml/badge.svg)](https://github.com/zerodep/piso/actions/workflows/build.yaml)[![Coverage Status](https://coveralls.io/repos/github/zerodep/piso/badge.svg?branch=main)](https://coveralls.io/github/zerodep/piso?branch=main)

ISO 8601 date, duration, and interval parsing package as declared on [Wikipedia ISO 8601](https://en.wikipedia.org/wiki/ISO_8601).

> In Spain, piso refers to the whole apartment, whereas in Mexico, it refers only to the floor of your departamento.
> But the above has nothing to do with this project.

## Contents

- [Api](#api)
  - [`parseInterval(iso8601Interval[, enforceUTC])`](#parseintervaliso8601interval-enforceutc)
  - [`parseDuration(iso8601Duration)`](#parsedurationiso8601duration)
  - [`getDate(iso8601Date[, enforceUTC])`](#getdateiso8601date-enforceutc)
  - [`getISOWeekString([date])`](#getisoweekstringdate)
  - [`getUTCWeekNumber([date])`](#getutcweeknumberdate)
  - [`getUTCLastWeekOfYear(Y)`](#getutclastweekofyeary)
  - [`getUTCWeekOneDate(Y)`](#getutcweekonedatey)

## Api

### `parseInterval(iso8601Interval[, enforceUTC])`

Parse interval from an ISO 8601 interval string.

- `iso8601Interval`: string with ISO 8601 interval source
- `enforceUTC`: optional boolean, enforce UTC if source lacks time zone offset

Returns [ISOInterval](#new-isointervalsource-enforceutc).

```javascript
import { parseInterval, ISOInterval } from '@0dep/piso';

const viableIntervals = [
  '2007-03-01/2007-04-01',
  'P2Y/2007-03-01T13:00:00Z',
  '2007-03-01T13:00:00Z/P2Y',
  'R5/P1Y/2025-05-01T13:00:00Z',
  'R-1/2009-07-01T00:00Z/P1M',
  'R-1/1972-07-01T00:02Z/PT1H3M',
  'R-1/P1M/2024-07-27T00:00Z',
  '2007-318/2007-319',
  '2007-318/319T24:00:00Z',
];

for (const i of viableIntervals) {
  console.log({ [i]: parseInterval(i).getExpireAt(), utc: parseInterval(i, true).getExpireAt() });
}
```

### `parseDuration(iso8601Duration)`

Parse duration from an ISO 8601 duration string.

- `iso8601Duration`: string with ISO 8601 duration source

Returns [ISODuration](#new-isodurationsource-offset).

```javascript
import { parseDuration } from '@0dep/piso';

const viableDurations = [
  'PT1M5S',
  'PT1M0.5S',
  'PT0.5S',
  'PT0.01S',
  'PT0.001S',
  'PT0.0001S',
  'PT0.5M',
  'PT0.5H',
  'PT1.5H',
  'P0.5D',
  'P1W',
  'P0.5W',
  'P0.5M',
  'P0.5D',
  'P1Y',
  'P1Y2M3W4DT5H6M7S',
  'PT0S',
  'P0D',
];

for (const d of viableDurations) {
  console.log({ [d]: parseDuration(d).getExpireAt() });
}

try {
  // fractions are only allowed on the smallest unit
  parseDuration('P0.5YT3S');
} catch (err) {
  console.log({ err });
}
```

### `getDate(iso8601Date[, enforceUTC])`

Get Date from an ISO 8601 date time string.

- `iso8601Date`: string with ISO 8601 date source, date and number are also accepted
- `enforceUTC`: optional boolean, enforce UTC if source lacks time zone offset

Returns date.

```javascript
import { getDate } from '@0dep/piso';

const viableDates = [
  '2024-01-27',
  '2024-02-28',
  '2024-02-29',
  '2020-02-29',
  '2016-02-29',
  '2024-W03-2',
  '2024-01',
  '2024-12',
  '20240127',
  '2024-012',
  '2024012',
  '2024-012T08:06:30',
  '2024-02-27T08:06:30',
  '2024-02-27T08:06:30.001',
  '2024-02-27T08:06:30.0011',
  '2024-02-27T08:06:30.0',
  '2024-02-27T08:06:30,001',
  '2024-02-27T08:06:30Z',
  '2024-02-03T08:06:30+02:00',
  '2024-02-03T08:06:30.5+02:00',
  '20240203T080630+0200',
  '2024-02-03T08:06:30-02:30',
  '2024-02-03T08:06:30-02',
  '2025-01-01T12:00:42.01-02:00',
  '2025-01-01T12:00:42.01+02:30',
  '2025-01-01T12:00:42.01+02:30:30',
  '2025-01-01T23:59',
  '2025-01-01T24:00',
  '2025-01-01T24:00:00',
  '2025-01-01T24:00:00.000',
  '2025-01-01T24:00Z',
  '2025-01-01T24:00+01',
  '2025-01-01T24:00:00+01',
  '2025-01-01T24:00:00.00+01',
  '20240127T1200',
  '20240127T120001',
  '20240127T120001,001',
  '2024',
  '+102024',
  '-00000012',
  new Date(2024, 3, 22),
  0,
  Date.UTC(2024, 3, 22),
];

for (const d of viableDates) {
  console.log({ [d]: getDate(d), utc: getDate(d, true) });
}

try {
  getDate('2023-02-29');
} catch (err) {
  console.log({ err });
}

try {
  // not this year
  getDate('2023-W53-1T12:00');
} catch (err) {
  console.log({ err });
}

try {
  // unbalanced separators
  getDate('2023-02-28T1200');
} catch (err) {
  console.log({ err });
}
```

> NB! string without timezone precision is considered local date, or as Wikipedia put it "If no UTC relation information is given with a time representation, the time is assumed to be in local time". Unless, of course, enforce UTC instruction is used.

### `getUTCLastWeekOfYear(Y)`

Get last week of year

- `Y`: full year

Returns 52 or 53.

```javascript
import { getUTCLastWeekOfYear } from '@0dep/piso';

console.log('last week number', getUTCLastWeekOfYear(2024));
```

### `getUTCWeekOneDate(Y)`

Get Monday week one date

- `Y`: full year

Returns date Monday week one

```javascript
import { getUTCWeekOneDate } from '@0dep/piso';

console.log('Monday week one', getUTCWeekOneDate(2021));
```

### `getISOWeekString([date])`

Get ISO week date string from date.

- `date`: optional date, defaults to now

```javascript
import { getISOWeekString } from '@0dep/piso';

console.log('date as week', getISOWeekString(new Date(2021, 11, 28)));
```

### `getUTCWeekNumber([date])`

Get weeknumber from date.

- `date`: optional date, defaults to now

Returns:

- `Y`: full year representation of week date
- `W`: week number
- `weekday`:

```javascript
import { getUTCWeekNumber } from '@0dep/piso';

console.log(getUTCWeekNumber(new Date(2016, 0, 1)));
```

## `new ISOInterval(source[, enforceUTC])`

Interval instance.

**Constructor:**

- `source`: ISO8601 interval source
- `enforceUTC`: optional boolean, enforce UTC if source lacks time zone offset

**Properties:**

- `repeat`: number of repeats
- `start`: start date as [ISODate](#new-isodatesource-options)
- `duration`: duration as [ISODuration](#new-isodurationsource-offset)
- `end`: end date as [ISODate](#new-isodatesource-options)
- `type`: [interval type](#intervaltype)
- `get startDate`: start date as date, requires [parse()](#intervalparse) to be called
- `get endDate`: end date as date, requires [parse()](#intervalparse) to be called

### `interval.type`

Number representing the interval type flags. Available after [parse](#intervalparse).

- `1`: Repeat
- `2`: Start date
- `4`: Duration
- `8`: End date

**Example flags**

- `3`: Repeat and start date, rather pointless but possible nevertheless
- `5`: Repeat and duration
- `6`: Start date and duration
- `7`: Repeat, start date, and duration
- `10`: Start- and end date
- `12`: Duration and end date
- `13`: Repeat, duration, and end date

> Do I have repeat in my interval?

```javascript
import { parseInterval } from '@0dep/piso';

console.log((parseInterval('R3/P1Y').type & 1) === 1 ? 'Yes' : 'No');
// Yes

console.log((parseInterval('R-1/P1Y').type & 1) === 1 ? 'Yes' : 'No');
// Yes, indefinite number of repetititions

console.log((parseInterval('R-1/2024-03-27/P1Y').type & 1) === 1 ? 'Yes' : 'No');
// Yes, indefinite number of repetititions from start date

console.log((parseInterval('R-1/P1Y/2024-03-27').type & 1) === 1 ? 'Yes' : 'No');
// Yes, indefinite number of repetititions until end date

console.log((parseInterval('R0/P1Y').type & 1) === 1 ? 'Yes' : 'No');
// No, zero is equal to once

console.log((parseInterval('R1/P1Y').type & 1) === 1 ? 'Yes' : 'No');
// No, since it's just once

console.log((parseInterval('R1/2024-03-28').type & 1) === 1 ? 'Yes' : 'No');
// No, pointless repeat

console.log((parseInterval('R1/2024-03-28/31').type & 1) === 1 ? 'Yes' : 'No');
// No, pointless repeat

console.log((parseInterval('R1/P1Y/2024-03-28').type & 1) === 1 ? 'Yes' : 'No');
// No
```

> Is start date defined in my interval?

```javascript
import { parseInterval } from '@0dep/piso';

const interval = parseInterval('R-1/2024-03-28/P1Y');

console.log((interval.type | 2) === interval.type ? 'Yes' : 'No');
```

### `interval.parse()`

Returns [ISOInterval](#new-isointervalsource-enforceutc).

Throws `RangeError` if something is off.

### `interval.toJSON()`

Get interval represented as JavaScript Object Notation.

```javascript
import { ISOInterval } from '@0dep/piso';

console.log(JSON.stringify({ interval: new ISOInterval('R2/P1Y/2024-03-28') }, null, 2));
```

## `new ISODate(source[, options])`

ISO date instance.

**Constructor**:

- `source`: ISO 8601 date source string
- `options`: optional parsing options
  - `offset`: source string offset column number, -1 is default
  - `endChars`: string with optional characters that mark the end of the ISO date, e.g. `/`
  - `enforceSeparators`: boolean that will require time part separators such as `-` and `:`
  - `enforceUTC`: optional boolean, enforce UTC if source lacks time zone offset

**Properties:**

- `result`:
  - `Y`: full year
  - `M`: javascript month
  - `D`: date or ordinal day
  - `H`: hours
  - `m`: minutes
  - `S`: seconds
  - `F`: milliseconds
  - `Z`: Z, +, −, or -
  - `OH`: offset hours
  - `Om`: offset minutes
  - `OS`: offset seconds
  - `isValid`: boolean indicating if parse was successful

### `date.parse()`

### `date.parsePartialDate(Y, M, D, W)`

Parse partial date as compared to passed date part arguments.

- `Y`: required full year
- `M`: optional javascript month, required is not ordinal day
- `D`: required date, weekday (1 = Monday .. 7 = Sunday) if `W` is passed, or ordinal day
- `W`: optional week number, then `D` is the week day

Returns [ISODate](#new-isodatesource-options)

### `date.toDate([enforceUTC])`

Get Date represented by source.

- `enforceUTC`: optional boolean, enforce UTC if source lacks time zone offset

### `date.toJSON()`

Get Date represented as JavaScript Object Notation.

## `new ISODuration(source[, offset])`

Duration instance.

**Constructor**:

- `source`: duration source string
- `offset`: optional source string offset column number

**Properties:**

- `result`:
  - `Y`: years
  - `M`: months
  - `W`: weeks
  - `D`: days
  - `H`: hours
  - `m`: minutes
  - `S`: seconds

### `duration.toMilliseconds([startDate])`

Get duration in milliseconds from optional start date.

### `duration.untilMilliseconds([endDate])`

Get duration in milliseconds until optional end date.

## Example

An example to get start and end date:

```javascript
import { parseInterval } from '@0dep/piso';

const source = '2007-03-01T13:00:00Z/P1Y2M10DT2H30M';

const interval = parseInterval(source);

console.log('starts at', interval.getStartAt());
console.log('expires at', interval.getExpireAt());
console.log('duration milliseconds', interval.duration.toMilliseconds());
```

An example to get duration milliseconds:

```javascript
import { parseDuration } from '@0dep/piso';

const duration = parseDuration('PT2H30M');

console.log('duration millisecods', duration.toMilliseconds(new Date()));
```

## Repetitions

### With end date

`R4/P2Y/2007-08-01`

| Repetition | start at   | expire at  |
| ---------: | ---------- | ---------- |
|          4 | 1999-08-01 | 2001-08-01 |
|          3 | 2001-08-01 | 2003-08-01 |
|          2 | 2003-08-01 | 2005-08-01 |
|          1 | 2005-08-01 | 2007-08-01 |

## Benchmarking

Seems to run 3 times more efficient than RegExp implementations. But date parsing is, of course, slower compared to `new Date('2024-03-26')`. On the other hand `new Date('2024-03-26')` resolves to UTC while `new Date(2024, 2, 26)` does not. Not sure what to expect but IMHO `new Date('2024-03-26')` should be a local date.

### Interval

| Capability         | piso | luxon |
| ------------------ | ---- | ----- |
| start/end          | ✓    | ✓     |
| start/duration     | ✓    | ✓     |
| duration/end       | ✓    | ✓     |
| Repeating interval | ✓    | ❌    |
| Relative end date  | ✓    | ❌    |

### Duration

| Capability                        | piso | iso8601-duration | luxon | [temporal](https://www.npmjs.com/package/@js-temporal/polyfill) |
| --------------------------------- | ---- | ---------------- | ----- | --------------------------------------------------------------- |
| Fractional time designator        | ✓    | ✓                | ✓     | ✓                                                               |
| Invalid if more than one fraction | ✓    | ✓                | ✓     | ✓                                                               |
| Year designator                   | ✓    | ✓                | ✓     | ❌                                                              |
| Fractional date designator        | ✓    | ❌               | ✓     | ❌                                                              |
| Comma as fraction separator       | ✓    | ✓                | ❌    | ✓                                                               |
| Repeated duration instruction     | ✓    | ❌\*             | ❌    | ❌                                                              |

> \* ignored

### Date

| Capability                  | piso | luxon | node 24 |
| --------------------------- | ---- | ----- | ------- |
| The 24:th hour              | ✓    | ✓     | ✓       |
| Year +10000                 | ✓    | ✓     | ✓       |
| Year 9999                   | ✓    | ✓     | ✓       |
| Year only (`YYYY`)          | ✓    | ✓     | ✓       |
| BC dates                    | ✓    | ✓     | ✓       |
| Week                        | ✓    | ✓     | ❌      |
| Ordinal date                | ✓    | ✓     | ❌\*    |
| Without separators          | ✓    | ✓     | ❌      |
| Without offset minutes      | ✓    | ✓     | ❌      |
| Comma as fraction separator | ✓    | ✓     | ❌      |
| Throw on invalid leap year  | ✓    | ✓     | ❌\*\*  |
| Offset unicode minus (−)    | ✓    | ❌    | ❌      |
| Offset seconds              | ✓    | ❌    | ❌      |
| 36 fractions of a second    | ❌   | ❌    | ✓       |

> \* node misinterprets `2024-012` as December and fails when `2024-013` or `2024-012T07:30` is passed<br/>
> \*\* node is benevolent when parsing `2100-02-29` as `2100-03-01`
