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 dcb0a9b9c..0ae70bef1 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 @@ -65,9 +65,29 @@ describe('parseEventInput', () => { it('should parse @calendar reference', () => { const result = parseEventInput('Meeting @Arbeit'); expect(result.calendarName).toBe('Arbeit'); + expect(result.attendees).toEqual([]); expect(result.title).not.toContain('@Arbeit'); }); + it('should parse calendar and attendees from multiple @references', () => { + const result = parseEventInput('Meeting @Arbeit @Max @Anna'); + expect(result.calendarName).toBe('Arbeit'); + expect(result.attendees).toEqual(['Max', 'Anna']); + expect(result.title).not.toContain('@'); + }); + + it('should parse only attendees when no calendar match (first @ref treated as calendarName)', () => { + const result = parseEventInput('Meeting @Max @Anna'); + expect(result.calendarName).toBe('Max'); + expect(result.attendees).toEqual(['Anna']); + }); + + it('should parse single @reference as calendarName with no attendees', () => { + const result = parseEventInput('Meeting @Arbeit'); + expect(result.calendarName).toBe('Arbeit'); + expect(result.attendees).toEqual([]); + }); + it('should parse #tags', () => { const result = parseEventInput('Meeting #wichtig #team'); expect(result.tagNames).toEqual(['wichtig', 'team']); @@ -109,10 +129,32 @@ describe('parseEventInput', () => { expect(result.endDate!.getHours()).toBe(17); }); + it('should parse recurrence "jeden Montag"', () => { + const result = parseEventInput('Standup jeden Montag 9 Uhr'); + expect(result.recurrenceRule).toBe('FREQ=WEEKLY;BYDAY=MO'); + expect(result.title).toBe('Standup'); + }); + + it('should parse recurrence "wöchentlich"', () => { + const result = parseEventInput('Team-Meeting wöchentlich 14 Uhr'); + expect(result.recurrenceRule).toBe('FREQ=WEEKLY'); + }); + + it('should parse recurrence "täglich"', () => { + const result = parseEventInput('Standup täglich'); + expect(result.recurrenceRule).toBe('FREQ=DAILY'); + }); + + it('should have no recurrence for normal input', () => { + const result = parseEventInput('Meeting morgen'); + expect(result.recurrenceRule).toBeUndefined(); + }); + it('should handle empty input', () => { const result = parseEventInput(''); expect(result.title).toBe(''); expect(result.tagNames).toEqual([]); + expect(result.attendees).toEqual([]); }); it('should parse time-only input (defaults to today)', () => { @@ -151,10 +193,32 @@ describe('resolveEventIds', () => { expect(resolved.calendarId).toBe('cal-1'); }); - it('should skip unknown calendar', () => { + it('should skip unknown calendar and treat it as attendee', () => { const parsed = parseEventInput('Meeting @Unbekannt'); const resolved = resolveEventIds(parsed, calendars, tags); expect(resolved.calendarId).toBeUndefined(); + expect(resolved.attendees).toEqual(['Unbekannt']); + }); + + it('should resolve calendar and keep attendees separate', () => { + const parsed = parseEventInput('Meeting @Arbeit @Max @Anna'); + const resolved = resolveEventIds(parsed, calendars, tags); + expect(resolved.calendarId).toBe('cal-1'); + expect(resolved.attendees).toEqual(['Max', 'Anna']); + }); + + it('should treat all @refs as attendees when no calendar matches', () => { + const parsed = parseEventInput('Meeting @Max @Anna'); + const resolved = resolveEventIds(parsed, calendars, tags); + expect(resolved.calendarId).toBeUndefined(); + expect(resolved.attendees).toEqual(['Max', 'Anna']); + }); + + it('should resolve calendar with no attendees', () => { + const parsed = parseEventInput('Meeting @Arbeit'); + const resolved = resolveEventIds(parsed, calendars, tags); + expect(resolved.calendarId).toBe('cal-1'); + expect(resolved.attendees).toEqual([]); }); it('should produce ISO date strings', () => { @@ -180,6 +244,12 @@ describe('formatParsedEventPreview', () => { expect(preview).toContain('Arbeit'); }); + it('should format attendees', () => { + const parsed = parseEventInput('Meeting @Arbeit @Max @Anna'); + const preview = formatParsedEventPreview(parsed); + expect(preview).toContain('👥 Max, Anna'); + }); + it('should format tags', () => { const parsed = parseEventInput('Meeting #team'); const preview = formatParsedEventPreview(parsed); 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 ff2ae55f9..56a547ad4 100644 --- a/apps/calendar/apps/web/src/lib/utils/event-parser.ts +++ b/apps/calendar/apps/web/src/lib/utils/event-parser.ts @@ -15,7 +15,8 @@ import { parseBaseInput, - extractAtReference, + extractAtReferences, + extractRecurrence, combineDateAndTime, formatDatePreview, formatTimePreview, @@ -30,7 +31,9 @@ export interface ParsedEvent { duration?: number; // in minutes isAllDay?: boolean; calendarName?: string; + attendees: string[]; location?: string; + recurrenceRule?: string; tagNames: string[]; } @@ -50,7 +53,9 @@ export interface ParsedEventWithIds { endTime?: string; isAllDay?: boolean; calendarId?: string; + attendees: string[]; location?: string; + recurrenceRule?: string; tagIds: string[]; } @@ -232,6 +237,11 @@ function extractAllDay( export function parseEventInput(input: string, locale: ParserLocale = 'de'): ParsedEvent { let text = input.trim(); + // Extract recurrence (before other extractions since "jeden Montag" could conflict with weekday) + const recurrenceResult = extractRecurrence(text, locale); + text = recurrenceResult.remaining; + const recurrenceRule = recurrenceResult.value; + // Extract all-day flag const allDayResult = extractAllDay(text, locale); text = allDayResult.remaining; @@ -246,10 +256,12 @@ export function parseEventInput(input: string, locale: ParserLocale = 'de'): Par text = durationResult.remaining; const duration = durationResult.duration; - // Extract calendar (@CalendarName) - const calendarResult = extractAtReference(text); - text = calendarResult.remaining; - const calendarName = calendarResult.value; + // Extract @references (first may be calendar, rest are attendees) + const atRefsResult = extractAtReferences(text); + text = atRefsResult.remaining; + const atRefs = atRefsResult.value ?? []; + const calendarName = atRefs.length > 0 ? atRefs[0] : undefined; + const attendees = atRefs.length > 1 ? atRefs.slice(1) : []; // Use base parser for common patterns (date, time, tags) const base = parseBaseInput(text, locale); @@ -292,7 +304,9 @@ export function parseEventInput(input: string, locale: ParserLocale = 'de'): Par duration, isAllDay: isAllDay || undefined, calendarName, + attendees, location, + recurrenceRule, tagNames: base.tagNames, }; } @@ -311,15 +325,19 @@ export function resolveEventIds( defaultCalendarId?: string ): ParsedEventWithIds { let calendarId: string | undefined; + const attendees: string[] = [...parsed.attendees]; const tagIds: string[] = []; - // Find calendar by name (case-insensitive) + // Try to match first @ref as calendar (case-insensitive) if (parsed.calendarName) { const calendar = calendars.find( (c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase() ); if (calendar) { calendarId = calendar.id; + } else { + // First @ref didn't match a calendar, treat it as an attendee + attendees.unshift(parsed.calendarName); } } @@ -342,7 +360,9 @@ export function resolveEventIds( endTime: parsed.endDate?.toISOString(), isAllDay: parsed.isAllDay, calendarId, + attendees, location: parsed.location, + recurrenceRule: parsed.recurrenceRule, tagIds, }; } @@ -392,10 +412,18 @@ export function formatParsedEventPreview(parsed: ParsedEvent, locale: ParserLoca parts.push(`📍 ${parsed.location}`); } + if (parsed.recurrenceRule) { + parts.push(`🔄 ${parsed.recurrenceRule}`); + } + if (parsed.calendarName) { parts.push(`📆 ${parsed.calendarName}`); } + if (parsed.attendees.length > 0) { + parts.push(`👥 ${parsed.attendees.join(', ')}`); + } + if (parsed.tagNames.length > 0) { parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); } diff --git a/apps/calendar/apps/web/src/lib/utils/syntax-help.ts b/apps/calendar/apps/web/src/lib/utils/syntax-help.ts new file mode 100644 index 000000000..f4fe5c8dc --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/syntax-help.ts @@ -0,0 +1,70 @@ +/** + * Calendar-specific syntax help patterns for InputBar help modal + */ +import type { SyntaxGroup } from '@manacore/shared-ui'; + +export const CALENDAR_SYNTAX: SyntaxGroup[] = [ + { + title: 'Kalender-Termin', + items: [ + { + pattern: 'Dauer', + description: 'Termindauer angeben', + examples: ['1h', '30min', '2h30m', '1 Stunde'], + color: 'accent', + }, + { + pattern: 'Zeitbereich', + description: 'Start- und Endzeit', + examples: ['14-16 Uhr', '10:00-11:30', '9-17 Uhr'], + color: 'accent', + }, + { + pattern: 'Ganztägig', + description: 'Ganztägiger Termin', + examples: ['ganztägig', 'ganzer Tag'], + color: 'warning', + }, + { + pattern: 'Ort', + description: 'Ort angeben', + examples: ['in Berlin', 'im Büro', 'bei Dr. Müller'], + color: 'success', + }, + { + pattern: 'Wiederholung', + description: 'Wiederkehrende Termine', + examples: ['täglich', 'wöchentlich', 'jeden Montag', 'monatlich'], + color: 'warning-soft', + }, + { + pattern: '@Kalender', + description: 'Kalender zuweisen', + examples: ['@Arbeit', '@Privat'], + color: 'success', + }, + { + pattern: '@Teilnehmer', + description: 'Teilnehmer hinzufügen', + examples: ['@Max', '@Anna'], + color: 'success', + }, + ], + }, +]; + +export const CALENDAR_LIVE_EXAMPLE = { + text: 'Teammeeting morgen 14-16 Uhr wöchentlich @Arbeit #wichtig', + highlights: [ + { type: 'text' as const, content: 'Teammeeting ' }, + { type: 'date' as const, content: 'morgen' }, + { type: 'text' as const, content: ' ' }, + { type: 'time' as const, content: '14-16 Uhr' }, + { type: 'text' as const, content: ' ' }, + { type: 'date' as const, content: 'wöchentlich' }, + { type: 'text' as const, content: ' ' }, + { type: 'reference' as const, content: '@Arbeit' }, + { type: 'text' as const, content: ' ' }, + { type: 'tag' as const, content: '#wichtig' }, + ], +}; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 64c4c99f0..5013ffb8b 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -47,6 +47,7 @@ resolveEventIds, formatParsedEventPreview, } from '$lib/utils/event-parser'; + import { CALENDAR_SYNTAX, CALENDAR_LIVE_EXAMPLE } from '$lib/utils/syntax-help'; import UnifiedBar from '$lib/components/calendar/UnifiedBar.svelte'; import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte'; @@ -581,7 +582,13 @@ - + (showSettingsModal = false)} /> diff --git a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte index 5cb638b61..c3e859cb5 100644 --- a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte @@ -24,6 +24,7 @@ import type { LayoutData } from './$types'; import { chatOnboarding } from '$lib/stores/app-onboarding.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; + import { SessionExpiredBanner } from '@manacore/shared-auth-ui'; // App switcher items const appItems = getPillAppItems('chat'); @@ -243,6 +244,7 @@ {/if} + {/if}