feat(mana/web/nutriphi): global quick-input adapter for the search bar

Adds nutriphi to the unified quick-input registry so the global search
bar gains meal-aware behaviour whenever the user is on a /nutriphi route.

Adapter contract (mirrors planta / todo / calendar):

  - onSearch: decrypts meals (description is in the encrypted allowlist)
    and substring-matches by description, sorted newest-first, capped at 10
  - onCreate: parses an optional meal-type prefix from the query
    ("frühstück: müsli mit beeren", "snack: apfel", english + ASCII
    variants accepted) and falls back to suggestMealType() based on
    time-of-day when no prefix is given
  - onParseCreate: shows a preview line so users see which meal type
    will be picked before they hit enter

Persistence goes through mealMutations.create — same code path the
workbench card uses, so encryption + sync work for free.

Tests: 13 cases covering parser branches (German + English prefixes,
case insensitivity, time-of-day fallback for the three meal windows,
edge cases like unknown prefixes, far-away colons, empty descriptions
after a prefix). Parser is exported to keep the test independent of
the adapter's network-touching hooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:14:37 +02:00
parent 68d1bda7e5
commit 5480a8dfdf
3 changed files with 221 additions and 0 deletions

View file

@ -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:');
});
});
});

View file

@ -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<string, MealType> = {
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<LocalMeal>('meals').toArray();
const visible = raw.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords<LocalMeal>('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,
});
},
};
}

View file

@ -13,6 +13,7 @@ const registry = new Map<string, () => Promise<AdapterModule>>([
['/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')],
]);
/**