nl2time 0.1.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.
@@ -0,0 +1,684 @@
1
+ import { addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
2
+ import { dayPeriodRules } from '../data/dayPeriods.js';
3
+ export class NotResolvableError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'NotResolvableError';
7
+ }
8
+ }
9
+ export class AmbiguityError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'AmbiguityError';
13
+ }
14
+ }
15
+ const GRAIN_ORDER = [
16
+ 'instant',
17
+ 'second',
18
+ 'minute',
19
+ 'hour',
20
+ 'day',
21
+ 'week',
22
+ 'month',
23
+ 'quarter',
24
+ 'year',
25
+ ];
26
+ function finerGrain(a, b) {
27
+ return GRAIN_ORDER.indexOf(a) <= GRAIN_ORDER.indexOf(b) ? a : b;
28
+ }
29
+ const MAX_CANDIDATES = 8;
30
+ export function resolve(expr, ctx) {
31
+ const cands = evalExpr(expr, ctx, undefined).slice(0, MAX_CANDIDATES);
32
+ return { candidates: cands.map((c) => toValue(c)) };
33
+ }
34
+ /** First candidate under policy, or throw. */
35
+ export function resolveOne(expr, ctx) {
36
+ const { candidates } = resolve(expr, ctx);
37
+ const first = candidates[0];
38
+ if (!first)
39
+ throw new AmbiguityError('expression produced no candidates');
40
+ return first;
41
+ }
42
+ function toValue(c) {
43
+ switch (c.type) {
44
+ case 'interval':
45
+ return {
46
+ kind: 'interval',
47
+ start: c.zi.start.toInstant(),
48
+ end: c.zi.end.toInstant(),
49
+ grain: c.zi.grain,
50
+ };
51
+ case 'duration':
52
+ return { kind: 'duration', iso: c.iso };
53
+ case 'amount':
54
+ return { kind: 'amount', amount: c.amount };
55
+ }
56
+ }
57
+ /**
58
+ * Evaluate an expression to ordered candidates. `anchor` carries the date
59
+ * context inside an intersect ("tomorrow at 4" evaluates the time literal
60
+ * against tomorrow, not today).
61
+ */
62
+ function evalExpr(expr, ctx, anchor) {
63
+ const cands = evalExprInner(expr, ctx, anchor);
64
+ // mod 'start'/'end' narrow an interval to its first/second half ("early
65
+ // July", "later this week"). Mid-points floor to the day for week+ grains
66
+ // (month/year to coarser boundaries), and when the reference day falls
67
+ // inside the interval it tightens the bound ("earlier this year" ends
68
+ // today, not at midyear).
69
+ if (expr.mod === 'start' || expr.mod === 'end') {
70
+ return intervalMap(cands, (zi) => {
71
+ if (zi.grain === 'instant')
72
+ return zi;
73
+ const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
74
+ let mid = zi.start.add({ milliseconds: Math.floor(totalMs / 2) });
75
+ const gi = GRAIN_ORDER.indexOf(zi.grain);
76
+ if (zi.grain === 'year')
77
+ mid = floorTo(mid, 'month', ctx.weekStart);
78
+ else if (gi > GRAIN_ORDER.indexOf('day'))
79
+ mid = floorTo(mid, 'day', ctx.weekStart);
80
+ let bound = mid;
81
+ if (gi > GRAIN_ORDER.indexOf('day')) {
82
+ const refDay = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
83
+ const inRange = Temporal_compare(refDay, zi.start) > 0 && Temporal_compare(refDay, zi.end) < 0;
84
+ if (inRange) {
85
+ bound =
86
+ expr.mod === 'start'
87
+ ? Temporal_compare(refDay, mid) < 0
88
+ ? refDay
89
+ : mid
90
+ : Temporal_compare(refDay, mid) > 0
91
+ ? refDay
92
+ : mid;
93
+ }
94
+ }
95
+ const narrowed = expr.mod === 'start'
96
+ ? { start: zi.start, end: bound, grain: zi.grain }
97
+ : { start: bound, end: zi.end, grain: zi.grain };
98
+ // A degenerate clamp (bound at an edge) falls back to the plain half.
99
+ if (Temporal_compare(narrowed.start, narrowed.end) >= 0) {
100
+ return expr.mod === 'start'
101
+ ? { start: zi.start, end: mid, grain: zi.grain }
102
+ : { start: mid, end: zi.end, grain: zi.grain };
103
+ }
104
+ return narrowed;
105
+ });
106
+ }
107
+ // mod 'mid': the middle stretch. Conventions follow common usage: mid-month
108
+ // = the 10th through the 20th; mid-day = 10:00–14:00; otherwise the middle
109
+ // half of the interval.
110
+ if (expr.mod === 'mid') {
111
+ return intervalMap(cands, (zi) => {
112
+ if (zi.grain === 'month') {
113
+ return { start: zi.start.add({ days: 9 }), end: zi.start.add({ days: 20 }), grain: 'day' };
114
+ }
115
+ if (zi.grain === 'day') {
116
+ return { start: zi.start.add({ hours: 10 }), end: zi.start.add({ hours: 14 }), grain: 'hour' };
117
+ }
118
+ const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
119
+ return {
120
+ start: zi.start.add({ milliseconds: Math.floor(totalMs / 4) }),
121
+ end: zi.start.add({ milliseconds: Math.floor((3 * totalMs) / 4) }),
122
+ grain: zi.grain,
123
+ };
124
+ });
125
+ }
126
+ return cands;
127
+ }
128
+ function evalExprInner(expr, ctx, anchor) {
129
+ switch (expr.op) {
130
+ case 'now':
131
+ return [{ type: 'interval', zi: pointInterval(ctx.zonedNow) }];
132
+ case 'literal':
133
+ return evalLiteral(expr, ctx, anchor);
134
+ case 'offset':
135
+ return intervalMap(evalExpr(expr.base, ctx, anchor), (zi) => ({
136
+ start: addUnits(zi.start, expr.unit, expr.amount),
137
+ end: addUnits(zi.end, expr.unit, expr.amount),
138
+ grain: zi.grain,
139
+ }));
140
+ case 'snap':
141
+ return intervalMap(evalExpr(expr.base, ctx, anchor), (zi) => {
142
+ const unit = containingUnit(zi.start, expr.unit, ctx.weekStart);
143
+ if (expr.edge === 'start')
144
+ return pointInterval(unit.start);
145
+ if (expr.edge === 'end')
146
+ return pointInterval(unit.end);
147
+ return unit;
148
+ });
149
+ case 'span':
150
+ return intervalMap(evalExpr(expr.anchor, ctx, anchor), (zi) => expr.business ? evalBusinessSpan(zi, expr.amount, ctx) : evalSpan(zi, expr.amount, ctx));
151
+ case 'between': {
152
+ const starts = evalExpr(expr.start, ctx, anchor);
153
+ const out = [];
154
+ for (const s of starts) {
155
+ if (s.type !== 'interval')
156
+ continue;
157
+ // The end operand resolves anchored to the start (shared context:
158
+ // "between 3 and 12 of Sept" — the 3 anchors to September).
159
+ for (const e of evalExpr(expr.end, ctx, s.zi)) {
160
+ if (e.type !== 'interval')
161
+ continue;
162
+ // Non-point ends are exclusive at their *start* ("May 2 to May 7"
163
+ // ends at May 7 00:00); point ends are used directly.
164
+ let endPoint = Temporal_compare(e.zi.start, e.zi.end) === 0 ? e.zi.end : e.zi.start;
165
+ const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day');
166
+ if (Temporal_compare(s.zi.start, endPoint) >= 0) {
167
+ // A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
168
+ // the end into the next day; date ranges just drop.
169
+ if (!timeGrained)
170
+ continue;
171
+ endPoint = endPoint.add({ days: 1 });
172
+ if (Temporal_compare(s.zi.start, endPoint) >= 0)
173
+ continue;
174
+ }
175
+ out.push({
176
+ type: 'interval',
177
+ zi: {
178
+ start: s.zi.start,
179
+ end: endPoint,
180
+ grain: finerGrain(s.zi.grain, e.zi.grain),
181
+ },
182
+ });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ case 'seek':
188
+ return evalSeek(expr, ctx, anchor);
189
+ case 'intersect': {
190
+ // Left-to-right: each part is evaluated with the accumulated interval as
191
+ // its anchor. Time-of-day parts (a clock time or day period with no date
192
+ // of their own) COMPOSE onto the anchor day — "last night" is 21:00 of
193
+ // yesterday wrapping to 06:00 today, which must not be clipped at the
194
+ // day boundary. Parts that carry their own date CONSTRAIN by interval
195
+ // intersection.
196
+ let acc = evalExpr(expr.parts[0], ctx, anchor);
197
+ for (const part of expr.parts.slice(1)) {
198
+ const partIsTimeOfDay = isTimeOfDayPart(part);
199
+ const next = [];
200
+ for (const a of acc) {
201
+ if (a.type !== 'interval')
202
+ continue;
203
+ // Clock times always compose onto the anchor's day ("tonight at
204
+ // 1am" escapes the night window). Day-period parts compose only
205
+ // onto day-or-coarser anchors; onto an already time-refined
206
+ // interval ("10am to 12pm Monday morning") they constrain.
207
+ const composes = partIsTimeOfDay &&
208
+ (isClockPart(part) || GRAIN_ORDER.indexOf(a.zi.grain) >= GRAIN_ORDER.indexOf('day'));
209
+ for (const b of evalExpr(part, ctx, a.zi)) {
210
+ if (b.type !== 'interval')
211
+ continue;
212
+ if (composes) {
213
+ next.push(b);
214
+ continue;
215
+ }
216
+ const zi = intersectIntervals(a.zi, b.zi);
217
+ if (zi)
218
+ next.push({ type: 'interval', zi });
219
+ }
220
+ }
221
+ acc = next;
222
+ }
223
+ return acc;
224
+ }
225
+ case 'duration':
226
+ return [{ type: 'duration', iso: expr.iso }];
227
+ case 'amount':
228
+ return [{ type: 'amount', amount: expr.amount }];
229
+ case 'holiday':
230
+ return evalHoliday(expr, ctx);
231
+ case 'recur':
232
+ throw new NotResolvableError('recurrence expressions are representable in v1 but not yet resolvable (planned for v2)');
233
+ }
234
+ }
235
+ /** A literal clock reading (no date of its own). */
236
+ function isClockPart(expr) {
237
+ return expr.op === 'literal' && expr.date === undefined && expr.time !== undefined;
238
+ }
239
+ /** A part that names a time within a day rather than a day: composes onto its anchor. */
240
+ function isTimeOfDayPart(expr) {
241
+ if (expr.op === 'literal')
242
+ return expr.date === undefined && (expr.time !== undefined || expr.dayPeriod !== undefined);
243
+ if (expr.op === 'seek')
244
+ return expr.target.kind === 'dayPeriod';
245
+ return false;
246
+ }
247
+ function intervalMap(cands, f) {
248
+ return cands.map((c) => (c.type === 'interval' ? { type: 'interval', zi: f(c.zi) } : c));
249
+ }
250
+ function Temporal_compare(a, b) {
251
+ const x = a.epochNanoseconds;
252
+ const y = b.epochNanoseconds;
253
+ return x < y ? -1 : x > y ? 1 : 0;
254
+ }
255
+ function intersectIntervals(a, b) {
256
+ // Point intervals (grain 'instant') act as points-in-time; containment check.
257
+ const start = Temporal_compare(a.start, b.start) >= 0 ? a.start : b.start;
258
+ const end = Temporal_compare(a.end, b.end) <= 0 ? a.end : b.end;
259
+ if (Temporal_compare(start, end) > 0)
260
+ return undefined;
261
+ if (Temporal_compare(start, end) === 0 && a.grain !== 'instant' && b.grain !== 'instant') {
262
+ return undefined;
263
+ }
264
+ return { start, end, grain: finerGrain(a.grain, b.grain) };
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // span
268
+ // ---------------------------------------------------------------------------
269
+ function smallestUnitOf(amount) {
270
+ if (amount.seconds)
271
+ return 'second';
272
+ if (amount.minutes)
273
+ return 'minute';
274
+ if (amount.hours)
275
+ return 'hour';
276
+ if (amount.days)
277
+ return 'day';
278
+ if (amount.weeks)
279
+ return 'week';
280
+ if (amount.months)
281
+ return 'month';
282
+ return 'year';
283
+ }
284
+ function addAmount(z, amount, factor) {
285
+ const scaled = {};
286
+ for (const [k, v] of Object.entries(amount)) {
287
+ if (typeof v === 'number' && v !== 0)
288
+ scaled[k] = v * factor;
289
+ }
290
+ return z.add(scaled);
291
+ }
292
+ function evalSpan(anchorZi, amount, ctx) {
293
+ const negative = Object.values(amount).some((v) => typeof v === 'number' && v < 0);
294
+ const unit = smallestUnitOf(amount);
295
+ const coarse = GRAIN_ORDER.indexOf(unit) >= GRAIN_ORDER.indexOf('day');
296
+ let anchorPoint = negative ? anchorZi.end : anchorZi.start;
297
+ // Complete-periods policy: "the last 3 days" ends at today's start; "the
298
+ // next 3 days" begins at tomorrow's start. Applies only to spans anchored
299
+ // at the reference instant itself — an explicit anchor point ("the week
300
+ // starting Monday") is exact.
301
+ if (ctx.partialPeriod === 'exclude' &&
302
+ anchorZi.grain === 'instant' &&
303
+ Temporal_compare(anchorPoint, ctx.zonedNow) === 0 &&
304
+ coarse) {
305
+ anchorPoint = floorTo(anchorPoint, 'day', ctx.weekStart);
306
+ if (!negative)
307
+ anchorPoint = anchorPoint.add({ days: 1 });
308
+ }
309
+ const other = addAmount(anchorPoint, amount, 1);
310
+ const [start, end] = Temporal_compare(anchorPoint, other) <= 0 ? [anchorPoint, other] : [other, anchorPoint];
311
+ return { start, end, grain: unit };
312
+ }
313
+ /** Business-day span: counts weekdays only, from the day after/before the anchor. */
314
+ function evalBusinessSpan(anchorZi, amount, ctx) {
315
+ const n = amount.days ?? 0;
316
+ const negative = n < 0;
317
+ let day = floorTo(negative ? anchorZi.end : anchorZi.start, 'day', ctx.weekStart);
318
+ const step = negative ? -1 : 1;
319
+ let first;
320
+ let last;
321
+ let remaining = Math.abs(n);
322
+ while (remaining > 0) {
323
+ day = day.add({ days: step });
324
+ if (day.dayOfWeek >= 6)
325
+ continue;
326
+ if (!first)
327
+ first = day;
328
+ last = day;
329
+ remaining -= 1;
330
+ }
331
+ const [start, end] = negative
332
+ ? [last, first.add({ days: 1 })]
333
+ : [first, last.add({ days: 1 })];
334
+ return { start, end, grain: 'day' };
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // seek
338
+ // ---------------------------------------------------------------------------
339
+ function evalSeek(expr, ctx, anchor) {
340
+ const bases = evalExpr(expr.base, ctx, anchor);
341
+ const out = [];
342
+ for (const b of bases) {
343
+ if (b.type !== 'interval')
344
+ continue;
345
+ // A bare weekday ("Friday", dir nearest, from a point) is genuinely
346
+ // ambiguous: produce both directions, bias-ordered.
347
+ if (expr.dir === 'nearest' &&
348
+ expr.target.kind === 'weekday' &&
349
+ (b.zi.grain === 'instant' || b.zi.grain === 'day')) {
350
+ // Strict adjacent occurrences (n: 1) — the dialect policy is for
351
+ // explicit "next X"/"last X", not for a bare weekday.
352
+ const fwd = seekFrom(b.zi, { ...expr, dir: 'next', n: 1 }, ctx);
353
+ const back = seekFrom(b.zi, { ...expr, dir: 'prev', n: 1 }, ctx);
354
+ const ordered = ctx.bias === 'past' ? [back, fwd] : [fwd, back];
355
+ for (const zi of ordered)
356
+ if (zi)
357
+ out.push({ type: 'interval', zi });
358
+ continue;
359
+ }
360
+ const zi = seekFrom(b.zi, expr, ctx);
361
+ if (zi)
362
+ out.push({ type: 'interval', zi });
363
+ }
364
+ return out;
365
+ }
366
+ function seekFrom(base, expr, ctx) {
367
+ const t = expr.target;
368
+ switch (t.kind) {
369
+ case 'weekday': {
370
+ const target = weekdayNumber(t.weekday);
371
+ const n = expr.n ?? 1;
372
+ if (base.grain !== 'instant' && GRAIN_ORDER.indexOf(base.grain) > GRAIN_ORDER.indexOf('day')) {
373
+ // "the 2nd Monday of March": nth occurrence inside the base interval.
374
+ let day = floorTo(base.start, 'day', ctx.weekStart);
375
+ const delta = (target - day.dayOfWeek + 7) % 7;
376
+ day = day.add({ days: delta + 7 * (n - 1) });
377
+ if (Temporal_compare(day, base.end) >= 0)
378
+ return undefined;
379
+ return { start: day, end: day.add({ days: 1 }), grain: 'day' };
380
+ }
381
+ // Deictic navigation from a point/day ("next Tuesday").
382
+ const today = floorTo(base.start, 'day', ctx.weekStart);
383
+ const dow = today.dayOfWeek;
384
+ let day;
385
+ // The nextWeekday dialect policy applies only to bare "next/last
386
+ // <weekday>"; an explicit n means strict occurrence counting.
387
+ const usePolicy = ctx.nextWeekday === 'week-after' && expr.n === undefined;
388
+ if (expr.dir === 'next') {
389
+ if (usePolicy) {
390
+ const weekStartDay = floorTo(today.add({ days: 7 }), 'week', ctx.weekStart);
391
+ const delta = (target - weekStartDay.dayOfWeek + 7) % 7;
392
+ day = weekStartDay.add({ days: delta });
393
+ }
394
+ else {
395
+ const delta = ((target - dow + 6) % 7) + 1; // strictly after today
396
+ day = today.add({ days: delta + 7 * (n - 1) });
397
+ }
398
+ }
399
+ else if (expr.dir === 'prev') {
400
+ if (usePolicy) {
401
+ // Symmetric dialect: "last Tuesday" = Tuesday of the previous week.
402
+ const weekStartDay = floorTo(today.subtract({ days: 7 }), 'week', ctx.weekStart);
403
+ const delta = (target - weekStartDay.dayOfWeek + 7) % 7;
404
+ day = weekStartDay.add({ days: delta });
405
+ }
406
+ else {
407
+ const delta = ((dow - target + 6) % 7) + 1; // strictly before today
408
+ day = today.subtract({ days: delta + 7 * (n - 1) });
409
+ }
410
+ }
411
+ else {
412
+ // 'nearest': bias decides; 'none' behaves like future ("Friday" usually
413
+ // means the upcoming one in conversation).
414
+ const forwardDelta = ((target - dow + 6) % 7) + 1;
415
+ const backDelta = ((dow - target + 6) % 7) + 1;
416
+ day =
417
+ ctx.bias === 'past'
418
+ ? today.subtract({ days: backDelta })
419
+ : today.add({ days: forwardDelta });
420
+ }
421
+ return { start: day, end: day.add({ days: 1 }), grain: 'day' };
422
+ }
423
+ case 'month': {
424
+ const refYearStart = floorTo(base.start, 'year', ctx.weekStart);
425
+ let m = refYearStart.with({ month: t.month });
426
+ const cmp = Temporal_compare(m, base.start);
427
+ if (expr.dir === 'next' && cmp <= 0)
428
+ m = m.add({ years: 1 });
429
+ if (expr.dir === 'prev' && cmp >= 0)
430
+ m = m.subtract({ years: 1 });
431
+ const start = floorTo(m, 'month', ctx.weekStart);
432
+ return { start, end: addUnits(start, 'month', 1), grain: 'month' };
433
+ }
434
+ case 'dayPeriod': {
435
+ return dayPeriodInterval(base, t.period, ctx);
436
+ }
437
+ case 'unit': {
438
+ const cur = containingUnit(base.start, t.unit, ctx.weekStart);
439
+ const n = expr.n ?? 1;
440
+ const shift = expr.dir === 'next' ? n : expr.dir === 'prev' ? -n : 0;
441
+ return {
442
+ start: addUnits(cur.start, t.unit, shift),
443
+ end: addUnits(cur.end, t.unit, shift),
444
+ grain: t.unit,
445
+ };
446
+ }
447
+ }
448
+ }
449
+ /** The day-period interval within (anchored to) the day of `base`. */
450
+ export function dayPeriodInterval(base, period, ctx) {
451
+ const rules = ctx.dayPeriods ?? dayPeriodRules(ctx.language);
452
+ const rule = rules.find((r) => r.period === period);
453
+ if (!rule)
454
+ return undefined;
455
+ const day = floorTo(base.start, 'day', ctx.weekStart);
456
+ const start = day.add({ hours: rule.from });
457
+ const end = rule.before > rule.from
458
+ ? day.add({ hours: rule.before })
459
+ : day.add({ days: 1 }).add({ hours: rule.before });
460
+ return { start, end, grain: 'hour' };
461
+ }
462
+ const HOLIDAYS = {
463
+ 'earth-day': { kind: 'fixed', month: 4, day: 22 },
464
+ 'st-patricks': { kind: 'fixed', month: 3, day: 17 },
465
+ 'workers-day': { kind: 'fixed', month: 5, day: 1 },
466
+ 'new-year': { kind: 'fixed', month: 1, day: 1 },
467
+ 'new-year-eve': { kind: 'fixed', month: 12, day: 31 },
468
+ valentines: { kind: 'fixed', month: 2, day: 14 },
469
+ halloween: { kind: 'fixed', month: 10, day: 31 },
470
+ christmas: { kind: 'fixed', month: 12, day: 25 },
471
+ 'christmas-eve': { kind: 'fixed', month: 12, day: 24 },
472
+ 'independence-day': { kind: 'fixed', month: 7, day: 4 },
473
+ thanksgiving: { kind: 'nth-weekday', month: 11, weekday: 4, n: 4 },
474
+ 'labor-day': { kind: 'nth-weekday', month: 9, weekday: 1, n: 1 },
475
+ 'mothers-day': { kind: 'nth-weekday', month: 5, weekday: 7, n: 2 },
476
+ 'fathers-day': { kind: 'nth-weekday', month: 6, weekday: 7, n: 3 },
477
+ 'memorial-day': { kind: 'last-weekday', month: 5, weekday: 1 },
478
+ easter: { kind: 'easter' },
479
+ };
480
+ /** Anonymous Gregorian computus. */
481
+ function easterDate(year) {
482
+ const a = year % 19;
483
+ const b = Math.floor(year / 100);
484
+ const c = year % 100;
485
+ const d = Math.floor(b / 4);
486
+ const e = b % 4;
487
+ const f = Math.floor((b + 8) / 25);
488
+ const g = Math.floor((b - f + 1) / 3);
489
+ const h = (19 * a + b - d - g + 15) % 30;
490
+ const i = Math.floor(c / 4);
491
+ const k = c % 4;
492
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
493
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
494
+ const month = Math.floor((h + l - 7 * m + 114) / 31);
495
+ const day = ((h + l - 7 * m + 114) % 31) + 1;
496
+ return { month, day };
497
+ }
498
+ function holidayDay(name, year, ctx) {
499
+ if (name === 'black-friday') {
500
+ return holidayDay('thanksgiving', year, ctx).add({ days: 1 });
501
+ }
502
+ const def = HOLIDAYS[name];
503
+ const base = ctx.zonedNow.with({ year, month: 1, day: 1 }).startOfDay();
504
+ if (def.kind === 'fixed') {
505
+ return base.with({ month: def.month, day: def.day });
506
+ }
507
+ if (def.kind === 'easter') {
508
+ const { month, day } = easterDate(year);
509
+ return base.with({ month, day });
510
+ }
511
+ const monthStart = base.with({ month: def.month, day: 1 });
512
+ if (def.kind === 'nth-weekday') {
513
+ const delta = (def.weekday - monthStart.dayOfWeek + 7) % 7;
514
+ return monthStart.add({ days: delta + 7 * (def.n - 1) });
515
+ }
516
+ // last-weekday
517
+ const monthEnd = monthStart.add({ months: 1 }).subtract({ days: 1 });
518
+ const back = (monthEnd.dayOfWeek - def.weekday + 7) % 7;
519
+ return monthEnd.subtract({ days: back });
520
+ }
521
+ function evalHoliday(expr, ctx) {
522
+ const mk = (year) => {
523
+ const day = holidayDay(expr.name, year, ctx);
524
+ return { start: day, end: day.add({ days: 1 }), grain: 'day' };
525
+ };
526
+ if (expr.year !== undefined) {
527
+ return [{ type: 'interval', zi: mk(expr.year) }];
528
+ }
529
+ const refYear = ctx.zonedNow.year;
530
+ const now = ctx.zonedNow;
531
+ if (expr.dir === 'prev') {
532
+ const cur = mk(refYear);
533
+ return [{ type: 'interval', zi: Temporal_compare(cur.end, now) <= 0 ? cur : mk(refYear - 1) }];
534
+ }
535
+ if (expr.dir === 'next') {
536
+ const cur = mk(refYear);
537
+ return [{ type: 'interval', zi: Temporal_compare(cur.start, now) > 0 ? cur : mk(refYear + 1) }];
538
+ }
539
+ const ordered = orderByBias(mk(0 + refYear), (d) => mk(refYear + d), ctx, 'years');
540
+ return ordered.map((zi) => ({ type: 'interval', zi }));
541
+ }
542
+ // ---------------------------------------------------------------------------
543
+ // literal
544
+ // ---------------------------------------------------------------------------
545
+ function evalLiteral(expr, ctx, anchor) {
546
+ const refDay = floorTo(anchor?.start ?? ctx.zonedNow, 'day', ctx.weekStart);
547
+ const dateCands = expr.date
548
+ ? resolveDate(expr.date, ctx, anchor)
549
+ : anchor
550
+ ? [anchor]
551
+ : [{ start: refDay, end: refDay.add({ days: 1 }), grain: 'day' }];
552
+ if (!expr.time && !expr.dayPeriod)
553
+ return dateCands.map((zi) => ({ type: 'interval', zi }));
554
+ const out = [];
555
+ for (const d of dateCands) {
556
+ if (expr.dayPeriod) {
557
+ const zi = dayPeriodInterval(d, expr.dayPeriod, ctx);
558
+ if (zi)
559
+ out.push({ type: 'interval', zi });
560
+ continue;
561
+ }
562
+ for (const timeCand of resolveTime(expr.time, d)) {
563
+ out.push({ type: 'interval', zi: timeCand });
564
+ }
565
+ }
566
+ return out;
567
+ }
568
+ function resolveDate(date, ctx, anchor) {
569
+ const ref = anchor?.start ?? ctx.zonedNow;
570
+ if (date.year !== undefined && date.month !== undefined && date.day !== undefined) {
571
+ const day = ref.with({ year: date.year, month: date.month, day: date.day }).startOfDay();
572
+ return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
573
+ }
574
+ if (date.year !== undefined && date.month !== undefined) {
575
+ const start = ref.with({ year: date.year, month: date.month, day: 1 }).startOfDay();
576
+ return [{ start, end: start.add({ months: 1 }), grain: 'month' }];
577
+ }
578
+ if (date.year !== undefined) {
579
+ const start = ref.with({ year: date.year, month: 1, day: 1 }).startOfDay();
580
+ return [{ start, end: start.add({ years: 1 }), grain: 'year' }];
581
+ }
582
+ if (date.month !== undefined && date.day !== undefined) {
583
+ // "May 29" with no year → the most recent occurrence starting on-or-
584
+ // before today, then the following one ("past + future" pairing).
585
+ const mk = (yearDelta) => {
586
+ const day = ref
587
+ .add({ years: yearDelta })
588
+ .with({ month: date.month, day: date.day })
589
+ .startOfDay();
590
+ return { start: day, end: day.add({ days: 1 }), grain: 'day' };
591
+ };
592
+ if (ctx.bias === 'none') {
593
+ const today = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
594
+ const base = Temporal_compare(mk(0).start, today) <= 0 ? 0 : -1;
595
+ return [mk(base), mk(base + 1)];
596
+ }
597
+ return orderByBias(mk(0), mk, ctx, 'years');
598
+ }
599
+ if (date.month !== undefined) {
600
+ const mk = (yearDelta) => {
601
+ const start = ref.add({ years: yearDelta }).with({ month: date.month, day: 1 }).startOfDay();
602
+ return { start, end: start.add({ months: 1 }), grain: 'month' };
603
+ };
604
+ return orderByBias(mk(0), mk, ctx, 'years');
605
+ }
606
+ if (date.day !== undefined) {
607
+ // "the 3rd" → most recent occurrence on-or-before today, then the next.
608
+ const mk = (monthDelta) => {
609
+ const day = ref.add({ months: monthDelta }).with({ day: date.day }).startOfDay();
610
+ return { start: day, end: day.add({ days: 1 }), grain: 'day' };
611
+ };
612
+ if (ctx.bias === 'none') {
613
+ const today = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
614
+ const base = Temporal_compare(mk(0).start, today) <= 0 ? 0 : -1;
615
+ return [mk(base), mk(base + 1)];
616
+ }
617
+ return orderByBias(mk(0), mk, ctx, 'months');
618
+ }
619
+ const day = floorTo(ref, 'day', ctx.weekStart);
620
+ return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
621
+ }
622
+ /**
623
+ * Order the this-period occurrence and its neighbor under the context bias:
624
+ * 'future' → soonest occurrence not in the past first; 'past' → most recent
625
+ * past occurrence first; 'none' → nearest by calendar distance first.
626
+ */
627
+ function orderByBias(current, mk, ctx, _unit) {
628
+ const now = ctx.zonedNow;
629
+ const isPast = Temporal_compare(current.end, now) <= 0;
630
+ const isFuture = Temporal_compare(current.start, now) > 0;
631
+ if (ctx.bias === 'future') {
632
+ return isPast ? [mk(1), current] : [current, mk(1)];
633
+ }
634
+ if (ctx.bias === 'past') {
635
+ return isFuture ? [mk(-1), current] : [current, mk(-1)];
636
+ }
637
+ // none → nearest first
638
+ if (isPast) {
639
+ const next = mk(1);
640
+ const dPast = Math.abs(calendarDaysBetween(current.start, now));
641
+ const dNext = Math.abs(calendarDaysBetween(now, next.start));
642
+ return dPast <= dNext ? [current, next] : [next, current];
643
+ }
644
+ if (isFuture) {
645
+ const prev = mk(-1);
646
+ const dFut = Math.abs(calendarDaysBetween(now, current.start));
647
+ const dPrev = Math.abs(calendarDaysBetween(prev.start, now));
648
+ return dFut <= dPrev ? [current, prev] : [prev, current];
649
+ }
650
+ // now falls inside the current occurrence: pair it with the previous one
651
+ // ("from Sep to Nov" said mid-September offers last year's too). Bias
652
+ // 'past'/'future' were handled above, so this is the 'none' path.
653
+ return [current, mk(-1)];
654
+ }
655
+ function resolveTime(time, day) {
656
+ const grain = time.second !== undefined ? 'second' : time.minute !== undefined ? 'minute' : 'hour';
657
+ const dayStart = day.start.startOfDay();
658
+ const mk = (hour24) => {
659
+ const start = dayStart.add({
660
+ hours: hour24,
661
+ minutes: time.minute ?? 0,
662
+ seconds: time.second ?? 0,
663
+ });
664
+ const end = grain === 'second'
665
+ ? start.add({ seconds: 1 })
666
+ : grain === 'minute'
667
+ ? start.add({ minutes: 1 })
668
+ : start.add({ hours: 1 });
669
+ return { start, end, grain };
670
+ };
671
+ const h = time.hour ?? 0;
672
+ if (time.meridiem === 'am')
673
+ return [mk(h === 12 ? 0 : h)];
674
+ if (time.meridiem === 'pm')
675
+ return [mk(h === 12 ? 12 : h + 12)];
676
+ if (time.meridiem === 'unknown' && h >= 1 && h <= 12) {
677
+ // Ambiguous clock reading: both readings, plausibility-ordered ("at 4"
678
+ // usually means 4pm; "at 9" usually means 9am).
679
+ const am = mk(h === 12 ? 0 : h);
680
+ const pm = mk(h === 12 ? 12 : h + 12);
681
+ return h >= 7 && h <= 11 ? [am, pm] : [pm, am];
682
+ }
683
+ return [mk(h)];
684
+ }