mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
90c438e267
commit
bf7517d24d
23 changed files with 842 additions and 19 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(', ')}`);
|
||||
}
|
||||
|
|
|
|||
70
apps/calendar/apps/web/src/lib/utils/syntax-help.ts
Normal file
70
apps/calendar/apps/web/src/lib/utils/syntax-help.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
48
apps/mukke/apps/web/src/lib/utils/syntax-help.ts
Normal file
48
apps/mukke/apps/web/src/lib/utils/syntax-help.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(' ');
|
||||
}
|
||||
|
|
|
|||
30
apps/nutriphi/apps/web/src/lib/utils/syntax-help.ts
Normal file
30
apps/nutriphi/apps/web/src/lib/utils/syntax-help.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
24
apps/planta/apps/web/src/lib/utils/syntax-help.ts
Normal file
24
apps/planta/apps/web/src/lib/utils/syntax-help.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
55
apps/todo/apps/web/src/lib/utils/syntax-help.ts
Normal file
55
apps/todo/apps/web/src/lib/utils/syntax-help.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(', ')}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue