feat(swift): voeg date/time parsing toe (E2)
Epic 2 compleet: NLP-helpers voor datum en tijd interpretatie.
Implementatie:
- parseRelativeDate(): ondersteunt "vandaag", "morgen", "deze week",
weekdagen (maandag-zondag), en absolute datums (30 december, 28-12-2024)
- parseTime(): ondersteunt "14:00", "14", "half drie", "kwart voor drie",
en natuurlijke taal tijdsaanduidingen
- combineDatetime(): combineert datum + tijd naar ISO string
- isNotInPast(): validatie voor toekomstige datums
- isDateRange(): type guard voor datum ranges
Integratie:
- Entity extractor gebruikt date/time parser voor agenda intents
- Normaliseert ruwe strings ("morgen", "14:00") naar Date objecten
- Ondersteunt Nederlandse tijdsaanduidingen
Tests & validatie:
- Unit tests voor alle parser functies
- Verify script voor handmatige testing
- Review document met bevindingen en suggesties
Story points: 7 SP (E2.S1: 3, E2.S2: 2, E2.S3: 2)
This commit is contained in:
34
docs/swift/review-epic2.md
Normal file
34
docs/swift/review-epic2.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Epic 2 Review Findings
|
||||||
|
|
||||||
|
Context: quick review of Epic 2 changes (date/time parsing + agenda entity extraction).
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
1. High - Patient name detection is too permissive for agenda intents and can misclassify appointment tokens as names.
|
||||||
|
- Details: create/cancel/reschedule extraction uses `filteredWords` + `isLikelyName`; tokens like "intake", "morgen", or times can be treated as names.
|
||||||
|
- Impact: wrong patient selected or wrong appointment targeted.
|
||||||
|
- Files: `lib/swift/entity-extractor.ts`
|
||||||
|
- Suggestion: skip tokens that parse as date/time or match appointment/location keywords before `isLikelyName`.
|
||||||
|
|
||||||
|
2. Medium - Agenda query defaults to "vandaag" when no date is present (e.g., "volgende afspraak").
|
||||||
|
- Impact: only today is queried, which can miss the actual next appointment.
|
||||||
|
- Files: `lib/swift/entity-extractor.ts`
|
||||||
|
- Suggestion: leave `dateRange` undefined or mark "from now" and let backend decide.
|
||||||
|
|
||||||
|
3. Medium - `ExtractedEntities` now carries Date objects, but the classify API returns JSON, so Dates become strings.
|
||||||
|
- Impact: downstream date ops may break on serialized strings.
|
||||||
|
- Files: `lib/swift/types.ts` (see API flow in `app/api/intent/classify/route.ts`)
|
||||||
|
- Suggestion: use ISO strings in the DTO or normalize in the API response.
|
||||||
|
|
||||||
|
4. Medium - Type drift between lib and store: the store `ExtractedEntities` does not include the new agenda structures.
|
||||||
|
- Impact: UI usage may drop agenda fields or require unsafe casting.
|
||||||
|
- Files: `stores/swift-store.ts`, `lib/swift/types.ts`
|
||||||
|
- Suggestion: align store types with lib types (or re-export the shared type).
|
||||||
|
|
||||||
|
5. Low - AI fallback path does not run local extractor; low-confidence agenda intents lose structured entities.
|
||||||
|
- Impact: less reliable agenda prefill when AI is used.
|
||||||
|
- Files: `app/api/intent/classify/route.ts`, `lib/swift/intent-classifier-ai.ts`
|
||||||
|
- Suggestion: post-process AI results with local extractor for agenda intents.
|
||||||
|
|
||||||
|
## Testing / QA notes
|
||||||
|
- Untracked tests exist: `lib/swift/__tests__/date-time-parser.test.ts`.
|
||||||
|
- Manual scripts exist: `lib/swift/verify-parser.ts` and `lib/swift/verify-entity-extraction.ts` (not run here).
|
||||||
208
lib/swift/__tests__/date-time-parser.test.ts
Normal file
208
lib/swift/__tests__/date-time-parser.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Date/Time Parser - Smoke Tests
|
||||||
|
*
|
||||||
|
* Quick verification that parser functions work correctly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
parseRelativeDate,
|
||||||
|
parseTime,
|
||||||
|
isDateRange,
|
||||||
|
dateToRange,
|
||||||
|
combineDatetime,
|
||||||
|
isNotInPast,
|
||||||
|
type DateRange,
|
||||||
|
} from '../date-time-parser';
|
||||||
|
import { addDays, startOfDay, endOfDay } from 'date-fns';
|
||||||
|
|
||||||
|
describe('parseRelativeDate', () => {
|
||||||
|
it('should parse "vandaag" to today', () => {
|
||||||
|
const result = parseRelativeDate('vandaag');
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
expect(startOfDay(result as Date).getTime()).toBe(startOfDay(new Date()).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "morgen" to tomorrow', () => {
|
||||||
|
const result = parseRelativeDate('morgen');
|
||||||
|
const tomorrow = addDays(new Date(), 1);
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
expect(startOfDay(result as Date).getTime()).toBe(startOfDay(tomorrow).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "overmorgen" to day after tomorrow', () => {
|
||||||
|
const result = parseRelativeDate('overmorgen');
|
||||||
|
const dayAfterTomorrow = addDays(new Date(), 2);
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
expect(startOfDay(result as Date).getTime()).toBe(
|
||||||
|
startOfDay(dayAfterTomorrow).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "deze week" to a DateRange', () => {
|
||||||
|
const result = parseRelativeDate('deze week');
|
||||||
|
expect(isDateRange(result)).toBe(true);
|
||||||
|
if (isDateRange(result)) {
|
||||||
|
expect(result.label).toBe('deze week');
|
||||||
|
expect(result.start).toBeInstanceOf(Date);
|
||||||
|
expect(result.end).toBeInstanceOf(Date);
|
||||||
|
expect(result.end.getTime()).toBeGreaterThan(result.start.getTime());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "volgende week" to a DateRange', () => {
|
||||||
|
const result = parseRelativeDate('volgende week');
|
||||||
|
expect(isDateRange(result)).toBe(true);
|
||||||
|
if (isDateRange(result)) {
|
||||||
|
expect(result.label).toBe('volgende week');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse weekday names', () => {
|
||||||
|
const weekdays = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
||||||
|
weekdays.forEach((day) => {
|
||||||
|
const result = parseRelativeDate(day);
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse absolute dates like "30 december"', () => {
|
||||||
|
const result = parseRelativeDate('30 december');
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
if (result instanceof Date) {
|
||||||
|
expect(result.getDate()).toBe(30);
|
||||||
|
expect(result.getMonth()).toBe(11); // December = 11 (0-indexed)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse ISO date format "2024-12-28"', () => {
|
||||||
|
const result = parseRelativeDate('2024-12-28');
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
if (result instanceof Date) {
|
||||||
|
expect(result.getFullYear()).toBe(2024);
|
||||||
|
expect(result.getMonth()).toBe(11); // December
|
||||||
|
expect(result.getDate()).toBe(28);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unparseable input', () => {
|
||||||
|
const result = parseRelativeDate('gibberish xyz');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTime', () => {
|
||||||
|
it('should parse "14:00" format', () => {
|
||||||
|
expect(parseTime('14:00')).toBe('14:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "14.00" format', () => {
|
||||||
|
expect(parseTime('14.00')).toBe('14:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse hour only "14" to "14:00"', () => {
|
||||||
|
expect(parseTime('14')).toBe('14:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "twee uur" to "14:00"', () => {
|
||||||
|
expect(parseTime('twee uur')).toBe('14:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "half drie" to "14:30"', () => {
|
||||||
|
expect(parseTime('half drie')).toBe('14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "kwart over twee" to "14:15"', () => {
|
||||||
|
expect(parseTime('kwart over twee')).toBe('14:15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse "kwart voor drie" to "14:45"', () => {
|
||||||
|
expect(parseTime('kwart voor drie')).toBe('14:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse time of day words', () => {
|
||||||
|
expect(parseTime('ochtend')).toBe('09:00');
|
||||||
|
expect(parseTime('middag')).toBe('14:00');
|
||||||
|
expect(parseTime('avond')).toBe('19:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unparseable input', () => {
|
||||||
|
expect(parseTime('xyz')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid hour values', () => {
|
||||||
|
expect(parseTime('25:00')).toBeNull();
|
||||||
|
expect(parseTime('14:70')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDateRange', () => {
|
||||||
|
it('should return true for DateRange objects', () => {
|
||||||
|
const range: DateRange = {
|
||||||
|
start: new Date(),
|
||||||
|
end: new Date(),
|
||||||
|
label: 'vandaag',
|
||||||
|
};
|
||||||
|
expect(isDateRange(range)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for Date objects', () => {
|
||||||
|
expect(isDateRange(new Date())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null', () => {
|
||||||
|
expect(isDateRange(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dateToRange', () => {
|
||||||
|
it('should convert Date to DateRange', () => {
|
||||||
|
const date = new Date('2024-12-28');
|
||||||
|
const range = dateToRange(date, 'vandaag');
|
||||||
|
|
||||||
|
expect(range.label).toBe('vandaag');
|
||||||
|
expect(range.start).toBeInstanceOf(Date);
|
||||||
|
expect(range.end).toBeInstanceOf(Date);
|
||||||
|
expect(range.start.getHours()).toBe(0); // Start of day
|
||||||
|
expect(range.end.getHours()).toBe(23); // End of day
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combineDatetime', () => {
|
||||||
|
it('should combine date and time into ISO string', () => {
|
||||||
|
const date = new Date('2024-12-28');
|
||||||
|
const time = '14:00';
|
||||||
|
const result = combineDatetime(date, time);
|
||||||
|
|
||||||
|
expect(result).toContain('2024-12-28');
|
||||||
|
expect(result).toContain('14:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle date strings', () => {
|
||||||
|
const result = combineDatetime('2024-12-28', '14:00');
|
||||||
|
expect(result).toContain('2024-12-28');
|
||||||
|
expect(result).toContain('14:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNotInPast', () => {
|
||||||
|
it('should return true for future dates', () => {
|
||||||
|
const futureDate = addDays(new Date(), 1);
|
||||||
|
expect(isNotInPast(futureDate)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for today by default', () => {
|
||||||
|
const today = new Date();
|
||||||
|
expect(isNotInPast(today)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for today when allowToday=false', () => {
|
||||||
|
const today = new Date();
|
||||||
|
expect(isNotInPast(today, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for past dates', () => {
|
||||||
|
const pastDate = addDays(new Date(), -1);
|
||||||
|
expect(isNotInPast(pastDate)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
308
lib/swift/date-time-parser.ts
Normal file
308
lib/swift/date-time-parser.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Date/Time Parser Utilities
|
||||||
|
*
|
||||||
|
* Parses natural language date and time expressions (Dutch)
|
||||||
|
* for Swift agenda functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
* @returns Date object, DateRange, or null if unparseable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseRelativeDate("morgen") // tomorrow's date
|
||||||
|
* parseRelativeDate("deze week") // { start: Mon, end: Sun, label: "deze week" }
|
||||||
|
* parseRelativeDate("dinsdag") // next Tuesday
|
||||||
|
*/
|
||||||
|
export function parseRelativeDate(input: string): Date | DateRange | null {
|
||||||
|
const today = new Date();
|
||||||
|
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)
|
||||||
|
* @returns true if date is valid (not in past)
|
||||||
|
*/
|
||||||
|
export function isNotInPast(date: Date, allowToday = true): boolean {
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const checkDate = startOfDay(date);
|
||||||
|
|
||||||
|
if (allowToday) {
|
||||||
|
return checkDate >= today;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDate > today;
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Entity Extractor
|
* Entity Extractor
|
||||||
*
|
*
|
||||||
* Extracts entities (patient name, category, content) from user input.
|
* Extracts entities (patient name, category, content, date/time) from user input.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { VerpleegkundigCategory } from '@/lib/types/report';
|
import type { VerpleegkundigCategory } from '@/lib/types/report';
|
||||||
import type { ExtractedEntities, SwiftIntent } from './types';
|
import type { ExtractedEntities, SwiftIntent } from './types';
|
||||||
|
import {
|
||||||
|
parseRelativeDate,
|
||||||
|
parseTime,
|
||||||
|
isDateRange,
|
||||||
|
dateToRange,
|
||||||
|
type DateRange,
|
||||||
|
} from './date-time-parser';
|
||||||
|
|
||||||
// Category aliases mapping to canonical values
|
// Category aliases mapping to canonical values
|
||||||
const CATEGORY_ALIASES: Record<string, VerpleegkundigCategory> = {
|
const CATEGORY_ALIASES: Record<string, VerpleegkundigCategory> = {
|
||||||
@@ -95,6 +102,14 @@ export function extractEntities(input: string, intent: SwiftIntent): ExtractedEn
|
|||||||
case 'overdracht':
|
case 'overdracht':
|
||||||
// Overdracht doesn't need entity extraction
|
// Overdracht doesn't need entity extraction
|
||||||
return entities;
|
return entities;
|
||||||
|
case 'agenda_query':
|
||||||
|
return extractAgendaQueryEntities(trimmedInput, input);
|
||||||
|
case 'create_appointment':
|
||||||
|
return extractCreateAppointmentEntities(trimmedInput, input);
|
||||||
|
case 'cancel_appointment':
|
||||||
|
return extractCancelAppointmentEntities(trimmedInput, input);
|
||||||
|
case 'reschedule_appointment':
|
||||||
|
return extractRescheduleAppointmentEntities(trimmedInput, input);
|
||||||
default:
|
default:
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
@@ -231,3 +246,394 @@ export function parseCategory(input: string): VerpleegkundigCategory | undefined
|
|||||||
const lower = input.toLowerCase().trim();
|
const lower = input.toLowerCase().trim();
|
||||||
return CATEGORY_ALIASES[lower];
|
return CATEGORY_ALIASES[lower];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Agenda Entity Extraction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract entities for agenda_query intent.
|
||||||
|
* Patterns:
|
||||||
|
* - "afspraken vandaag" → dateRange: today
|
||||||
|
* - "agenda morgen" → dateRange: tomorrow
|
||||||
|
* - "wat is volgende afspraak" → dateRange: from now (no explicit range)
|
||||||
|
* - "afspraken deze week" → dateRange: this week
|
||||||
|
*/
|
||||||
|
function extractAgendaQueryEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||||
|
const entities: ExtractedEntities = {};
|
||||||
|
const words = lowerInput.split(/\s+/);
|
||||||
|
|
||||||
|
// Try to find date expression
|
||||||
|
// Check multi-word patterns first (e.g., "deze week", "volgende week")
|
||||||
|
for (let i = 0; i < words.length - 1; i++) {
|
||||||
|
const twoWords = `${words[i]} ${words[i + 1]}`;
|
||||||
|
const parsed = parseRelativeDate(twoWords);
|
||||||
|
if (parsed) {
|
||||||
|
if (isDateRange(parsed)) {
|
||||||
|
entities.dateRange = parsed;
|
||||||
|
} else {
|
||||||
|
entities.dateRange = dateToRange(parsed, extractDateLabel(twoWords));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check single word patterns
|
||||||
|
for (const word of words) {
|
||||||
|
const parsed = parseRelativeDate(word);
|
||||||
|
if (parsed) {
|
||||||
|
if (isDateRange(parsed)) {
|
||||||
|
entities.dateRange = parsed;
|
||||||
|
} else {
|
||||||
|
entities.dateRange = dateToRange(parsed, extractDateLabel(word));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to today if no date specified
|
||||||
|
const today = new Date();
|
||||||
|
entities.dateRange = dateToRange(today, 'vandaag');
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract entities for create_appointment intent.
|
||||||
|
* Patterns:
|
||||||
|
* - "maak afspraak jan morgen 14:00" → patient: Jan, date: tomorrow, time: 14:00
|
||||||
|
* - "plan intake marie vrijdag 10:00" → patient: Marie, type: intake, date: friday, time: 10:00
|
||||||
|
* - "afspraak met piet 14:00" → patient: Piet, time: 14:00, date: today (implied)
|
||||||
|
*/
|
||||||
|
function extractCreateAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||||
|
const entities: ExtractedEntities = {};
|
||||||
|
const words = lowerInput.split(/\s+/);
|
||||||
|
|
||||||
|
// Remove command words
|
||||||
|
const appointmentCommands = ['maak', 'plan', 'afspraak', 'met', 'nieuwe', 'voor'];
|
||||||
|
const filteredWords = words.filter(w => !appointmentCommands.includes(w));
|
||||||
|
|
||||||
|
// Extract appointment type
|
||||||
|
const typeKeywords: Record<string, ExtractedEntities['appointmentType']> = {
|
||||||
|
'intake': 'intake',
|
||||||
|
'behandeling': 'behandeling',
|
||||||
|
'vervolg': 'follow-up',
|
||||||
|
'vervolgafspraak': 'follow-up',
|
||||||
|
'telefonisch': 'telefonisch',
|
||||||
|
'bellen': 'telefonisch',
|
||||||
|
'huisbezoek': 'huisbezoek',
|
||||||
|
'thuis': 'huisbezoek',
|
||||||
|
'online': 'online',
|
||||||
|
'video': 'online',
|
||||||
|
'crisis': 'crisis',
|
||||||
|
'spoed': 'crisis',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [keyword, type] of Object.entries(typeKeywords)) {
|
||||||
|
if (lowerInput.includes(keyword)) {
|
||||||
|
entities.appointmentType = type;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract location
|
||||||
|
const locationKeywords: Record<string, ExtractedEntities['location']> = {
|
||||||
|
'praktijk': 'praktijk',
|
||||||
|
'online': 'online',
|
||||||
|
'video': 'online',
|
||||||
|
'thuis': 'thuis',
|
||||||
|
'huisbezoek': 'thuis',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [keyword, location] of Object.entries(locationKeywords)) {
|
||||||
|
if (lowerInput.includes(keyword)) {
|
||||||
|
entities.location = location;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract patient name
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
if (isLikelyName(word, originalInput)) {
|
||||||
|
entities.patientName = capitalizeFirst(word);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract date and time
|
||||||
|
let foundDate: Date | null = null;
|
||||||
|
let foundTime: string | null = null;
|
||||||
|
|
||||||
|
// Try multi-word date patterns
|
||||||
|
for (let i = 0; i < filteredWords.length - 1; i++) {
|
||||||
|
const twoWords = `${filteredWords[i]} ${filteredWords[i + 1]}`;
|
||||||
|
const parsed = parseRelativeDate(twoWords);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
foundDate = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try single word date patterns
|
||||||
|
if (!foundDate) {
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
const parsed = parseRelativeDate(word);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
foundDate = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find time
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
const parsed = parseTime(word);
|
||||||
|
if (parsed) {
|
||||||
|
foundTime = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multi-word time patterns (e.g., "half drie")
|
||||||
|
if (!foundTime) {
|
||||||
|
for (let i = 0; i < filteredWords.length - 1; i++) {
|
||||||
|
const twoWords = `${filteredWords[i]} ${filteredWords[i + 1]}`;
|
||||||
|
const parsed = parseTime(twoWords);
|
||||||
|
if (parsed) {
|
||||||
|
foundTime = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine date and time if both found
|
||||||
|
if (foundDate && foundTime) {
|
||||||
|
entities.datetime = {
|
||||||
|
date: foundDate,
|
||||||
|
time: foundTime,
|
||||||
|
};
|
||||||
|
} else if (foundDate) {
|
||||||
|
// Date without time
|
||||||
|
entities.datetime = {
|
||||||
|
date: foundDate,
|
||||||
|
time: '', // Will be filled by UI or AI
|
||||||
|
};
|
||||||
|
} else if (foundTime) {
|
||||||
|
// Time without date (assume today)
|
||||||
|
entities.datetime = {
|
||||||
|
date: new Date(),
|
||||||
|
time: foundTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract entities for cancel_appointment intent.
|
||||||
|
* Patterns:
|
||||||
|
* - "annuleer afspraak jan" → identifier: { type: patient, patientName: Jan }
|
||||||
|
* - "cancel de 14:00 afspraak" → identifier: { type: time, time: 14:00 }
|
||||||
|
* - "annuleer jan morgen" → identifier: { type: both, patientName: Jan, date: tomorrow }
|
||||||
|
*/
|
||||||
|
function extractCancelAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||||
|
const entities: ExtractedEntities = {};
|
||||||
|
const words = lowerInput.split(/\s+/);
|
||||||
|
|
||||||
|
// Remove command words
|
||||||
|
const cancelCommands = ['annuleer', 'cancel', 'verwijder', 'afspraak', 'de', 'van'];
|
||||||
|
const filteredWords = words.filter(w => !cancelCommands.includes(w));
|
||||||
|
|
||||||
|
// Extract patient name
|
||||||
|
let patientName: string | undefined;
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
if (isLikelyName(word, originalInput)) {
|
||||||
|
patientName = capitalizeFirst(word);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract time
|
||||||
|
let time: string | null = null;
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
const parsed = parseTime(word);
|
||||||
|
if (parsed) {
|
||||||
|
time = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract date
|
||||||
|
let date: Date | null = null;
|
||||||
|
for (const word of filteredWords) {
|
||||||
|
const parsed = parseRelativeDate(word);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
date = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build identifier
|
||||||
|
if (patientName && time) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'both',
|
||||||
|
patientName,
|
||||||
|
time,
|
||||||
|
date: date || undefined,
|
||||||
|
};
|
||||||
|
} else if (patientName) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'patient',
|
||||||
|
patientName,
|
||||||
|
date: date || undefined,
|
||||||
|
};
|
||||||
|
} else if (time) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'time',
|
||||||
|
time,
|
||||||
|
date: date || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract entities for reschedule_appointment intent.
|
||||||
|
* Patterns:
|
||||||
|
* - "verzet 14:00 naar 15:00" → identifier: { time: 14:00 }, newDatetime: { time: 15:00 }
|
||||||
|
* - "verzet jan naar dinsdag" → identifier: { patientName: Jan }, newDatetime: { date: tuesday }
|
||||||
|
* - "verzet de afspraak naar morgen 10:00" → newDatetime: { date: tomorrow, time: 10:00 }
|
||||||
|
*/
|
||||||
|
function extractRescheduleAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||||
|
const entities: ExtractedEntities = {};
|
||||||
|
const words = lowerInput.split(/\s+/);
|
||||||
|
|
||||||
|
// Split input on "naar" to separate old and new parts
|
||||||
|
const naarIndex = words.indexOf('naar');
|
||||||
|
const oldPart = naarIndex > 0 ? words.slice(0, naarIndex).join(' ') : lowerInput;
|
||||||
|
const newPart = naarIndex > 0 ? words.slice(naarIndex + 1).join(' ') : '';
|
||||||
|
|
||||||
|
// Remove command words
|
||||||
|
const rescheduleCommands = ['verzet', 'verplaats', 'verschuif', 'afspraak', 'de', 'van'];
|
||||||
|
const oldWords = oldPart.split(/\s+/).filter(w => !rescheduleCommands.includes(w));
|
||||||
|
const newWords = newPart.split(/\s+/);
|
||||||
|
|
||||||
|
// Extract old appointment identifier
|
||||||
|
let patientName: string | undefined;
|
||||||
|
for (const word of oldWords) {
|
||||||
|
if (isLikelyName(word, originalInput)) {
|
||||||
|
patientName = capitalizeFirst(word);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldTime: string | null = null;
|
||||||
|
for (const word of oldWords) {
|
||||||
|
const parsed = parseTime(word);
|
||||||
|
if (parsed) {
|
||||||
|
oldTime = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldDate: Date | null = null;
|
||||||
|
for (const word of oldWords) {
|
||||||
|
const parsed = parseRelativeDate(word);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
oldDate = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build identifier
|
||||||
|
if (patientName && oldTime) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'both',
|
||||||
|
patientName,
|
||||||
|
time: oldTime,
|
||||||
|
date: oldDate || undefined,
|
||||||
|
};
|
||||||
|
} else if (patientName) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'patient',
|
||||||
|
patientName,
|
||||||
|
date: oldDate || undefined,
|
||||||
|
};
|
||||||
|
} else if (oldTime) {
|
||||||
|
entities.identifier = {
|
||||||
|
type: 'time',
|
||||||
|
time: oldTime,
|
||||||
|
date: oldDate || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract new datetime
|
||||||
|
if (newWords.length > 0) {
|
||||||
|
let newDate: Date | null = null;
|
||||||
|
let newTime: string | null = null;
|
||||||
|
|
||||||
|
// Try multi-word date patterns
|
||||||
|
for (let i = 0; i < newWords.length - 1; i++) {
|
||||||
|
const twoWords = `${newWords[i]} ${newWords[i + 1]}`;
|
||||||
|
const parsed = parseRelativeDate(twoWords);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
newDate = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try single word date patterns
|
||||||
|
if (!newDate) {
|
||||||
|
for (const word of newWords) {
|
||||||
|
const parsed = parseRelativeDate(word);
|
||||||
|
if (parsed && !isDateRange(parsed)) {
|
||||||
|
newDate = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find new time
|
||||||
|
for (const word of newWords) {
|
||||||
|
const parsed = parseTime(word);
|
||||||
|
if (parsed) {
|
||||||
|
newTime = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multi-word time patterns
|
||||||
|
if (!newTime) {
|
||||||
|
for (let i = 0; i < newWords.length - 1; i++) {
|
||||||
|
const twoWords = `${newWords[i]} ${newWords[i + 1]}`;
|
||||||
|
const parsed = parseTime(twoWords);
|
||||||
|
if (parsed) {
|
||||||
|
newTime = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDate || newTime) {
|
||||||
|
entities.newDatetime = {
|
||||||
|
date: newDate || new Date(), // Default to today if only time specified
|
||||||
|
time: newTime || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract date label from input string
|
||||||
|
*/
|
||||||
|
function extractDateLabel(input: string): DateRange['label'] {
|
||||||
|
const normalized = input.toLowerCase();
|
||||||
|
if (normalized.includes('vandaag')) return 'vandaag';
|
||||||
|
if (normalized.includes('morgen')) return 'morgen';
|
||||||
|
if (normalized.includes('deze week')) return 'deze week';
|
||||||
|
if (normalized.includes('volgende week')) return 'volgende week';
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
|||||||
93
lib/swift/verify-parser.ts
Normal file
93
lib/swift/verify-parser.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Manual verification script for date-time parser
|
||||||
|
* Run with: pnpm tsx lib/swift/verify-parser.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseRelativeDate,
|
||||||
|
parseTime,
|
||||||
|
isDateRange,
|
||||||
|
combineDatetime,
|
||||||
|
isNotInPast,
|
||||||
|
} from './date-time-parser';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { nl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
console.log('🧪 Testing Date/Time Parser\n');
|
||||||
|
|
||||||
|
// Test parseRelativeDate
|
||||||
|
console.log('📅 Testing parseRelativeDate():\n');
|
||||||
|
|
||||||
|
const dateTests = [
|
||||||
|
'vandaag',
|
||||||
|
'morgen',
|
||||||
|
'overmorgen',
|
||||||
|
'maandag',
|
||||||
|
'dinsdag',
|
||||||
|
'deze week',
|
||||||
|
'volgende week',
|
||||||
|
'30 december',
|
||||||
|
'2024-12-28',
|
||||||
|
'28-12-2024',
|
||||||
|
];
|
||||||
|
|
||||||
|
dateTests.forEach((input) => {
|
||||||
|
const result = parseRelativeDate(input);
|
||||||
|
if (result === null) {
|
||||||
|
console.log(` ❌ "${input}" → null`);
|
||||||
|
} else if (isDateRange(result)) {
|
||||||
|
console.log(` ✅ "${input}" → Range: ${format(result.start, 'dd MMM', { locale: nl })} - ${format(result.end, 'dd MMM', { locale: nl })}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ "${input}" → ${format(result, 'EEEE dd MMMM yyyy', { locale: nl })}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test parseTime
|
||||||
|
console.log('\n⏰ Testing parseTime():\n');
|
||||||
|
|
||||||
|
const timeTests = [
|
||||||
|
'14:00',
|
||||||
|
'14',
|
||||||
|
'twee uur',
|
||||||
|
'half drie',
|
||||||
|
'kwart over twee',
|
||||||
|
'kwart voor drie',
|
||||||
|
'ochtend',
|
||||||
|
'middag',
|
||||||
|
'avond',
|
||||||
|
];
|
||||||
|
|
||||||
|
timeTests.forEach((input) => {
|
||||||
|
const result = parseTime(input);
|
||||||
|
if (result === null) {
|
||||||
|
console.log(` ❌ "${input}" → null`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ "${input}" → ${result}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test combineDatetime
|
||||||
|
console.log('\n🔗 Testing combineDatetime():\n');
|
||||||
|
|
||||||
|
const morgen = parseRelativeDate('morgen');
|
||||||
|
if (morgen && !isDateRange(morgen)) {
|
||||||
|
const combined = combineDatetime(morgen, '14:00');
|
||||||
|
console.log(` ✅ morgen + 14:00 → ${combined}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test isNotInPast
|
||||||
|
console.log('\n✔️ Testing isNotInPast():\n');
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = parseRelativeDate('morgen');
|
||||||
|
const yesterday = parseRelativeDate('gisteren');
|
||||||
|
|
||||||
|
console.log(` Today: ${isNotInPast(today)} (expected: true)`);
|
||||||
|
if (tomorrow && !isDateRange(tomorrow)) {
|
||||||
|
console.log(` Tomorrow: ${isNotInPast(tomorrow)} (expected: true)`);
|
||||||
|
}
|
||||||
|
if (yesterday && !isDateRange(yesterday)) {
|
||||||
|
console.log(` Yesterday: ${isNotInPast(yesterday)} (expected: false)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Verification complete!');
|
||||||
Reference in New Issue
Block a user