daymath 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -32
- package/index.d.ts +21 -0
- package/index.js +463 -167
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -155,8 +155,11 @@ Amounts are finite integers.
|
|
|
155
155
|
|
|
156
156
|
## Temporal
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
Built on [`temporal-polyfill/fns`](https://www.npmjs.com/package/temporal-polyfill), the functional
|
|
159
|
+
API, rather than the `Temporal` class. A class is one unit to a bundler, so the class build shipped
|
|
160
|
+
whole for the twenty-odd operations daymath uses; free functions drop what you do not call. It runs
|
|
161
|
+
on native `Temporal` where the runtime has it and on the bundled build elsewhere, and `fns` makes
|
|
162
|
+
that choice itself. Measured on a three-call program: **24.7 kB gzip to 16.3 kB, −34%.**
|
|
160
163
|
|
|
161
164
|
A `Temporal.PlainDate` from *any* implementation is accepted — native, the bundled polyfill,
|
|
162
165
|
or a second copy of it in the same dependency tree. daymath reads its ISO day and builds its
|
|
@@ -164,58 +167,159 @@ own instance, so it never depends on `instanceof` agreeing across copies. The co
|
|
|
164
167
|
`Temporal.Now.plainDateISO()`: daymath has no `today()` on purpose, so that is where a caller
|
|
165
168
|
gets one.
|
|
166
169
|
|
|
167
|
-
|
|
168
|
-
`PlainDate`, and it names the same *day* with different numbers:
|
|
170
|
+
### Calendars
|
|
169
171
|
|
|
170
|
-
|
|
171
|
-
|---|---|---|---|---|
|
|
172
|
-
| `iso8601` | 2026 | 1 | 31 | `2026-01-31` |
|
|
173
|
-
| `buddhist` | **2569** | 1 | 31 | `2026-01-31[u-ca=buddhist]` |
|
|
174
|
-
| `hebrew` | 5786 | 5 | 13 | `2026-01-31[u-ca=hebrew]` |
|
|
175
|
-
| `chinese` | 2025 | 13 | 13 | `2026-01-31[u-ca=chinese]` |
|
|
172
|
+
Temporal can put a calendar on a `PlainDate`. The same *day* then carries different numbers:
|
|
176
173
|
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
| calendar | year | month | day | `toString()` | daymath |
|
|
175
|
+
|---|---|---|---|---|---|
|
|
176
|
+
| `iso8601` | 2026 | 1 | 31 | `2026-01-31` | accepted |
|
|
177
|
+
| `buddhist` | **2569** | 1 | 31 | `2026-01-31[u-ca=buddhist]` | **accepted** |
|
|
178
|
+
| `roc` | **115** | 1 | 31 | `2026-01-31[u-ca=roc]` | **accepted** |
|
|
179
|
+
| `japanese` | 2026 | 1 | 31 | `2026-01-31[u-ca=japanese]` | **accepted** |
|
|
180
|
+
| `gregory` | 2026 | 1 | 31 | `2026-01-31[u-ca=gregory]` | **accepted** |
|
|
181
|
+
| `hebrew` | 5786 | **5** | **13** | `2026-01-31[u-ca=hebrew]` | refused |
|
|
182
|
+
| `chinese` | 2025 | **13** | **13** | `2026-01-31[u-ca=chinese]` | refused |
|
|
183
|
+
|
|
184
|
+
**daymath accepts a calendar that only relabels the year, and refuses one that renumbers.** The
|
|
185
|
+
line is measured at runtime, not held as a list, so a calendar CLDR adds later needs no code
|
|
186
|
+
change here. Two conditions, both required, on nine probe dates spanning 1900 to 2100:
|
|
187
|
+
|
|
188
|
+
1. **Month and day equal the ISO fields.** A month *count* would be wrong: `hebrew` is lunisolar,
|
|
189
|
+
so it has 12 months in 2025 and 13 in 2027.
|
|
190
|
+
2. **The year offset is constant.** Two probe pairs straddle a Japanese era boundary, because an
|
|
191
|
+
era change inside one ISO year is what separates a label from a renumbering.
|
|
192
|
+
|
|
193
|
+
For the accepting family only the year label moves, so every export still answers honestly, and
|
|
194
|
+
the annotation rides along:
|
|
179
195
|
|
|
180
196
|
```js
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
getYear('2026-01-31[u-ca=buddhist]') // 2569, not 2026
|
|
198
|
+
getMonth('2026-01-31[u-ca=buddhist]') // 1
|
|
199
|
+
addDays('2026-01-31[u-ca=buddhist]', 1) // '2026-02-01[u-ca=buddhist]'
|
|
200
|
+
setYear('2026-01-31[u-ca=buddhist]', 2570) // '2027-01-31[u-ca=buddhist]'
|
|
201
|
+
getYear('2026-01-31[u-ca=roc]') // 115
|
|
202
|
+
getYear('2026-01-31[u-ca=japanese]') // 2026
|
|
184
203
|
```
|
|
185
204
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
object says `2569`. So it throws, naming the calendar and the way out:
|
|
205
|
+
A renumbering calendar is refused, and the message names the calendar and the way out. A calendar
|
|
206
|
+
this runtime cannot build at all gets its own message, so a typo does not read as a renumbering
|
|
207
|
+
calendar:
|
|
190
208
|
|
|
191
209
|
```js
|
|
192
|
-
|
|
193
|
-
// RangeError: daymath: date
|
|
210
|
+
getMonth('2026-01-31[u-ca=hebrew]')
|
|
211
|
+
// RangeError: daymath: date calendar "hebrew" renumbers months or days, so daymath
|
|
212
|
+
// cannot answer a day in it (convert with withCalendar('iso8601'))
|
|
213
|
+
|
|
214
|
+
getYear('2026-01-31[u-ca=buddhst]')
|
|
215
|
+
// RangeError: daymath: date calendar "buddhst" is not a calendar this runtime knows
|
|
194
216
|
// (convert with withCalendar('iso8601'))
|
|
195
217
|
|
|
196
|
-
format(
|
|
218
|
+
format(hebrewDate.withCalendar('iso8601')) // '2026-01-31'
|
|
197
219
|
```
|
|
198
220
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
221
|
+
**A day is the same day whatever its year is labelled, so a mixed pair measures rather than
|
|
222
|
+
throwing.** Temporal's own `since` refuses this with `Mismatched calendars`, and its `equals`
|
|
223
|
+
compares the calendar as well as the day. Neither matters to a day count, so daymath normalises
|
|
224
|
+
both sides for measurement. Only the exports that read or write a field honour the label:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
differenceInDays('2026-03-01', '2026-01-31[u-ca=buddhist]') // 29
|
|
228
|
+
isEqual('2026-01-31[u-ca=buddhist]', '2026-01-31') // true
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**The object form is a different day, and that is Temporal's rule, not daymath's.** A string's
|
|
232
|
+
date part is always ISO; the annotation changes how fields are *read*, never how the string
|
|
233
|
+
*parses*:
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]') // ISO 2026-01-31, .year 2569
|
|
237
|
+
Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
|
|
238
|
+
calendar: 'buddhist'}) // ISO 1483-01-31, .year 2026
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
543 years apart. daymath takes strings and `PlainDate` objects, never the fields form, so it
|
|
242
|
+
inherits Temporal's rule and stays consistent with it.
|
|
243
|
+
|
|
244
|
+
`[u-ca=iso8601]` is accepted and dropped rather than carried. Temporal writes it itself for
|
|
245
|
+
`toString({ calendarName: 'always' })`, and daymath already answers in it:
|
|
202
246
|
|
|
203
247
|
```js
|
|
204
248
|
const written = plainDate.toString({ calendarName: 'always' }) // '2026-01-31[u-ca=iso8601]'
|
|
205
249
|
getYear(written) // 2026
|
|
206
250
|
```
|
|
207
251
|
|
|
208
|
-
A calendar is
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
252
|
+
A calendar is judged where it is **applied**. On a day string it is, and with a `[Zone]` bracket
|
|
253
|
+
it is. Without a bracket the string names an `Instant`, which has no year, month or day for a
|
|
254
|
+
calendar to renumber, so the annotation is inert.
|
|
255
|
+
|
|
256
|
+
**`day()` accepts every annotation the other exports accept, and drops it from the result.** It is
|
|
257
|
+
the normaliser: a moment converts to a plain ISO day, and so does a day. One rule, three input
|
|
258
|
+
shapes:
|
|
212
259
|
|
|
213
260
|
```js
|
|
214
|
-
day('2026-08-08T20:00:00Z[u-ca=buddhist]')
|
|
215
|
-
day('2026-08-08T12:00[America/New_York][u-ca=buddhist]')
|
|
261
|
+
day('2026-08-08T20:00:00Z[u-ca=buddhist]') // '2026-08-08'
|
|
262
|
+
day('2026-08-08T12:00[America/New_York][u-ca=buddhist]') // '2026-08-08'
|
|
263
|
+
day('2026-08-08[u-ca=buddhist]') // '2026-08-08'
|
|
264
|
+
day('1999-06-06[Asia/Tokyo][u-ca=hebrew]') // throws
|
|
265
|
+
|
|
266
|
+
parse('2026-08-08[u-ca=buddhist]') // '2026-08-08[u-ca=buddhist]' parse keeps it
|
|
216
267
|
```
|
|
217
268
|
|
|
218
|
-
|
|
269
|
+
`parse` validates and preserves. `day()` normalises. Every other export carries the annotation,
|
|
270
|
+
because the caller asked for that numbering.
|
|
271
|
+
|
|
272
|
+
### Working in a non-ISO calendar
|
|
273
|
+
|
|
274
|
+
**Do not pass the calendar's own year as a bare ISO year.** This is the one way to get a wrong
|
|
275
|
+
answer with no error, and it is why the annotation is not decoration.
|
|
276
|
+
|
|
277
|
+
Buddhist 2567 is ISO 2024, which is a leap year. The Buddhist year is ISO + 543, and 543 mod 4 is
|
|
278
|
+
3, so the leap years land in different places:
|
|
279
|
+
|
|
280
|
+
| call | bare `2567` | annotated, the real Buddhist 2567 |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| `isLeapYear` | `false` | `true` |
|
|
283
|
+
| `getDaysInMonth` for February | `28` | `29` |
|
|
284
|
+
| `parse('…-02-29')` | throws `invalid date` | `2024-02-29[u-ca=buddhist]` |
|
|
285
|
+
| `addDays(Feb 28, 1)` | `2567-03-01` | `2024-02-29[u-ca=buddhist]` |
|
|
286
|
+
|
|
287
|
+
**The two disagree in 49 of the 101 Buddhist years from 2500 to 2600.** Nothing throws on the
|
|
288
|
+
bare form, and `addDays('2567-02-28', 1)` answering `2567-03-01` looks reasonable, so a date lands
|
|
289
|
+
one day early for the rest of that year. Every other month is identical, because February is the
|
|
290
|
+
only month whose length varies.
|
|
291
|
+
|
|
292
|
+
**A `Date` cannot help you here, because a `Date` has no calendar.** It is one number of
|
|
293
|
+
milliseconds. `getUTCFullYear()` is always Gregorian, so a Thai user's `Date` already holds `2026`.
|
|
294
|
+
The `2569` exists only at display time, when `Intl` formats it:
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
new Intl.DateTimeFormat('th-TH-u-ca-buddhist', {dateStyle: 'short'}).format(d) // '8/8/69'
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
So the recipe is:
|
|
301
|
+
|
|
302
|
+
1. From a `Date` or a timestamp, call `day(…)`. You get a plain ISO day.
|
|
303
|
+
2. To work in Buddhist years, annotate that day: `'2026-08-08[u-ca=buddhist]'`. Now `getYear` is
|
|
304
|
+
`2569`, `isLeapYear` is right, and every export carries the annotation through.
|
|
305
|
+
3. **From a Buddhist year *number*, use Temporal's fields form.** This is the one place the
|
|
306
|
+
fields/string asymmetry helps rather than traps:
|
|
307
|
+
|
|
308
|
+
```js
|
|
309
|
+
Temporal.PlainDate.from({year: 2569, month: 8, day: 8, calendar: 'buddhist'}).toString()
|
|
310
|
+
// '2026-08-08[u-ca=buddhist]' <- and daymath accepts the object directly too
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
4. For display, use `Intl`. daymath does no localised formatting.
|
|
314
|
+
|
|
315
|
+
One more trap in the same family: `'2569-08-08[u-ca=buddhist]'` is a valid string, and its
|
|
316
|
+
`getYear` is **3112**. The date part is ISO 2569, and the annotation adds 543 on top.
|
|
317
|
+
|
|
318
|
+
Supporting these calendars costs **4.2 kB gzip**, because daymath resolves them with `getAny`, the
|
|
319
|
+
`fns` resolver that carries every calendar's data. The narrower resolvers cannot serve the rule: on
|
|
320
|
+
a runtime without native `Temporal` they drop the annotation instead of refusing it, so the same
|
|
321
|
+
program would answer 2569 on one lane and 2026 on another. `getAny` answers identically everywhere,
|
|
322
|
+
which is the point.
|
|
219
323
|
|
|
220
324
|
Error messages quote no Temporal text, because implementations word the same failure
|
|
221
325
|
differently. The original error is on `cause`.
|
package/index.d.ts
CHANGED
|
@@ -8,6 +8,18 @@ import type { Temporal } from 'temporal-polyfill'
|
|
|
8
8
|
* Usable range is the Temporal `PlainDate` range `-271821-04-19` …
|
|
9
9
|
* `+275760-09-13`; a day outside it throws a `RangeError`.
|
|
10
10
|
* `Date` is rejected at runtime (TypeError).
|
|
11
|
+
*
|
|
12
|
+
* A `[u-ca=…]` calendar annotation is accepted when the calendar only relabels
|
|
13
|
+
* the year, and it rides along into the result:
|
|
14
|
+
* `getYear('2026-01-31[u-ca=buddhist]')` is `2569` and `addDays` of it is
|
|
15
|
+
* `'2026-02-01[u-ca=buddhist]'`. Today that admits `buddhist`, `roc`,
|
|
16
|
+
* `japanese` and `gregory`. A calendar that renumbers months or days, such as
|
|
17
|
+
* `hebrew` or `chinese`, throws a `RangeError`. The line is measured at runtime
|
|
18
|
+
* rather than held as a list, so no code names a calendar.
|
|
19
|
+
*
|
|
20
|
+
* Measurement ignores the label, because a day is the same day whatever its
|
|
21
|
+
* year is called: `differenceInDays` and `isEqual` normalise both operands, so a
|
|
22
|
+
* mixed pair answers instead of throwing `Mismatched calendars`.
|
|
11
23
|
*/
|
|
12
24
|
export type DayInput = string | Temporal.PlainDate
|
|
13
25
|
|
|
@@ -44,6 +56,15 @@ export type WeekOptions = {
|
|
|
44
56
|
* A lone string takes one of four roles, in this order: a day, a zoned time, an
|
|
45
57
|
* instant, then a zone. The zone test is by shape — an IANA name, which carries
|
|
46
58
|
* no `:`, or a bare offset — so a timestamp can never be read as a zone.
|
|
59
|
+
*
|
|
60
|
+
* **day() is the normaliser, and that is the whole rule: a moment converts to a
|
|
61
|
+
* plain ISO day, and so does a day.** A `[u-ca=…]` annotation is accepted
|
|
62
|
+
* wherever the other exports accept it, and then dropped from the result, so
|
|
63
|
+
* `day` never returns `[u-ca=…]`. Every other export carries it. `parse`
|
|
64
|
+
* validates and preserves; `day` normalises.
|
|
65
|
+
*
|
|
66
|
+
* A calendar that renumbers months or days is still refused where it is
|
|
67
|
+
* applied, which means with a `[Zone]` bracket and on a day string.
|
|
47
68
|
*/
|
|
48
69
|
export function day(tz?: string): string
|
|
49
70
|
export function day(
|