feat(planner): add computeReasoningTags pure helper

Derives ReasoningTag[] from slotMap + recipe. Covers Neues Protein
(protein not yet in week) and Aufwand: leicht (cookTimeMin < 30 or
effort einfach/leicht). No component dependency — Vitest-testable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:45:42 +02:00
parent f2071ca5d8
commit f37f20d34e
2 changed files with 169 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { computeReasoningTags, type ReasoningTag } from './reasoningTags';
// SlotMap fixture helpers
const emptySlotMap = {};
const slotMapWithChicken = {
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Chicken curry', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
};
const slotMapWithBeefAndChicken = {
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Steak', tags: [{ id: 't2', name: 'Rind', tagType: 'protein' }] } },
'2026-04-08': { id: 's2', slotDate: '2026-04-08', recipe: { id: 'r2', name: 'Chicken', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
};
const fishRecipe = { id: 'r3', name: 'Lachs', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
const chickenRecipe = { id: 'r1', name: 'Chicken curry', cookTimeMin: 45, effort: 'mittel', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] };
const easyRecipe = { id: 'r4', name: 'Salat', cookTimeMin: 15, effort: 'einfach', tags: [] };
const heavyRecipe = { id: 'r5', name: 'Roulade', cookTimeMin: 90, effort: 'aufwändig', tags: [] };
describe('computeReasoningTags', () => {
describe('Neues Protein tag', () => {
it('returns Neues Protein tag when recipe protein is not in week', () => {
const tags = computeReasoningTags(slotMapWithChicken, fishRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).toContain('neues-protein');
});
it('does not return Neues Protein when recipe protein is already in week', () => {
const tags = computeReasoningTags(slotMapWithChicken, chickenRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).not.toContain('neues-protein');
});
it('returns Neues Protein when recipe has protein tag and slotMap is empty', () => {
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).toContain('neues-protein');
});
it('does not return Neues Protein when recipe has no protein tag', () => {
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).not.toContain('neues-protein');
});
});
describe('Aufwand: leicht tag', () => {
it('returns Aufwand tag when cookTimeMin is less than 30', () => {
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).toContain('aufwand-leicht');
});
it('returns Aufwand tag when effort is einfach regardless of cookTime', () => {
const recipe = { ...fishRecipe, cookTimeMin: 45 };
const tags = computeReasoningTags(emptySlotMap, recipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).toContain('aufwand-leicht');
});
it('does not return Aufwand tag for heavy recipe', () => {
const tags = computeReasoningTags(emptySlotMap, heavyRecipe);
const tagTypes = tags.map((t: ReasoningTag) => t.id);
expect(tagTypes).not.toContain('aufwand-leicht');
});
it('returns Aufwand tag exactly at cookTimeMin 29', () => {
const recipe = { ...heavyRecipe, cookTimeMin: 29, effort: undefined };
const tags = computeReasoningTags(emptySlotMap, recipe);
expect(tags.map((t: ReasoningTag) => t.id)).toContain('aufwand-leicht');
});
it('does not return Aufwand tag at cookTimeMin 30 with non-easy effort', () => {
const recipe = { ...heavyRecipe, cookTimeMin: 30, effort: 'mittel' };
const tags = computeReasoningTags(emptySlotMap, recipe);
expect(tags.map((t: ReasoningTag) => t.id)).not.toContain('aufwand-leicht');
});
});
describe('ReasoningTag shape', () => {
it('each tag has id, label, and color', () => {
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
for (const tag of tags) {
expect(tag).toHaveProperty('id');
expect(tag).toHaveProperty('label');
expect(tag).toHaveProperty('color');
}
});
});
describe('multiple tags', () => {
it('returns multiple tags when multiple conditions are true', () => {
const recipe = { id: 'r6', name: 'Easy fish', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
const tags = computeReasoningTags(slotMapWithBeefAndChicken, recipe);
const tagIds = tags.map((t: ReasoningTag) => t.id);
expect(tagIds).toContain('neues-protein');
expect(tagIds).toContain('aufwand-leicht');
});
it('returns empty array when no conditions are true', () => {
const tags = computeReasoningTags(slotMapWithChicken, { ...chickenRecipe, cookTimeMin: 60, effort: 'aufwändig' });
expect(tags).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,63 @@
export interface ReasoningTag {
id: 'neues-protein' | 'aufwand-leicht';
label: string;
color: 'green' | 'yellow';
}
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface Recipe {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
tags?: TagItem[];
}
interface SlotRecipe {
id?: string;
tags?: TagItem[];
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: SlotRecipe | null;
}
type SlotMap = Record<string, Slot>;
export function computeReasoningTags(slotMap: SlotMap, recipe: Recipe): ReasoningTag[] {
const tags: ReasoningTag[] = [];
// Neues Protein: recipe has a protein tag not already present in any filled slot
const recipeProtein = recipe.tags?.find((t) => t.tagType === 'protein')?.name;
if (recipeProtein) {
const weekProteins = new Set<string>();
for (const slot of Object.values(slotMap)) {
if (slot.recipe) {
for (const tag of slot.recipe.tags ?? []) {
if (tag.tagType === 'protein' && tag.name) {
weekProteins.add(tag.name);
}
}
}
}
if (!weekProteins.has(recipeProtein)) {
tags.push({ id: 'neues-protein', label: 'Neues Protein', color: 'green' });
}
}
// Aufwand: leicht — cookTimeMin < 30 OR effort is 'einfach'/'leicht'
const isEasyEffort = recipe.effort === 'einfach' || recipe.effort === 'leicht';
const isQuick = recipe.cookTimeMin != null && recipe.cookTimeMin < 30;
if (isEasyEffort || isQuick) {
tags.push({ id: 'aufwand-leicht', label: 'Aufwand: leicht', color: 'yellow' });
}
return tags;
}