mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(calendar): add multi-event splitting, duration estimation, and conflict detection
Extend the event parser with multi-event splitting on keywords (danach, dann, ;) with context inheritance (date/time/calendar chain and automatic time offsets). Add event-estimator.ts with history-based duration estimation (weighted similarity on calendar, title, tags) and conflict detection against existing events. All features run offline against IndexedDB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2222ce25e5
commit
31faa5b994
5 changed files with 650 additions and 1 deletions
|
|
@ -478,6 +478,49 @@ EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
|||
</script>
|
||||
```
|
||||
|
||||
## Quick Add Syntax
|
||||
|
||||
Natural language event creation via `event-parser.ts`:
|
||||
|
||||
```
|
||||
"Meeting morgen 14 Uhr 1h @Arbeit #wichtig"
|
||||
```
|
||||
|
||||
Recognized patterns:
|
||||
- **Date**: heute, morgen, nächsten Montag, 15.12.
|
||||
- **Time**: um 14 Uhr, 14:00
|
||||
- **Time Range**: 14-16 Uhr, 10:00-11:30
|
||||
- **Duration**: 30min, 2h, 1.5 Stunden, 2h30m
|
||||
- **All-Day**: ganztägig, ganzer Tag
|
||||
- **Calendar**: @Kalender (first @ref matches calendar)
|
||||
- **Attendees**: @Name (subsequent @refs become attendees)
|
||||
- **Tags**: #tag1 #tag2
|
||||
- **Location**: in Berlin, im Büro, bei Dr. Müller
|
||||
- **Recurrence**: jeden Tag, wöchentlich, monatlich
|
||||
|
||||
### Multi-Event Input
|
||||
|
||||
Split multiple events with keywords (`danach`, `dann`, `und dann`, `anschließend`) or semicolons:
|
||||
|
||||
```
|
||||
"Meeting 14 Uhr 1h danach Review 30min"
|
||||
→ Event 1: Meeting (14:00-15:00)
|
||||
→ Event 2: Review (15:00-15:30, auto-offset)
|
||||
|
||||
"Standup 9 Uhr 30min @Arbeit; Sprint Planning 1h; Code Review 30min"
|
||||
→ 3 events chained: 9:00-9:30, 9:30-10:30, 10:30-11:00
|
||||
```
|
||||
|
||||
Context inheritance: subsequent events inherit date, time, and calendar from the first event. If the first event has a duration, the next event starts where it ends.
|
||||
|
||||
### Duration Estimation
|
||||
|
||||
`estimateEventDuration()` in `event-estimator.ts` suggests event duration based on past events. Uses weighted similarity (calendar, title overlap, tags). Runs fully offline against IndexedDB.
|
||||
|
||||
### Conflict Detection
|
||||
|
||||
`detectConflicts()` in `event-estimator.ts` checks for overlapping events. Ignores all-day events, supports exclude-by-ID for edit mode. Returns list of conflicting events with title and time.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Datenbank erstellen
|
||||
|
|
|
|||
191
apps/calendar/apps/web/src/lib/utils/event-estimator.test.ts
Normal file
191
apps/calendar/apps/web/src/lib/utils/event-estimator.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
estimateEventDuration,
|
||||
detectConflicts,
|
||||
type HistoricalEventData,
|
||||
} from './event-estimator';
|
||||
|
||||
function makeEvent(overrides: Partial<HistoricalEventData> = {}): HistoricalEventData {
|
||||
return {
|
||||
title: 'Default Event',
|
||||
calendarId: null,
|
||||
startDate: '2026-03-28T10:00:00Z',
|
||||
endDate: '2026-03-28T11:00:00Z', // 60 min
|
||||
allDay: false,
|
||||
tagIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('estimateEventDuration', () => {
|
||||
it('should return null with insufficient data', () => {
|
||||
const result = estimateEventDuration(
|
||||
{ title: 'New event' },
|
||||
[makeEvent(), makeEvent()] // only 2, need 3
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should estimate from events in same calendar', () => {
|
||||
const history = Array.from({ length: 5 }, () =>
|
||||
makeEvent({
|
||||
calendarId: 'cal-1',
|
||||
startDate: '2026-03-28T10:00:00Z',
|
||||
endDate: '2026-03-28T10:30:00Z', // 30 min
|
||||
})
|
||||
);
|
||||
|
||||
const result = estimateEventDuration({ title: 'Something', calendarId: 'cal-1' }, history);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minutes).toBe(30);
|
||||
});
|
||||
|
||||
it('should weight title overlap strongly', () => {
|
||||
const history = [
|
||||
makeEvent({
|
||||
title: 'Standup Meeting',
|
||||
startDate: '2026-03-28T09:00:00Z',
|
||||
endDate: '2026-03-28T09:15:00Z',
|
||||
}),
|
||||
makeEvent({
|
||||
title: 'Standup Meeting',
|
||||
startDate: '2026-03-27T09:00:00Z',
|
||||
endDate: '2026-03-27T09:15:00Z',
|
||||
}),
|
||||
makeEvent({
|
||||
title: 'Standup Meeting',
|
||||
startDate: '2026-03-26T09:00:00Z',
|
||||
endDate: '2026-03-26T09:15:00Z',
|
||||
}),
|
||||
// Unrelated longer events
|
||||
makeEvent({
|
||||
title: 'Workshop',
|
||||
startDate: '2026-03-28T10:00:00Z',
|
||||
endDate: '2026-03-28T14:00:00Z',
|
||||
}),
|
||||
makeEvent({
|
||||
title: 'Konferenz',
|
||||
startDate: '2026-03-27T10:00:00Z',
|
||||
endDate: '2026-03-27T14:00:00Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = estimateEventDuration({ title: 'Standup Meeting' }, history);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minutes).toBe(15);
|
||||
});
|
||||
|
||||
it('should ignore all-day events', () => {
|
||||
const history = [
|
||||
...Array.from({ length: 3 }, () => makeEvent({ title: 'Urlaub', allDay: true })),
|
||||
...Array.from({ length: 3 }, () =>
|
||||
makeEvent({
|
||||
title: 'Urlaub',
|
||||
allDay: false,
|
||||
startDate: '2026-03-28T10:00:00Z',
|
||||
endDate: '2026-03-28T11:00:00Z',
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const result = estimateEventDuration({ title: 'Urlaub' }, history);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minutes).toBe(60);
|
||||
});
|
||||
|
||||
it('should round to nice numbers', () => {
|
||||
const history = Array.from({ length: 5 }, () =>
|
||||
makeEvent({
|
||||
calendarId: 'cal-1',
|
||||
startDate: '2026-03-28T10:00:00Z',
|
||||
endDate: '2026-03-28T10:37:00Z', // 37 min
|
||||
})
|
||||
);
|
||||
|
||||
const result = estimateEventDuration({ title: 'Task', calendarId: 'cal-1' }, history);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minutes % 5).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectConflicts', () => {
|
||||
const existingEvents = [
|
||||
{
|
||||
id: 'e1',
|
||||
title: 'Standup',
|
||||
startDate: '2026-03-30T09:00:00Z',
|
||||
endDate: '2026-03-30T09:30:00Z',
|
||||
calendarId: 'cal-1',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
title: 'Meeting',
|
||||
startDate: '2026-03-30T14:00:00Z',
|
||||
endDate: '2026-03-30T15:00:00Z',
|
||||
calendarId: 'cal-1',
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
title: 'Urlaub',
|
||||
startDate: '2026-03-30T00:00:00Z',
|
||||
endDate: '2026-03-30T23:59:59Z',
|
||||
calendarId: 'cal-2',
|
||||
allDay: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('should detect overlap with existing event', () => {
|
||||
const result = detectConflicts('2026-03-30T14:30:00Z', '2026-03-30T15:30:00Z', existingEvents);
|
||||
expect(result.hasConflict).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(1);
|
||||
expect(result.conflicts[0].title).toBe('Meeting');
|
||||
});
|
||||
|
||||
it('should detect no conflict when time is free', () => {
|
||||
const result = detectConflicts('2026-03-30T10:00:00Z', '2026-03-30T11:00:00Z', existingEvents);
|
||||
expect(result.hasConflict).toBe(false);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect multiple overlaps', () => {
|
||||
const result = detectConflicts('2026-03-30T08:45:00Z', '2026-03-30T15:30:00Z', existingEvents);
|
||||
expect(result.hasConflict).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(2); // Standup + Meeting (not Urlaub, it's allDay)
|
||||
});
|
||||
|
||||
it('should ignore all-day events', () => {
|
||||
const result = detectConflicts('2026-03-30T12:00:00Z', '2026-03-30T13:00:00Z', existingEvents);
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
|
||||
it('should not conflict with adjacent events (end = start)', () => {
|
||||
const result = detectConflicts(
|
||||
'2026-03-30T09:30:00Z', // starts exactly when Standup ends
|
||||
'2026-03-30T10:00:00Z',
|
||||
existingEvents
|
||||
);
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
|
||||
it('should exclude specified event ID (edit mode)', () => {
|
||||
const result = detectConflicts(
|
||||
'2026-03-30T14:00:00Z',
|
||||
'2026-03-30T15:00:00Z',
|
||||
existingEvents,
|
||||
'e2' // editing the Meeting itself
|
||||
);
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid range gracefully', () => {
|
||||
const result = detectConflicts(
|
||||
'2026-03-30T15:00:00Z',
|
||||
'2026-03-30T14:00:00Z', // end before start
|
||||
existingEvents
|
||||
);
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
});
|
||||
262
apps/calendar/apps/web/src/lib/utils/event-estimator.ts
Normal file
262
apps/calendar/apps/web/src/lib/utils/event-estimator.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Event Duration Estimator & Conflict Detector
|
||||
*
|
||||
* Duration estimation: suggests event duration based on historical events
|
||||
* using weighted similarity (calendar, title overlap, tags, time of day).
|
||||
*
|
||||
* Conflict detection: checks for overlapping events in a given time range.
|
||||
*
|
||||
* Both run fully offline against local IndexedDB data.
|
||||
*/
|
||||
|
||||
// ─── Duration Estimation ───────────────────────────────────
|
||||
|
||||
export interface HistoricalEventData {
|
||||
title: string;
|
||||
calendarId?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay?: boolean;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export interface DurationEstimate {
|
||||
minutes: number;
|
||||
confidence: 'low' | 'medium' | 'high';
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
interface ScoredEvent {
|
||||
duration: number; // minutes
|
||||
score: number;
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'der',
|
||||
'die',
|
||||
'das',
|
||||
'ein',
|
||||
'eine',
|
||||
'und',
|
||||
'oder',
|
||||
'für',
|
||||
'mit',
|
||||
'von',
|
||||
'zu',
|
||||
'im',
|
||||
'am',
|
||||
'an',
|
||||
'auf',
|
||||
'in',
|
||||
'den',
|
||||
'dem',
|
||||
'des',
|
||||
'bei',
|
||||
'nach',
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'or',
|
||||
'for',
|
||||
'with',
|
||||
'from',
|
||||
'to',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
]);
|
||||
|
||||
function tokenize(title: string): string[] {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zäöüßàáâãèéêëìíîïòóôõùúûü0-9\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function titleOverlap(a: string[], b: string[]): number {
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
const setB = new Set(b);
|
||||
const shared = a.filter((w) => setB.has(w)).length;
|
||||
return shared / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event duration in minutes. Ignores all-day events and unreasonable durations.
|
||||
*/
|
||||
function getEventDuration(event: HistoricalEventData): number | null {
|
||||
if (event.allDay) return null;
|
||||
|
||||
const start = new Date(event.startDate).getTime();
|
||||
const end = new Date(event.endDate).getTime();
|
||||
const minutes = (end - start) / 60_000;
|
||||
|
||||
// Only use reasonable durations (5 min to 12 hours)
|
||||
if (minutes >= 5 && minutes <= 720) {
|
||||
return Math.round(minutes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function similarity(
|
||||
newEvent: { title: string; calendarId?: string | null; tagIds?: string[] },
|
||||
historical: HistoricalEventData,
|
||||
newTokens: string[]
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
// Same calendar is strongest signal
|
||||
if (
|
||||
newEvent.calendarId &&
|
||||
historical.calendarId &&
|
||||
newEvent.calendarId === historical.calendarId
|
||||
) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
// Shared tags
|
||||
if (newEvent.tagIds && historical.tagIds) {
|
||||
const histSet = new Set(historical.tagIds);
|
||||
const shared = newEvent.tagIds.filter((id) => histSet.has(id)).length;
|
||||
score += shared * 2;
|
||||
}
|
||||
|
||||
// Title word overlap
|
||||
const histTokens = tokenize(historical.title);
|
||||
const overlap = titleOverlap(newTokens, histTokens);
|
||||
if (overlap > 0.5)
|
||||
score += 4; // title match is very strong for events
|
||||
else if (overlap > 0.2) score += 2;
|
||||
else if (overlap > 0) score += 1;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round minutes to human-friendly values
|
||||
*/
|
||||
function roundToNice(minutes: number): number {
|
||||
if (minutes <= 10) return Math.round(minutes / 5) * 5 || 5;
|
||||
if (minutes <= 30) return Math.round(minutes / 5) * 5;
|
||||
if (minutes <= 60) return Math.round(minutes / 15) * 15;
|
||||
if (minutes <= 240) return Math.round(minutes / 30) * 30;
|
||||
return Math.round(minutes / 60) * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate duration for a new event based on past events.
|
||||
*/
|
||||
export function estimateEventDuration(
|
||||
newEvent: { title: string; calendarId?: string | null; tagIds?: string[] },
|
||||
history: HistoricalEventData[],
|
||||
minSamples = 3
|
||||
): DurationEstimate | null {
|
||||
const newTokens = tokenize(newEvent.title);
|
||||
const scored: ScoredEvent[] = [];
|
||||
|
||||
for (const event of history) {
|
||||
const duration = getEventDuration(event);
|
||||
if (duration === null) continue;
|
||||
|
||||
const score = similarity(newEvent, event, newTokens);
|
||||
if (score > 0) {
|
||||
scored.push({ duration, score });
|
||||
}
|
||||
}
|
||||
|
||||
if (scored.length < minSamples) return null;
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const top = scored.slice(0, 20);
|
||||
|
||||
let totalWeight = 0;
|
||||
let totalDuration = 0;
|
||||
for (const { duration, score } of top) {
|
||||
totalWeight += score;
|
||||
totalDuration += duration * score;
|
||||
}
|
||||
|
||||
const minutes = roundToNice(Math.round(totalDuration / totalWeight));
|
||||
|
||||
const maxScore = top[0].score;
|
||||
const confidence: DurationEstimate['confidence'] =
|
||||
top.length >= 10 && maxScore >= 5
|
||||
? 'high'
|
||||
: top.length >= 5 && maxScore >= 3
|
||||
? 'medium'
|
||||
: 'low';
|
||||
|
||||
return { minutes, confidence, sampleSize: top.length };
|
||||
}
|
||||
|
||||
// ─── Conflict Detection ────────────────────────────────────
|
||||
|
||||
export interface ConflictingEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface ConflictResult {
|
||||
hasConflict: boolean;
|
||||
conflicts: ConflictingEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proposed event overlaps with existing events.
|
||||
* Two events overlap if: eventA.start < eventB.end AND eventA.end > eventB.start
|
||||
*
|
||||
* @param startDate - Proposed event start (ISO string or Date)
|
||||
* @param endDate - Proposed event end (ISO string or Date)
|
||||
* @param existingEvents - All events to check against
|
||||
* @param excludeEventId - Exclude this event (for editing existing events)
|
||||
*/
|
||||
export function detectConflicts(
|
||||
startDate: string | Date,
|
||||
endDate: string | Date,
|
||||
existingEvents: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
calendarId: string;
|
||||
allDay?: boolean;
|
||||
}[],
|
||||
excludeEventId?: string
|
||||
): ConflictResult {
|
||||
const newStart = new Date(startDate).getTime();
|
||||
const newEnd = new Date(endDate).getTime();
|
||||
|
||||
if (newStart >= newEnd) {
|
||||
return { hasConflict: false, conflicts: [] };
|
||||
}
|
||||
|
||||
const conflicts: ConflictingEvent[] = [];
|
||||
|
||||
for (const event of existingEvents) {
|
||||
if (event.id === excludeEventId) continue;
|
||||
if (event.allDay) continue; // all-day events don't block time
|
||||
|
||||
const eventStart = new Date(event.startDate).getTime();
|
||||
const eventEnd = new Date(event.endDate).getTime();
|
||||
|
||||
// Overlap check: A.start < B.end AND A.end > B.start
|
||||
if (newStart < eventEnd && newEnd > eventStart) {
|
||||
conflicts.push({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
calendarId: event.calendarId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflict: conflicts.length > 0,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './event-parser';
|
||||
import {
|
||||
parseEventInput,
|
||||
resolveEventIds,
|
||||
formatParsedEventPreview,
|
||||
parseMultiEventInput,
|
||||
} from './event-parser';
|
||||
|
||||
describe('parseEventInput', () => {
|
||||
it('should parse a simple title', () => {
|
||||
|
|
@ -273,3 +278,81 @@ describe('formatParsedEventPreview', () => {
|
|||
expect(preview).toContain(' · ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMultiEventInput', () => {
|
||||
it('should return single event for simple input', () => {
|
||||
const events = parseMultiEventInput('Meeting morgen');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Meeting');
|
||||
});
|
||||
|
||||
it('should split on "danach"', () => {
|
||||
const events = parseMultiEventInput('Meeting danach Review');
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].title).toBe('Meeting');
|
||||
expect(events[1].title).toBe('Review');
|
||||
});
|
||||
|
||||
it('should split on semicolon', () => {
|
||||
const events = parseMultiEventInput('Meeting; Review; Retro');
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should inherit calendar from first event', () => {
|
||||
const events = parseMultiEventInput('Meeting @Arbeit danach Review');
|
||||
expect(events[0].calendarName).toBe('Arbeit');
|
||||
expect(events[1].calendarName).toBe('Arbeit');
|
||||
});
|
||||
|
||||
it('should offset time based on first event end', () => {
|
||||
const events = parseMultiEventInput('Meeting 14 Uhr 1h danach Review 30min');
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].startDate).toBeDefined();
|
||||
expect(events[0].startDate!.getHours()).toBe(14);
|
||||
// Second event should start at 15:00 (14 + 1h)
|
||||
expect(events[1].startDate).toBeDefined();
|
||||
expect(events[1].startDate!.getHours()).toBe(15);
|
||||
expect(events[1].startDate!.getMinutes()).toBe(0);
|
||||
// Second event should end at 15:30
|
||||
expect(events[1].endDate).toBeDefined();
|
||||
expect(events[1].endDate!.getHours()).toBe(15);
|
||||
expect(events[1].endDate!.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it('should chain three events with time offsets', () => {
|
||||
const events = parseMultiEventInput(
|
||||
'Standup 9 Uhr 30min; Sprint Planning 1h; Code Review 30min'
|
||||
);
|
||||
expect(events).toHaveLength(3);
|
||||
// Standup: 9:00-9:30
|
||||
expect(events[0].startDate!.getHours()).toBe(9);
|
||||
expect(events[0].endDate!.getHours()).toBe(9);
|
||||
expect(events[0].endDate!.getMinutes()).toBe(30);
|
||||
// Sprint Planning: 9:30-10:30
|
||||
expect(events[1].startDate!.getHours()).toBe(9);
|
||||
expect(events[1].startDate!.getMinutes()).toBe(30);
|
||||
expect(events[1].endDate!.getHours()).toBe(10);
|
||||
expect(events[1].endDate!.getMinutes()).toBe(30);
|
||||
// Code Review: 10:30-11:00
|
||||
expect(events[2].startDate!.getHours()).toBe(10);
|
||||
expect(events[2].startDate!.getMinutes()).toBe(30);
|
||||
expect(events[2].endDate!.getHours()).toBe(11);
|
||||
expect(events[2].endDate!.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle ", danach" pattern', () => {
|
||||
const events = parseMultiEventInput('Zahnarzt, danach Apotheke');
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].title).toBe('Zahnarzt');
|
||||
expect(events[1].title).toBe('Apotheke');
|
||||
});
|
||||
|
||||
it('should default to 1h when no duration on follow-up events', () => {
|
||||
const events = parseMultiEventInput('Meeting 14 Uhr 1h danach Review');
|
||||
expect(events[1].startDate).toBeDefined();
|
||||
expect(events[1].endDate).toBeDefined();
|
||||
// Review default: 1h
|
||||
const diff = events[1].endDate!.getTime() - events[1].startDate!.getTime();
|
||||
expect(diff).toBe(60 * 60_000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -311,6 +311,76 @@ export function parseEventInput(input: string, locale: ParserLocale = 'de'): Par
|
|||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Event Splitting
|
||||
// ============================================================================
|
||||
|
||||
const EVENT_SPLITTERS =
|
||||
/\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem|afterwards|then|and then|also)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend|afterwards|then|and then)\s+)/i;
|
||||
|
||||
/**
|
||||
* Parse input that may contain multiple events separated by keywords.
|
||||
* Subsequent events inherit date/time/calendar context from the first event.
|
||||
* If the first event has a known end time, the next event starts there.
|
||||
*
|
||||
* Examples:
|
||||
* - "Meeting 14 Uhr 1h danach Review 30min" → Meeting 14-15, Review 15-15:30
|
||||
* - "Morgen Workshop 9-12 Uhr @Arbeit; Mittagessen; Retro 1h" → 3 events
|
||||
*/
|
||||
export function parseMultiEventInput(input: string, locale: ParserLocale = 'de'): ParsedEvent[] {
|
||||
const parts = input.split(EVENT_SPLITTERS).filter((s) => s.trim().length > 0);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return [parseEventInput(input, locale)];
|
||||
}
|
||||
|
||||
const results: ParsedEvent[] = [];
|
||||
let contextDate: Date | undefined;
|
||||
let contextCalendar: string | undefined;
|
||||
let lastEndDate: Date | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const parsed = parseEventInput(parts[i].trim(), locale);
|
||||
|
||||
if (i === 0) {
|
||||
contextDate = parsed.startDate;
|
||||
contextCalendar = parsed.calendarName;
|
||||
lastEndDate = parsed.endDate;
|
||||
} else {
|
||||
// Inherit calendar
|
||||
if (!parsed.calendarName && contextCalendar) {
|
||||
parsed.calendarName = contextCalendar;
|
||||
}
|
||||
|
||||
// Inherit date/time: use lastEndDate as start if no explicit time
|
||||
if (!parsed.startDate && lastEndDate) {
|
||||
parsed.startDate = new Date(lastEndDate);
|
||||
// Calculate endDate
|
||||
if (parsed.duration) {
|
||||
parsed.endDate = new Date(parsed.startDate.getTime() + parsed.duration * 60_000);
|
||||
} else {
|
||||
// Default 1h
|
||||
parsed.endDate = addHours(parsed.startDate, 1);
|
||||
}
|
||||
} else if (!parsed.startDate && contextDate) {
|
||||
// Fallback: same date, no specific time
|
||||
parsed.startDate = new Date(contextDate);
|
||||
if (parsed.duration) {
|
||||
parsed.endDate = new Date(parsed.startDate.getTime() + parsed.duration * 60_000);
|
||||
} else {
|
||||
parsed.endDate = addHours(parsed.startDate, 1);
|
||||
}
|
||||
}
|
||||
|
||||
lastEndDate = parsed.endDate;
|
||||
}
|
||||
|
||||
results.push(parsed);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ID Resolution
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue