Files
triqura-ecd/lib/cortex/date-time-parser.ts
colinislit 011815072b refactor(cortex): consistente datumbepaling met referenceDate parameter
SOC: Parser en entity-extractor accepteren nu optionele referenceDate
parameter. Caller bepaalt wat "nu" is, niet de functies intern.

Wijzigingen:
- parseRelativeDate(input, referenceDate?) - referenceDate parameter
- extractEntities(input, intent, referenceDate?) - doorgeeft aan parser
- API routes gebruiken new Date() als referenceDate

Verwijderd (YAGNI):
- lib/utils/server-date.ts - wrapper om new Date() was overbodig

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:49:20 +01:00

329 lines
8.8 KiB
TypeScript

/**
* 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<string, () => 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<string, () => 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<string, string> = {
// 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;
}