src / dateParser.ts
src / dateParser.ts
export type SearchDateParameter = "since" | "before";
type RelativeDateUnit = "day" | "week" | "month" | "year";
function startOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function shiftCalendarMonths(date: Date, months: number): Date {
const result = startOfLocalDay(date);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + months);
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
}
/**
* Parse exact and relative dates accepted by email_search.
*
* Supported examples:
* - 2026-08-05
* - today / yesterday / tomorrow
* - 2 days ago / a month ago
* - last week / last 3 months
*/
export function parseEmailSearchDate(
input: string,
parameter: SearchDateParameter,
now: Date = new Date(),
): Date {
const raw = input.trim();
const normalized = raw.toLowerCase().replace(/\s+/g, " ");
if (!normalized) throw new Error(`${parameter} date cannot be empty.`);
const today = startOfLocalDay(now);
if (normalized === "today") return today;
if (normalized === "yesterday") {
const result = new Date(today);
result.setDate(result.getDate() - 1);
return result;
}
if (normalized === "tomorrow") {
const result = new Date(today);
result.setDate(result.getDate() + 1);
return result;
}
// Bare YYYY-MM-DD is parsed explicitly as local time instead of UTC.
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized);
if (isoMatch) {
const year = Number(isoMatch[1]);
const month = Number(isoMatch[2]);
const day = Number(isoMatch[3]);
const result = new Date(year, month - 1, day);
result.setHours(0, 0, 0, 0);
if (
result.getFullYear() !== year ||
result.getMonth() !== month - 1 ||
result.getDate() !== day
) {
throw new Error(
`Invalid ${parameter} date "${raw}". Use a real date in YYYY-MM-DD format.`,
);
}
return result;
}
let amount: number | undefined;
let unit: RelativeDateUnit | undefined;
const agoMatch = /^(?:(\d+)|a|an|one)\s+(day|week|month|year)s?\s+ago$/.exec(
normalized,
);
if (agoMatch) {
amount = agoMatch[1] ? Number(agoMatch[1]) : 1;
unit = agoMatch[2] as RelativeDateUnit;
} else {
const lastMatch = /^last\s+(?:(\d+)\s+)?(day|week|month|year)s?$/.exec(normalized);
if (lastMatch) {
amount = lastMatch[1] ? Number(lastMatch[1]) : 1;
unit = lastMatch[2] as RelativeDateUnit;
}
}
if (amount === undefined || unit === undefined) {
throw new Error(
`Unsupported ${parameter} date "${raw}". Use YYYY-MM-DD, today, yesterday, ` +
`"2 weeks ago", or "last 2 weeks".`,
);
}
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new Error(`Invalid ${parameter} date quantity in "${raw}".`);
}
let result: Date;
switch (unit) {
case "day":
result = new Date(today);
result.setDate(result.getDate() - amount);
break;
case "week":
result = new Date(today);
result.setDate(result.getDate() - amount * 7);
break;
case "month":
result = shiftCalendarMonths(today, -amount);
break;
case "year":
result = shiftCalendarMonths(today, -amount * 12);
break;
}
if (Number.isNaN(result.getTime())) {
throw new Error(`The ${parameter} date "${raw}" is outside the supported range.`);
}
return result;
}
export function validateDateRange(since?: Date, before?: Date): void {
if (since && before && since.getTime() >= before.getTime()) {
throw new Error(
`Invalid date range: since (${since.toISOString()}) must be earlier than before (${before.toISOString()}).`,
);
}
}
export type SearchDateParameter = "since" | "before";
type RelativeDateUnit = "day" | "week" | "month" | "year";
function startOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function shiftCalendarMonths(date: Date, months: number): Date {
const result = startOfLocalDay(date);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + months);
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
}
/**
* Parse exact and relative dates accepted by email_search.
*
* Supported examples:
* - 2026-08-05
* - today / yesterday / tomorrow
* - 2 days ago / a month ago
* - last week / last 3 months
*/
export function parseEmailSearchDate(
input: string,
parameter: SearchDateParameter,
now: Date = new Date(),
): Date {
const raw = input.trim();
const normalized = raw.toLowerCase().replace(/\s+/g, " ");
if (!normalized) throw new Error(`${parameter} date cannot be empty.`);
const today = startOfLocalDay(now);
if (normalized === "today") return today;
if (normalized === "yesterday") {
const result = new Date(today);
result.setDate(result.getDate() - 1);
return result;
}
if (normalized === "tomorrow") {
const result = new Date(today);
result.setDate(result.getDate() + 1);
return result;
}
// Bare YYYY-MM-DD is parsed explicitly as local time instead of UTC.
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized);
if (isoMatch) {
const year = Number(isoMatch[1]);
const month = Number(isoMatch[2]);
const day = Number(isoMatch[3]);
const result = new Date(year, month - 1, day);
result.setHours(0, 0, 0, 0);
if (
result.getFullYear() !== year ||
result.getMonth() !== month - 1 ||
result.getDate() !== day
) {
throw new Error(
`Invalid ${parameter} date "${raw}". Use a real date in YYYY-MM-DD format.`,
);
}
return result;
}
let amount: number | undefined;
let unit: RelativeDateUnit | undefined;
const agoMatch = /^(?:(\d+)|a|an|one)\s+(day|week|month|year)s?\s+ago$/.exec(
normalized,
);
if (agoMatch) {
amount = agoMatch[1] ? Number(agoMatch[1]) : 1;
unit = agoMatch[2] as RelativeDateUnit;
} else {
const lastMatch = /^last\s+(?:(\d+)\s+)?(day|week|month|year)s?$/.exec(normalized);
if (lastMatch) {
amount = lastMatch[1] ? Number(lastMatch[1]) : 1;
unit = lastMatch[2] as RelativeDateUnit;
}
}
if (amount === undefined || unit === undefined) {
throw new Error(
`Unsupported ${parameter} date "${raw}". Use YYYY-MM-DD, today, yesterday, ` +
`"2 weeks ago", or "last 2 weeks".`,
);
}
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new Error(`Invalid ${parameter} date quantity in "${raw}".`);
}
let result: Date;
switch (unit) {
case "day":
result = new Date(today);
result.setDate(result.getDate() - amount);
break;
case "week":
result = new Date(today);
result.setDate(result.getDate() - amount * 7);
break;
case "month":
result = shiftCalendarMonths(today, -amount);
break;
case "year":
result = shiftCalendarMonths(today, -amount * 12);
break;
}
if (Number.isNaN(result.getTime())) {
throw new Error(`The ${parameter} date "${raw}" is outside the supported range.`);
}
return result;
}
export function validateDateRange(since?: Date, before?: Date): void {
if (since && before && since.getTime() >= before.getTime()) {
throw new Error(
`Invalid date range: since (${since.toISOString()}) must be earlier than before (${before.toISOString()}).`,
);
}
}