diff --git a/.changeset/iso-basic-format-and-bare-hours.md b/.changeset/iso-basic-format-and-bare-hours.md new file mode 100644 index 0000000..c650796 --- /dev/null +++ b/.changeset/iso-basic-format-and-bare-hours.md @@ -0,0 +1,19 @@ +--- +"@taskade/temporal-parser": patch +--- + +Add support for ISO 8601 basic format and bare hours in `parseTimeString()` + +**New formats supported:** + +1. **ISO 8601 basic format** (compact, no colons): + - `"1430"` → 14:30 + - `"143045"` → 14:30:45 + - `"143045.123"` → 14:30:45.123 + +2. **Bare hours** (defaults to :00 minutes): + - `"7"` → 7:00 + - `"7 AM"` → 7:00 AM + - `"23"` → 23:00 + +The parser intelligently detects the format based on digit count and presence of colons, maintaining full backward compatibility with existing formats. diff --git a/src/parseTimeString.test.ts b/src/parseTimeString.test.ts index 99d7b48..f0bb049 100644 --- a/src/parseTimeString.test.ts +++ b/src/parseTimeString.test.ts @@ -340,18 +340,15 @@ describe('parseTimeString', () => { } }); - it('should throw on missing colon with format hint', () => { - try { - parseTimeString('1430'); - expect.fail('Should have thrown'); - } catch (e) { - expect(e).toBeInstanceOf(ParseError); - const message = (e as ParseError).message; - expect(message).toBe( - 'Invalid time format: "1430". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM") at token index 1', - ); - console.log('Missing colon error:', message); - } + it('should parse ISO 8601 basic format: "1430"', () => { + const result = parseTimeString('1430'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); }); it('should throw on invalid hour in 24-hour format with helpful message', () => { @@ -696,4 +693,283 @@ describe('parseTimeString', () => { }); }); }); + + describe('bare single-digit hours', () => { + it('should parse single digit "0" as 00:00', () => { + const result = parseTimeString('0'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse single digit "7" as 07:00', () => { + const result = parseTimeString('7'); + expect(result).toEqual({ + kind: 'Time', + hour: 7, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse single digit "9" as 09:00', () => { + const result = parseTimeString('9'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse double digit "23" as 23:00', () => { + const result = parseTimeString('23'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "7 AM" as 7:00 AM', () => { + const result = parseTimeString('7 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 7, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "12 PM" as 12:00 PM (noon)', () => { + const result = parseTimeString('12 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 12, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "12 AM" as 12:00 AM (midnight)', () => { + const result = parseTimeString('12 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "3 pm" (lowercase) as 15:00', () => { + const result = parseTimeString('3 pm'); + expect(result).toEqual({ + kind: 'Time', + hour: 15, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should throw on "0 AM" (invalid 12-hour format)', () => { + try { + parseTimeString('0 AM'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + } + }); + + it('should throw on "24" (invalid 24-hour format)', () => { + try { + parseTimeString('24'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid hour for 24-hour format: 24'); + } + }); + + it('should throw on "13 AM" (invalid 12-hour format)', () => { + try { + parseTimeString('13 AM'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid hour for 12-hour format: 13'); + } + }); + }); + + describe('ISO 8601 basic format (compact, no colons)', () => { + it('should parse "1430" as 14:30', () => { + const result = parseTimeString('1430'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "0900" as 09:00', () => { + const result = parseTimeString('0900'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "0000" as 00:00 (midnight)', () => { + const result = parseTimeString('0000'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "2359" as 23:59', () => { + const result = parseTimeString('2359'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 59, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse "143045" as 14:30:45 (with seconds)', () => { + const result = parseTimeString('143045'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: undefined, + }); + }); + + it('should parse "090000" as 09:00:00', () => { + const result = parseTimeString('090000'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: 0, + fraction: undefined, + }); + }); + + it('should parse "235959" as 23:59:59', () => { + const result = parseTimeString('235959'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 59, + second: 59, + fraction: undefined, + }); + }); + + it('should parse "143045.123" with fractional seconds', () => { + const result = parseTimeString('143045.123'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse "143045,123" with comma separator', () => { + const result = parseTimeString('143045,123'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should throw on invalid basic format hour "2430"', () => { + try { + parseTimeString('2430'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid hour for 24-hour format: 24'); + } + }); + + it('should throw on invalid basic format minute "1460"', () => { + try { + parseTimeString('1460'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid minute: 60'); + } + }); + + it('should throw on invalid basic format second "143060"', () => { + try { + parseTimeString('143060'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid second: 60'); + } + }); + + it('should not confuse 3-digit number "123" with basic format', () => { + // "123" should be treated as bare hour 123, which will fail validation + try { + parseTimeString('123'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid hour for 24-hour format: 123'); + } + }); + + it('should not confuse 5-digit number "12345" with basic format', () => { + // "12345" should be treated as bare hour 12345, which will fail validation + try { + parseTimeString('12345'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toContain('Invalid hour for 24-hour format: 12345'); + } + }); + }); }); diff --git a/src/parseTimeString.ts b/src/parseTimeString.ts index b25bdb8..3e87f3c 100644 --- a/src/parseTimeString.ts +++ b/src/parseTimeString.ts @@ -11,16 +11,18 @@ import type { TimeAst } from './parser-types.js'; /** * Parse a time-of-day string from multiple common formats. - * Supports: locale time ("9:07 AM"), 24h ("09:00", "14:30"), short 24h ("9:00"), - * and 12h with lowercase period ("9:07 am"). + * Supports: locale time ("9:07 AM"), 24h ("09:00", "14:30"), ISO 8601 basic format ("1430"), + * short 24h ("9:00"), bare hours ("7", "7 AM"), and 12h with lowercase period ("9:07 am"). * * TAA (LLM) often generates time in 24h format ("09:00") instead of the locale * format ("9:00 AM") that Luxon's 't' token expects. * * Supported formats: * - Locale time (12-hour with AM/PM): "9:07 AM", "2:30 PM", "02:30PM", "2:30 p.m." - * - 24-hour format: "09:00", "14:30", "23:59" + * - 24-hour format (extended): "09:00", "14:30", "23:59" + * - ISO 8601 basic format (compact): "1430", "0900", "143045", "143045.123" * - Short 24-hour (single digit hour): "9:00", "9:30" + * - Bare hours (defaults to :00): "7" (→ 7:00), "0" (→ 0:00), "7 AM" (→ 7:00 AM) * - Lowercase am/pm: "9:07 am", "2:30 pm" * - With optional seconds: "2:30:45 PM", "14:30:45" * - With optional fractional seconds: "2:30:45.123 PM", "14:30:45.123" @@ -62,44 +64,44 @@ export function parseTimeString(input: string): TimeAst { return null; }; - // Parse: Number : Number [: Number [. Number]] [Ident] - // Example: 2:30 PM, 14:30, 2:30:45.123 PM + // Parse: Number [: Number [: Number [. Number]]] [Ident] + // Example: 2:30 PM, 14:30, 2:30:45.123 PM, 7 AM, 9 + // Also supports ISO 8601 basic format: 1430, 143045, 143045.123 - // Parse hour + // Parse hour (or entire time in basic format) const hourTok = eat(TokType.Number); - let hour = toInt(hourTok.value, 'hour', i); + let hour: number; + let minute = 0; + let second: number | undefined; + let fraction: string | undefined; - // Parse colon - eat(TokType.Colon); + // Check if this is ISO 8601 basic format (no colons) + // Basic format: hhmm (4 digits), hhmmss (6 digits), or hhmmss.fff + const numLen = hourTok.value.length; - // Parse minute - const minuteTok = eat(TokType.Number); - const minute = toInt(minuteTok.value, 'minute', i); + if (!at(TokType.Colon) && (numLen === 4 || numLen === 6)) { + // ISO 8601 basic format + const timeStr = hourTok.value; - // Validate minute format (must be 2 digits) - if (minuteTok.value.length !== 2) { - throw new ParseError( - `Invalid time format: "${trimmed}". Minutes must be 2 digits (e.g., "9:07" not "9:7")`, - i, - ); - } + // Extract hour (first 2 digits) + hour = toInt(timeStr.substring(0, 2), 'hour', i); - // Validate minute - if (minute < 0 || minute > 59) { - throw new ParseError(`Invalid minute: ${minute}. Minutes must be between 00-59`, i); - } + // Extract minute (next 2 digits) + minute = toInt(timeStr.substring(2, 4), 'minute', i); - // Optional seconds - let second: number | undefined; - let fraction: string | undefined; + // Extract optional seconds (last 2 digits if length is 6) + if (numLen === 6) { + second = toInt(timeStr.substring(4, 6), 'second', i); - if (tryEat(TokType.Colon)) { - const secondTok = eat(TokType.Number); - second = toInt(secondTok.value, 'second', i); + // Validate second + if (second < 0 || second > 59) { + throw new ParseError(`Invalid second: ${second}. Seconds must be between 00-59`, i); + } + } - // Validate second - if (second < 0 || second > 59) { - throw new ParseError(`Invalid second: ${second}. Seconds must be between 00-59`, i); + // Validate minute + if (minute < 0 || minute > 59) { + throw new ParseError(`Invalid minute: ${minute}. Minutes must be between 00-59`, i); } // Optional fractional seconds (. or ,) @@ -107,6 +109,46 @@ export function parseTimeString(input: string): TimeAst { const fracTok = eat(TokType.Number); fraction = fracTok.value; } + } else { + // Extended format (with colons) or bare hour + hour = toInt(hourTok.value, 'hour', i); + + // Parse optional colon and minute + if (tryEat(TokType.Colon)) { + // Parse minute + const minuteTok = eat(TokType.Number); + minute = toInt(minuteTok.value, 'minute', i); + + // Validate minute format (must be 2 digits) + if (minuteTok.value.length !== 2) { + throw new ParseError( + `Invalid time format: "${trimmed}". Minutes must be 2 digits (e.g., "9:07" not "9:7")`, + i, + ); + } + + // Validate minute + if (minute < 0 || minute > 59) { + throw new ParseError(`Invalid minute: ${minute}. Minutes must be between 00-59`, i); + } + + // Optional seconds + if (tryEat(TokType.Colon)) { + const secondTok = eat(TokType.Number); + second = toInt(secondTok.value, 'second', i); + + // Validate second + if (second < 0 || second > 59) { + throw new ParseError(`Invalid second: ${second}. Seconds must be between 00-59`, i); + } + + // Optional fractional seconds (. or ,) + if (tryEat(TokType.Dot) || tryEat(TokType.Comma)) { + const fracTok = eat(TokType.Number); + fraction = fracTok.value; + } + } + } } // Optional AM/PM