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.
Files changed (4) hide show
  1. package/README.md +136 -32
  2. package/index.d.ts +21 -0
  3. package/index.js +463 -167
  4. 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
- Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill),
159
- which does that resolution itself so on a runtime with native Temporal the polyfill steps aside.
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
- A non-ISO calendar is refused, as an object and as a string alike. Temporal can put one on a
168
- `PlainDate`, and it names the same *day* with different numbers:
170
+ ### Calendars
169
171
 
170
- | calendar | year | month | day | `toString()` |
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
- Thai Buddhist years run 543 ahead, so the same day is 2569. Worse, the same number means two
178
- different days depending on where you write it:
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
- Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]') // ISO 2026-01-31, .year 2569
182
- Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
183
- calendar: 'buddhist'}) // ISO 1483-01-31, .year 2026
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
- 543 years apart. The date part of a string is always ISO; the annotation changes how fields are
187
- *read*, never how the string *parses*. daymath could strip the annotation and answer
188
- `2026-01-31`, which is the right day — but `getYear` would then return `2026` where your own
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
- getYear(buddhistDate)
193
- // RangeError: daymath: date must use the ISO 8601 calendar, not "buddhist"
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(buddhistDate.withCalendar('iso8601')) // '2026-01-31'
218
+ format(hebrewDate.withCalendar('iso8601')) // '2026-01-31'
197
219
  ```
198
220
 
199
- `[u-ca=iso8601]` is the one annotation daymath accepts, and it drops it. Temporal writes it
200
- itself for `toString({ calendarName: 'always' })`, and it names the very calendar daymath
201
- reads, so refusing your own round-trip would be arbitrary.
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 refused where it is **applied**. On a day string it is, and with a `[Zone]`
209
- bracket it is, because the fields get renumbered. Without a bracket the string names an
210
- `Instant`, which has no year, month or day for a calendar to renumber, so the annotation is
211
- inert and `day()` answers:
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]') // '2026-08-08'
215
- day('2026-08-08T12:00[America/New_York][u-ca=buddhist]') // throws
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
- Accepting `buddhist` and `roc` is planned; see `FUTURE.md` for the rule that would allow it.
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(