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
18 changes: 18 additions & 0 deletions .changeset/european-decimal-separator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@taskade/temporal-parser': patch
---

fix: Support comma as decimal separator in fractional seconds (European format)

Add support for comma (`,`) as a decimal separator in fractional seconds for both time and duration components, as specified in ISO 8601. This enables parsing of European-formatted temporal strings while maintaining canonical dot (`.`) notation in serialized output.

**Supported formats:**
- Time with fractional seconds: `T10:30:45,123` → `T10:30:45.123`
- Duration with fractional seconds: `PT1,5S` → `PT1.5S`

**Behavior:**
- Parser accepts both `.` and `,` as decimal separators
- Stringify normalizes all output to use `.` for consistency
- Full round-trip compatibility maintained

This change improves ISO 8601 compliance and enables parsing of temporal strings from European locales where comma is the standard decimal separator.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const range = parseTemporal('2025-01-01/2025-12-31');
- Hour-Minute: `T10:30`
- With seconds: `T10:30:45`
- With fractional seconds: `T10:30:45.123456789`
- European format (comma): `T10:30:45,123` (normalized to dot in output)

### Timezones
- UTC: `Z`
Expand All @@ -65,6 +66,7 @@ const range = parseTemporal('2025-01-01/2025-12-31');
- Date parts: `P1Y2M3D` (1 year, 2 months, 3 days)
- Time parts: `PT4H5M6S` (4 hours, 5 minutes, 6 seconds)
- Combined: `P1Y2M3DT4H5M6S`
- Fractional seconds: `PT1.5S` or `PT1,5S` (comma normalized to dot)

### IXDTF Annotations
- Calendar: `[u-ca=gregory]`
Expand Down
28 changes: 28 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ describe('parseTemporal', () => {
time: { fraction: '123456789' },
});
});

it('should parse fractional seconds with comma (European format)', () => {
const ast = parseTemporal('2025-01-07T10:30:45,123');
expect(ast).toMatchObject({
kind: 'DateTime',
date: { year: 2025, month: 1, day: 7 },
time: { hour: 10, minute: 30, second: 45, fraction: '123' },
annotations: [],
});
});

it('should parse fractional seconds with comma and high precision', () => {
const ast = parseTemporal('2025-01-07T10:30:45,123456789');
expect(ast).toMatchObject({
kind: 'DateTime',
time: { fraction: '123456789' },
});
});
});

describe('timezone parsing', () => {
Expand Down Expand Up @@ -369,6 +387,16 @@ describe('parseTemporal', () => {
});
});

it('should parse fractional seconds with comma (European format)', () => {
const ast = parseTemporal('PT1,5S');
expect(ast).toMatchObject({
kind: 'Duration',
seconds: 1,
secondsFraction: '5',
raw: 'PT1,5S',
});
});

it('should parse combined time parts', () => {
const ast = parseTemporal('PT2H30M45S');
expect(ast).toMatchObject({
Expand Down
9 changes: 6 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ class Parser {
const sTok = this.eat(TokType.Number);
second = toInt(sTok.value, 'second', this.i);

if (this.tryEat(TokType.Dot)) {
// ISO 8601 allows both . and , as decimal separators for fractional seconds
if (this.tryEat(TokType.Dot) || this.tryEat(TokType.Comma)) {
const fracTok = this.eat(TokType.Number);
fraction = fracTok.value; // keep raw digits
}
Expand Down Expand Up @@ -332,8 +333,10 @@ class Parser {
rawParts.push(numTok.value);

let fraction: string | undefined;
if (this.tryEat(TokType.Dot)) {
rawParts.push('.');
// ISO 8601 allows both . and , as decimal separators
const dotOrComma = this.tryEat(TokType.Dot) || this.tryEat(TokType.Comma);
if (dotOrComma) {
rawParts.push(dotOrComma.value); // Preserve the actual separator (. or ,)
const fracTok = this.eat(TokType.Number);
rawParts.push(fracTok.value);
fraction = fracTok.value;
Expand Down
12 changes: 12 additions & 0 deletions src/stringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,18 @@ describe('format normalization', () => {
const result = stringifyTemporal(ast);
expect(result).toBe('2025-01-12T10:00:00-08:00');
});

it('should normalize comma decimal separator to dot', () => {
const ast = parseTemporal('2025-01-12T10:30:45,123');
const result = stringifyTemporal(ast);
expect(result).toBe('2025-01-12T10:30:45.123');
});

it('should normalize comma in duration to dot', () => {
const ast = parseTemporal('PT1,5S');
const result = stringifyTemporal(ast);
expect(result).toBe('PT1.5S');
});
});

describe('edge cases', () => {
Expand Down