feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal file
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/src/lib/planner/reasoningTags.ts
Normal file
63
frontend/src/lib/planner/reasoningTags.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user