diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.test.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.test.ts new file mode 100644 index 000000000..c5c05fef3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for the meal-input parser used by the global quick-input + * bar. The parser is the only non-trivial logic in the adapter — the + * surrounding onSearch / onCreate hooks are thin wrappers over the + * already-tested mealMutations layer. + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { parseMealInput } from './quick-input-adapter'; + +afterEach(() => { + vi.useRealTimers(); +}); + +function pinTime(hour: number) { + const date = new Date(2026, 3, 9, hour, 0, 0); // 2026-04-09 + vi.useFakeTimers(); + vi.setSystemTime(date); +} + +describe('parseMealInput', () => { + describe('explicit prefix', () => { + it('recognises German Frühstück prefix', () => { + const r = parseMealInput('Frühstück: Müsli mit Beeren'); + expect(r.mealType).toBe('breakfast'); + expect(r.description).toBe('Müsli mit Beeren'); + expect(r.hadExplicitPrefix).toBe(true); + }); + + it('recognises ASCII fruehstueck variant', () => { + const r = parseMealInput('fruehstueck: Toast'); + expect(r.mealType).toBe('breakfast'); + expect(r.description).toBe('Toast'); + }); + + it('recognises lunch and Mittagessen', () => { + expect(parseMealInput('lunch: Salat').mealType).toBe('lunch'); + expect(parseMealInput('Mittagessen: Suppe').mealType).toBe('lunch'); + expect(parseMealInput('mittag: Pasta').mealType).toBe('lunch'); + }); + + it('recognises dinner and Abendessen', () => { + expect(parseMealInput('dinner: Pizza').mealType).toBe('dinner'); + expect(parseMealInput('Abendessen: Reis').mealType).toBe('dinner'); + expect(parseMealInput('abend: Bowl').mealType).toBe('dinner'); + }); + + it('recognises snack and zwischendurch', () => { + expect(parseMealInput('snack: Apfel').mealType).toBe('snack'); + expect(parseMealInput('zwischendurch: Nüsse').mealType).toBe('snack'); + }); + + it('is case insensitive on the prefix', () => { + expect(parseMealInput('LUNCH: Burger').mealType).toBe('lunch'); + expect(parseMealInput('Snack: Banane').mealType).toBe('snack'); + }); + + it('trims whitespace around the prefix and description', () => { + const r = parseMealInput(' lunch : Pasta mit Pesto '); + expect(r.mealType).toBe('lunch'); + expect(r.description).toBe('Pasta mit Pesto'); + }); + }); + + describe('no prefix → time-of-day fallback', () => { + it('falls back to breakfast in the morning', () => { + pinTime(8); + const r = parseMealInput('Müsli'); + expect(r.mealType).toBe('breakfast'); + expect(r.description).toBe('Müsli'); + expect(r.hadExplicitPrefix).toBe(false); + }); + + it('falls back to lunch around noon', () => { + pinTime(13); + expect(parseMealInput('Salat').mealType).toBe('lunch'); + }); + + it('falls back to dinner in the evening', () => { + pinTime(19); + expect(parseMealInput('Pasta').mealType).toBe('dinner'); + }); + }); + + describe('edge cases', () => { + it('treats unknown prefix-like text as plain description', () => { + pinTime(13); + const r = parseMealInput('Hähnchen: gegrillt'); + expect(r.hadExplicitPrefix).toBe(false); + expect(r.description).toBe('Hähnchen: gegrillt'); + expect(r.mealType).toBe('lunch'); // from time-of-day + }); + + it('does not treat far-away colons as prefixes', () => { + pinTime(13); + const longPrefix = 'das ist eine sehr lange beschreibung: foo'; + const r = parseMealInput(longPrefix); + expect(r.hadExplicitPrefix).toBe(false); + expect(r.description).toBe(longPrefix); + }); + + it('rejects empty description after prefix', () => { + pinTime(13); + const r = parseMealInput('lunch: '); + expect(r.hadExplicitPrefix).toBe(false); + expect(r.description).toBe('lunch:'); + }); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.ts new file mode 100644 index 000000000..b6a717597 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/quick-input-adapter.ts @@ -0,0 +1,111 @@ +/** + * NutriPhi QuickInputBar Adapter + * + * Parses meal-type prefixes from the query so power users can type + * "frühstück: müsli mit beeren" or "snack: apfel" without picking the + * type from the UI. Falls back to time-of-day suggestion when no + * prefix is given. + */ + +import type { InputBarAdapter } from '$lib/quick-input/types'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { mealMutations } from './mutations'; +import { suggestMealType, MEAL_TYPE_LABELS } from './constants'; +import type { LocalMeal, MealType } from './types'; + +interface ParsedMealInput { + mealType: MealType; + description: string; + hadExplicitPrefix: boolean; +} + +// Map of recognised lowercase prefixes → MealType. Both German and +// English forms are accepted so the bar works regardless of UI locale. +const PREFIX_TO_MEALTYPE: Record = { + breakfast: 'breakfast', + frühstück: 'breakfast', + fruehstueck: 'breakfast', + lunch: 'lunch', + mittag: 'lunch', + mittagessen: 'lunch', + dinner: 'dinner', + abend: 'dinner', + abendessen: 'dinner', + snack: 'snack', + zwischendurch: 'snack', +}; + +export function parseMealInput(raw: string): ParsedMealInput { + const trimmed = raw.trim(); + const colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0 && colonIdx < 20) { + const prefix = trimmed.slice(0, colonIdx).trim().toLowerCase(); + const rest = trimmed.slice(colonIdx + 1).trim(); + const mealType = PREFIX_TO_MEALTYPE[prefix]; + if (mealType && rest.length > 0) { + return { mealType, description: rest, hadExplicitPrefix: true }; + } + } + return { + mealType: suggestMealType(), + description: trimmed, + hadExplicitPrefix: false, + }; +} + +export function createAdapter(): InputBarAdapter { + return { + placeholder: 'Mahlzeit hinzufügen oder suchen…', + appIcon: 'nutriphi', + deferSearch: true, + createText: 'Hinzufügen', + emptyText: 'Keine Mahlzeiten gefunden', + + async onSearch(query) { + const q = query.toLowerCase(); + // `description` is encrypted on disk — decrypt before substring matching. + const raw = await db.table('meals').toArray(); + const visible = raw.filter((m) => !m.deletedAt); + const decrypted = await decryptRecords('meals', visible); + return decrypted + .filter((m) => m.description?.toLowerCase().includes(q)) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) + .slice(0, 10) + .map((m) => ({ + id: m.id, + title: m.description || '(ohne Beschreibung)', + subtitle: `${MEAL_TYPE_LABELS[m.mealType]?.de ?? m.mealType}${ + m.nutrition ? ` · ${Math.round(m.nutrition.calories)} kcal` : '' + }`, + })); + }, + + onSelect() { + // Selecting an existing meal is informational only — there's no + // edit-in-place from the global bar. The user can navigate to + // /nutriphi/[id] from the workbench card row instead. + }, + + onParseCreate(query) { + if (!query.trim()) return null; + const parsed = parseMealInput(query); + const typeLabel = MEAL_TYPE_LABELS[parsed.mealType]?.de ?? parsed.mealType; + return { + title: `"${parsed.description}" als ${typeLabel} hinzufügen`, + subtitle: parsed.hadExplicitPrefix + ? 'Mahlzeittyp aus Eingabe erkannt' + : 'Mahlzeittyp aus Tageszeit', + }; + }, + + async onCreate(query) { + if (!query.trim()) return; + const parsed = parseMealInput(query); + await mealMutations.create({ + mealType: parsed.mealType, + description: parsed.description, + }); + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/quick-input/registry.ts b/apps/mana/apps/web/src/lib/quick-input/registry.ts index 413b83eb5..fb370fe05 100644 --- a/apps/mana/apps/web/src/lib/quick-input/registry.ts +++ b/apps/mana/apps/web/src/lib/quick-input/registry.ts @@ -13,6 +13,7 @@ const registry = new Map Promise>([ ['/contacts', () => import('$lib/modules/contacts/quick-input-adapter')], ['/times', () => import('$lib/modules/times/quick-input-adapter')], ['/planta', () => import('$lib/modules/planta/quick-input-adapter')], + ['/nutriphi', () => import('$lib/modules/nutriphi/quick-input-adapter')], ]); /**