/** * Date/Time Parser Utilities * * Parses natural language date and time expressions (Dutch) * for Cortex agenda functionality. * * IMPORTANT: All functions accept an optional `referenceDate` parameter. * - Server-side: pass `getServerNow()` from `lib/utils/server-date.ts` * - Client-side: omit for backward compatibility (uses `new Date()`) * * This ensures consistent date handling between Cortex and regular EPD. */ import { addDays, addWeeks, startOfWeek, endOfWeek, startOfDay, endOfDay, nextMonday, nextTuesday, nextWednesday, nextThursday, nextFriday, nextSaturday, nextSunday, parse, isValid, } from 'date-fns'; import { nl } from 'date-fns/locale'; /** * Date range type for queries like "deze week" */ export interface DateRange { start: Date; end: Date; label: 'vandaag' | 'morgen' | 'deze week' | 'volgende week' | 'custom'; } /** * Parse relative date expressions (Dutch) * * Supports: * - "vandaag", "morgen", "overmorgen" * - "maandag", "dinsdag", etc. (next occurrence of weekday) * - "deze week", "volgende week" (returns DateRange) * - Absolute dates: "30 december", "28-12-2024" * * @param input - Natural language date expression * @param referenceDate - Reference date for relative calculations (default: new Date()) * Server-side: pass getServerNow() for consistency * @returns Date object, DateRange, or null if unparseable * * @example * // Client-side (backward compatible): * parseRelativeDate("morgen") * * // Server-side (consistent): * import { getServerNow } from '@/lib/utils/server-date'; * parseRelativeDate("morgen", getServerNow()) */ export function parseRelativeDate( input: string, referenceDate: Date = new Date() ): Date | DateRange | null { const today = referenceDate; const normalized = input.toLowerCase().trim(); // Single day patterns (check longer patterns first to avoid "morgen" matching in "overmorgen") const singleDayPatterns: Record Date> = { overmorgen: () => addDays(today, 2), eergisteren: () => addDays(today, -2), // Voor queries vandaag: () => today, morgen: () => addDays(today, 1), gisteren: () => addDays(today, -1), // Voor queries }; // Check single day patterns (exact match or word boundary) for (const [pattern, fn] of Object.entries(singleDayPatterns)) { // Use word boundaries to avoid partial matches const regex = new RegExp(`\\b${pattern}\\b`, 'i'); if (regex.test(normalized)) { return fn(); } } // Weekday patterns (next occurrence) const weekdayPatterns: Record Date> = { maandag: () => nextMonday(today), dinsdag: () => nextTuesday(today), woensdag: () => nextWednesday(today), donderdag: () => nextThursday(today), vrijdag: () => nextFriday(today), zaterdag: () => nextSaturday(today), zondag: () => nextSunday(today), }; // Check weekday patterns for (const [pattern, fn] of Object.entries(weekdayPatterns)) { if (normalized === pattern || normalized.includes(pattern)) { return fn(); } } // Week range patterns (returns DateRange) const weekRangePatterns: Record< string, () => DateRange > = { 'deze week': () => ({ start: startOfWeek(today, { locale: nl, weekStartsOn: 1 }), // Monday end: endOfWeek(today, { locale: nl, weekStartsOn: 1 }), // Sunday label: 'deze week', }), 'volgende week': () => { const nextWeek = addWeeks(today, 1); return { start: startOfWeek(nextWeek, { locale: nl, weekStartsOn: 1 }), end: endOfWeek(nextWeek, { locale: nl, weekStartsOn: 1 }), label: 'volgende week', }; }, }; // Check week range patterns for (const [pattern, fn] of Object.entries(weekRangePatterns)) { if (normalized.includes(pattern)) { return fn(); } } // Try parsing absolute dates // Format: "30 december", "30 dec", "28-12-2024", "28/12/2024" const absoluteDatePatterns = [ 'd MMMM', // "30 december" 'd MMM', // "30 dec" 'd-M-yyyy', // "28-12-2024" 'dd-MM-yyyy', // "28-12-2024" 'd/M/yyyy', // "28/12/2024" 'dd/MM/yyyy', // "28/12/2024" 'yyyy-MM-dd', // ISO format "2024-12-28" ]; for (const pattern of absoluteDatePatterns) { try { const parsed = parse(normalized, pattern, today, { locale: nl }); if (isValid(parsed)) { return parsed; } } catch { // Continue to next pattern } } // Could not parse return null; } /** * Check if a parsed result is a DateRange */ export function isDateRange(result: Date | DateRange | null): result is DateRange { return result !== null && typeof result === 'object' && 'start' in result && 'end' in result; } /** * Convert single date to DateRange (start of day to end of day) */ export function dateToRange(date: Date, label: DateRange['label'] = 'custom'): DateRange { return { start: startOfDay(date), end: endOfDay(date), label, }; } /** * Parse time expressions (Dutch) * * Supports: * - "14:00" → "14:00" * - "14" → "14:00" * - "twee uur" → "14:00" * - "half drie" → "14:30" * - "kwart voor drie" → "14:45" * - "kwart over twee" → "14:15" * * @param input - Natural language time expression * @returns Time string in HH:mm format, or null if unparseable * * @example * parseTime("14:00") // "14:00" * parseTime("half drie") // "14:30" * parseTime("twee uur") // "14:00" */ export function parseTime(input: string): string | null { const normalized = input.toLowerCase().trim(); // Direct time format: "14:00" or "14.00" const timePattern = /^(\d{1,2})[:\.](\d{2})$/; const match = normalized.match(timePattern); if (match) { const hours = parseInt(match[1], 10); const minutes = parseInt(match[2], 10); if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; } } // Just hour: "14" → "14:00" const hourPattern = /^(\d{1,2})$/; const hourMatch = normalized.match(hourPattern); if (hourMatch) { const hours = parseInt(hourMatch[1], 10); if (hours >= 0 && hours <= 23) { return `${hours.toString().padStart(2, '0')}:00`; } } // Dutch time words const timeWords: Record = { // Full hours 'een uur': '13:00', 'twee uur': '14:00', 'drie uur': '15:00', 'vier uur': '16:00', 'vijf uur': '17:00', 'zes uur': '18:00', 'zeven uur': '19:00', 'acht uur': '20:00', 'negen uur': '21:00', 'tien uur': '22:00', 'elf uur': '23:00', 'twaalf uur': '12:00', // Morning variants 'ochtend': '09:00', // Default morning time 's ochtends': '09:00', 'ochtendje': '09:00', // Afternoon/evening 'middag': '14:00', 's middags': '14:00', 'namiddag': '14:00', 'avond': '19:00', 's avonds': '19:00', 'vanavond': '19:00', // Half hours (common expressions) 'half een': '12:30', 'half twee': '13:30', 'half drie': '14:30', 'half vier': '15:30', 'half vijf': '16:30', 'half zes': '17:30', 'half zeven': '18:30', 'half acht': '19:30', 'half negen': '20:30', 'half tien': '21:30', 'half elf': '22:30', 'half twaalf': '23:30', // Quarter hours 'kwart over een': '13:15', 'kwart over twee': '14:15', 'kwart over drie': '15:15', 'kwart voor twee': '13:45', 'kwart voor drie': '14:45', 'kwart voor vier': '15:45', }; // Check if input matches any time word pattern for (const [pattern, time] of Object.entries(timeWords)) { if (normalized === pattern || normalized.includes(pattern)) { return time; } } // Could not parse return null; } /** * Combine date and time into ISO datetime string * * @param date - Date object or date string * @param time - Time string in HH:mm format * @returns ISO datetime string * * @example * combineDatetime(new Date('2024-12-28'), '14:00') * // "2024-12-28T14:00:00" */ export function combineDatetime(date: Date | string, time: string): string { const dateObj = typeof date === 'string' ? new Date(date) : date; const [hours, minutes] = time.split(':').map(Number); const combined = new Date(dateObj); combined.setHours(hours, minutes, 0, 0); return combined.toISOString(); } /** * Validate that a date is not in the past * * @param date - Date to validate * @param allowToday - Whether today is considered valid (default: true) * @param referenceDate - Reference date for "today" (default: new Date()) * Server-side: pass getServerNow() for consistency * @returns true if date is valid (not in past) */ export function isNotInPast( date: Date, allowToday = true, referenceDate: Date = new Date() ): boolean { const today = startOfDay(referenceDate); const checkDate = startOfDay(date); if (allowToday) { return checkDate >= today; } return checkDate > today; }