diff --git a/apps/calendar/CLAUDE.md b/apps/calendar/CLAUDE.md index f291896b2..db32d23dc 100644 --- a/apps/calendar/CLAUDE.md +++ b/apps/calendar/CLAUDE.md @@ -478,6 +478,49 @@ EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 ``` +## 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 diff --git a/apps/calendar/apps/web/src/lib/utils/event-estimator.test.ts b/apps/calendar/apps/web/src/lib/utils/event-estimator.test.ts new file mode 100644 index 000000000..f7e64713f --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/event-estimator.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest'; +import { + estimateEventDuration, + detectConflicts, + type HistoricalEventData, +} from './event-estimator'; + +function makeEvent(overrides: Partial = {}): 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); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/utils/event-estimator.ts b/apps/calendar/apps/web/src/lib/utils/event-estimator.ts new file mode 100644 index 000000000..ef436f458 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/event-estimator.ts @@ -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, + }; +} diff --git a/apps/calendar/apps/web/src/lib/utils/event-parser.test.ts b/apps/calendar/apps/web/src/lib/utils/event-parser.test.ts index 0ae70bef1..c89bd26df 100644 --- a/apps/calendar/apps/web/src/lib/utils/event-parser.test.ts +++ b/apps/calendar/apps/web/src/lib/utils/event-parser.test.ts @@ -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); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/utils/event-parser.ts b/apps/calendar/apps/web/src/lib/utils/event-parser.ts index 56a547ad4..c2489499c 100644 --- a/apps/calendar/apps/web/src/lib/utils/event-parser.ts +++ b/apps/calendar/apps/web/src/lib/utils/event-parser.ts @@ -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 // ============================================================================