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}