Skip to content
Merged
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
38 changes: 38 additions & 0 deletions .changeset/bc-date-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@taskade/temporal-parser': minor
---

feat: Add support for BC dates (negative years in ISO 8601)

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.

**Key features:**
- Parse BC dates with negative year notation: `-0044-03-15` (44 BC)
- Support year 0 representing 1 BC: `0000-01-01`
- Handle BC dates with time and timezone components
- Proper year padding in output: `-0044`, `-0001`
- Full round-trip compatibility

**Supported formats:**
- BC year only: `-0100`
- BC year-month: `-0753-04`
- BC full date: `-0044-03-15`
- BC datetime: `-0044-03-15T12:00:00`
- BC with timezone: `-0044-03-15T12:00:00Z`

**Examples:**
```typescript
// Parse the Ides of March, 44 BC
const bcDate = parseTemporal('-0044-03-15');
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }

// Stringify BC date
stringifyDate({ kind: 'Date', year: -44, month: 3, day: 15 });
// Returns: '-0044-03-15'

// Year 0 represents 1 BC in ISO 8601
parseTemporal('0000-01-01');
// { kind: 'DateTime', date: { year: 0, month: 1, day: 1 }, ... }
```

This implementation maintains full backward compatibility and follows ISO 8601:2004 extended year format specification.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const duration = parseTemporal('P1Y2M3DT4H5M6S');
// Parse a date range
const range = parseTemporal('2025-01-01/2025-12-31');
// { kind: 'Range', start: {...}, end: {...} }

// Parse BC dates (negative years in ISO 8601)
const bcDate = parseTemporal('-0044-03-15');
// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... }
```

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

### Times
- Hour-Minute: `T10:30`
Expand Down
57 changes: 57 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,63 @@ describe('parseTemporal', () => {
annotations: [],
});
});

it('should parse BC date (negative year)', () => {
const ast = parseTemporal('-0044-03-15');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: -44, month: 3, day: 15 },
annotations: [],
});
});

it('should parse BC year only', () => {
const ast = parseTemporal('-0100');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: -100 },
annotations: [],
});
});

it('should parse BC year-month', () => {
const ast = parseTemporal('-0753-04');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: -753, month: 4 },
annotations: [],
});
});

it('should parse year 0 (1 BC in ISO 8601)', () => {
const ast = parseTemporal('0000-01-01');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: 0, month: 1, day: 1 },
annotations: [],
});
});

it('should parse BC datetime with time', () => {
const ast = parseTemporal('-0044-03-15T12:00:00');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: -44, month: 3, day: 15 },
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
annotations: [],
});
});

it('should parse BC datetime with timezone', () => {
const ast = parseTemporal('-0044-03-15T12:00:00Z');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { kind: 'Date', year: -44, month: 3, day: 15 },
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
offset: { kind: 'UtcOffset' },
annotations: [],
});
});
});

describe('time parsing', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,15 @@
}

private parseDate(): DateAst {
// 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);

Check warning on line 146 in src/parser.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Delete `····`

Check warning on line 146 in src/parser.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Delete `····`
const yTok = this.eat(TokType.Number);
const year = toInt(yTok.value, 'year', this.i);
let year = toInt(yTok.value, 'year', this.i);
if (isNegative) {
year = -year;
}

// Optional -MM
if (!this.tryEat(TokType.Dash)) {
Expand Down
59 changes: 59 additions & 0 deletions src/stringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,62 @@ describe('RFC 9557 format', () => {
expect(result).toBe('2025[u-ca=gregory]');
});
});

describe('BC dates (negative years)', () => {
it('should stringify BC date (negative year)', () => {
// Manually construct AST for 44 BC (year -43 in ISO 8601, since year 0 exists)
const result = stringifyDate({ kind: 'Date', year: -43, month: 3, day: 15 });
expect(result).toBe('-0043-03-15');
});

it('should stringify BC year only', () => {
const result = stringifyDate({ kind: 'Date', year: -100 });
expect(result).toBe('-0100');
});

it('should stringify BC year-month', () => {
const result = stringifyDate({ kind: 'Date', year: -753, month: 4 });
expect(result).toBe('-0753-04');
});

it('should stringify year 1 BC (year 0 in ISO 8601)', () => {
const result = stringifyDate({ kind: 'Date', year: 0, month: 1, day: 1 });
expect(result).toBe('0000-01-01');
});

it('should stringify BC datetime', () => {
const result = stringifyDateTime({
kind: 'DateTime',
date: { kind: 'Date', year: -43, month: 3, day: 15 },
time: { kind: 'Time', hour: 12, minute: 0, second: 0 },
annotations: [],
});
expect(result).toBe('-0043-03-15T12:00:00');
});

it('should handle negative year padding', () => {
const result = stringifyDate({ kind: 'Date', year: -1, month: 1, day: 1 });
expect(result).toBe('-0001-01-01');
});

it('should round-trip BC date parsing and stringifying', () => {
const input = '-0044-03-15';
const ast = parseTemporal(input);
const result = stringifyTemporal(ast);
expect(result).toBe(input);
});

it('should round-trip BC year only', () => {
const input = '-0100';
const ast = parseTemporal(input);
const result = stringifyTemporal(ast);
expect(result).toBe(input);
});

it('should round-trip BC datetime', () => {
const input = '-0044-03-15T12:00:00Z';
const ast = parseTemporal(input);
const result = stringifyTemporal(ast);
expect(result).toBe(input);
});
});
12 changes: 11 additions & 1 deletion src/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,17 @@ export function stringifyTemporal(ast: TemporalAst): string {
* @returns Date string (YYYY, YYYY-MM, or YYYY-MM-DD)
*/
export function stringifyDate(date: DateAst): string {
const parts: string[] = [date.year.toString().padStart(4, '0')];
// Handle negative years (BC dates) - ISO 8601 uses negative years
// Year 0 = 1 BC, Year -1 = 2 BC, etc.
let yearStr: string;
if (date.year < 0) {
// For negative years, pad the absolute value and prepend the minus sign
yearStr = '-' + Math.abs(date.year).toString().padStart(4, '0');
} else {
yearStr = date.year.toString().padStart(4, '0');
}

const parts: string[] = [yearStr];

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