mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
68d1bda7e5
commit
5480a8dfdf
3 changed files with 221 additions and 0 deletions
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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')],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue