import { format, formatDistanceToNow, isThisMonth, isThisWeek, isToday, isValid, isYesterday, parse, startOfMonth, startOfWeek, startOfYear, subMonths, } from 'date-fns'; import { MAX_RELATIVE_DAYS } from './constants'; function isDateOnlyInput(input: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(input.trim()); } /** * Parse date string from various formats */ export function parseDateInput(input: string): Date | null { // Try ISO format const iso = parse(input, 'yyyy-MM-dd', new Date()); if (isValid(iso)) return iso; // Try with time const isoWithTime = parse(input, "yyyy-MM-dd'T'HH:mm:ss", new Date()); if (isValid(isoWithTime)) return isoWithTime; // Try relative dates const lower = input.toLowerCase().trim(); if (lower === 'today') { const today = new Date(); today.setHours(0, 0, 0, 0); return today; } if (lower === 'yesterday') { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); yesterday.setHours(0, 0, 0, 0); return yesterday; } if (lower === 'week' || lower === 'this week') { return startOfWeek(new Date()); } if (lower === 'month' || lower === 'this month') { return startOfMonth(new Date()); } if (lower === 'year' || lower === 'this year') { return startOfYear(new Date()); } // Try "N days ago" const daysAgoMatch = lower.match(/^(\d+)\s*days?\s*ago$/); if (daysAgoMatch) { const [, daysText] = daysAgoMatch; if (!daysText) return null; const days = parseInt(daysText, 10); if (days > MAX_RELATIVE_DAYS) return null; const date = new Date(); date.setDate(date.getDate() - days); date.setHours(0, 0, 0, 0); return date; } // Try "N weeks ago" const weeksAgoMatch = lower.match(/^(\d+)\s*weeks?\s*ago$/); if (weeksAgoMatch) { const [, weeksText] = weeksAgoMatch; if (!weeksText) return null; const weeks = parseInt(weeksText, 10); if (weeks * 7 > MAX_RELATIVE_DAYS) return null; const date = new Date(); date.setDate(date.getDate() - weeks * 7); date.setHours(0, 0, 0, 0); return date; } // Try "N months ago" const monthsAgoMatch = lower.match(/^(\d+)\s*months?\s*ago$/); if (monthsAgoMatch) { const [, monthsText] = monthsAgoMatch; if (!monthsText) return null; const months = parseInt(monthsText, 10); if (months * 30 > MAX_RELATIVE_DAYS) return null; // Use subMonths (not setMonth) so month-ends clamp instead of overflow // (e.g. Mar 31 minus 1 month -> Feb 28, not Mar 3). const date = subMonths(new Date(), months); date.setHours(0, 0, 0, 0); return date; } return null; } /** * Format date for display */ export function formatDateDisplay(date: Date | number | null | undefined): string { if (date == null) return 'Unknown'; const d = typeof date === 'number' ? new Date(date) : date; if (!isValid(d)) return 'Invalid date'; if (isToday(d)) { return `Today at ${format(d, 'HH:mm')}`; } if (isYesterday(d)) { return `Yesterday at ${format(d, 'HH:mm')}`; } if (isThisWeek(d)) { return format(d, "EEEE 'at' HH:mm"); } if (isThisMonth(d)) { return format(d, "MMM d 'at' HH:mm"); } return format(d, 'MMM d, yyyy'); } /** * Format relative time */ export function formatRelativeTime(date: Date | number | null | undefined): string { if (date == null) return 'Unknown'; const d = typeof date === 'number' ? new Date(date) : date; if (!isValid(d)) return 'Invalid date'; return formatDistanceToNow(d, { addSuffix: true }); } /** * Format date for export */ export function formatDateExport(date: Date | number | null | undefined): string { if (date == null) return ''; const d = typeof date === 'number' ? new Date(date) : date; if (!isValid(d)) return ''; return format(d, "yyyy-MM-dd'T'HH:mm:ssxxx"); } /** * Parse date range string */ export function parseDateRange(range: string): { start?: Date; end?: Date } | null { // Single date const single = parseDateInput(range); if (single) { const end = new Date(); end.setHours(23, 59, 59, 999); return { start: single, end }; } // Date range with ".." or " to " const rangeMatch = range.match(/^(.+?)\s*(?:\.\.|\s+to\s+)(.+)$/i); if (rangeMatch) { const [, startText, endText] = rangeMatch; if (!startText || !endText) return null; const start = parseDateInput(startText.trim()); const end = parseDateInput(endText.trim()); if (start && end) { // A calendar date means its whole day; an explicit timestamp at midnight // remains a precise endpoint. if (isDateOnlyInput(endText)) { end.setHours(23, 59, 59, 999); } return { start, end }; } } return null; }