diff --git a/frontend/src/lib/planner/reasoningTags.test.ts b/frontend/src/lib/planner/reasoningTags.test.ts new file mode 100644 index 0000000..9099f96 --- /dev/null +++ b/frontend/src/lib/planner/reasoningTags.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/lib/planner/reasoningTags.ts b/frontend/src/lib/planner/reasoningTags.ts new file mode 100644 index 0000000..e0a5c09 --- /dev/null +++ b/frontend/src/lib/planner/reasoningTags.ts @@ -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; + +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(); + 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; +}