Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/locales/en/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ENYearMonthDayParser from "./parsers/ENYearMonthDayParser";
import ENYearMonthNameParser from "./parsers/ENYearMonthNameParser";
import ENSlashMonthFormatParser from "./parsers/ENSlashMonthFormatParser";
import ENTimeExpressionParser from "./parsers/ENTimeExpressionParser";
import ENMilitaryTimeParser from "./parsers/ENMilitaryTimeParser";
import ENTimeUnitAgoFormatParser from "./parsers/ENTimeUnitAgoFormatParser";
import ENTimeUnitLaterFormatParser from "./parsers/ENTimeUnitLaterFormatParser";
import ENMergeDateRangeRefiner from "./refiners/ENMergeDateRangeRefiner";
Expand Down Expand Up @@ -64,6 +65,7 @@ export default class ENDefaultConfiguration {
new ENWeekdayParser(),
new ENSlashMonthFormatParser(),
new ENTimeExpressionParser(strictMode),
new ENMilitaryTimeParser(),
new ENTimeUnitAgoFormatParser(strictMode),
new ENTimeUnitLaterFormatParser(strictMode),
new ENYearMonthNameParser(),
Expand Down
52 changes: 52 additions & 0 deletions src/locales/en/parsers/ENMilitaryTimeParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ParsingContext } from "../../../chrono";
import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary";
import { Meridiem } from "../../../types";

// Military time: a four-digit HHMM group, e.g. "1430" for 14:30.
//
// Bare four-digit numbers are deliberately NOT parsed as times by
// chrono (they are far more often years - "2019 to 2020"), so this
// parser only accepts the shapes that mark the number as a time:
// - with an "hours" suffix: "1430 hours", "0930hrs", "0800 hr"
// - introduced by "at": "at 1430", "meet at 0930"
// - with a "Z" (Zulu/UTC) designator: "1430Z", "1430 Z"
//
// The HHMM value itself is validated: hour must be 00-23 and minute
// 00-59, so year-like values such as "at 2019" only parse when they
// also form a valid time of day (20:19) - which, preceded by "at",
// is the reading the writer intended.
const PATTERN = /(at\s+)?(\d{4})(?:\s*(hours?|hrs?)\b|\s*(z)\b)?(?!\d)(?![.:]\d)/i;

export default class ENMilitaryTimeParser extends AbstractParserWithWordBoundaryChecking {
innerPattern() {
return PATTERN;
}

innerExtract(context: ParsingContext, match: RegExpMatchArray) {
const atPrefix = match[1];
const digits = match[2];
const hoursSuffix = match[3];
const zuluSuffix = match[4];

// A bare HHMM with no time marker at all is not military
// time - leave it to the year handling.
if (!atPrefix && !hoursSuffix && !zuluSuffix) {
return null;
}

const hour = parseInt(digits.substring(0, 2), 10);
const minute = parseInt(digits.substring(2, 4), 10);
if (hour > 23 || minute > 59) {
return null;
}

const components = context.createParsingComponents();
components.assign("hour", hour);
components.assign("minute", minute);
components.assign("meridiem", hour < 12 ? Meridiem.AM : Meridiem.PM);
if (zuluSuffix) {
components.assign("timezoneOffset", 0);
}
return components.addTag("parser/ENMilitaryTimeParser");
}
}
92 changes: 92 additions & 0 deletions test/en/en_military_time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as chrono from "../../src";
import { testSingleCase, testUnexpectedResult } from "../test_util";
import { Meridiem } from "../../src";

test("Test - Military time with 'hours' suffix", function () {
testSingleCase(chrono, "The briefing is at 1430 hours", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.index).toBe(16);
expect(result.text).toBe("at 1430 hours");
expect(result.start.get("hour")).toBe(14);
expect(result.start.get("minute")).toBe(30);
expect(result.start.get("meridiem")).toBe(Meridiem.PM);
expect(result.tags()).toContain("parser/ENMilitaryTimeParser");
});

testSingleCase(chrono, "Depart at 0930hrs", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.index).toBe(7);
expect(result.text).toBe("at 0930hrs");
expect(result.start.get("hour")).toBe(9);
expect(result.start.get("minute")).toBe(30);
expect(result.start.get("meridiem")).toBe(Meridiem.AM);
});

testSingleCase(chrono, "Report by 0800 hr", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.text).toBe("0800 hr");
expect(result.start.get("hour")).toBe(8);
expect(result.start.get("minute")).toBe(0);
});

testSingleCase(chrono, "2359 hours", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.text).toBe(text);
expect(result.start.get("hour")).toBe(23);
expect(result.start.get("minute")).toBe(59);
});

testSingleCase(chrono, "0000 hours", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.text).toBe(text);
expect(result.start.get("hour")).toBe(0);
expect(result.start.get("minute")).toBe(0);
expect(result.start.get("meridiem")).toBe(Meridiem.AM);
});
});

test("Test - Military time with 'at' prefix", function () {
testSingleCase(chrono, "Meet at 1430", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.index).toBe(5);
expect(result.text).toBe("at 1430");
expect(result.start.get("hour")).toBe(14);
expect(result.start.get("minute")).toBe(30);
});

testSingleCase(chrono, "The train leaves at 0745 tomorrow", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.text).toBe("at 0745 tomorrow");
expect(result.start.get("day")).toBe(22);
expect(result.start.get("hour")).toBe(7);
expect(result.start.get("minute")).toBe(45);
});
});

test("Test - Military time with Zulu designator", function () {
testSingleCase(chrono, "Pickup at 1430Z", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.text).toBe("at 1430Z");
expect(result.start.get("hour")).toBe(14);
expect(result.start.get("minute")).toBe(30);
expect(result.start.get("timezoneOffset")).toBe(0);
});
});

test("Test - Military time merges with a date", function () {
testSingleCase(chrono, "On Oct 22 at 1430 hours", new Date(2026, 9, 21, 8), (result, text) => {
expect(result.start.get("year")).toBe(2026);
expect(result.start.get("month")).toBe(10);
expect(result.start.get("day")).toBe(22);
expect(result.start.get("hour")).toBe(14);
expect(result.start.get("minute")).toBe(30);
});
});

test("Test - Bare four-digit numbers are not military time", function () {
testUnexpectedResult(chrono, "2019 to 2020");

testUnexpectedResult(chrono, "The year 2020 was long");

testUnexpectedResult(chrono, "1430");
});

test("Test - Invalid military times are rejected", function () {
testUnexpectedResult(chrono, "Meet at 2460");

testUnexpectedResult(chrono, "Meet at 1299 hours");

testUnexpectedResult(chrono, "Meet at 2500 hours");
});