Skip to content

Commit c557ac0

Browse files
authored
feat: Add support for BC dates (negative years in ISO 8601) (#12)
Implement parsing and stringification of BC dates using ISO 8601 extended year format with negative year numbers. This follows the astronomical year numbering system where year 0 = 1 BC, year -1 = 2 BC, etc. Changes: - Parser: Handle optional leading dash before year component - Stringify: Format negative years with proper padding (e.g., -0044) - Add comprehensive test coverage for BC dates, including: - Full dates, year-month, and year-only formats - Year 0 (1 BC in proleptic Gregorian calendar) - BC dates with time and timezone components - Round-trip parsing and stringification Examples: - parseTemporal('-0044-03-15') → year: -44 (44 BC) - parseTemporal('0000-01-01') → year: 0 (1 BC) - stringifyDate({ year: -44, month: 3, day: 15 }) → '-0044-03-15' The implementation maintains backward compatibility and follows ISO 8601:2004 extended year format specification.
1 parent 692b0f7 commit c557ac0

File tree

6 files changed

+178
-2
lines changed

6 files changed

+178
-2
lines changed

.changeset/bc-date-support.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@taskade/temporal-parser': minor
3+
---
4+
5+
feat: Add support for BC dates (negative years in ISO 8601)
6+
7+
Implement parsing and stringification of BC dates using ISO 8601 extended year format with negative year numbers. This follows the astronomical year numbering system where year 0 = 1 BC, year -1 = 2 BC, etc.
8+
9+
**Key features:**
10+
- Parse BC dates with negative year notation: `-0044-03-15` (44 BC)
11+
- Support year 0 representing 1 BC: `0000-01-01`
12+
- Handle BC dates with time and timezone components
13+
- Proper year padding in output: `-0044`, `-0001`
14+
- Full round-trip compatibility
15+
16+
**Supported formats:**
17+
- BC year only: `-0100`
18+
- BC year-month: `-0753-04`
19+
- BC full date: `-0044-03-15`
20+
- BC datetime: `-0044-03-15T12:00:00`
21+
- BC with timezone: `-0044-03-15T12:00:00Z`
22+
23+
**Examples:**
24+
```typescript
25+
// Parse the Ides of March, 44 BC
26+
const bcDate = parseTemporal('-0044-03-15');
27+
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }
28+
29+
// Stringify BC date
30+
stringifyDate({ kind: 'Date', year: -44, month: 3, day: 15 });
31+
// Returns: '-0044-03-15'
32+
33+
// Year 0 represents 1 BC in ISO 8601
34+
parseTemporal('0000-01-01');
35+
// { kind: 'DateTime', date: { year: 0, month: 1, day: 1 }, ... }
36+
```
37+
38+
This implementation maintains full backward compatibility and follows ISO 8601:2004 extended year format specification.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ const duration = parseTemporal('P1Y2M3DT4H5M6S');
4242
// Parse a date range
4343
const range = parseTemporal('2025-01-01/2025-12-31');
4444
// { kind: 'Range', start: {...}, end: {...} }
45+
46+
// Parse BC dates (negative years in ISO 8601)
47+
const bcDate = parseTemporal('-0044-03-15');
48+
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }
4549
```
4650

4751
## Supported Formats
@@ -50,6 +54,7 @@ const range = parseTemporal('2025-01-01/2025-12-31');
5054
- Year: `2025`
5155
- Year-Month: `2025-01`
5256
- Full date: `2025-01-12`
57+
- BC dates (negative years): `-0044-03-15` (44 BC), `0000-01-01` (1 BC)
5358

5459
### Times
5560
- Hour-Minute: `T10:30`

src/parser.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,63 @@ describe('parseTemporal', () => {
3232
annotations: [],
3333
});
3434
});
35+
36+
it('should parse BC date (negative year)', () => {
37+
const ast = parseTemporal('-0044-03-15');
38+
expect(ast).toMatchObject({
39+
kind: 'DateTime',
40+
date: { kind: 'Date', year: -44, month: 3, day: 15 },
41+
annotations: [],
42+
});
43+
});
44+
45+
it('should parse BC year only', () => {
46+
const ast = parseTemporal('-0100');
47+
expect(ast).toMatchObject({
48+
kind: 'DateTime',
49+
date: { kind: 'Date', year: -100 },
50+
annotations: [],
51+
});
52+
});
53+
54+
it('should parse BC year-month', () => {
55+
const ast = parseTemporal('-0753-04');
56+
expect(ast).toMatchObject({
57+
kind: 'DateTime',
58+
date: { kind: 'Date', year: -753, month: 4 },
59+
annotations: [],
60+
});
61+
});
62+
63+
it('should parse year 0 (1 BC in ISO 8601)', () => {
64+
const ast = parseTemporal('0000-01-01');
65+
expect(ast).toMatchObject({
66+
kind: 'DateTime',
67+
date: { kind: 'Date', year: 0, month: 1, day: 1 },
68+
annotations: [],
69+
});
70+
});
71+
72+
it('should parse BC datetime with time', () => {
73+
const ast = parseTemporal('-0044-03-15T12:00:00');
74+
expect(ast).toMatchObject({
75+
kind: 'DateTime',
76+
date: { kind: 'Date', year: -44, month: 3, day: 15 },
77+
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
78+
annotations: [],
79+
});
80+
});
81+
82+
it('should parse BC datetime with timezone', () => {
83+
const ast = parseTemporal('-0044-03-15T12:00:00Z');
84+
expect(ast).toMatchObject({
85+
kind: 'DateTime',
86+
date: { kind: 'Date', year: -44, month: 3, day: 15 },
87+
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
88+
offset: { kind: 'UtcOffset' },
89+
annotations: [],
90+
});
91+
});
3592
});
3693

3794
describe('time parsing', () => {

src/parser.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,15 @@ class Parser {
140140
}
141141

142142
private parseDate(): DateAst {
143+
// Check for optional leading dash (negative year / BC date)
144+
// ISO 8601: Year 0 = 1 BC, Year -1 = 2 BC, etc.
145+
const isNegative = this.tryEat(TokType.Dash);
146+
143147
const yTok = this.eat(TokType.Number);
144-
const year = toInt(yTok.value, 'year', this.i);
148+
let year = toInt(yTok.value, 'year', this.i);
149+
if (isNegative) {
150+
year = -year;
151+
}
145152

146153
// Optional -MM
147154
if (!this.tryEat(TokType.Dash)) {

src/stringify.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,3 +641,62 @@ describe('RFC 9557 format', () => {
641641
expect(result).toBe('2025[u-ca=gregory]');
642642
});
643643
});
644+
645+
describe('BC dates (negative years)', () => {
646+
it('should stringify BC date (negative year)', () => {
647+
// Manually construct AST for 44 BC (year -43 in ISO 8601, since year 0 exists)
648+
const result = stringifyDate({ kind: 'Date', year: -43, month: 3, day: 15 });
649+
expect(result).toBe('-0043-03-15');
650+
});
651+
652+
it('should stringify BC year only', () => {
653+
const result = stringifyDate({ kind: 'Date', year: -100 });
654+
expect(result).toBe('-0100');
655+
});
656+
657+
it('should stringify BC year-month', () => {
658+
const result = stringifyDate({ kind: 'Date', year: -753, month: 4 });
659+
expect(result).toBe('-0753-04');
660+
});
661+
662+
it('should stringify year 1 BC (year 0 in ISO 8601)', () => {
663+
const result = stringifyDate({ kind: 'Date', year: 0, month: 1, day: 1 });
664+
expect(result).toBe('0000-01-01');
665+
});
666+
667+
it('should stringify BC datetime', () => {
668+
const result = stringifyDateTime({
669+
kind: 'DateTime',
670+
date: { kind: 'Date', year: -43, month: 3, day: 15 },
671+
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
672+
annotations: [],
673+
});
674+
expect(result).toBe('-0043-03-15T12:00:00');
675+
});
676+
677+
it('should handle negative year padding', () => {
678+
const result = stringifyDate({ kind: 'Date', year: -1, month: 1, day: 1 });
679+
expect(result).toBe('-0001-01-01');
680+
});
681+
682+
it('should round-trip BC date parsing and stringifying', () => {
683+
const input = '-0044-03-15';
684+
const ast = parseTemporal(input);
685+
const result = stringifyTemporal(ast);
686+
expect(result).toBe(input);
687+
});
688+
689+
it('should round-trip BC year only', () => {
690+
const input = '-0100';
691+
const ast = parseTemporal(input);
692+
const result = stringifyTemporal(ast);
693+
expect(result).toBe(input);
694+
});
695+
696+
it('should round-trip BC datetime', () => {
697+
const input = '-0044-03-15T12:00:00Z';
698+
const ast = parseTemporal(input);
699+
const result = stringifyTemporal(ast);
700+
expect(result).toBe(input);
701+
});
702+
});

src/stringify.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,17 @@ export function stringifyTemporal(ast: TemporalAst): string {
4343
* @returns Date string (YYYY, YYYY-MM, or YYYY-MM-DD)
4444
*/
4545
export function stringifyDate(date: DateAst): string {
46-
const parts: string[] = [date.year.toString().padStart(4, '0')];
46+
// Handle negative years (BC dates) - ISO 8601 uses negative years
47+
// Year 0 = 1 BC, Year -1 = 2 BC, etc.
48+
let yearStr: string;
49+
if (date.year < 0) {
50+
// For negative years, pad the absolute value and prepend the minus sign
51+
yearStr = '-' + Math.abs(date.year).toString().padStart(4, '0');
52+
} else {
53+
yearStr = date.year.toString().padStart(4, '0');
54+
}
55+
56+
const parts: string[] = [yearStr];
4757

4858
if (date.month != null) {
4959
parts.push(date.month.toString().padStart(2, '0'));

0 commit comments

Comments
 (0)