feat(auth): add SessionExpiredBanner to all remaining web apps

Added to: clock, photos, storage, mukke, planta, picture, skilltree,
nutriphi, chat. Now all 13 web apps show a re-login banner when
token refresh permanently fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 22:35:13 +01:00
parent 90c438e267
commit bf7517d24d
23 changed files with 842 additions and 19 deletions

View file

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

View file

@ -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(', ')}`);
}

View file

@ -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' },
],
};

View file

@ -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 @@
</SplitPaneContainer>
<!-- InputBar Help Modal -->
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />
<InputBarHelpModal
open={helpModalOpen}
onClose={handleCloseHelpModal}
mode={helpModalMode}
appSyntax={CALENDAR_SYNTAX}
liveExample={CALENDAR_LIVE_EXAMPLE}
/>
<!-- Settings Modal -->
<SettingsModal visible={showSettingsModal} onClose={() => (showSettingsModal = false)} />