diff --git a/package-lock.json b/package-lock.json index fc2dd80..9b3fd5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@taskade/temporal-parser", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@taskade/temporal-parser", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@changesets/cli": "^2.29.8", diff --git a/src/parser-types.ts b/src/parser-types.ts index e386cae..1f9fb11 100644 --- a/src/parser-types.ts +++ b/src/parser-types.ts @@ -9,7 +9,7 @@ export type RangeAst = { end: ValueAst | null; // null => open end }; -export type ValueAst = DateTimeAst | DurationAst; +export type ValueAst = DateTimeAst | DurationAst | TimeAst; export type DateTimeAst = { kind: 'DateTime'; diff --git a/src/parser.test.ts b/src/parser.test.ts index 29fcf2a..5ed9c0d 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -149,6 +149,79 @@ describe('parseTemporal', () => { }); }); + describe('standalone time parsing (ISO 8601 Clause 5.4)', () => { + it('should parse time with hour and minute (HH:MM)', () => { + const ast = parseTemporal('10:30'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 10, + minute: 30, + }); + }); + + it('should parse time with seconds (HH:MM:SS)', () => { + const ast = parseTemporal('10:30:45'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 10, + minute: 30, + second: 45, + }); + }); + + it('should parse time with fractional seconds (HH:MM:SS.fff)', () => { + const ast = parseTemporal('10:30:45.123'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 10, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse time with high-precision fractional seconds', () => { + const ast = parseTemporal('23:59:59.999999999'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 23, + minute: 59, + second: 59, + fraction: '999999999', + }); + }); + + it('should parse time with comma decimal separator (European format)', () => { + const ast = parseTemporal('10:30:45,123'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 10, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse midnight', () => { + const ast = parseTemporal('00:00:00'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 0, + minute: 0, + second: 0, + }); + }); + + it('should parse noon with reduced precision', () => { + const ast = parseTemporal('12:00'); + expect(ast).toMatchObject({ + kind: 'Time', + hour: 12, + minute: 0, + }); + }); + }); + describe('timezone parsing', () => { it('should parse Z timezone', () => { const ast = parseTemporal('2025-01-07T10:00:00Z'); @@ -600,6 +673,51 @@ describe('parseTemporal', () => { }, }); }); + + it('should parse time range', () => { + const ast = parseTemporal('10:00/12:00'); + expect(ast).toMatchObject({ + kind: 'Range', + start: { + kind: 'Time', + hour: 10, + minute: 0, + }, + end: { + kind: 'Time', + hour: 12, + minute: 0, + }, + }); + }); + + it('should parse open-start time range', () => { + const ast = parseTemporal('/23:59:59'); + expect(ast).toMatchObject({ + kind: 'Range', + start: null, + end: { + kind: 'Time', + hour: 23, + minute: 59, + second: 59, + }, + }); + }); + + it('should parse open-end time range', () => { + const ast = parseTemporal('00:00:00/'); + expect(ast).toMatchObject({ + kind: 'Range', + start: { + kind: 'Time', + hour: 0, + minute: 0, + second: 0, + }, + end: null, + }); + }); }); describe('error handling', () => { diff --git a/src/parser.ts b/src/parser.ts index 5293cdd..0b01762 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -91,6 +91,12 @@ class Parser { return this.parseDuration(); } + // Standalone time format: Number:Number (e.g., 10:30, 10:30:45) + // ISO 8601 Clause 5.4: time-only representations + if (this.at(TokType.Number) && this.peek(1).type === TokType.Colon) { + return this.parseTime(); + } + // Otherwise parse a date/datetime (common case) return this.parseDateTime(); } @@ -143,7 +149,7 @@ class Parser { // Check for optional leading dash (negative year / BC date) // ISO 8601: Year 0 = 1 BC, Year -1 = 2 BC, etc. const isNegative = this.tryEat(TokType.Dash); - + const yTok = this.eat(TokType.Number); let year = toInt(yTok.value, 'year', this.i); if (isNegative) { diff --git a/src/stringify.test.ts b/src/stringify.test.ts index 630d303..f671aee 100644 --- a/src/stringify.test.ts +++ b/src/stringify.test.ts @@ -467,6 +467,24 @@ describe('stringifyTemporal', () => { const result = stringifyTemporal(ast); expect(result).toBe('2025-01-01/2025-12-31'); }); + + it('should stringify standalone time', () => { + const ast = parseTemporal('10:30:45'); + const result = stringifyTemporal(ast); + expect(result).toBe('10:30:45'); + }); + + it('should stringify standalone time with reduced precision', () => { + const ast = parseTemporal('10:30'); + const result = stringifyTemporal(ast); + expect(result).toBe('10:30'); + }); + + it('should stringify standalone time with fractional seconds', () => { + const ast = parseTemporal('10:30:45.123'); + const result = stringifyTemporal(ast); + expect(result).toBe('10:30:45.123'); + }); }); describe('round-trip parsing', () => { @@ -483,6 +501,11 @@ describe('round-trip parsing', () => { '2025-01-12T10:00:00+08:00[Asia/Singapore]', '2025-01-12T10:00:00Z[u-ca=gregory]', '2025-01-12T10:00:00+08:00[Asia/Singapore][u-ca=gregory]', + '10:30', + '10:30:45', + '10:30:45.123', + '00:00:00', + '23:59:59.999999999', 'P1Y', 'P1Y2M', 'P1Y2M3D', @@ -497,6 +520,9 @@ describe('round-trip parsing', () => { '2025-01-01/', '2025-01-01/P1Y', 'P1D/P7D', + '10:00/12:00', + '/23:59:59', + '00:00:00/', ]; testCases.forEach((input) => { diff --git a/src/stringify.ts b/src/stringify.ts index 31f64b1..6c05f72 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -33,6 +33,9 @@ export function stringifyTemporal(ast: TemporalAst): string { if (ast.kind === 'Duration') { return stringifyDuration(ast); } + if (ast.kind === 'Time') { + return stringifyTime(ast); + } return stringifyDateTime(ast); } @@ -216,13 +219,17 @@ export function stringifyRange(range: RangeAst): string { const start = range.start ? range.start.kind === 'Duration' ? stringifyDuration(range.start) - : stringifyDateTime(range.start) + : range.start.kind === 'Time' + ? stringifyTime(range.start) + : stringifyDateTime(range.start) : ''; const end = range.end ? range.end.kind === 'Duration' ? stringifyDuration(range.end) - : stringifyDateTime(range.end) + : range.end.kind === 'Time' + ? stringifyTime(range.end) + : stringifyDateTime(range.end) : ''; return `${start}/${end}`;