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)} />

View file

@ -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 @@
<MiniOnboardingModal store={chatOnboarding} appName="Chat" appEmoji="💬" />
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<style>

View file

@ -32,6 +32,7 @@
import { timersApi } from '$lib/api/timers';
import { clockOnboarding } 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('clock');
@ -333,6 +334,7 @@
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<style>

View file

@ -70,6 +70,59 @@ describe('parseSongInput', () => {
const result = parseSongInput('Track 5bpm'); // too low
expect(result.bpm).toBeUndefined();
});
it('should extract album from parentheses', () => {
const result = parseSongInput('Queen - Bohemian Rhapsody (A Night at the Opera)');
expect(result.artist).toBe('Queen');
expect(result.title).toBe('Bohemian Rhapsody');
expect(result.album).toBe('A Night at the Opera');
});
it('should extract album from title without artist', () => {
const result = parseSongInput('Song (Album)');
expect(result.title).toBe('Song');
expect(result.album).toBe('Album');
});
it('should detect multi-artist with ft.', () => {
const result = parseSongInput('Daft Punk ft. Pharrell - Get Lucky');
expect(result.artist).toBe('Daft Punk');
expect(result.artists).toEqual(['Daft Punk', 'Pharrell']);
expect(result.title).toBe('Get Lucky');
});
it('should detect multi-artist with &', () => {
const result = parseSongInput('ACDC & Brian Johnson - Thunderstruck');
expect(result.artist).toBe('ACDC');
expect(result.artists).toEqual(['ACDC', 'Brian Johnson']);
expect(result.title).toBe('Thunderstruck');
});
it('should detect multi-artist with feat.', () => {
const result = parseSongInput('Jay-Z feat. Kanye West - Niggas in Paris');
expect(result.artist).toBe('Jay-Z');
expect(result.artists).toEqual(['Jay-Z', 'Kanye West']);
});
it('should detect multi-artist with featuring', () => {
const result = parseSongInput('Eminem featuring Rihanna - Love the Way You Lie');
expect(result.artist).toBe('Eminem');
expect(result.artists).toEqual(['Eminem', 'Rihanna']);
});
it('should not set artists for single artist', () => {
const result = parseSongInput('Queen - Bohemian Rhapsody');
expect(result.artist).toBe('Queen');
expect(result.artists).toBeUndefined();
});
it('should combine album and multi-artist', () => {
const result = parseSongInput('Daft Punk ft. Pharrell - Get Lucky (Random Access Memories)');
expect(result.artist).toBe('Daft Punk');
expect(result.artists).toEqual(['Daft Punk', 'Pharrell']);
expect(result.title).toBe('Get Lucky');
expect(result.album).toBe('Random Access Memories');
});
});
describe('formatParsedSongPreview', () => {
@ -97,6 +150,12 @@ describe('formatParsedSongPreview', () => {
expect(preview).toContain('Neue Playlist');
});
it('should format album', () => {
const parsed = parseSongInput('Queen - Bohemian Rhapsody (A Night at the Opera)');
const preview = formatParsedSongPreview(parsed);
expect(preview).toContain('💿 A Night at the Opera');
});
it('should return empty for simple title', () => {
const parsed = parseSongInput('Simple Song');
expect(formatParsedSongPreview(parsed)).toBe('');

View file

@ -20,6 +20,7 @@ import { extractTags, type ParserLocale } from '@manacore/shared-utils';
export interface ParsedSong {
title: string;
artist?: string;
artists?: string[];
album?: string;
genre?: string;
bpm?: number;
@ -53,6 +54,12 @@ const PROJECT_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
it: [/\bnuovo\s+progetto\b/i, /\bprogetto\b/i],
};
// Album pattern: trailing parenthesized text e.g. "Title (Album Name)"
const ALBUM_PATTERN = /\(([^)]+)\)\s*$/;
// Multi-artist separator patterns
const MULTI_ARTIST_PATTERN = /\s+(?:ft\.?|feat\.?|featuring|&|x|vs\.?)\s+/i;
// "Artist - Title" separator
const ARTIST_TITLE_SEPARATOR = /\s+[-–—]\s+/;
@ -67,6 +74,21 @@ function extractBpm(text: string): { bpm?: number; remaining: string } {
return { bpm: undefined, remaining: text };
}
function extractAlbum(text: string): { album?: string; remaining: string } {
const match = text.match(ALBUM_PATTERN);
if (match) {
return { album: match[1].trim(), remaining: text.replace(ALBUM_PATTERN, '').trim() };
}
return { album: undefined, remaining: text };
}
function extractArtists(artist: string): string[] {
return artist
.split(MULTI_ARTIST_PATTERN)
.map((a) => a.trim())
.filter((a) => a.length > 0);
}
function extractYear(text: string): { year?: number; remaining: string } {
const match = text.match(YEAR_PATTERN);
if (match) {
@ -124,6 +146,10 @@ export function parseSongInput(input: string, locale: ParserLocale = 'de'): Pars
const typeResult = extractTypeKeyword(text, locale);
text = typeResult.remaining;
// Extract album from parentheses (before other extractions to avoid confusion)
const albumResult = extractAlbum(text);
text = albumResult.remaining;
// Extract BPM
const bpmResult = extractBpm(text);
text = bpmResult.remaining;
@ -134,12 +160,22 @@ export function parseSongInput(input: string, locale: ParserLocale = 'de'): Pars
// Try "Artist - Title" format
let artist: string | undefined;
let artists: string[] | undefined;
let title: string;
if (ARTIST_TITLE_SEPARATOR.test(text)) {
const parts = text.split(ARTIST_TITLE_SEPARATOR, 2);
artist = parts[0].trim();
const rawArtist = parts[0].trim();
title = parts[1].trim();
// Detect multi-artist patterns
const artistList = extractArtists(rawArtist);
if (artistList.length > 1) {
artist = artistList[0];
artists = artistList;
} else {
artist = rawArtist;
}
} else {
title = text.replace(/\s+/g, ' ').trim();
}
@ -147,6 +183,8 @@ export function parseSongInput(input: string, locale: ParserLocale = 'de'): Pars
return {
title,
artist,
artists,
album: albumResult.album,
genre,
bpm: bpmResult.bpm,
year: yearResult.year,
@ -173,6 +211,10 @@ export function formatParsedSongPreview(parsed: ParsedSong, locale: ParserLocale
parts.push(`🎤 ${parsed.artist}`);
}
if (parsed.album) {
parts.push(`💿 ${parsed.album}`);
}
if (parsed.genre) {
parts.push(`🎵 ${parsed.genre}`);
}

View file

@ -0,0 +1,48 @@
/**
* Mukke-specific syntax help patterns
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const MUKKE_SYNTAX: SyntaxGroup[] = [
{
title: 'Musik',
items: [
{
pattern: 'Artist - Title',
description: 'Interpret und Titel mit Bindestrich trennen',
examples: ['Queen - Bohemian Rhapsody', 'Daft Punk ft. Pharrell - Get Lucky'],
color: 'primary',
},
{
pattern: '(Album)',
description: 'Album in Klammern',
examples: ['Queen - Song (A Night at the Opera)'],
color: 'accent',
},
{
pattern: '#genre',
description: 'Genre als Tag',
examples: ['#rock', '#electronic', '#jazz'],
color: 'primary',
},
{
pattern: 'BPM',
description: 'Tempo in BPM',
examples: ['120bpm', '90 BPM'],
color: 'warning',
},
{
pattern: 'Neue Playlist',
description: 'Playlist erstellen',
examples: ['Neue Playlist Workout', 'Playlist Chill'],
color: 'success',
},
{
pattern: 'Neues Projekt',
description: 'Editor-Projekt erstellen',
examples: ['Neues Projekt Demo', 'Projekt Remix'],
color: 'success',
},
],
},
];

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseMealInput, formatParsedMealPreview } from './meal-parser';
import { parseMealInput, formatParsedMealPreview, parseFoodItems } from './meal-parser';
describe('parseMealInput', () => {
it('should parse food description', () => {
@ -62,6 +62,60 @@ describe('parseMealInput', () => {
});
});
describe('parseFoodItems', () => {
it('should parse "200g Reis, 2 Eier, 1 Scheibe Brot" into 3 items with amounts', () => {
const items = parseFoodItems('200g Reis, 2 Eier, 1 Scheibe Brot');
expect(items).toHaveLength(3);
expect(items[0]).toEqual({ amount: 200, unit: 'g', name: 'Reis' });
expect(items[1]).toEqual({ amount: 2, name: 'Eier' });
expect(items[2]).toEqual({ amount: 1, unit: 'Scheibe', name: 'Brot' });
});
it('should parse "Spaghetti Bolognese" as 1 item with no amount', () => {
const items = parseFoodItems('Spaghetti Bolognese');
expect(items).toHaveLength(1);
expect(items[0]).toEqual({ name: 'Spaghetti Bolognese' });
});
it('should parse "2 Eier, Toast, Orangensaft" into 3 items', () => {
const items = parseFoodItems('2 Eier, Toast, Orangensaft');
expect(items).toHaveLength(3);
expect(items[0]).toEqual({ amount: 2, name: 'Eier' });
expect(items[1]).toEqual({ name: 'Toast' });
expect(items[2]).toEqual({ name: 'Orangensaft' });
});
it('should handle fractions like 1/2', () => {
const items = parseFoodItems('1/2 Tasse Milch');
expect(items).toHaveLength(1);
expect(items[0]).toEqual({ amount: 0.5, unit: 'Tasse', name: 'Milch' });
});
it('should return empty array for empty input', () => {
expect(parseFoodItems('')).toEqual([]);
});
});
describe('parseMealInput with foodItems', () => {
it('should include foodItems in parsed result', () => {
const result = parseMealInput('200g Reis, 2 Eier, 1 Scheibe Brot');
expect(result.foodItems).toHaveLength(3);
expect(result.foodItems[0]).toEqual({ amount: 200, unit: 'g', name: 'Reis' });
});
it('should parse "2 Eier, Toast, Orangensaft Frühstück" with 3 items and breakfast type', () => {
const result = parseMealInput('2 Eier, Toast, Orangensaft Frühstück');
expect(result.mealType).toBe('breakfast');
expect(result.mealTypeExplicit).toBe(true);
expect(result.foodItems).toHaveLength(3);
expect(result.foodItems[0]).toEqual({ amount: 2, name: 'Eier' });
expect(result.foodItems[1]).toEqual({ name: 'Toast' });
expect(result.foodItems[2]).toEqual({ name: 'Orangensaft' });
});
});
describe('formatParsedMealPreview', () => {
it('should show meal type', () => {
const parsed = parseMealInput('Toast Frühstück');
@ -80,4 +134,10 @@ describe('formatParsedMealPreview', () => {
const preview = formatParsedMealPreview(parsed);
expect(preview).not.toContain('automatisch');
});
it('should show items count in preview', () => {
const parsed = parseMealInput('200g Reis, 2 Eier, 1 Scheibe Brot Mittagessen');
const preview = formatParsedMealPreview(parsed);
expect(preview).toContain('🥚 3 items');
});
});

View file

@ -15,10 +15,17 @@ import type { MealType } from '@nutriphi/shared';
import { suggestMealType } from '@nutriphi/shared';
import type { ParserLocale } from '@manacore/shared-utils';
export interface FoodItem {
amount?: number;
unit?: string;
name: string;
}
export interface ParsedMeal {
description: string;
mealType: MealType;
mealTypeExplicit: boolean; // Was meal type explicitly mentioned?
foodItems: FoodItem[];
}
// Meal type patterns per locale
@ -109,6 +116,62 @@ const AUTO_HINT_BY_LOCALE: Record<ParserLocale, string> = {
it: 'automatico',
};
// Units recognized by the parser
const UNITS_PATTERN =
/^(g|kg|mg|ml|l|dl|cl|Stück|Scheibe|Scheiben|Tasse|Tassen|EL|TL|pieces?|cups?|slices?|oz|lb)\b/i;
// Amount pattern: integers, decimals, or fractions like 1/2
const AMOUNT_PATTERN = /^(\d+\/\d+|\d+(?:[.,]\d+)?)/;
/**
* Parse a single food item string into structured FoodItem
*/
function parseSingleFoodItem(text: string): FoodItem {
let remaining = text.trim();
// Try to extract amount
let amount: number | undefined;
const amountMatch = remaining.match(AMOUNT_PATTERN);
if (amountMatch) {
const raw = amountMatch[1];
if (raw.includes('/')) {
const [num, den] = raw.split('/');
amount = parseInt(num, 10) / parseInt(den, 10);
} else {
amount = parseFloat(raw.replace(',', '.'));
}
remaining = remaining.slice(amountMatch[0].length).trim();
}
// Try to extract unit (only if we found an amount, or unit is at the start)
let unit: string | undefined;
const unitMatch = remaining.match(UNITS_PATTERN);
if (unitMatch) {
unit = unitMatch[1];
remaining = remaining.slice(unitMatch[0].length).trim();
}
return {
...(amount !== undefined && { amount }),
...(unit !== undefined && { unit }),
name: remaining,
};
}
/**
* Parse a description string into individual food items.
* Splits on commas and parses each segment for amount, unit, and name.
*/
export function parseFoodItems(text: string): FoodItem[] {
if (!text.trim()) return [];
const segments = text
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return segments.map(parseSingleFoodItem);
}
function extractMealType(
text: string,
locale: ParserLocale = 'de'
@ -138,6 +201,9 @@ export function parseMealInput(input: string, locale: ParserLocale = 'de'): Pars
// Clean up description
const description = text.replace(/\s+/g, ' ').trim();
// Parse food items from description
const foodItems = parseFoodItems(description);
// Use explicit meal type or auto-detect based on time of day
const mealType = mealTypeResult.mealType || suggestMealType();
@ -145,6 +211,7 @@ export function parseMealInput(input: string, locale: ParserLocale = 'de'): Pars
description,
mealType,
mealTypeExplicit: !!mealTypeResult.mealType,
foodItems,
};
}
@ -161,5 +228,9 @@ export function formatParsedMealPreview(parsed: ParsedMeal, locale: ParserLocale
parts.push(`(${AUTO_HINT_BY_LOCALE[locale]})`);
}
if (parsed.foodItems.length > 0) {
parts.push(`🥚 ${parsed.foodItems.length} items`);
}
return parts.join(' ');
}

View file

@ -0,0 +1,30 @@
/**
* NutriPhi-specific syntax help patterns
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const NUTRIPHI_SYNTAX: SyntaxGroup[] = [
{
title: 'Mahlzeiten',
items: [
{
pattern: 'Mahlzeittyp',
description: 'Art der Mahlzeit',
examples: ['Frühstück', 'Mittagessen', 'Abendessen', 'Snack'],
color: 'success',
},
{
pattern: 'Mengen',
description: 'Mengenangaben',
examples: ['200g Reis', '2 Eier', '1 Scheibe Brot', '100ml Milch'],
color: 'accent',
},
{
pattern: 'Komma-Liste',
description: 'Mehrere Zutaten mit Komma trennen',
examples: ['Reis, Hähnchen, Brokkoli'],
color: 'primary',
},
],
},
];

View file

@ -12,6 +12,7 @@
import { tagStore } from '$lib/stores/tags.svelte';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
let { children } = $props();
@ -143,6 +144,7 @@
</div>
</main>
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<style>

View file

@ -21,6 +21,7 @@
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { pictureOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { viewMode, setViewMode } from '$lib/stores/view';
import type { ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
@ -295,6 +296,7 @@
<MiniOnboardingModal store={pictureOnboarding} appName="Picture" appEmoji="🎨" />
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/auth/login" />
{/if}
<style>

View file

@ -53,6 +53,51 @@ describe('parsePlantInput', () => {
});
});
describe('parsePlantInput - care actions', () => {
it('should parse "Monstera gegossen" as watered', () => {
const result = parsePlantInput('Monstera gegossen');
expect(result.name).toBe('Monstera');
expect(result.action).toBe('watered');
});
it('should parse "Ficus umgetopft heute" as repotted with date', () => {
const result = parsePlantInput('Ficus umgetopft heute');
expect(result.name).toBe('Ficus');
expect(result.action).toBe('repotted');
expect(result.acquiredAt).toBeDefined();
});
it('should parse "Rose pruned" in English as pruned', () => {
const result = parsePlantInput('Rose pruned', 'en');
expect(result.name).toBe('Rose');
expect(result.action).toBe('pruned');
});
it('should have no action for plain "Monstera"', () => {
const result = parsePlantInput('Monstera');
expect(result.name).toBe('Monstera');
expect(result.action).toBeUndefined();
});
it('should parse "Orchidee gedüngt" as fertilized', () => {
const result = parsePlantInput('Orchidee gedüngt');
expect(result.name).toBe('Orchidee');
expect(result.action).toBe('fertilized');
});
it('should parse "Ficus geschnitten" as pruned', () => {
const result = parsePlantInput('Ficus geschnitten');
expect(result.name).toBe('Ficus');
expect(result.action).toBe('pruned');
});
it('should parse "gewässert" as watered (alternative DE word)', () => {
const result = parsePlantInput('Monstera gewässert');
expect(result.name).toBe('Monstera');
expect(result.action).toBe('watered');
});
});
describe('resolvePlantData', () => {
it('should produce ISO date string', () => {
const parsed = parsePlantInput('Ficus heute gekauft');
@ -86,4 +131,18 @@ describe('formatParsedPlantPreview', () => {
const parsed = parsePlantInput('Monstera');
expect(formatParsedPlantPreview(parsed)).toBe('');
});
it('should format care action in preview', () => {
const parsed = parsePlantInput('Monstera gegossen');
const preview = formatParsedPlantPreview(parsed);
expect(preview).toContain('💧');
expect(preview).toContain('Gegossen');
});
it('should format care action with English locale', () => {
const parsed = parsePlantInput('Rose watered', 'en');
const preview = formatParsedPlantPreview(parsed, 'en');
expect(preview).toContain('💧');
expect(preview).toContain('Watered');
});
});

View file

@ -20,10 +20,13 @@ import {
type ParserLocale,
} from '@manacore/shared-utils';
export type CareAction = 'watered' | 'repotted' | 'fertilized' | 'pruned';
export interface ParsedPlant {
name: string;
acquiredAt?: Date;
tagNames: string[];
action?: CareAction;
}
export interface ParsedPlantWithIds {
@ -31,6 +34,79 @@ export interface ParsedPlantWithIds {
acquiredAt?: string;
}
// Care action patterns per locale
const CARE_ACTION_PATTERNS_BY_LOCALE: Record<
ParserLocale,
{ action: CareAction; pattern: RegExp }[]
> = {
de: [
{ action: 'watered', pattern: /\b(?:gegossen|gewässert)\b/i },
{ action: 'repotted', pattern: /\bumgetopft\b/i },
{ action: 'fertilized', pattern: /\bgedüngt\b/i },
{ action: 'pruned', pattern: /\b(?:geschnitten|gestutzt)\b/i },
],
en: [
{ action: 'watered', pattern: /\bwatered\b/i },
{ action: 'repotted', pattern: /\brepotted\b/i },
{ action: 'fertilized', pattern: /\bfertilized\b/i },
{ action: 'pruned', pattern: /\b(?:pruned|trimmed)\b/i },
],
fr: [
{ action: 'watered', pattern: /\barrosé\b/i },
{ action: 'repotted', pattern: /\brempoté\b/i },
{ action: 'fertilized', pattern: /\bfertilisé\b/i },
{ action: 'pruned', pattern: /\btaillé\b/i },
],
es: [
{ action: 'watered', pattern: /\bregado\b/i },
{ action: 'repotted', pattern: /\btrasplantado\b/i },
{ action: 'fertilized', pattern: /\bfertilizado\b/i },
{ action: 'pruned', pattern: /\bpodado\b/i },
],
it: [
{ action: 'watered', pattern: /\bannaffiato\b/i },
{ action: 'repotted', pattern: /\brinvasato\b/i },
{ action: 'fertilized', pattern: /\bfertilizzato\b/i },
{ action: 'pruned', pattern: /\bpotato\b/i },
],
};
const ACTION_LABELS: Record<CareAction, Record<ParserLocale, string>> = {
watered: { de: 'Gegossen', en: 'Watered', fr: 'Arrosé', es: 'Regado', it: 'Annaffiato' },
repotted: { de: 'Umgetopft', en: 'Repotted', fr: 'Rempoté', es: 'Trasplantado', it: 'Rinvasato' },
fertilized: {
de: 'Gedüngt',
en: 'Fertilized',
fr: 'Fertilisé',
es: 'Fertilizado',
it: 'Fertilizzato',
},
pruned: { de: 'Geschnitten', en: 'Pruned', fr: 'Taillé', es: 'Podado', it: 'Potato' },
};
const ACTION_EMOJIS: Record<CareAction, string> = {
watered: '💧',
repotted: '🌱',
fertilized: '🧪',
pruned: '✂️',
};
function extractCareAction(
text: string,
locale: ParserLocale = 'de'
): { action?: CareAction; remaining: string } {
const patterns = CARE_ACTION_PATTERNS_BY_LOCALE[locale];
for (const { action, pattern } of patterns) {
if (pattern.test(text)) {
return {
action,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { action: undefined, remaining: text };
}
// Acquisition keywords per locale
const ACQUIRED_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bgekauft\b/i, /\bbekommen\b/i, /\berhalten\b/i, /\bgepflanzt\b/i],
@ -67,6 +143,10 @@ function extractAcquiredKeyword(
export function parsePlantInput(input: string, locale: ParserLocale = 'de'): ParsedPlant {
let text = input.trim();
// Extract care action BEFORE base parser so the action word is removed from title
const careResult = extractCareAction(text, locale);
text = careResult.remaining;
// Check for acquisition keywords
const acquiredResult = extractAcquiredKeyword(text, locale);
text = acquiredResult.remaining;
@ -86,6 +166,7 @@ export function parsePlantInput(input: string, locale: ParserLocale = 'de'): Par
name: base.title,
acquiredAt,
tagNames: base.tagNames,
action: careResult.action,
};
}
@ -105,6 +186,12 @@ export function resolvePlantData(parsed: ParsedPlant): ParsedPlantWithIds {
export function formatParsedPlantPreview(parsed: ParsedPlant, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.action) {
const emoji = ACTION_EMOJIS[parsed.action];
const label = ACTION_LABELS[parsed.action][locale];
parts.push(`${emoji} ${label}`);
}
if (parsed.acquiredAt) {
parts.push(`📅 ${formatDatePreview(parsed.acquiredAt, locale)}`);
}

View file

@ -0,0 +1,24 @@
/**
* Planta-specific syntax help patterns
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const PLANTA_SYNTAX: SyntaxGroup[] = [
{
title: 'Pflanzen',
items: [
{
pattern: 'Pflege',
description: 'Pflege-Aktion loggen',
examples: ['Monstera gegossen', 'Ficus umgetopft', 'Rose gedüngt'],
color: 'success',
},
{
pattern: 'Erworben',
description: 'Erwerbsdatum angeben',
examples: ['gekauft', 'gepflanzt', 'bekommen'],
color: 'accent',
},
],
},
];

View file

@ -8,6 +8,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
let { children } = $props();
@ -41,4 +42,5 @@
{#if skilltreeOnboarding.shouldShow}
<MiniOnboardingModal store={skilltreeOnboarding} appName="SkillTree" appEmoji="🌳" />
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}

View file

@ -16,6 +16,7 @@
import { ToastContainer } from '@manacore/shared-ui';
import { storageOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import '../app.css';
// App switcher items
@ -227,6 +228,7 @@
<MiniOnboardingModal store={storageOnboarding} appName="Storage" appEmoji="☁️" />
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<style>

View file

@ -0,0 +1,55 @@
/**
* Todo-specific syntax help patterns for InputBar help modal
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const TODO_SYNTAX: SyntaxGroup[] = [
{
title: 'Aufgaben',
items: [
{
pattern: 'Priorität',
description: 'Dringlichkeit festlegen',
examples: [
{ text: '!!!', label: 'dringend', color: 'error' },
{ text: '!!', label: 'hoch', color: 'warning' },
{ text: 'normal', label: 'normal', color: 'warning-soft' },
{ text: 'später', label: 'niedrig', color: 'success' },
],
color: 'error',
},
{
pattern: '@Projekt',
description: 'Projekt zuweisen',
examples: ['@Arbeit', '@Privat', '@Einkauf'],
color: 'success',
},
{
pattern: 'Wiederholung',
description: 'Wiederkehrende Aufgabe',
examples: ['täglich', 'wöchentlich', 'jeden Montag', 'monatlich'],
color: 'warning-soft',
},
{
pattern: 'Subtasks',
description: 'Unteraufgaben mit Doppelpunkt + Komma',
examples: ['Einkaufen: Milch, Brot, Eier'],
color: 'accent',
},
],
},
];
export const TODO_LIVE_EXAMPLE = {
text: 'Einkaufen: Milch, Brot morgen !! @Privat #wichtig',
highlights: [
{ type: 'text' as const, content: 'Einkaufen: Milch, Brot ' },
{ type: 'date' as const, content: 'morgen' },
{ type: 'text' as const, content: ' ' },
{ type: 'priority' as const, content: '!!' },
{ type: 'text' as const, content: ' ' },
{ type: 'reference' as const, content: '@Privat' },
{ type: 'text' as const, content: ' ' },
{ type: 'tag' as const, content: '#wichtig' },
],
};

View file

@ -73,6 +73,53 @@ describe('parseTaskInput', () => {
const result = parseTaskInput('#arbeit #privat');
expect(result.labelNames).toEqual(['arbeit', 'privat']);
});
it('should parse recurrence "täglich"', () => {
const result = parseTaskInput('Standup täglich 9 Uhr');
expect(result.recurrenceRule).toBe('FREQ=DAILY');
expect(result.title).toBe('Standup');
});
it('should parse recurrence "jeden Montag"', () => {
const result = parseTaskInput('Wochenbericht jeden Montag');
expect(result.recurrenceRule).toBe('FREQ=WEEKLY;BYDAY=MO');
});
it('should parse recurrence "wöchentlich"', () => {
const result = parseTaskInput('Review wöchentlich @Arbeit');
expect(result.recurrenceRule).toBe('FREQ=WEEKLY');
expect(result.projectName).toBe('Arbeit');
});
it('should have no recurrence for normal input', () => {
const result = parseTaskInput('Einfache Aufgabe');
expect(result.recurrenceRule).toBeUndefined();
});
it('should parse subtasks "Einkaufen: Milch, Brot, Eier"', () => {
const result = parseTaskInput('Einkaufen: Milch, Brot, Eier');
expect(result.title).toBe('Einkaufen');
expect(result.subtasks).toEqual(['Milch', 'Brot', 'Eier']);
});
it('should parse subtasks with semicolons', () => {
const result = parseTaskInput('Aufräumen: Küche; Bad; Wohnzimmer');
expect(result.title).toBe('Aufräumen');
expect(result.subtasks).toEqual(['Küche', 'Bad', 'Wohnzimmer']);
});
it('should not parse subtasks with single item', () => {
const result = parseTaskInput('Note: important thing');
expect(result.subtasks).toBeUndefined();
});
it('should parse subtasks with other fields', () => {
const result = parseTaskInput('Einkaufen: Milch, Brot morgen !! @Privat');
expect(result.title).toBe('Einkaufen');
expect(result.subtasks).toEqual(['Milch', 'Brot']);
expect(result.priority).toBe('high');
expect(result.projectName).toBe('Privat');
});
});
describe('resolveTaskIds', () => {

View file

@ -9,6 +9,7 @@
import {
parseBaseInput,
extractAtReference,
extractRecurrence,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
@ -22,6 +23,8 @@ export interface ParsedTask {
priority?: TaskPriority;
projectName?: string;
labelNames: string[];
recurrenceRule?: string;
subtasks?: string[];
}
interface Project {
@ -40,6 +43,8 @@ export interface ParsedTaskWithIds {
priority?: TaskPriority;
projectId?: string;
labelIds: string[];
recurrenceRule?: string;
subtasks?: string[];
}
// Priority keyword translations per locale
@ -54,6 +59,31 @@ const PRIORITY_KEYWORDS: Record<
it: { urgent: 'urgente', high: 'importante', medium: 'normale', low: 'dopo' },
};
/**
* Extract subtasks from "Title: item1, item2, item3" pattern
*/
function extractSubtasks(text: string): { title: string; subtasks?: string[] } {
// Match "Title: list" where list has commas or semicolons
const colonIndex = text.indexOf(':');
if (colonIndex === -1 || colonIndex < 2) return { title: text };
const beforeColon = text.substring(0, colonIndex).trim();
const afterColon = text.substring(colonIndex + 1).trim();
if (!afterColon) return { title: text };
// Split by comma or semicolon
const items = afterColon
.split(/[,;]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Only treat as subtasks if there are at least 2 items
if (items.length < 2) return { title: text };
return { title: beforeColon, subtasks: items };
}
/**
* Build locale-aware priority patterns
*/
@ -99,7 +129,12 @@ function extractPriority(
export function parseTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask {
let text = input.trim();
// Extract priority first (task-specific)
// Extract recurrence (before priority, since "jeden Tag" shouldn't be confused)
const recurrenceResult = extractRecurrence(text, locale);
text = recurrenceResult.remaining;
const recurrenceRule = recurrenceResult.value;
// Extract priority (task-specific)
const priorityResult = extractPriority(text, locale);
text = priorityResult.remaining;
const priority = priorityResult.priority;
@ -115,12 +150,17 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars
// Combine date and time
const dueDate = combineDateAndTime(base.date, base.time);
// Check for subtask pattern "Title: item1, item2, item3"
const subtaskResult = extractSubtasks(base.title);
return {
title: base.title,
title: subtaskResult.title,
dueDate,
priority,
projectName,
labelNames: base.tagNames,
recurrenceRule,
subtasks: subtaskResult.subtasks,
};
}
@ -159,6 +199,8 @@ export function resolveTaskIds(
priority: parsed.priority,
projectId,
labelIds,
recurrenceRule: parsed.recurrenceRule,
subtasks: parsed.subtasks,
};
}
@ -199,6 +241,14 @@ export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale
parts.push(`📁 ${parsed.projectName}`);
}
if (parsed.recurrenceRule) {
parts.push(`🔄 ${parsed.recurrenceRule}`);
}
if (parsed.subtasks && parsed.subtasks.length > 0) {
parts.push(`📋 ${parsed.subtasks.length} Subtasks`);
}
if (parsed.labelNames.length > 0) {
parts.push(`🏷️ ${parsed.labelNames.join(', ')}`);
}

View file

@ -1,23 +1,27 @@
<script lang="ts">
import { HelpModal } from '../help';
import { COMMON_SHORTCUTS, COMMON_SYNTAX, DEFAULT_LIVE_EXAMPLE } from '../help';
import type { HelpModalConfig } from '../help';
import type { HelpModalConfig, SyntaxGroup } from '../help';
interface Props {
open: boolean;
onClose: () => void;
mode?: 'shortcuts' | 'syntax';
/** App-specific syntax patterns (shown before common patterns) */
appSyntax?: SyntaxGroup[];
/** Override the live example */
liveExample?: HelpModalConfig['liveExample'];
}
let { open, onClose, mode = 'shortcuts' }: Props = $props();
let { open, onClose, mode = 'shortcuts', appSyntax, liveExample }: Props = $props();
// Build the config for HelpModal using common defaults
const config: HelpModalConfig = {
// Build the config for HelpModal: app-specific syntax first, then common
const config = $derived<HelpModalConfig>({
shortcuts: COMMON_SHORTCUTS,
syntax: COMMON_SYNTAX,
syntax: [...(appSyntax || []), ...COMMON_SYNTAX],
defaultTab: mode,
liveExample: DEFAULT_LIVE_EXAMPLE,
};
liveExample: liveExample || DEFAULT_LIVE_EXAMPLE,
});
</script>
<HelpModal {open} {onClose} {config} />