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:
Till JS 2026-03-30 01:04:55 +02:00
parent 2222ce25e5
commit 31faa5b994
5 changed files with 650 additions and 1 deletions

View file

@ -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

View 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);
});
});

View 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,
};
}

View file

@ -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);
});
});

View file

@ -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
// ============================================================================