Skip to content

Commit ddf780a

Browse files
committed
add IsoDate and IsoDatetime for improved ISO 8601 functionality needed for date search
1 parent 953250d commit ddf780a

5 files changed

Lines changed: 618 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Unit tests for IsoDate
3+
*
4+
* AI Usage:
5+
* - This code was originally generated using
6+
* 1. OpenAI/GPT OSS 120b on Roo Code
7+
* 2. Gemini 2.5 Flash and 2.5 Pro
8+
* then modified to fix incorrect implementations and fit project needs.
9+
* The first commit contains these corrections so that all code committed
10+
* works as designed.
11+
*/
12+
13+
import { describe, test, expect } from '@jest/globals';
14+
import { IsoDate, isValidIsoDate } from './IsoDate';
15+
16+
describe('IsoDate.parse – valid inputs', () => {
17+
18+
test('full date with hyphens', () => {
19+
const str = '2025-01-01';
20+
expect(isValidIsoDate(str)).toBeTruthy();
21+
const d = IsoDate.parse(str);
22+
expect(d.year).toBe(2025);
23+
expect(d.month).toBe(1);
24+
expect(d.day).toBe(1);
25+
expect(d.toString()).toBe('2025-01-01');
26+
});
27+
28+
test('year‑month', () => {
29+
const str = '2025-01';
30+
expect(isValidIsoDate(str)).toBeTruthy();
31+
const d = IsoDate.parse(str);
32+
expect(d.year).toBe(2025);
33+
expect(d.month).toBe(1);
34+
expect(d.day).toBeUndefined();
35+
expect(d.toString()).toBe('2025-01');
36+
});
37+
38+
test('year only', () => {
39+
const str = '2025';
40+
expect(isValidIsoDate(str)).toBeTruthy();
41+
const d = IsoDate.parse(str);
42+
expect(d.year).toBe(2025);
43+
expect(d.month).toBeUndefined();
44+
expect(d.day).toBeUndefined();
45+
expect(d.toString()).toBe('2025');
46+
});
47+
48+
test('leap‑year February 29', () => {
49+
const str = '2024-02-29';
50+
expect(isValidIsoDate(str)).toBeTruthy();
51+
const d = IsoDate.parse(str);
52+
expect(d.year).toBe(2024);
53+
expect(d.month).toBe(2);
54+
expect(d.day).toBe(29);
55+
expect(d.toString()).toBe('2024-02-29');
56+
});
57+
58+
test('month‑day boundary', () => {
59+
const str = '2025-01-30';
60+
expect(isValidIsoDate(str)).toBeTruthy();
61+
const d = IsoDate.parse(str);
62+
expect(d.year).toBe(2025);
63+
expect(d.month).toBe(1);
64+
expect(d.day).toBe(30);
65+
expect(d.toString()).toBe('2025-01-30');
66+
});
67+
});
68+
69+
describe('IsoDate.parse – invalid inputs', () => {
70+
const invalid = [
71+
'202501', // year+month without hyphen
72+
'20250101', // compact full date (properly rejected in this class)
73+
'250101', // two‑digit year
74+
'2025-13-01', // invalid month
75+
'2025-02-30', // invalid day (Feb 30)
76+
'2025-04-31', // invalid day (April 31)
77+
'-2025-04-31', // invalid year
78+
'--01-01', // leading hyphens
79+
'-2025-01', // leading hyphen before year
80+
'2025--01', // double hyphen between year and month
81+
'2025-01--01', // double hyphen before day
82+
'2025-02-29', // illegal leap year
83+
'2025-01-01T014:00:00:00Z', // datetime does not match in this class
84+
];
85+
86+
invalid.forEach((value) => {
87+
test(`throws for "${value}"`, () => {
88+
expect(() => IsoDate.parse(value)).toThrow(Error);
89+
});
90+
});
91+
92+
invalid.forEach((value) => {
93+
test(`"${value}" is not an IsoDate`, () => {
94+
expect(isValidIsoDate(value)).toBeFalsy();
95+
});
96+
});
97+
});
98+
99+
describe('IsoDate.toString', () => {
100+
const tests: Array<{ input: string; expected: string; }> = [
101+
{ input: '2025-01-01', expected: '2025-01-01' },
102+
{ input: '2025-01', expected: '2025-01' },
103+
{ input: '2025', expected: '2025' }
104+
];
105+
106+
tests.forEach(({input, expected}) => {
107+
test(`properly prints out '${input}' as '${expected}'`, () => {
108+
const isoDate = IsoDate.parse(input)
109+
expect(isoDate.toString()).toBe(expected)
110+
});
111+
});
112+
})

src/common/IsoDate/IsoDate.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* IsoDate – a lightweight class for representing calendar dates.
3+
*
4+
* Supported input formats:
5+
* - YYYY-MM-DD (e.g., 2025-01-01)
6+
* - YYYY-MM (e.g., 2025-01)
7+
* - YYYY (e.g., 2025)
8+
*
9+
* Note we do not support (even though it is allowed by ISO 8601)
10+
* - "compact date" (i.e., YYYYMMDD)
11+
* - years previous to 1 AD (i.e., zero and negative years)
12+
* - years after 2500
13+
*
14+
* The class validates monthly day rules and leap‑year rules.
15+
* It provides a normalized `toString()` output:
16+
* - full date → YYYY-MM-DD
17+
* - year‑month → YYYY-MM
18+
* - year only → YYYY
19+
*
20+
* Example:
21+
* const d = IsoDate.parse('2025-01-01');
22+
* console.log(d.year, d.month, d.day); // 2025 1 1
23+
* console.log(d.toString()); // "2025-01-01"
24+
*
25+
* AI Usage:
26+
* - This code was originally generated using
27+
* 1. OpenAI/GPT OSS 120b on Roo Code
28+
* 2. Gemini 2.5 Flash and 2.5 Pro
29+
* then modified to fix incorrect implementations and fit project needs.
30+
* The first commit contains these corrections so that all code committed
31+
* works as designed.
32+
*/
33+
34+
export class IsoDate {
35+
36+
/** Full year (e.g., 2025) */
37+
public readonly year: number;
38+
/** Month number 1‑12 (optional) */
39+
public readonly month?: number;
40+
/** Day number 1‑31 (optional, requires month) */
41+
public readonly day?: number;
42+
43+
protected constructor(year: number, month?: number, day?: number) {
44+
this.year = year;
45+
if (month !== undefined) this.month = month;
46+
if (day !== undefined) this.day = day;
47+
}
48+
49+
/**
50+
* Parse a string into a IsoDate.
51+
* Throws an Error if the string does not match any supported format
52+
* or if the date components are out of range.
53+
*/
54+
public static parse(value: string): IsoDate {
55+
// Regex with named capture groups for clarity.
56+
// 1. YYYY‑MM‑DD
57+
// 2. we do not allow YYYYMMDD anymore
58+
// 3. YYYY‑MM
59+
// 4. YYYY
60+
const regex =
61+
// GPT OSS 120b generated regex
62+
// /^(?<year>\d{4})(?:[-]?(?<month>\d{2})(?:[-]?(?<day>\d{2})?)?)?$/;
63+
/^(?<year>\d{4})(?:[-](?<month>\d{2})(?:[-](?<day>\d{2})?)?)?$/;
64+
65+
const match = regex.exec(value);
66+
if (!match || !match.groups) {
67+
throw new Error(`Invalid calendar date format: "${value}": must be one of YYYY-MM-DD, YYYY-MM, or YYYY`);
68+
}
69+
70+
const year = Number(match.groups.year);
71+
const monthStr = match.groups.month;
72+
const dayStr = match.groups.day;
73+
74+
// Validate year range (reasonable limits)
75+
if (year < 1 || year > 2500) {
76+
throw new Error(`Year out of range: ${year}`);
77+
}
78+
79+
// If month is present, validate it.
80+
if (monthStr !== undefined) {
81+
const month = Number(monthStr);
82+
if (month < 1 || month > 12) {
83+
throw new Error(`Month out of range: ${monthStr}`);
84+
}
85+
86+
// If day is present, validate day according to month & leap year.
87+
if (dayStr !== undefined) {
88+
const day = Number(dayStr);
89+
const maxDay = IsoDate.daysInMonth(year, month);
90+
if (day < 1 || day > maxDay) {
91+
throw new Error(
92+
`Day out of range for ${year}-${String(month).padStart(
93+
2,
94+
'0'
95+
)}: ${dayStr}`
96+
);
97+
}
98+
return new IsoDate(year, month, day);
99+
}
100+
101+
// Month only (no day)
102+
return new IsoDate(year, month);
103+
}
104+
105+
// Year only
106+
return new IsoDate(year);
107+
}
108+
109+
/** Return true if the stored year is a leap year. */
110+
public isLeapYear(): boolean {
111+
return IsoDate.isLeapYear(this.year);
112+
}
113+
114+
/** Normalized string representation. */
115+
public toString(): string {
116+
const y = String(this.year).padStart(4, '0');
117+
if (this.month !== undefined) {
118+
const m = String(this.month).padStart(2, '0');
119+
if (this.day !== undefined) {
120+
const d = String(this.day).padStart(2, '0');
121+
return `${y}-${m}-${d}`;
122+
}
123+
return `${y}-${m}`;
124+
}
125+
return y;
126+
}
127+
128+
/** Static helper – leap‑year check for any year. */
129+
public static isLeapYear(year: number): boolean {
130+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
131+
}
132+
133+
/** Static helper – number of days in a given month/year. */
134+
public static daysInMonth(year: number, month: number): number {
135+
switch (month) {
136+
case 2:
137+
return IsoDate.isLeapYear(year) ? 29 : 28;
138+
case 4:
139+
case 6:
140+
case 9:
141+
case 11:
142+
return 30;
143+
default:
144+
return 31;
145+
}
146+
}
147+
}
148+
149+
// Utility function to check if a string is a valid ISO date according to IsoDate parsing rules.
150+
export function isValidIsoDate(value: string): boolean {
151+
try {
152+
IsoDate.parse(value);
153+
return true;
154+
} catch {
155+
return false;
156+
}
157+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import { IsoDate } from './IsoDate.js';
3+
import { IsoDatetime } from './IsoDatetime.js';
4+
5+
describe('IsoDatetime.fromIsoDate', () => {
6+
test('converts an IsoDate to midnight UTC IsoDatetime', () => {
7+
const date = IsoDate.parse('2025-03-15');
8+
const datetime = IsoDatetime.fromIsoDate(date);
9+
expect(datetime).toBeInstanceOf(IsoDatetime);
10+
expect(datetime.toString()).toBe('2025-03-15T00:00:00Z');
11+
});
12+
});

0 commit comments

Comments
 (0)