Skip to content

Commit d162853

Browse files
committed
feat(time): add ISODate, ISODateTime, LiteralMonthDayYear, ClockTime parsers, noon/midnight
New Time parsers: - ISODate: YYYY-MM-DD (e.g. 2024-05-18) - ISODateTime: ISO with offset/no-Z (e.g. 2024-05-18T10:30:00+02:00) - LiteralMonthDayYear: Month Day[,] Year (e.g. July 13, 2016) - ClockTime: HH:MM[:SS] [AM/PM] [(TZ)] (e.g. 23:28 (UTC), 3:45 PM) - noon/midnight added to Common parser 15 new tests (168 total).
1 parent 03a07c9 commit d162853

2 files changed

Lines changed: 311 additions & 0 deletions

File tree

src/Time.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const time = (
9191

9292
type TimeLanguage = {
9393
ISODateTimeZ: Parser<TimeEntity>;
94+
ISODateTime: Parser<TimeEntity>;
9495
Grain: Parser<string>;
9596
UnspecifiedGrainAmount: Parser<TimeEntity>;
9697
DayOfWeek: Parser<TimeEntity>;
@@ -107,6 +108,9 @@ type TimeLanguage = {
107108
QualifiedDay: Parser<number>;
108109
QualifiedGrain: Parser<TimeEntity>;
109110
PartialDateDayMonth: Parser<TimeEntity>;
111+
ISODate: Parser<TimeEntity>;
112+
LiteralMonthDayYear: Parser<TimeEntity>;
113+
ClockTime: Parser<TimeEntity>;
110114
FullDate: Parser<TimeEntity>;
111115
PartialDateMonthYearEra: Parser<TimeEntity>;
112116
FullDateEra: Parser<TimeEntity>;
@@ -136,6 +140,26 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
136140
},
137141
);
138142
},
143+
ISODateTime(_s) {
144+
// 2024-05-18T10:30:00, 2024-05-18T10:30:00+02:00, 2024-05-18T10:30:00-05:00
145+
// Also handles optional seconds and milliseconds.
146+
return safe(
147+
map(
148+
regex(
149+
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d{1,3})?(?:[+-]\d{2}:\d{2})?/,
150+
"iso-datetime",
151+
),
152+
(raw, b, a) => {
153+
const d = new Date(raw);
154+
if (Number.isNaN(d.getTime())) {
155+
return time({ when: raw, grain: "second" }, b, a);
156+
}
157+
return time({ when: d.toISOString(), grain: "second" }, b, a);
158+
},
159+
),
160+
"valid date",
161+
);
162+
},
139163
Grain(_s) {
140164
return any(
141165
regex(/sec(ond)?s?/i, "second"),
@@ -234,6 +258,12 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
234258
map(fuzzyCase("weekend"), (_res, b, a) => {
235259
return time({ when: "weekend", grain: "week" }, b, a);
236260
}),
261+
map(fuzzyCase("noon"), (_res, b, a) => {
262+
return time({ when: "12:00", grain: "hour" }, b, a);
263+
}),
264+
map(fuzzyCase("midnight"), (_res, b, a) => {
265+
return time({ when: "00:00", grain: "hour" }, b, a);
266+
}),
237267
);
238268
},
239269
GrainQuantity(s) {
@@ -456,6 +486,100 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
456486
"valid date",
457487
);
458488
},
489+
ISODate(s) {
490+
// YYYY-MM-DD (and optional time HH:MM[:SS])
491+
return safe(
492+
map(
493+
seq(
494+
s.Year,
495+
str("-"),
496+
s.NumericMonth,
497+
str("-"),
498+
s.Day,
499+
),
500+
([year, , month, , day], b, a) => {
501+
const pad = (n: number) => String(n).padStart(2, "0");
502+
const iso = `${year}-${pad(month)}-${pad(day)}T00:00:00.000Z`;
503+
return time(
504+
{
505+
when: new Date(iso).toISOString(),
506+
grain: "day",
507+
},
508+
b,
509+
a,
510+
);
511+
},
512+
),
513+
"valid date",
514+
);
515+
},
516+
LiteralMonthDayYear(s) {
517+
// "July 13, 2016" / "July 13 2016"
518+
return safe(
519+
map(
520+
seq(
521+
s.LiteralMonth,
522+
skip1(space()),
523+
s.Day,
524+
optional(str(",")),
525+
skip1(space()),
526+
s.Year,
527+
),
528+
([month, , day, , , year], b, a) => {
529+
return time(
530+
{
531+
when: new Date(`${day} ${month} ${year}`).toISOString(),
532+
grain: "day",
533+
},
534+
b,
535+
a,
536+
);
537+
},
538+
),
539+
"valid date",
540+
);
541+
},
542+
ClockTime(_s) {
543+
// 23:28, 23:28:59, 14:00 UTC, 23:28 (UTC), 3:45 PM, 3:45:00 AM
544+
return map(
545+
seq(
546+
regex(/(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?/, "clock-time"),
547+
optional(
548+
seq(
549+
optional(space()),
550+
any(
551+
regex(/[AP]M/i, "am-pm"),
552+
seq(
553+
optional(str("(")),
554+
regex(/[A-Z]{2,5}/, "timezone"),
555+
optional(str(")")),
556+
),
557+
),
558+
),
559+
),
560+
),
561+
([clockStr, suffix], b, a) => {
562+
let label = clockStr;
563+
if (suffix) {
564+
const [maybeSpace, tz] = suffix;
565+
if (typeof tz === "string") {
566+
label = `${clockStr}${maybeSpace ?? ""}${tz}`;
567+
} else {
568+
const [open, tzName, close] = tz;
569+
label = `${clockStr}${maybeSpace ?? ""}${open ?? ""}${tzName}${close ?? ""}`;
570+
}
571+
}
572+
return time(
573+
{
574+
when: label,
575+
grain: clockStr.split(":").length > 2 ? "second" : "minute",
576+
},
577+
b,
578+
a,
579+
);
580+
},
581+
);
582+
},
459583
FullDate(s) {
460584
return any(
461585
safe(
@@ -582,8 +706,12 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
582706
return dot(
583707
any(
584708
s.ISODateTimeZ,
709+
s.ISODateTime,
710+
s.ISODate,
585711
s.FullDateEra,
712+
s.LiteralMonthDayYear,
586713
s.FullDate,
714+
s.ClockTime,
587715
s.Relative,
588716
s.PartialDateMonthYearEra,
589717
s.PartialDateMonthYear,

tests/Time.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,186 @@ Deno.test("FullDate: does not crash on nonsense date-like input", () => {
462462
}
463463
}
464464
});
465+
466+
// ── ISODate (YYYY-MM-DD) ───────────────────────────────────────────
467+
468+
Deno.test("ISODate: basic YYYY-MM-DD", () => {
469+
const res = Duckling([Time.parser]).extract("Published 2024-05-18.");
470+
471+
assertEquals(res, [
472+
{
473+
end: 20,
474+
kind: "time",
475+
start: 10,
476+
text: "2024-05-18",
477+
value: {
478+
era: "CE",
479+
grain: "day",
480+
when: "2024-05-18T00:00:00.000Z",
481+
},
482+
},
483+
]);
484+
});
485+
486+
Deno.test("ISODate: multiple YYYY-MM-DD in sentence", () => {
487+
const res = Duckling([Time.parser]).extract(
488+
"Born on 1990-03-15, died 2060-01-01.",
489+
);
490+
491+
assertEquals(res.length, 2);
492+
assertEquals(res[0].text, "1990-03-15");
493+
assertEquals(res[0].value.when, "1990-03-15T00:00:00.000Z");
494+
assertEquals(res[1].text, "2060-01-01");
495+
assertEquals(res[1].value.when, "2060-01-01T00:00:00.000Z");
496+
});
497+
498+
// ── ISODateTime (with offset / without Z) ──────────────────────────
499+
500+
Deno.test("ISODateTime: with positive offset", () => {
501+
const res = Duckling([Time.parser]).extract(
502+
"Logged at 2024-05-18T10:30:00+02:00.",
503+
);
504+
505+
assertEquals(res.length, 1);
506+
assertEquals(res[0].text, "2024-05-18T10:30:00+02:00");
507+
assertEquals(res[0].value.when, "2024-05-18T08:30:00.000Z");
508+
assertEquals(res[0].value.grain, "second");
509+
});
510+
511+
Deno.test("ISODateTime: with negative offset", () => {
512+
const res = Duckling([Time.parser]).extract(
513+
"Event: 2024-05-18T10:30:00-05:00.",
514+
);
515+
516+
assertEquals(res.length, 1);
517+
assertEquals(res[0].text, "2024-05-18T10:30:00-05:00");
518+
assertEquals(res[0].value.when, "2024-05-18T15:30:00.000Z");
519+
});
520+
521+
Deno.test("ISODateTime: without timezone (local time)", () => {
522+
const res = Duckling([Time.parser]).extract(
523+
"Timestamp: 2024-05-18T10:30:00.",
524+
);
525+
526+
assertEquals(res.length, 1);
527+
assertEquals(res[0].text, "2024-05-18T10:30:00");
528+
assertEquals(res[0].value.grain, "second");
529+
});
530+
531+
Deno.test("ISODateTime: without seconds", () => {
532+
const res = Duckling([Time.parser]).extract(
533+
"Meeting at 2024-05-18T10:30.",
534+
);
535+
536+
assertEquals(res.length, 1);
537+
assertEquals(res[0].text, "2024-05-18T10:30");
538+
assertEquals(res[0].value.grain, "second");
539+
});
540+
541+
// ── LiteralMonthDayYear ────────────────────────────────────────────
542+
543+
Deno.test("LiteralMonthDayYear: with comma", () => {
544+
const res = Duckling([Time.parser]).extract("On July 13, 2016 we met.");
545+
546+
assertEquals(res.length, 1);
547+
assertEquals(res[0].text, "July 13, 2016");
548+
assertEquals(res[0].value.grain, "day");
549+
});
550+
551+
Deno.test("LiteralMonthDayYear: without comma", () => {
552+
const res = Duckling([Time.parser]).extract("On March 3 1990 we met.");
553+
554+
assertEquals(res.length, 1);
555+
assertEquals(res[0].text, "March 3 1990");
556+
assertEquals(res[0].value.grain, "day");
557+
});
558+
559+
Deno.test("LiteralMonthDayYear: various months", () => {
560+
const inputs = [
561+
"January 1, 2000",
562+
"December 25, 2024",
563+
"September 5, 2019",
564+
];
565+
for (const input of inputs) {
566+
const res = Duckling([Time.parser]).extract(`Published: ${input}.`);
567+
assertEquals(
568+
res.length >= 1,
569+
true,
570+
`Should extract at least one entity from "${input}", got ${res.length}`,
571+
);
572+
assertEquals(res[0].text, input);
573+
assertEquals(res[0].value.grain, "day");
574+
}
575+
});
576+
577+
// ── ClockTime ──────────────────────────────────────────────────────
578+
579+
Deno.test("ClockTime: HH:MM with timezone in parens", () => {
580+
const res = Duckling([Time.parser]).extract("The call is at 23:28 (UTC).");
581+
582+
assertEquals(res.length, 1);
583+
assertEquals(res[0].text, "23:28 (UTC)");
584+
assertEquals(res[0].value.when, "23:28 (UTC)");
585+
assertEquals(res[0].value.grain, "minute");
586+
});
587+
588+
Deno.test("ClockTime: HH:MM:SS", () => {
589+
const res = Duckling([Time.parser]).extract("Logged at 23:28:59.");
590+
591+
assertEquals(res.length, 1);
592+
assertEquals(res[0].text, "23:28:59");
593+
assertEquals(res[0].value.grain, "second");
594+
});
595+
596+
Deno.test("ClockTime: 12-hour AM/PM", () => {
597+
const cases = [
598+
{ input: "Meet at 3:45 PM.", expected: "3:45 PM" },
599+
{ input: "Wake up at 6:00 AM.", expected: "6:00 AM" },
600+
{ input: "Deadline 11:59 pm.", expected: "11:59 pm" },
601+
];
602+
for (const { input, expected } of cases) {
603+
const res = Duckling([Time.parser]).extract(input);
604+
assertEquals(
605+
res.length >= 1,
606+
true,
607+
`Should extract at least one entity from "${input}"`,
608+
);
609+
assertEquals(res[0].text, expected);
610+
assertEquals(res[0].value.grain, "minute");
611+
}
612+
});
613+
614+
Deno.test("ClockTime: HH:MM with bare timezone", () => {
615+
const cases = [
616+
{ input: "14:00 UTC", expected: "14:00 UTC" },
617+
{ input: "09:30 EST", expected: "09:30 EST" },
618+
{ input: "11:15 CET", expected: "11:15 CET" },
619+
];
620+
for (const { input, expected } of cases) {
621+
const res = Duckling([Time.parser]).extract(input);
622+
assertEquals(
623+
res.length >= 1,
624+
true,
625+
`Should extract at least one entity from "${input}"`,
626+
);
627+
assertEquals(res[0].text, expected);
628+
}
629+
});
630+
631+
// ── Noon / Midnight ────────────────────────────────────────────────
632+
633+
Deno.test("Common: noon", () => {
634+
const res = Duckling([Time.parser]).extract("We eat lunch at noon.");
635+
assertEquals(res.length, 1);
636+
assertEquals(res[0].text, "noon");
637+
assertEquals(res[0].value.when, "12:00");
638+
assertEquals(res[0].value.grain, "hour");
639+
});
640+
641+
Deno.test("Common: midnight", () => {
642+
const res = Duckling([Time.parser]).extract("Come back before midnight.");
643+
assertEquals(res.length, 1);
644+
assertEquals(res[0].text, "midnight");
645+
assertEquals(res[0].value.when, "00:00");
646+
assertEquals(res[0].value.grain, "hour");
647+
});

0 commit comments

Comments
 (0)