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,18 @@
1
+ /**
2
+ * CLDR flexible day-period boundary rules.
3
+ *
4
+ * Source: CLDR supplemental/dayPeriods.json (rule set per language). v1 ships
5
+ * the English rules; the generator script (issue #7) will widen coverage.
6
+ * Boundaries are [from, before) in local hours; a period may wrap midnight
7
+ * (night: 21:00 → 06:00).
8
+ */
9
+ import type { DayPeriod } from '../ir/types.js';
10
+ export interface DayPeriodRule {
11
+ period: DayPeriod;
12
+ /** Start hour (inclusive), local time. */
13
+ from: number;
14
+ /** End hour (exclusive). May be < from, meaning the period wraps midnight. */
15
+ before: number;
16
+ }
17
+ export declare function dayPeriodRules(language: string): readonly DayPeriodRule[];
18
+ export declare function periodForHour(language: string, hour: number): DayPeriod;
@@ -0,0 +1,24 @@
1
+ const EN_RULES = [
2
+ { period: 'morning', from: 6, before: 12 },
3
+ { period: 'afternoon', from: 12, before: 18 },
4
+ { period: 'evening', from: 18, before: 21 },
5
+ { period: 'night', from: 21, before: 6 },
6
+ ];
7
+ export function dayPeriodRules(language) {
8
+ // Only 'en' data is bundled so far; other languages fall back to the en
9
+ // boundaries, which match CLDR's defaults closely enough for v1.
10
+ void language;
11
+ return EN_RULES;
12
+ }
13
+ export function periodForHour(language, hour) {
14
+ for (const rule of dayPeriodRules(language)) {
15
+ if (rule.from < rule.before) {
16
+ if (hour >= rule.from && hour < rule.before)
17
+ return rule.period;
18
+ }
19
+ else if (hour >= rule.from || hour < rule.before) {
20
+ return rule.period;
21
+ }
22
+ }
23
+ return 'night';
24
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * CLDR supplemental weekData slice (firstDay by territory).
3
+ *
4
+ * Source: CLDR supplemental/weekData.json. Territories not listed use the
5
+ * world default (001): Monday. This file is intended to be regenerated from
6
+ * CLDR by scripts/generate-cldr.ts (issue #7); the slice below covers the
7
+ * common territories so v1 behaves correctly for them.
8
+ */
9
+ import type { Weekday } from '../ir/types.js';
10
+ export declare const DEFAULT_FIRST_DAY: Weekday;
11
+ export declare function firstDayForRegion(region: string | undefined): Weekday;
@@ -0,0 +1,25 @@
1
+ export const DEFAULT_FIRST_DAY = 'mon';
2
+ const SUN = [
3
+ 'AG', 'AS', 'BD', 'BR', 'BS', 'BT', 'BW', 'BZ', 'CA', 'CO', 'DM', 'DO', 'ET',
4
+ 'GT', 'GU', 'HK', 'HN', 'ID', 'IL', 'IN', 'JM', 'JP', 'KE', 'KH', 'KR', 'LA',
5
+ 'MH', 'MM', 'MO', 'MT', 'MX', 'MZ', 'NI', 'NP', 'PA', 'PE', 'PH', 'PK', 'PR',
6
+ 'PT', 'PY', 'SA', 'SG', 'SV', 'TH', 'TT', 'TW', 'UM', 'US', 'VE', 'VI', 'WS',
7
+ 'YE', 'ZA', 'ZW',
8
+ ];
9
+ const SAT = [
10
+ 'AE', 'AF', 'BH', 'DJ', 'DZ', 'EG', 'IQ', 'IR', 'JO', 'KW', 'LY', 'OM', 'QA',
11
+ 'SD', 'SY',
12
+ ];
13
+ const FRI = ['MV'];
14
+ export function firstDayForRegion(region) {
15
+ if (!region)
16
+ return DEFAULT_FIRST_DAY;
17
+ const r = region.toUpperCase();
18
+ if (SUN.includes(r))
19
+ return 'sun';
20
+ if (SAT.includes(r))
21
+ return 'sat';
22
+ if (FRI.includes(r))
23
+ return 'fri';
24
+ return DEFAULT_FIRST_DAY;
25
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The reverse direction: value → (TimeExpr, localized text).
3
+ *
4
+ * Stage 1 (select) chooses framing/unit and builds the IR expression that
5
+ * describes the value; stage 2 (render) turns it into locale text via Intl.
6
+ * The round-trip invariant: resolve(describe(v).expr, ctx) must contain v at
7
+ * the stated grain (tested in test/roundtrip.test.ts).
8
+ */
9
+ import { type Instant } from '../clock/index.js';
10
+ import type { TimeContext } from '../context.js';
11
+ import type { TimeValue } from '../engine/resolve.js';
12
+ import type { Grain, TimeExpr } from '../ir/types.js';
13
+ export type Framing = 'calendar' | 'elapsed' | 'absolute';
14
+ export interface DescribeOptions {
15
+ framing?: Framing | 'auto';
16
+ /** 'casual' produces "9pm last night"; 'neutral' produces "yesterday at 9:00 PM". */
17
+ style?: 'neutral' | 'casual';
18
+ }
19
+ export interface Description {
20
+ text: string;
21
+ expr: TimeExpr;
22
+ grain: Grain;
23
+ framing: Framing;
24
+ }
25
+ export declare function describe(value: Instant | TimeValue, ctx: TimeContext, opts?: DescribeOptions): Description;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The reverse direction: value → (TimeExpr, localized text).
3
+ *
4
+ * Stage 1 (select) chooses framing/unit and builds the IR expression that
5
+ * describes the value; stage 2 (render) turns it into locale text via Intl.
6
+ * The round-trip invariant: resolve(describe(v).expr, ctx) must contain v at
7
+ * the stated grain (tested in test/roundtrip.test.ts).
8
+ */
9
+ import { calendarDaysBetween, floorTo, toZoned, } from '../clock/index.js';
10
+ import { periodForHour } from '../data/dayPeriods.js';
11
+ const NOW = { op: 'now' };
12
+ const WEEKDAYS_ISO = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
13
+ export function describe(value, ctx, opts = {}) {
14
+ const style = opts.style ?? 'neutral';
15
+ const framing = opts.framing ?? 'auto';
16
+ if (typeof value === 'object' && 'kind' in value) {
17
+ if (value.kind === 'duration' || value.kind === 'amount') {
18
+ return describeAmount(value, ctx);
19
+ }
20
+ const start = toZoned(value.start, ctx.timeZone);
21
+ return describeInterval(start, value.grain, ctx, framing, style);
22
+ }
23
+ // A bare instant: describe at minute grain (a DB timestamp answer).
24
+ const z = toZoned(value, ctx.timeZone);
25
+ return describeInterval(z, 'minute', ctx, framing, style);
26
+ }
27
+ function describeInterval(z, grain, ctx, framing, style) {
28
+ if (framing === 'elapsed')
29
+ return describeElapsed(z, ctx);
30
+ if (framing === 'absolute')
31
+ return describeAbsolute(z, grain, ctx);
32
+ // auto / calendar
33
+ const coarse = ['day', 'week', 'month', 'quarter', 'year'].includes(grain);
34
+ if (coarse)
35
+ return describeCoarse(z, grain, ctx);
36
+ return describeDateTime(z, grain, ctx, style);
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // coarse calendar grains: day / week / month / quarter / year
40
+ // ---------------------------------------------------------------------------
41
+ function describeCoarse(z, unit, ctx) {
42
+ const now = ctx.zonedNow;
43
+ const thisUnitStart = floorTo(now, unit, ctx.weekStart);
44
+ const valueStart = floorTo(z, unit, ctx.weekStart);
45
+ const deltaMap = {
46
+ day: ['yesterday', 'today', 'tomorrow'],
47
+ week: ['last week', 'this week', 'next week'],
48
+ month: ['last month', 'this month', 'next month'],
49
+ quarter: ['last quarter', 'this quarter', 'next quarter'],
50
+ year: ['last year', 'this year', 'next year'],
51
+ };
52
+ const delta = unitsBetween(thisUnitStart, valueStart, unit);
53
+ if (delta >= -1 && delta <= 1) {
54
+ const expr = delta === 0
55
+ ? { op: 'snap', base: NOW, unit }
56
+ : { op: 'snap', base: { op: 'offset', base: NOW, amount: delta, unit }, unit };
57
+ return { text: deltaMap[unit][delta + 1], expr, grain: unit, framing: 'calendar' };
58
+ }
59
+ if (unit === 'day')
60
+ return describeFarDay(z, ctx);
61
+ if (unit === 'week') {
62
+ const text = `the week of ${fmt(z, ctx, { month: 'long', day: 'numeric' })}`;
63
+ return {
64
+ text,
65
+ expr: {
66
+ op: 'snap',
67
+ base: { op: 'literal', date: { year: z.year, month: z.month, day: z.day } },
68
+ unit: 'week',
69
+ },
70
+ grain: 'week',
71
+ framing: 'calendar',
72
+ };
73
+ }
74
+ if (unit === 'month') {
75
+ const sameYear = z.year === now.year;
76
+ const text = sameYear ? fmt(z, ctx, { month: 'long' }) : fmt(z, ctx, { month: 'long', year: 'numeric' });
77
+ const expr = { op: 'literal', date: { month: z.month, year: z.year } };
78
+ return { text, expr, grain: 'month', framing: 'calendar' };
79
+ }
80
+ if (unit === 'quarter') {
81
+ const q = Math.floor((z.month - 1) / 3) + 1;
82
+ return {
83
+ text: `Q${q} ${z.year}`,
84
+ expr: {
85
+ op: 'snap',
86
+ base: { op: 'literal', date: { year: z.year, month: (q - 1) * 3 + 1 } },
87
+ unit: 'quarter',
88
+ },
89
+ grain: 'quarter',
90
+ framing: 'calendar',
91
+ };
92
+ }
93
+ return {
94
+ text: String(z.year),
95
+ expr: { op: 'literal', date: { year: z.year } },
96
+ grain: 'year',
97
+ framing: 'calendar',
98
+ };
99
+ }
100
+ /** A specific day beyond yesterday/tomorrow: weekday within a week, else a date. */
101
+ function describeFarDay(z, ctx) {
102
+ const now = ctx.zonedNow;
103
+ const dayDiff = calendarDaysBetween(now, z);
104
+ const weekday = WEEKDAYS_ISO[z.dayOfWeek - 1];
105
+ if (dayDiff < 0 && dayDiff > -7) {
106
+ return {
107
+ text: `last ${fmt(z, ctx, { weekday: 'long' })}`,
108
+ expr: { op: 'seek', base: NOW, dir: 'prev', target: { kind: 'weekday', weekday } },
109
+ grain: 'day',
110
+ framing: 'calendar',
111
+ };
112
+ }
113
+ if (dayDiff > 0 && dayDiff < 7) {
114
+ return {
115
+ text: `on ${fmt(z, ctx, { weekday: 'long' })}`,
116
+ expr: { op: 'seek', base: NOW, dir: 'next', target: { kind: 'weekday', weekday } },
117
+ grain: 'day',
118
+ framing: 'calendar',
119
+ };
120
+ }
121
+ const sameYear = z.year === now.year;
122
+ const text = sameYear
123
+ ? fmt(z, ctx, { month: 'long', day: 'numeric' })
124
+ : fmt(z, ctx, { month: 'long', day: 'numeric', year: 'numeric' });
125
+ // The emitted IR always carries the year (the text may omit it): a bare
126
+ // month+day literal resolves past-first, which would break the round-trip
127
+ // for future dates.
128
+ const date = { year: z.year, month: z.month, day: z.day };
129
+ return { text, expr: { op: 'literal', date }, grain: 'day', framing: 'calendar' };
130
+ }
131
+ // ---------------------------------------------------------------------------
132
+ // datetimes (grain finer than day)
133
+ // ---------------------------------------------------------------------------
134
+ function describeDateTime(z, grain, ctx, style) {
135
+ const day = describeCoarse(z, 'day', ctx);
136
+ const timeExpr = timeLiteralFor(z, grain);
137
+ const expr = { op: 'intersect', parts: [day.expr, timeExpr] };
138
+ const outGrain = grain === 'instant' ? 'minute' : grain;
139
+ if (style === 'casual') {
140
+ const casualDay = casualDayPhrase(z, ctx);
141
+ if (casualDay) {
142
+ return {
143
+ text: `${casualTime(z, ctx)} ${casualDay}`,
144
+ expr,
145
+ grain: outGrain,
146
+ framing: 'calendar',
147
+ };
148
+ }
149
+ }
150
+ const timeText = fmt(z, ctx, { hour: 'numeric', minute: '2-digit' });
151
+ // "on Tuesday" already carries a preposition; "today"/"last Friday" need "at".
152
+ const text = `${day.text} at ${timeText}`;
153
+ return { text, expr, grain: outGrain, framing: 'calendar' };
154
+ }
155
+ function timeLiteralFor(z, grain) {
156
+ const h12 = z.hour % 12 === 0 ? 12 : z.hour % 12;
157
+ const meridiem = z.hour < 12 ? 'am' : 'pm';
158
+ const time = {
159
+ hour: h12,
160
+ meridiem,
161
+ };
162
+ if (grain !== 'hour')
163
+ time.minute = z.minute;
164
+ if (grain === 'second')
165
+ time.second = z.second;
166
+ return { op: 'literal', time };
167
+ }
168
+ /**
169
+ * Casual day-period phrase for near days: "last night", "this morning",
170
+ * "tonight", "tomorrow evening". Returns undefined beyond ±1 day.
171
+ */
172
+ function casualDayPhrase(z, ctx) {
173
+ const dayDiff = calendarDaysBetween(ctx.zonedNow, z);
174
+ if (dayDiff < -1 || dayDiff > 1)
175
+ return undefined;
176
+ const period = z.hour < 6 ? 'morning' : periodForHour(ctx.language, z.hour);
177
+ if (dayDiff === 0) {
178
+ if (period === 'night')
179
+ return 'tonight';
180
+ return `this ${period}`;
181
+ }
182
+ if (dayDiff === -1) {
183
+ if (period === 'evening' || period === 'night')
184
+ return 'last night';
185
+ return `yesterday ${period}`;
186
+ }
187
+ if (period === 'night')
188
+ return 'tomorrow night';
189
+ return `tomorrow ${period}`;
190
+ }
191
+ function casualTime(z, ctx) {
192
+ void ctx;
193
+ const h12 = z.hour % 12 === 0 ? 12 : z.hour % 12;
194
+ const suffix = z.hour < 12 ? 'am' : 'pm';
195
+ return z.minute === 0 ? `${h12}${suffix}` : `${h12}:${String(z.minute).padStart(2, '0')}${suffix}`;
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // elapsed & absolute framings
199
+ // ---------------------------------------------------------------------------
200
+ const ELAPSED_LADDER = [
201
+ { unit: 'second', seconds: 1, max: 60 },
202
+ { unit: 'minute', seconds: 60, max: 3600 },
203
+ { unit: 'hour', seconds: 3600, max: 86400 * 2 },
204
+ { unit: 'day', seconds: 86400, max: 86400 * 30 },
205
+ { unit: 'month', seconds: 86400 * 30, max: 86400 * 365 },
206
+ { unit: 'year', seconds: 86400 * 365, max: Infinity },
207
+ ];
208
+ function describeElapsed(z, ctx) {
209
+ const diffSec = (z.epochMilliseconds - ctx.zonedNow.epochMilliseconds) / 1000;
210
+ const abs = Math.abs(diffSec);
211
+ if (abs < 1) {
212
+ return { text: 'now', expr: { op: 'now' }, grain: 'second', framing: 'elapsed' };
213
+ }
214
+ const rung = ELAPSED_LADDER.find((r) => abs < r.max) ?? ELAPSED_LADDER[ELAPSED_LADDER.length - 1];
215
+ const n = Math.max(1, Math.trunc(abs / rung.seconds));
216
+ const signed = diffSec < 0 ? -n : n;
217
+ const rtf = new Intl.RelativeTimeFormat(ctx.locale, { numeric: 'always' });
218
+ return {
219
+ text: rtf.format(signed, rung.unit),
220
+ expr: { op: 'offset', base: NOW, amount: signed, unit: rung.unit },
221
+ grain: rung.unit,
222
+ framing: 'elapsed',
223
+ };
224
+ }
225
+ function describeAbsolute(z, grain, ctx) {
226
+ const fine = ['instant', 'second', 'minute', 'hour'].includes(grain);
227
+ const dateOpts = fine
228
+ ? { dateStyle: 'medium', timeStyle: 'short' }
229
+ : { dateStyle: 'medium' };
230
+ const date = { year: z.year, month: z.month, day: z.day };
231
+ const expr = fine
232
+ ? { op: 'intersect', parts: [{ op: 'literal', date }, timeLiteralFor(z, grain)] }
233
+ : { op: 'literal', date };
234
+ return {
235
+ text: fmt(z, ctx, dateOpts),
236
+ expr,
237
+ grain: fine ? (grain === 'instant' ? 'minute' : grain) : grain,
238
+ framing: 'absolute',
239
+ };
240
+ }
241
+ function describeAmount(value, ctx) {
242
+ void ctx;
243
+ if (value.kind === 'duration') {
244
+ return {
245
+ text: value.iso,
246
+ expr: { op: 'duration', iso: value.iso },
247
+ grain: 'second',
248
+ framing: 'absolute',
249
+ };
250
+ }
251
+ const parts = Object.entries(value.amount)
252
+ .filter(([, v]) => typeof v === 'number' && v !== 0)
253
+ .map(([k, v]) => `${v} ${Math.abs(v) === 1 ? k.slice(0, -1) : k}`);
254
+ return {
255
+ text: parts.join(' '),
256
+ expr: { op: 'amount', amount: value.amount },
257
+ grain: 'second',
258
+ framing: 'absolute',
259
+ };
260
+ }
261
+ // ---------------------------------------------------------------------------
262
+ function unitsBetween(a, b, unit) {
263
+ const dur = b.since(a, {
264
+ largestUnit: unit === 'quarter' ? 'month' : unit,
265
+ smallestUnit: unit === 'quarter' ? 'month' : unit,
266
+ roundingMode: 'trunc',
267
+ });
268
+ switch (unit) {
269
+ case 'second':
270
+ return dur.seconds;
271
+ case 'minute':
272
+ return dur.minutes;
273
+ case 'hour':
274
+ return dur.hours;
275
+ case 'day':
276
+ return dur.days;
277
+ case 'week':
278
+ return dur.weeks;
279
+ case 'month':
280
+ return dur.months;
281
+ case 'quarter':
282
+ return Math.trunc(dur.months / 3);
283
+ case 'year':
284
+ return dur.years;
285
+ }
286
+ }
287
+ function fmt(z, ctx, opts) {
288
+ return new Intl.DateTimeFormat(ctx.locale, { ...opts, timeZone: ctx.timeZone }).format(new Date(z.epochMilliseconds));
289
+ }
@@ -0,0 +1,31 @@
1
+ import { type Instant, type ZInterval } from '../clock/index.js';
2
+ import type { TimeContext } from '../context.js';
3
+ import type { CalendarAmount, DayPeriod, Grain, TimeExpr } from '../ir/types.js';
4
+ export declare class NotResolvableError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export declare class AmbiguityError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ /** A resolved value. Intervals are half-open [start, end). */
11
+ export type TimeValue = {
12
+ kind: 'interval';
13
+ start: Instant;
14
+ end: Instant;
15
+ grain: Grain;
16
+ } | {
17
+ kind: 'duration';
18
+ iso: string;
19
+ } | {
20
+ kind: 'amount';
21
+ amount: CalendarAmount;
22
+ };
23
+ export interface Resolution {
24
+ /** Ordered best-first under the context policy. Ambiguity is data, not an error. */
25
+ candidates: TimeValue[];
26
+ }
27
+ export declare function resolve(expr: TimeExpr, ctx: TimeContext): Resolution;
28
+ /** First candidate under policy, or throw. */
29
+ export declare function resolveOne(expr: TimeExpr, ctx: TimeContext): TimeValue;
30
+ /** The day-period interval within (anchored to) the day of `base`. */
31
+ export declare function dayPeriodInterval(base: ZInterval, period: DayPeriod, ctx: TimeContext): ZInterval | undefined;