From 520dae5adf3bbd6771b294ddce13dab39c4e01ed Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 20:23:28 +0200 Subject: [PATCH] feat(recipes): add image upload, fix save 500, seed HelloFresh data - Store hero image as base64 data URI in text column (V023 migration) - Add file upload UI to RecipeForm with FileReader preview - Remove isChildFriendly from RecipeCreateRequest (no form field) - Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed from primitive short to nullable Integer to survive omitted fields - Fix empty categories panel: removed stale tagType=category filter - Group category tags by type with German headings in recipe form - Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe - Seed 11 HelloFresh recipes with ingredients, steps and tags (V101) - Add frontend e2e scaffold, specs and dev yml Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 7 +- .../planning/dto/SuggestionResponse.java | 5 +- .../com/recipeapp/recipe/RecipeService.java | 11 +- .../recipe/dto/RecipeCreateRequest.java | 8 +- .../com/recipeapp/recipe/entity/Recipe.java | 2 +- .../src/main/resources/application-dev.yml | 3 + .../db/migration/V023__hero_image_text.sql | 1 + .../db/seed/V101__dev_seed_recipes.sql | 434 ++++ .../planning/WeekPlanControllerTest.java | 2 +- .../recipe/RecipeControllerTest.java | 2 +- .../recipeapp/recipe/RecipeServiceTest.java | 14 +- frontend/.gitignore | 23 + frontend/.npmrc | 1 + frontend/e2e/startseite.test.ts | 6 + frontend/playwright.config.ts | 12 + frontend/src/lib/assets/favicon.svg | 1 + frontend/src/lib/index.ts | 1 + .../lib/planner/VarietyWarningCards.svelte | 53 +- frontend/src/lib/recipes/RecipeForm.svelte | 130 +- .../(app)/recipes/[id]/edit/+page.server.ts | 8 +- .../recipes/[id]/edit/page.server.test.ts | 34 +- .../routes/(app)/recipes/new/+page.server.ts | 8 +- .../(app)/recipes/new/page.server.test.ts | 34 +- frontend/src/routes/+layout.svelte | 7 + frontend/static/robots.txt | 3 + specs/backend/api-design.html | 1456 +++++++++++++ specs/backend/data-model.html | 896 ++++++++ specs/e1-settings.html | 764 +++++++ specs/e2-members.html | 761 +++++++ specs/planner-c-e-combined.html | 755 +++++++ specs/planner-fullbleed-tiles.html | 773 +++++++ specs/planner-layout-mockups.html | 1820 +++++++++++++++++ specs/planner-main-area-mockups.html | 1064 ++++++++++ specs/planner-tall-tiles.html | 847 ++++++++ 34 files changed, 9862 insertions(+), 84 deletions(-) create mode 100644 backend/src/main/resources/application-dev.yml create mode 100644 backend/src/main/resources/db/migration/V023__hero_image_text.sql create mode 100644 backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/e2e/startseite.test.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/index.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/static/robots.txt create mode 100644 specs/backend/api-design.html create mode 100644 specs/backend/data-model.html create mode 100644 specs/e1-settings.html create mode 100644 specs/e2-members.html create mode 100644 specs/planner-c-e-combined.html create mode 100644 specs/planner-fullbleed-tiles.html create mode 100644 specs/planner-layout-mockups.html create mode 100644 specs/planner-main-area-mockups.html create mode 100644 specs/planner-tall-tiles.html diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 4a4ae85..900b218 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -153,7 +153,7 @@ public class PlanningService { plan, candidate, slotDate, config, recentlyCookedIds); double scoreDelta = simulatedScore - currentScore; boolean hasConflict = scoreDelta < 0; - return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict); + return new SuggestionResponse.SuggestionItem(toSuggestionRecipe(candidate), scoreDelta, hasConflict); }) .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .limit(limit) @@ -422,6 +422,11 @@ public class PlanningService { recipe.getCookTimeMin(), recipe.getHeroImageUrl()); } + private SuggestionResponse.SuggestionRecipe toSuggestionRecipe(Recipe recipe) { + return new SuggestionResponse.SuggestionRecipe(recipe.getId(), recipe.getName(), + recipe.getEffort(), recipe.getCookTimeMin()); + } + private boolean hasConsecutiveDays(List days) { if (days.size() < 2) return false; List sorted = days.stream().sorted().toList(); diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java index 1844fcc..75cf9f5 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -1,11 +1,14 @@ package com.recipeapp.planning.dto; import java.util.List; +import java.util.UUID; public record SuggestionResponse(List suggestions) { + public record SuggestionRecipe(UUID id, String name, String effort, short cookTimeMin) {} + public record SuggestionItem( - SlotResponse.SlotRecipe recipe, + SuggestionRecipe recipe, double scoreDelta, boolean hasConflict ) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index 497ed2f..de5471b 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -60,8 +60,10 @@ public class RecipeService { Household household = householdRepository.findById(householdId) .orElseThrow(() -> new ResourceNotFoundException("Household not found")); - Recipe recipe = new Recipe(household, request.name(), request.serves(), - request.cookTimeMin(), request.effort(), request.isChildFriendly()); + Recipe recipe = new Recipe(household, request.name(), + request.serves() != null ? request.serves().shortValue() : 0, + request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0, + request.effort(), false); recipe.setHeroImageUrl(request.heroImageUrl()); addIngredients(recipe, household, request.ingredients()); @@ -78,10 +80,9 @@ public class RecipeService { Household household = recipe.getHousehold(); recipe.setName(request.name()); - recipe.setServes(request.serves()); - recipe.setCookTimeMin(request.cookTimeMin()); + recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0); + recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0); recipe.setEffort(request.effort()); - recipe.setChildFriendly(request.isChildFriendly()); recipe.setHeroImageUrl(request.heroImageUrl()); recipe.getIngredients().clear(); diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java index cba85c0..5852b48 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java @@ -6,13 +6,13 @@ import java.math.BigDecimal; import java.util.List; import java.util.UUID; + public record RecipeCreateRequest( @NotBlank @Size(max = 200) String name, - @Min(1) @Max(20) short serves, - @Min(0) short cookTimeMin, + Integer serves, + Integer cookTimeMin, @NotBlank @Pattern(regexp = "easy|medium|hard") String effort, - boolean isChildFriendly, - @Size(max = 500) String heroImageUrl, + String heroImageUrl, @NotEmpty @Valid List ingredients, @Valid List steps, @NotEmpty List tagIds diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java index f15c1e5..508cd79 100644 --- a/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java +++ b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java @@ -36,7 +36,7 @@ public class Recipe { @Column(name = "is_child_friendly", nullable = false) private boolean isChildFriendly; - @Column(name = "hero_image_url", length = 500) + @Column(name = "hero_image_url", columnDefinition = "text") private String heroImageUrl; @Column(name = "deleted_at") diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..6bbe76c --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,3 @@ +spring: + flyway: + locations: classpath:db/migration,classpath:db/seed diff --git a/backend/src/main/resources/db/migration/V023__hero_image_text.sql b/backend/src/main/resources/db/migration/V023__hero_image_text.sql new file mode 100644 index 0000000..a78d634 --- /dev/null +++ b/backend/src/main/resources/db/migration/V023__hero_image_text.sql @@ -0,0 +1 @@ +ALTER TABLE recipe ALTER COLUMN hero_image_url TYPE text; diff --git a/backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql b/backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql new file mode 100644 index 0000000..31d0404 --- /dev/null +++ b/backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql @@ -0,0 +1,434 @@ +-- Dev seed: 11 HelloFresh vegetarian recipes (4 persons) +-- Fixed UUIDs so the migration is idempotent and references are stable. +-- Ingredients use dd000002-prefix, tags ee000001-prefix, recipes ff000002-prefix. + +-- ─── Tags ──────────────────────────────────────────────────────────────────── + +INSERT INTO tag (id, household_id, name, tag_type) VALUES + ('ee000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Vegetarisch', 'dietary'), + ('ee000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Glutenfrei', 'dietary'), + ('ee000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Deutsch', 'cuisine'), + ('ee000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mediterran', 'cuisine'), + ('ee000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Asiatisch', 'cuisine'), + ('ee000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mexikanisch', 'cuisine'), + ('ee000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Käse', 'protein'), + ('ee000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 'protein'), + ('ee000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', 'protein'), + ('ee000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Auflauf', 'other'), + ('ee000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nudeln', 'other'), + ('ee000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Reis', 'other'), + ('ee000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnell', 'other'), + ('ee000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Ofengericht', 'other'), + ('ee000001-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchen', 'other') +ON CONFLICT ON CONSTRAINT uq_tag_name DO NOTHING; + +-- ─── Additional Ingredients ────────────────────────────────────────────────── + +INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES + -- Gemüse + ('dd000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rucola', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kirschtomaten', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilischote', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gurke', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Radieschen', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Zwiebeln', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Spitzpaprika', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gelbe Paprika', false, 'cc000001-0000-0000-0000-000000000001'), + ('dd000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Feldsalat', false, 'cc000001-0000-0000-0000-000000000001'), + -- Obst + ('dd000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Avocado', false, 'cc000001-0000-0000-0000-000000000002'), + ('dd000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Äpfel', false, 'cc000001-0000-0000-0000-000000000002'), + -- Milchprodukte & Eier + ('dd000002-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hartkäse ital. Art', false, 'cc000001-0000-0000-0000-000000000004'), + ('dd000002-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Cheddar (gerieben)', false, 'cc000001-0000-0000-0000-000000000004'), + ('dd000002-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Frischkäse', false, 'cc000001-0000-0000-0000-000000000004'), + ('dd000002-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Joghurt', false, 'cc000001-0000-0000-0000-000000000004'), + ('dd000002-0000-0000-0000-000000000016', 'bbbbbbbb-0000-0000-0000-000000000001', 'Halloumi', false, 'cc000001-0000-0000-0000-000000000004'), + ('dd000002-0000-0000-0000-000000000017', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tex-Mex-Käsemischung', false, 'cc000001-0000-0000-0000-000000000004'), + -- Getreide & Nudeln + ('dd000002-0000-0000-0000-000000000018', 'bbbbbbbb-0000-0000-0000-000000000001', 'Orzonudeln', false, 'cc000001-0000-0000-0000-000000000005'), + ('dd000002-0000-0000-0000-000000000019', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tortellini', false, 'cc000001-0000-0000-0000-000000000005'), + ('dd000002-0000-0000-0000-000000000020', 'bbbbbbbb-0000-0000-0000-000000000001', 'Jasminreis', true, 'cc000001-0000-0000-0000-000000000005'), + ('dd000002-0000-0000-0000-000000000021', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gnocchi (frisch)', false, 'cc000001-0000-0000-0000-000000000005'), + ('dd000002-0000-0000-0000-000000000022', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fladenbrot', false, 'cc000001-0000-0000-0000-000000000005'), + -- Hülsenfrüchte + ('dd000002-0000-0000-0000-000000000023', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarze Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'), + -- Konserven + ('dd000002-0000-0000-0000-000000000024', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten-Polpa', true, 'cc000001-0000-0000-0000-000000000007'), + ('dd000002-0000-0000-0000-000000000025', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilipolpa', true, 'cc000001-0000-0000-0000-000000000007'), + ('dd000002-0000-0000-0000-000000000026', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getrocknete Tomaten', true, 'cc000001-0000-0000-0000-000000000007'), + ('dd000002-0000-0000-0000-000000000027', 'bbbbbbbb-0000-0000-0000-000000000001', 'Grüne Oliven', true, 'cc000001-0000-0000-0000-000000000007'), + -- Gewürze & Kräuter + ('dd000002-0000-0000-0000-000000000028', 'bbbbbbbb-0000-0000-0000-000000000001', 'Petersilie (frisch)', false, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000029', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (frisch)', false, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000030', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnittlauch', false, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000031', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander (frisch)', false, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000032', 'bbbbbbbb-0000-0000-0000-000000000001', 'Scharfes Currypulver', true, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000033', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kumin (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000034', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander & Kumin', true, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000035', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMexico', true, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000036', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung Kartoffelknaller', true, 'cc000001-0000-0000-0000-000000000008'), + ('dd000002-0000-0000-0000-000000000037', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMediterraneo',true, 'cc000001-0000-0000-0000-000000000008'), + -- Tiefkühl + ('dd000002-0000-0000-0000-000000000038', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchenteig', false, 'cc000001-0000-0000-0000-000000000013'), + -- Saucen & Pasten + ('dd000002-0000-0000-0000-000000000039', 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamicocreme', true, 'cc000001-0000-0000-0000-000000000010'), + ('dd000002-0000-0000-0000-000000000040', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikumpaste', true, 'cc000001-0000-0000-0000-000000000010'), + ('dd000002-0000-0000-0000-000000000041', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mayonnaise', true, 'cc000001-0000-0000-0000-000000000010'), + -- Nüsse & Samen + ('dd000002-0000-0000-0000-000000000042', 'bbbbbbbb-0000-0000-0000-000000000001', 'Haselnusskerne', true, 'cc000001-0000-0000-0000-000000000011') +ON CONFLICT (id) DO NOTHING; + +-- ─── Recipes ───────────────────────────────────────────────────────────────── + +INSERT INTO recipe (id, household_id, name, serves, cook_time_min, effort, is_child_friendly) VALUES + ('ff000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Scharfer Auflauf mit Orzonudeln', 4, 30, 'easy', false), + ('ff000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Tortellini mit Ricotta-Füllung', 4, 25, 'easy', true), + ('ff000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Knuspriger Flammkuchen mit Mozzarella', 4, 35, 'easy', false), + ('ff000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Fruchtiges Tomatenrisotto mit Zitrone', 4, 30, 'medium', false), + ('ff000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Karotten-Hafer-Puffer', 4, 40, 'medium', false), + ('ff000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Überbackene Penne mit getrockneten Tomaten', 4, 50, 'easy', false), + ('ff000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Chili sin Carne', 4, 40, 'easy', false), + ('ff000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Gebratene Gnocchi mit Ofenzucchini', 4, 35, 'easy', false), + ('ff000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Pasta nach Art Caponata', 4, 45, 'easy', false), + ('ff000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Auflauf mit Halloumi und Aubergine', 4, 40, 'medium', false), + ('ff000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', + 'Buntes Ofengemüse mit Halloumi', 4, 30, 'easy', false) +ON CONFLICT (id) DO NOTHING; + +-- ─── Recipe Ingredients ────────────────────────────────────────────────────── +-- V100 ingredients referenced by name via subquery (gen_random_uuid IDs). +-- New dd000002 ingredients referenced by fixed UUID. + +-- 01 Scharfer Auflauf mit Orzonudeln +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000024', 2, 'Dose', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Oliven (schwarz)'), 100, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000018', 300, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000013', 100, 'g', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 11), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 800, 'ml', 12); + +-- 02 Tortellini mit Ricotta-Füllung +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000029', 5, 'g', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000028', 3, 'g', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000030', 2, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000019', 800, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Sonnenblumenkerne'), 10, 'g', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 4, 'EL', 10); + +-- 03 Knuspriger Flammkuchen mit Mozzarella +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000028', 5, 'g', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000030', 5, 'g', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000014', 200, 'g', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000038', 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 250, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000005', 200, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000015', 200, 'g', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000001', 200, 'g', 11), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 3, 'EL', 12), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 2, 'EL', 13); + +-- 04 Fruchtiges Tomatenrisotto mit Zitrone +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 2, 'Stück', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Risottoreis (Arborio)'), 600, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 2, 'Stück', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000040', 24, 'ml', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 1, 'EL', 12); + +-- 05 Karotten-Hafer-Puffer +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Kartoffeln'), 1200,'g', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000036', 4, 'g', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000011', 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000015', 150, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000041', 4, 'EL', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Haferflocken'), 50, 'g', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000017', 200, 'g', 11), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000032', 2, 'g', 12), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000009', 150, 'g', 13), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Eier'), 2, 'Stück', 14), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Balsamico-Essig'), 2, 'EL', 15); + +-- 06 Überbackene Penne mit getrockneten Tomaten +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000030', 10, 'g', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000002', 300, 'g', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000013', 200, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Butter'), 5, 'g', 9); + +-- 07 Chili sin Carne +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000020', 300, 'g', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 3, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000007', 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000008', 2, 'Stück', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000023', 2, 'Dose', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000035', 8, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 11), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 12), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Schmand'), 150, 'g', 13), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000028', 10, 'g', 14), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 15); + +-- 08 Gebratene Gnocchi mit Ofenzucchini +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000037', 6, 'g', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000042', 40, 'g', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000021', 800, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 9); + +-- 09 Pasta nach Art Caponata +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000027', 120, 'g', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11); + +-- 10 Auflauf mit Halloumi und Aubergine +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 4, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 4, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 6, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 2, 'Stück', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000034', 4, 'g', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomatenmark'), 70, 'g', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000016', 400, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000029', 20, 'g', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000022', 1, 'Stück', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 5, 'EL', 11); + +-- 11 Buntes Ofengemüse mit Halloumi +INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Süßkartoffeln'), 2, 'Stück', 1), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 2), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 2, 'Stück', 3), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000028', 20, 'g', 4), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 5), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 6), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 7), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000033', 2, 'g', 8), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000003', 1, 'Stück', 9), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000016', 500, 'g', 10), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11); + +-- ─── Recipe Steps ───────────────────────────────────────────────────────────── + +-- 01 Scharfer Auflauf mit Orzonudeln +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 1, 'Backofen auf 200 °C (Grillfunktion) vorheizen. Zwiebeln und Knoblauch abziehen und fein hacken. Aubergine in ca. 2 cm große Würfel schneiden. Heiße Gemüsebrühe vorbereiten. Chilischote halbieren, Kerne entfernen und in feine Streifen schneiden (Achtung: scharf!).'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten. Orzonudeln und Aubergine zugeben und anbraten, bis das Öl vollständig aufgenommen ist. Brühe, Tomaten-Polpa und Chili zugeben, verrühren und ca. 10 Min. bei mittlerer Hitze köcheln lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 3, 'Zucchini längs halbieren und in 0,5 cm Scheiben schneiden. Oliven in Ringe schneiden. Zucchini und die Hälfte der Oliven zum Orzo geben und ca. 3 Min. mitkochen. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 4, 'Hartkäse fein reiben. Cheddar unter den Orzo heben und alles in eine Auflaufform füllen. Mit Hartkäse bestreuen und im Backofen 5–10 Min. gratinieren, bis der Käse goldbraun ist.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 5, 'Öl, Salz und Pfeffer in einer großen Schüssel vermengen. Rucola und restliche Oliven unterheben.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 6, 'Orzoauflauf auf Teller verteilen und mit dem Rucola-Oliven-Salat servieren.'); + +-- 02 Tortellini mit Ricotta-Füllung +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Knoblauch abziehen. Zucchini in 0,5 cm dünne Scheiben schneiden. Kirschtomaten halbieren. Gemüse in eine große Schüssel geben, Knoblauch hinzupressen, mit Olivenöl, Salz und Pfeffer vermengen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 2, 'Gemüse auf einem mit Backpapier belegten Blech verteilen und 18–20 Min. backen, bis die Zucchini leicht bräunt und die Tomaten fast geschmolzen sind. Währenddessen Kräuter abzupfen, Basilikum und Petersilie fein hacken, Schnittlauch in Röllchen schneiden.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 3, 'Großen Topf mit gesalzenem Wasser zum Kochen bringen. Frischkäse mit den gehackten Kräutern verrühren, mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 4, 'Sonnenblumenkerne in einer kleinen Pfanne ohne Fett bei mittlerer Hitze goldbraun rösten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 5, 'Tortellini in den letzten 3–4 Min. der Gemüse-Backzeit in das kochende Wasser geben und al dente garen. Abgießen, zurück in den Topf geben. Gebackenes Gemüse und 4 EL Kräuterfrischkäse unterheben.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 6, 'Tortellini auf Teller verteilen. Restlichen Kräuterfrischkäse als Kleckse darauf verteilen, mit Sonnenblumenkernen bestreuen und mit Basilikum dekorieren.'); + +-- 03 Knuspriger Flammkuchen mit Mozzarella +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Schnittlauch und Petersilie fein hacken und unter den Frischkäse heben. Flammkuchenteig auf einem mit Backpapier belegten Blech ausrollen und gleichmäßig mit dem Kräuterfrischkäse bestreichen (ca. 1 cm Rand frei lassen). Mit Salz und Pfeffer bestreuen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 2, 'Rote Zwiebeln abziehen, halbieren und in feine Streifen schneiden. Mozzarella in kleine Stücke zupfen. Flammkuchen mit Zwiebelstreifen belegen und Mozzarellastücke darauf verteilen. Auf der mittleren Schiene 13–15 Min. knusprig backen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 3, 'Gurke in lange, dünne Scheiben hobeln oder schneiden. Radieschen vierteln.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 4, 'Joghurt, Senf, Olivenöl, Weißweinessig, Salz und Pfeffer in einer großen Schüssel zu einem Dressing verrühren.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 5, 'Rucola, Gurkenstreifen und Radieschen in die Schüssel geben und unterheben. Bis zum Anrichten ziehen lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 6, 'Flammkuchen in Stücke schneiden und auf Teller verteilen. Mit dem Salat servieren.'); + +-- 04 Fruchtiges Tomatenrisotto mit Zitrone +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 1, '1200 ml Wasser erhitzen. Karotten schälen und grob reiben. Zwiebeln fein würfeln. Knoblauch in dünne Scheiben schneiden. Hartkäse fein reiben. Zitronenschale abreiben, Zitronen halbieren und entsaften. Gemüsebrühe im heißen Wasser auflösen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 2, 'Öl in einem großen Topf erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 3, 'Risottoreis zugeben und unter Rühren erhitzen, bis das Öl vollständig aufgenommen ist. Karotten und ein Drittel der Brühe zugeben und gut verrühren. Restliche Brühe nach und nach einrühren. Insgesamt ca. 20 Min. köcheln lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 4, 'Mozzarella in mundgerechte Stücke schneiden. Mit Basilikumpaste, Olivenöl, Weißweinessig, Salz, Pfeffer und 1 Prise Zucker marinieren und ziehen lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 5, 'Kirschtomaten und Hartkäse in den Risotto einrühren. Mit Zitronenabrieb und Zitronensaft abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 6, 'Risotto auf Teller verteilen und mit dem marinierten Basilikummozzarella toppen.'); + +-- 05 Karotten-Hafer-Puffer +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Kartoffeln ungeschält in Spalten (Wedges) schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Öl beträufeln, mit Gewürzmischung Kartoffelknaller, Salz und Pfeffer würzen. 20–25 Min. backen, bis die Wedges innen weich und außen knusprig sind.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 2, 'Gurke längs halbieren und in Halbmondscheiben schneiden. Äpfel entkernen und in dünne Halbmonde schneiden. Balsamicoessig, Öl und Senf zu einem Dressing verrühren, mit Salz und Pfeffer abschmecken. Gurke und Apfel unterheben und marinieren lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 3, 'Koriander fein hacken und mit Joghurt und Mayonnaise verrühren. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 4, 'Karotten schälen und grob raspeln. In einer großen Schüssel Karotten, Haferflocken, Eier, Tex-Mex-Käsemischung und Currypulver vermischen. Mit Salz und Pfeffer würzen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 5, 'Öl in einer großen Pfanne erhitzen. Karottenmischung mithilfe eines Esslöffels zu Puffern formen und leicht flach drücken. Von beiden Seiten je ca. 3 Min. goldbraun braten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 6, 'Feldsalat unter den Apfel-Gurken-Salat heben. Auf Tellern anrichten, Karottenpuffer und Kartoffelwedges dazu platzieren. Mit Korianderdip beträufeln und genießen.'); + +-- 06 Überbackene Penne mit getrockneten Tomaten +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Reichlich gesalzenes Wasser zum Kochen bringen. Penne 7–9 Min. bissfest garen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 2, 'Knoblauch abziehen und fein würfeln. Schnittlauch in Röllchen schneiden. Kirschtomaten halbieren. Getrocknete Tomaten grob zerkleinern.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 3, 'Frischkäse mit Knoblauch, Senf und dem Großteil des Schnittlauchs verrühren. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 4, 'Penne abgießen, dabei 100 ml Kochwasser auffangen. Penne zurück in den Topf geben. Frischkäsemischung und getrocknete Tomaten einrühren, bei Bedarf Kochwasser zugeben, bis eine cremige Konsistenz entsteht. Kirschtomaten unterheben.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 5, 'Penne-Mischung in eine mit Butter eingefettete Auflaufform füllen. Mit Cheddar bestreuen und im Backofen 6–7 Min. gratinieren.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 6, 'Auflauf auf Teller verteilen und mit restlichem Schnittlauch bestreuen.'); + +-- 07 Chili sin Carne +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 1, 'Jasminreis mit 600 ml heißem Wasser in einem kleinen Topf aufkochen. Bei niedriger Hitze ca. 10 Min. köcheln lassen, vom Herd nehmen und abgedeckt 10 Min. quellen lassen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 2, 'Knoblauch abziehen und in feine Streifen schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Paprika halbieren, entkernen und in Streifen schneiden. Schwarze Bohnen abgießen und kalt abspülen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 3, 'Öl in einem großen Topf erhitzen. Zwiebeln und Paprika 2–3 Min. anbraten. Knoblauch und Gewürzmischung HelloMexico zugeben und 1 Min. mitbraten. Mit Salz und Pfeffer würzen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 4, 'Schwarze Bohnen, Chilipolpa, Gemüsebrühe und Balsamicocreme zugeben. Chili 25–30 Min. bei niedriger Hitze köcheln lassen, bis die Paprika weich und das Chili cremig ist. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 5, 'Koriander und Petersilie fein hacken. Chilischote entkernen und in Streifen schneiden (Achtung: scharf!). Avocado halbieren, Stein entfernen und in Streifen schneiden. Schmand mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 6, 'Reis mit einer Gabel auflockern, Koriander unterheben und auf Teller verteilen. Chili daneben anrichten, mit Chili und Petersilie bestreuen. Mit Avocado und einem Klecks Schmand servieren.'); + +-- 08 Gebratene Gnocchi mit Ofenzucchini +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Zucchini in 0,5 cm dünne Scheiben schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Gewürzmischung HelloMediterraneo, Öl, Salz und Pfeffer würzen. Ca. 15 Min. backen, bis die Zucchini weich ist.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 2, 'Haselnusskerne in einer großen Pfanne ohne Fett bei mittlerer Hitze rösten, bis sie duften. Herausnehmen, abkühlen lassen und grob hacken. Pfanne beiseite stellen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 3, 'Knoblauch abziehen. Getrocknete Tomaten grob hacken und mit Frischkäse, Knoblauch und 200 ml Wasser in ein hohes Gefäß geben. Mit einem Pürierstab zu einer glatten Soße mixen. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 4, 'Öl in der Pfanne bei mittlerer Hitze erhitzen. Gnocchi darin 8–9 Min. anbraten, bis sie knusprig und leicht gebräunt sind.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 5, 'Soße zu den Gnocchi geben, alles vermengen und ca. 2 Min. einkochen lassen. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 6, 'Gnocchi auf Teller verteilen, mit gebackener Zucchini, geriebenem Hartkäse und Haselnusskernen toppen.'); + +-- 09 Pasta nach Art Caponata +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 1, 'Reichlich gesalzenes Wasser für die Pasta aufkochen. Getrocknete Tomaten und grüne Oliven grob hacken (Öl der Oliven auffangen). Knoblauch abziehen und fein hacken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 2, 'Aubergine in 1–2 cm Würfel schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Olivenöl in einer großen Pfanne stark erhitzen. Aubergine 3–4 Min. scharf anbraten. Zwiebeln, getrocknete Tomaten, Oliven und Knoblauch zugeben und 2 Min. mitbraten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 3, 'Hitze reduzieren, Chilipolpa zugeben und alles 10–12 Min. köcheln lassen, bis die Soße eingedickt und das Gemüse weich ist. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 4, 'Penne ca. 10 Min. bissfest garen und abgießen. Zitrone heiß abwaschen, Schale abreiben und in Spalten schneiden.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 5, 'Penne zur Soße in die Pfanne geben und gut vermengen. Mit Zitronenabrieb und Zitronensaft abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 6, 'Pasta in tiefen Tellern anrichten, mit geriebenem Hartkäse und Rucola bestreuen.'); + +-- 10 Auflauf mit Halloumi und Aubergine +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. 2 Knoblauchzehen mit dem Messerrücken andrücken und 15 Min. im Ofen rösten. Restlichen Knoblauch abziehen und fein hacken. Zwiebeln in Streifen schneiden. Tomaten und Auberginen in ca. 2 cm Würfel schneiden.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und gehackten Knoblauch 3 Min. andünsten. Aubergine, Tomatenwürfel sowie Koriander & Kumin zugeben und kurz anbraten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 3, 'Gemüse mit 200 ml Wasser ablöschen. Tomatenmark und Balsamicocreme einrühren und ca. 10 Min. köcheln lassen. Mit Salz und Pfeffer abschmecken. Währenddessen Halloumi in 0,5 cm Scheiben schneiden und Basilikumblätter abzupfen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 4, 'Soße in eine große Auflaufform geben und Halloumischeiben darüber verteilen. Ca. 20 Min. im Ofen backen. Fladenbrot in 2 cm Scheiben schneiden.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 5, 'Geröstete Knoblauchzehen abziehen und fein hacken. Mit Olivenöl, Salz und Pfeffer verrühren. Knoblauchöl auf die Brotscheiben träufeln, auf ein Backblech legen und 5–10 Min. knusprig aufbacken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 6, 'Auflauf 2 Min. unter dem Grill bräunen, bis der Halloumi goldbraun ist. Mit Basilikumblättern bestreuen und mit dem Knoblauchbrot servieren.'); + +-- 11 Buntes Ofengemüse mit Halloumi +INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Süßkartoffeln schälen und in 2 cm Würfel schneiden. Rote Zwiebeln halbieren und in ca. 1 cm Spalten schneiden.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 2, 'Süßkartoffelwürfel und Zwiebelspalten auf einem mit Backpapier belegten Blech verteilen, mit Salz und Pfeffer würzen. Ca. 25 Min. backen, bis die Süßkartoffeln weich sind.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 3, 'Tomaten halbieren und in Spalten schneiden. Petersilie fein hacken. Avocado würfeln. Die Hälfte der Petersilie und die Avocadowürfel zu den Tomaten geben und beiseitestellen.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 4, 'Knoblauch und Chilischote fein hacken. Restliche Petersilie mit Kumin, Knoblauch, Chili (Achtung: scharf!), Zitronensaft und Olivenöl zu einem Chimichurri verrühren. Mit Salz und Pfeffer abschmecken.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 5, 'Halloumi in ca. 3 cm Würfel schneiden. In einer Pfanne mit etwas Öl bei mittlerer Hitze rundherum 3–4 Min. goldbraun braten.'), + (gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 6, 'Geröstetes Gemüse und Zwiebeln in die Schüssel mit Tomaten und Avocado geben, vorsichtig vermengen. Auf Teller verteilen, mit Halloumiwürfeln toppen und Petersilien-Chimichurri darüberträufeln.'); + +-- ─── Recipe Tags ────────────────────────────────────────────────────────────── + +INSERT INTO recipe_tag (recipe_id, tag_id) VALUES + -- 01 Scharfer Auflauf + ('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf + -- 02 Tortellini + ('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln + ('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000013'), -- Schnell + -- 03 Flammkuchen + ('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000015'), -- Flammkuchen + -- 04 Tomatenrisotto + ('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000012'), -- Reis + ('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000013'), -- Schnell + -- 05 Karotten-Hafer-Puffer + ('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000003'), -- Deutsch + ('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000009'), -- Eier + -- 06 Überbackene Penne + ('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf + ('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln + -- 07 Chili sin Carne + ('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000006'), -- Mexikanisch + ('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000008'), -- Hülsenfrüchte + ('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000012'), -- Reis + -- 08 Gnocchi + ('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000013'), -- Schnell + -- 09 Pasta Caponata + ('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln + -- 10 Auflauf Halloumi + ('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf + -- 11 Buntes Ofengemüse + ('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch + ('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000002'), -- Glutenfrei + ('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran + ('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000007'), -- Käse + ('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000014') -- Ofengericht +ON CONFLICT DO NOTHING; diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index 77cd512..0d46ba7 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -161,7 +161,7 @@ class WeekPlanControllerTest { @Test void getSuggestionsShouldReturn200() throws Exception { - var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); + var recipe = new SuggestionResponse.SuggestionRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15); var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false); var response = new SuggestionResponse(List.of(item)); diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index 7d8d9d5..6f160da 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -165,7 +165,7 @@ class RecipeControllerTest { private RecipeCreateRequest sampleCreateRequest() { var ingredientId = UUID.randomUUID(); return new RecipeCreateRequest( - "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + "Spaghetti Bolognese", 4, 45, "medium", null, List.of(new RecipeCreateRequest.IngredientEntry( ingredientId, null, new BigDecimal("400"), "g", (short) 1)), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 8b62c2f..350e135 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -126,7 +126,7 @@ class RecipeServiceTest { }); var request = new RecipeCreateRequest( - "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + "Spaghetti Bolognese", 4, 45, "medium", null, List.of(new RecipeCreateRequest.IngredientEntry( ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), @@ -166,7 +166,7 @@ class RecipeServiceTest { }); var request = new RecipeCreateRequest( - "Carbonara", (short) 2, (short) 30, "medium", false, null, + "Carbonara", 2, 30, "medium", null, List.of(new RecipeCreateRequest.IngredientEntry( null, "pancetta", new BigDecimal("100"), "g", (short) 1)), List.of(), @@ -192,7 +192,7 @@ class RecipeServiceTest { when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0)); var request = new RecipeCreateRequest( - "Chicken Rice", (short) 3, (short) 25, "easy", true, null, + "Chicken Rice", 3, 25, "easy", null, List.of(new RecipeCreateRequest.IngredientEntry( ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")), @@ -450,7 +450,7 @@ class RecipeServiceTest { when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); var request = new RecipeCreateRequest( - "Test", (short) 2, (short) 15, "easy", false, null, + "Test", 2, 15, "easy", null, List.of(), List.of(), List.of()); assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)) @@ -466,7 +466,7 @@ class RecipeServiceTest { when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty()); var request = new RecipeCreateRequest( - "Test", (short) 2, (short) 15, "easy", false, null, + "Test", 2, 15, "easy", null, List.of(new RecipeCreateRequest.IngredientEntry( ingredientId, null, new BigDecimal("100"), "g", (short) 1)), List.of(), List.of()); @@ -491,7 +491,7 @@ class RecipeServiceTest { }); var request = new RecipeCreateRequest( - "Simple", (short) 1, (short) 5, "easy", false, null, + "Simple", 1, 5, "easy", null, null, null, null); RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); @@ -518,7 +518,7 @@ class RecipeServiceTest { .thenReturn(Optional.empty()); var request = new RecipeCreateRequest( - "Updated", (short) 2, (short) 20, "easy", false, null, + "Updated", 2, 20, "easy", null, List.of(), List.of(), List.of()); assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request)) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/e2e/startseite.test.ts b/frontend/e2e/startseite.test.ts new file mode 100644 index 0000000..eb82209 --- /dev/null +++ b/frontend/e2e/startseite.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('Startseite lädt korrekt', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible(); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..89e9e50 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,12 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + webServer: { + command: 'npm run build && npm run preview', + port: 4173 + }, + testDir: 'e2e', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; + +export default config; diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/planner/VarietyWarningCards.svelte b/frontend/src/lib/planner/VarietyWarningCards.svelte index dcf0794..aca5591 100644 --- a/frontend/src/lib/planner/VarietyWarningCards.svelte +++ b/frontend/src/lib/planner/VarietyWarningCards.svelte @@ -1,22 +1,51 @@ -{#each warnings as warning} +{#each warnings as warning (warning.title)}
-

- {warning.title} -

-

- {warning.explanation} -

+ +
+

+ {warning.title} +

+
+ + + {#each warning.items as item (item.slotId)} +
+ +
+ + {item.dayShort} + + + {item.recipeName} + +
+ + + + Tauschen → + +
+ {/each}
{/each} diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index 5560e81..2bb39a0 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -23,13 +23,30 @@ } = $props(); const effortOptions = [ - { label: 'Leicht', value: 'Easy' }, - { label: 'Mittel', value: 'Medium' }, - { label: 'Schwer', value: 'Hard' } + { label: 'Leicht', value: 'easy' }, + { label: 'Mittel', value: 'medium' }, + { label: 'Schwer', value: 'hard' } ]; const initial = (() => $state.snapshot(recipe))(); + const TAG_TYPE_LABELS: Record = { + dietary: 'Ernährung', + cuisine: 'Küche', + protein: 'Protein', + other: 'Sonstiges' + }; + + const groupedCategories = $derived( + Object.entries( + categories.reduce>((acc, cat) => { + const type = cat.tagType ?? 'other'; + (acc[type] ??= []).push(cat); + return acc; + }, {}) + ) + ); + let name = $state(initial?.name ?? ''); let serves = $state(initial?.serves ?? ''); let cookTimeMin = $state(initial?.cookTimeMin ?? ''); @@ -43,6 +60,17 @@ })) ?? [{ name: '', quantity: '' as number | '', unit: '' }] ); let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']); + let heroImageUrl = $state(initial?.heroImageUrl ?? null); + + function handleImageChange(e: Event) { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + heroImageUrl = reader.result as string; + }; + reader.readAsDataURL(file); + }
@@ -140,6 +168,37 @@ + +
+

Bild

+ {#if heroImageUrl} + + + {/if} + + +
+

Zutaten

@@ -227,35 +286,42 @@
-

Kategorien

-
- {#each categories as cat (cat.id)} - - {/each} -
+

Kategorien

+ {#each groupedCategories as [type, tags] (type)} +
+

+ {TAG_TYPE_LABELS[type] ?? type} +

+
+ {#each tags as cat (cat.id)} + + {/each} +
+
+ {/each}
diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts index 22a5715..89494c7 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -2,7 +2,7 @@ import { error, redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; -const VALID_EFFORTS = ['Easy', 'Medium', 'Hard']; +const VALID_EFFORTS = ['easy', 'medium', 'hard']; export const load: PageServerLoad = async ({ fetch, params }) => { const api = apiClient(fetch); @@ -17,9 +17,7 @@ export const load: PageServerLoad = async ({ fetch, params }) => { const recipe = recipeResult.data; const allTags = tagsResult.data ?? []; - const categories = allTags - .filter((t) => t.tagType === 'category') - .map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); + const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); return { recipe: { @@ -50,6 +48,7 @@ export const actions: Actions = { const serves = formData.get('serves'); const cookTimeMin = formData.get('cookTimeMin'); const effort = formData.get('effort') as string; + const heroImageUrl = (formData.get('heroImageUrl') as string) || null; const ingredientsJson = formData.get('ingredientsJson') as string; const stepsJson = formData.get('stepsJson') as string; const tagIds = formData.getAll('tagIds') as string[]; @@ -76,6 +75,7 @@ export const actions: Actions = { serves: serves ? Number(serves) || undefined : undefined, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, effort, + heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) .filter((ing) => ing.name?.trim()) .map((ing, i) => ({ diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts index cddb051..0195b1f 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -26,7 +26,7 @@ describe('edit recipe page — load', () => { name: 'Spaghetti Bolognese', serves: 4, cookTimeMin: 30, - effort: 'Easy', + effort: 'easy', ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }], steps: [{ stepNumber: 1, instruction: 'Kochen' }], tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }] @@ -53,7 +53,7 @@ describe('edit recipe page — load', () => { }); const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any); expect(result.recipe.name).toBe('Spaghetti Bolognese'); - expect(result.recipe.effort).toBe('Easy'); + expect(result.recipe.effort).toBe('easy'); }); it('returns categories from tags', async () => { @@ -82,7 +82,7 @@ describe('edit recipe page — update action', () => { const makeFormData = (overrides: Record = {}) => { const base: Record = { name: 'Test Rezept', - effort: 'Easy', + effort: 'easy', tagIds: ['t1'], ingredientsJson: '[]', stepsJson: '[]', @@ -174,7 +174,33 @@ describe('edit recipe page — update action', () => { } as any).catch(() => {}); expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({ params: { path: { id: 'r1' } }, - body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) + body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) + })); + }); + + it('sends heroImageUrl in PUT body when provided', async () => { + mockPut.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); + await actions.update({ + request: { formData: async () => fd }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any).catch(() => {}); + expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({ + body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' }) + })); + }); + + it('sends null heroImageUrl when field is empty', async () => { + mockPut.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ heroImageUrl: '' }); + await actions.update({ + request: { formData: async () => fd }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any).catch(() => {}); + expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({ + body: expect.objectContaining({ heroImageUrl: null }) })); }); diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts index 4a21b7f..657e4cd 100644 --- a/frontend/src/routes/(app)/recipes/new/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -2,16 +2,14 @@ import { redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; -const VALID_EFFORTS = ['Easy', 'Medium', 'Hard']; +const VALID_EFFORTS = ['easy', 'medium', 'hard']; export const load: PageServerLoad = async ({ fetch }) => { const api = apiClient(fetch); const { data, error } = await api.GET('/v1/tags', {}); const allTags = error || !data ? [] : data; - const categories = allTags - .filter((t) => t.tagType === 'category') - .map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); + const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); return { recipe: null, categories }; }; @@ -23,6 +21,7 @@ export const actions: Actions = { const serves = formData.get('serves'); const cookTimeMin = formData.get('cookTimeMin'); const effort = formData.get('effort') as string; + const heroImageUrl = (formData.get('heroImageUrl') as string) || null; const ingredientsJson = formData.get('ingredientsJson') as string; const stepsJson = formData.get('stepsJson') as string; const tagIds = formData.getAll('tagIds') as string[]; @@ -48,6 +47,7 @@ export const actions: Actions = { serves: serves ? Number(serves) || undefined : undefined, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, effort, + heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) .filter((ing) => ing.name?.trim()) .map((ing, i) => ({ diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts index ac054bb..3734634 100644 --- a/frontend/src/routes/(app)/recipes/new/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -22,8 +22,10 @@ describe('new recipe page — load', () => { }); const mockTags = [ - { id: 't1', name: 'Pasta', tagType: 'category' }, - { id: 't2', name: 'Fleisch', tagType: 'category' } + { id: 't1', name: 'Vegetarisch', tagType: 'dietary' }, + { id: 't2', name: 'Mediterran', tagType: 'cuisine' }, + { id: 't3', name: 'Käse', tagType: 'protein' }, + { id: 't4', name: 'Auflauf', tagType: 'other' } ]; it('fetches tags from GET /v1/tags', async () => { @@ -32,11 +34,11 @@ describe('new recipe page — load', () => { expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything()); }); - it('returns categories filtered from tags', async () => { + it('returns all tags as categories regardless of tagType', async () => { mockGet.mockResolvedValue({ data: mockTags, error: undefined }); const result = await load({ fetch: vi.fn() } as any); - expect(result.categories).toHaveLength(2); - expect(result.categories[0].name).toBe('Pasta'); + expect(result.categories).toHaveLength(4); + expect(result.categories[0].name).toBe('Vegetarisch'); }); it('returns empty categories when API fails', async () => { @@ -58,7 +60,7 @@ describe('new recipe page — create action', () => { const makeFormData = (overrides: Record = {}) => { const base: Record = { name: 'Test Rezept', - effort: 'Easy', + effort: 'easy', tagIds: ['t1'], ingredientsJson: '[]', stepsJson: '[]', @@ -140,7 +142,25 @@ describe('new recipe page — create action', () => { await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch( () => {} ); - expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) })); + expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) })); + }); + + it('sends heroImageUrl in POST body when provided', async () => { + mockPost.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); + await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {}); + expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ + body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' }) + })); + }); + + it('sends null heroImageUrl when field is empty', async () => { + mockPost.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ heroImageUrl: '' }); + await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {}); + expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ + body: expect.objectContaining({ heroImageUrl: null }) + })); }); it('returns fail(500) when API returns error', async () => { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..b93e9ba --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/specs/backend/api-design.html b/specs/backend/api-design.html new file mode 100644 index 0000000..efeb69c --- /dev/null +++ b/specs/backend/api-design.html @@ -0,0 +1,1456 @@ + + + + + + Recipe App — API Design v1.2 + + + + +
+ +
+
+

API design

+

Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine

+
+
+ v1.3
+ Style: REST + JSON
+ Framework: Spring Boot 4.0
+ Auth: Spring Security 7 (session)
+ Endpoints: 33
+ Designed by: Nexus +
+
+ +
+

v1.3 changes from v1.2

+
    +
  • Ingredient category is now a reference table. ingredient.category varchar(30) replaced by ingredient.category_id FK → ingredient_category. New ingredient_category table (id, household_id, name). API responses now return "category": { "id": "...", "name": "meat" } instead of "category": "meat". Two new endpoints: GET /v1/ingredient-categories and POST /v1/ingredient-categories.
  • +
  • Endpoint count: 31 → 33.
  • +
+
+ + + + +
+
Stack — boring, predictable, yours
+
Everything runs on one machine. No managed services, no per-request pricing, no vendor lock-in. A single JAR, a single database, a single deployment target.
+ +
+
+

API framework

+
Spring Boot 4.0
+

Spring Framework 7 underneath. Modular starters (smaller JARs). JSpecify null-safety. Built-in API versioning. Java 17+ baseline, first-class Java 25 support. Embedded Tomcat — one executable JAR.

+
+
+

Database

+
PostgreSQL 16
+

Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.

+
+
+

Auth

+
Spring Security 7 (sessions)
+

Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.

+
+
+ +
+
33
REST endpoints
+
18
JPA entities
+
6
Domains
+
1
Deployable JAR
+
0
External services
+
+ +
+

Key dependencies

+

+ spring-boot-starter-web REST + Jackson · + spring-boot-starter-data-jpa Hibernate + repos · + spring-boot-starter-security session auth + CSRF · + spring-boot-starter-validation Bean Validation · + flyway-core DB migrations · + postgresql JDBC driver · + springdoc-openapi Swagger UI +

+
+
+ + + +
+
API conventions
+
+
Base URL · Headers · Naming
+
https://yourapp.com/v1                — frontend + API on same domain
+Cookie: JSESSIONID=...                 — automatic (session auth)
+X-XSRF-TOKEN: ...                     — CSRF (POST/PUT/PATCH/DELETE)
+Content-Type: application/json        — request bodies
+
+URLs:  kebab-case    /v1/week-plans/{id}/slots
+JSON:  camelCase     cookTimeMin, isChildFriendly
+Java:  camelCase     DB cols: snake_case
+
+
+
Response envelopes
+
// Success
+{ "status": "success", "data": { ... }, "meta": { "pagination": { ... } } }
+
+// Error
+{ "status": "error", "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } }
+
+// Pagination & filtering
+?limit=20&offset=0&sort=-cookTimeMin&effort=easy&search=pasta
+
+
+

Click any endpoint row to expand request/response bodies

+

Every endpoint below has a collapsible detail panel showing the exact JSON shapes. Fields marked required are validated with Bean Validation annotations. Fields marked optional can be omitted. All responses are wrapped in the standard envelope above — the examples below show the data payload only.

+
+
+ + +
+
Project structure — package by domain
+
+
src/main/java/com/recipeapp
+
com.recipeapp
+├── auth/            AuthController · AuthService · SecurityConfig · CustomUserDetailsService
+├── household/       HouseholdController · InviteController · entities/ · dtos/ · repos/
+├── recipe/          RecipeController · IngredientController · TagController · RecipeService
+├── planning/        WeekPlanController · SuggestionService · VarietyService · CookingLogController
+├── shopping/        ShoppingListController · ShoppingListService
+├── pantry/          PantryController
+├── admin/           AdminController
+└── common/          ApiResponse · ApiError · GlobalExceptionHandler · HouseholdContext
+
+
+ + + +
+
Auth & household endpoints
+ +
+

Authentication

Auth
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/auth/signup
Create account. Sets session cookie. Returns user object.
+ public + J6 +
+
+
Request body
{
+  "email": "[email protected]",     // required, valid email
+  "password": "s3cure!Pass",         // required, min 8 chars
+  "displayName": "Sarah"             // required, 1–100 chars
+}
+
Response · 201 Created
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": null,              // no household yet
+  "householdRole": null
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
+
+
+ +
+
+ POST +
/v1/auth/login
Login. Creates session. Returns Set-Cookie + user.
+ public + J6 +
+
+
Request body
{
+  "email": "[email protected]",
+  "password": "s3cure!Pass"
+}
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": "7c9e6679-...",
+  "householdRole": "planner",
+  "systemRole": "user"
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
+
+
+ +
+
+ POST +
/v1/auth/logout
Invalidate session. Clears cookie.
+ auth + +
+
+
Request body
// empty — no body needed
+
Response · 204 No Content
// empty body
++ Set-Cookie: JSESSIONID=; Max-Age=0
+
+
+ +
+
+ GET +
/v1/auth/me
Current user + household + role. Every app launch.
+ auth + +
+
+
Request
// no body — GET request
+// session cookie sent automatically
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": "7c9e6679-...",
+  "householdName": "Smith family",
+  "householdRole": "planner",
+  "systemRole": "user"
+}
+
+
+ +
+
+ PATCH +
/v1/auth/me
Update own displayName or password. Screen E1.
+ auth + +
+
+
Request body
{
+  "displayName": "Sarah S.",       // optional
+  "currentPassword": "old...",     // required if changing pw
+  "newPassword": "new..."          // optional, min 8 chars
+}
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah S."
+}
+
+
+
+ +
+

Households & members

Auth
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/households
Create household + planner role + seed staples & tags. @Transactional.
+ auth + J6 +
+
+
Request body
{
+  "name": "Smith family"            // required, 1–100 chars
+}
+// Seeds ~20 default staple ingredients
+// Seeds default tags (protein types, dietary)
+// Adds current user as planner
+
Response · 201 Created
{
+  "id": "7c9e6679-...",
+  "name": "Smith family",
+  "members": [
+    {
+      "userId": "550e8400-...",
+      "displayName": "Sarah",
+      "role": "planner",
+      "joinedAt": "2026-04-01T10:00:00Z"
+    }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/households/mine
Current user's household with members. Screen E2.
+ auth + J6 +
+
+
Request
// no body — GET
+
Response · 200 OK
{
+  "id": "7c9e6679-...",
+  "name": "Smith family",
+  "members": [
+    { "userId": "550e...", "displayName": "Sarah",
+      "role": "planner", "joinedAt": "..." },
+    { "userId": "661f...", "displayName": "Tom",
+      "role": "member", "joinedAt": "..." }
+  ]
+}
+
+
+ +
+
+ POST +
/v1/households/mine/invites
Generate invite code. Expires 48h.
+ planner + J6 +
+
+
Request body
// empty — server generates the code
+
Response · 201 Created
{
+  "inviteCode": "ABC12XYZ",
+  "shareUrl": "https://yourapp.com/join/ABC12XYZ",
+  "expiresAt": "2026-04-03T10:00:00Z"
+}
+
+
+ +
+
+ POST +
/v1/invites/{code}/accept
Accept invite → join as member.
+ auth + J6 +
+
+
Request
// code is in the URL path
+// no request body needed
+
Response · 200 OK
{
+  "householdId": "7c9e6679-...",
+  "householdName": "Smith family",
+  "role": "member"
+}
+// 409 if code already used
+// 422 if code expired
+// 409 if user already in a household
+
+
+ +
+
+ GET +
/v1/households/mine/members
List members with names and roles.
+ auth + J6 +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "userId": "550e...", "displayName": "Sarah",
+    "role": "planner", "joinedAt": "..." },
+  { "userId": "661f...", "displayName": "Tom",
+    "role": "member", "joinedAt": "..." }
+]
+
+
+
+
+ + + +
+
Recipe endpoints
+ +
+

Recipes

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/recipes
List recipes (B1). Summary fields. ?search, ?effort, ?isChildFriendly, ?sort, ?limit, ?offset.
+ planner + J1J2 +
+
+
Query parameters
?search=pasta              // ILIKE on name
+?effort=easy               // exact match
+?isChildFriendly=true      // boolean
+?cookTimeMin.lte=30        // ≤ 30 minutes
+?sort=-cookTimeMin         // descending
+?limit=20&offset=0         // pagination
+
Response · 200 OK
{
+  "data": [
+    {
+      "id": "a1b2c3d4-...",
+      "name": "Spaghetti Bolognese",
+      "serves": 4,
+      "cookTimeMin": 45,
+      "effort": "medium",
+      "isChildFriendly": true,
+      "heroImageUrl": "/uploads/recipes/a1b2.jpg"
+    }
+  ],
+  "meta": {
+    "pagination": {
+      "total": 47, "limit": 20,
+      "offset": 0, "hasMore": true
+    }
+  }
+}
+
+
+ +
+
+ GET +
/v1/recipes/{id}
Full detail (B2). Ingredients, steps, tags. @EntityGraph — one query.
+ planner + J3 +
+
+
Request
// no body — GET
+// {id} = recipe UUID
+
Response · 200 OK
{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese",
+  "serves": 4,
+  "cookTimeMin": 45,
+  "effort": "medium",
+  "isChildFriendly": true,
+  "heroImageUrl": "/uploads/recipes/a1b2.jpg",
+  "ingredients": [
+    { "ingredientId": "f1e2-...",
+      "name": "spaghetti",
+      "category": { "id": "cat-01-...", "name": "pasta" },
+      "quantity": 400, "unit": "g", "sortOrder": 1 },
+    { "ingredientId": "d3c4-...",
+      "name": "ground beef",
+      "category": { "id": "cat-02-...", "name": "meat" },
+      "quantity": 500, "unit": "g", "sortOrder": 2 }
+  ],
+  "steps": [
+    { "stepNumber": 1,
+      "instruction": "Boil water and cook pasta." },
+    { "stepNumber": 2,
+      "instruction": "Brown the beef in a pan." }
+  ],
+  "tags": [
+    { "id": "t1-...", "name": "beef", "tagType": "protein" },
+    { "id": "t2-...", "name": "Italian", "tagType": "cuisine" }
+  ]
+}
+
+
+ +
+
+ POST +
/v1/recipes
Create with nested ingredients, steps, tag IDs. @Transactional.
+ planner + J1 +
+
+
Request body
{
+  "name": "Spaghetti Bolognese",
+  "serves": 4,                      // 1–20
+  "cookTimeMin": 45,                // ≥ 0
+  "effort": "medium",               // easy|medium|hard
+  "isChildFriendly": true,          // default false
+  "heroImageUrl": null,
+  "ingredients": [
+    { "ingredientId": "f1e2-...",   // existing id
+      "quantity": 400,
+      "unit": "g",
+      "sortOrder": 1 },
+    { "newIngredientName": "pancetta",// OR create new
+      "quantity": 100,
+      "unit": "g",
+      "sortOrder": 2 }
+  ],
+  "steps": [
+    { "stepNumber": 1,
+      "instruction": "Boil water..." }
+  ],
+  "tagIds": ["t1-...", "t2-..."]    // ≥ 2 (effort + 1 cat)
+}
+
Response · 201 Created
// full RecipeDetail (same shape as GET /recipes/{id})
+{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese",
+  ...
+}
++ Location: /v1/recipes/a1b2c3d4-...
+
Ingredients can reference an existing ingredientId or create a new ingredient inline via newIngredientName. Tags must include at least the effort level tag plus one category tag.
+
+ +
+
+ PUT +
/v1/recipes/{id}
Full replace. Same shape as POST body. Replaces all children.
+ planner + J1 +
+
+
Request body
// identical shape to POST /v1/recipes
+// sends the complete new state
+// server deletes old children, inserts new
+{
+  "name": "Spaghetti Bolognese (updated)",
+  "serves": 4,
+  "cookTimeMin": 40,
+  "effort": "medium",
+  "ingredients": [ ... ],
+  "steps": [ ... ],
+  "tagIds": [ ... ]
+}
+
Response · 200 OK
// full RecipeDetail with updated data
+{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese (updated)",
+  ...
+}
+
+
+ +
+
+ DELETE +
/v1/recipes/{id}
Soft delete (sets deletedAt).
+ planner + +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// empty body
+
+
+
+ +
+

Ingredients & tags

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/ingredients?search={q}
Autocomplete by name. Limit 10.
+ auth + J1 +
+
+
Query params
?search=chick             // ILIKE '%chick%'
+?isStaple=true            // filter staples (A3/D3)
+
Response · 200 OK
[
+  { "id": "f1e2-...", "name": "chicken breast",
+    "category": { "id": "cat-02-...", "name": "meat" },
+    "isStaple": false },
+  { "id": "g3h4-...", "name": "chickpeas",
+    "category": { "id": "cat-05-...", "name": "legumes" },
+    "isStaple": false }
+]
+
+
+ +
+
+ PATCH +
/v1/ingredients/{id}
Toggle isStaple. Update name or categoryId.
+ planner + J6 +
+
+
Request body
{
+  "isStaple": true,
+  "name": "olive oil",
+  "categoryId": "cat-03-..."       // FK → ingredient_category
+}
+
Response · 200 OK
{ "id": "f1e2-...", "name": "olive oil",
+  "category": { "id": "cat-03-...", "name": "oil" },
+  "isStaple": true }
+
+
+ +
+
+ GET +
/v1/tags
All tags grouped by tagType. For B3 picker.
+ auth + J1 +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "id": "t1-...", "name": "chicken", "tagType": "protein" },
+  { "id": "t2-...", "name": "beef", "tagType": "protein" },
+  { "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
+  { "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
+]
+
+
+ +
+
+ POST +
/v1/tags
Create custom tag.
+ planner + J1 +
+
+
Request body
{
+  "name": "Thai",
+  "tagType": "cuisine"            // protein|dietary|cuisine
+}
+
Response · 201 Created
{ "id": "t9-...", "name": "Thai",
+  "tagType": "cuisine" }
+
+
+
+ +
+

Ingredient categories

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/ingredient-categories
List all ingredient categories. Used in B3 recipe form and for shopping list grouping (D1).
+ auth + J1J5 +
+
+
Request
// no body — GET
+// scoped to user's household automatically
+
Response · 200 OK
[
+  { "id": "cat-01-...", "name": "pasta" },
+  { "id": "cat-02-...", "name": "meat" },
+  { "id": "cat-03-...", "name": "oil" },
+  { "id": "cat-04-...", "name": "dairy" },
+  { "id": "cat-05-...", "name": "legumes" },
+  { "id": "cat-06-...", "name": "vegetable" },
+  { "id": "cat-07-...", "name": "spice" }
+]
+// seeded on household creation
+// ordered alphabetically by name
+
+
+ +
+
+ POST +
/v1/ingredient-categories
Create custom category. Planner can extend the default list.
+ planner + J1 +
+
+
Request body
{
+  "name": "frozen"                  // required, 1–50 chars
+}
+// 409 if name already exists in household
+
Response · 201 Created
{
+  "id": "cat-08-...",
+  "name": "frozen"
+}
+
+
+
+
+ + + +
+
Planning endpoints
+ +
+

Week plans & slots

Planning
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/week-plans?weekStart={date}
Week plan + slots + recipe summaries. C1 home screen.
+ auth + J2J3 +
+
+
Query params
?weekStart=2026-04-06      // ISO date, must be Monday
+
Response · 200 OK
{
+  "id": "wp-1234-...",
+  "weekStart": "2026-04-06",
+  "status": "draft",
+  "confirmedAt": null,
+  "slots": [
+    { "id": "sl-01-...",
+      "slotDate": "2026-04-06",
+      "recipe": {
+        "id": "a1b2-...",
+        "name": "Spaghetti Bolognese",
+        "effort": "medium",
+        "cookTimeMin": 45,
+        "heroImageUrl": "/uploads/recipes/a1b2.jpg"
+      }
+    },
+    { "id": "sl-02-...",
+      "slotDate": "2026-04-07",
+      "recipe": null                // empty day
+    }
+  ]
+}
+// 404 if no plan exists for that week yet
+
+
+ +
+
+ POST +
/v1/week-plans
Create week plan (draft).
+ planner + J2 +
+
+
Request body
{
+  "weekStart": "2026-04-06"        // must be a Monday
+}
+
Response · 201 Created
{
+  "id": "wp-1234-...",
+  "weekStart": "2026-04-06",
+  "status": "draft",
+  "slots": []
+}
+// 409 if plan already exists for that week
+
+
+ +
+
+ POST +
/v1/week-plans/{id}/slots
Assign recipe to a day.
+ planner + J2 +
+
+
Request body
{
+  "slotDate": "2026-04-07",        // within plan week
+  "recipeId": "a1b2c3d4-..."
+}
+
Response · 201 Created
{
+  "id": "sl-03-...",
+  "slotDate": "2026-04-07",
+  "recipe": {
+    "id": "a1b2c3d4-...",
+    "name": "Spaghetti Bolognese",
+    "effort": "medium",
+    "cookTimeMin": 45,
+    "heroImageUrl": "..."
+  }
+}
+
+
+ +
+
+ PATCH +
/v1/week-plans/{planId}/slots/{slotId}
Swap recipe. The ≤ 3-tap mid-week swap.
+ planner + J4 +
+
+
Request body
{
+  "recipeId": "x9y8z7-..."         // new recipe
+}
+
Response · 200 OK
{
+  "id": "sl-03-...",
+  "slotDate": "2026-04-07",
+  "recipe": {
+    "id": "x9y8z7-...",
+    "name": "Quick Stir Fry",
+    "effort": "easy", "cookTimeMin": 15, ...
+  }
+}
+
+
+ +
+
+ DELETE +
/v1/week-plans/{planId}/slots/{slotId}
Clear a day slot.
+ planner + J4 +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// empty body
+
+
+ +
+
+ POST +
/v1/week-plans/{id}/confirm
Confirm plan. Validates ≥1 slot. 422 if already confirmed.
+ planner + J2 +
+
+
Request body
// empty — action endpoint
+
Response · 200 OK
{
+  "id": "wp-1234-...",
+  "status": "confirmed",
+  "confirmedAt": "2026-04-05T18:30:00Z"
+}
+// 422 if no slots filled
+// 422 if already confirmed
+
+
+
+ +
+

Suggestions & variety

Planning
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/week-plans/{id}/suggestions?slotDate={date}
3–5 suggestions. Filters: ingredients (3d), protein, effort.
+ planner + J2J4 +
+
+
Query params
?slotDate=2026-04-08       // target day
+
Response · 200 OK
{
+  "suggestions": [
+    {
+      "recipe": {
+        "id": "r1-...", "name": "Quick Stir Fry",
+        "effort": "easy", "cookTimeMin": 15,
+        "heroImageUrl": "..."
+      },
+      "fitReasons": [
+        "not_cooked_recently",
+        "effort_balance",
+        "no_protein_repeat"
+      ],
+      "warnings": []
+    },
+    {
+      "recipe": { "id": "r2-...", "name": "Fish Tacos", ... },
+      "fitReasons": ["effort_balance"],
+      "warnings": ["shares_ingredient_with_yesterday"]
+    }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/week-plans/{id}/variety-score
Computed score (0–10) + breakdown.
+ auth + J2 +
+
+
Request
// no body — GET
+
Response · 200 OK
{
+  "score": 7.5,
+  "ingredientOverlaps": [
+    { "ingredientName": "onion",
+      "days": ["2026-04-06", "2026-04-07"] }
+  ],
+  "proteinRepeats": [],
+  "effortBalance": {
+    "easy": 2, "medium": 3, "hard": 2
+  }
+}
+
+
+ +
+
+ POST +
/v1/cooking-logs
Mark meal cooked. Immutable INSERT.
+ planner + J3 +
+
+
Request body
{
+  "recipeId": "a1b2c3d4-...",
+  "cookedOn": "2026-04-07"          // default: today
+}
+
Response · 201 Created
{
+  "id": "cl-01-...",
+  "recipeId": "a1b2c3d4-...",
+  "cookedOn": "2026-04-07",
+  "cookedBy": "550e8400-..."
+}
+
+
+ +
+
+ GET +
/v1/cooking-logs?limit=30
Recent history (desc by cookedOn).
+ auth + J2 +
+
+
Query params
?limit=30                  // default 30
+?offset=0
+
Response · 200 OK
[
+  { "id": "cl-01-...", "recipeId": "a1b2-...",
+    "recipeName": "Spaghetti Bolognese",
+    "cookedOn": "2026-04-07",
+    "cookedBy": "550e8400-..." }
+]
+
+
+
+
+ + + +
+
Shopping endpoints
+ +
+

Shopping list

Shopping
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/week-plans/{id}/shopping-list
Generate from plan. Merge, sum, filter staples. Draft for preview.
+ planner + J5 +
+
+
Request body
// empty — generated from the week plan
+// server merges ingredients across meals,
+// sums quantities, filters staples
+
Response · 201 Created
{
+  "id": "shl-01-...",
+  "weekPlanId": "wp-1234-...",
+  "status": "draft",
+  "items": [
+    { "id": "si-01-...",
+      "ingredientId": "f1e2-...",
+      "name": "spaghetti",
+      "category": { "id": "cat-01-...", "name": "pasta" },
+      "quantity": 800, "unit": "g",
+      "isChecked": false,
+      "sourceRecipes": ["a1b2-...", "c3d4-..."] },
+    { "id": "si-02-...",
+      "ingredientId": "d3c4-...",
+      "name": "ground beef",
+      "category": { "id": "cat-02-...", "name": "meat" },
+      "quantity": 500, "unit": "g",
+      "isChecked": false,
+      "sourceRecipes": ["a1b2-..."] }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/shopping-lists/{id}
Full list with items. Both roles. Pull-to-refresh target.
+ auth + J5 +
+
+
Request
// no body — GET
+// this is the refresh action:
+// pull-to-refresh calls this endpoint
+
Response · 200 OK
// same shape as POST response above
+{
+  "id": "shl-01-...",
+  "status": "published",
+  "items": [ ... ]
+}
+
+
+ +
+
+ POST +
/v1/shopping-lists/{id}/publish
Publish (draft → published). Live for members.
+ planner + J5 +
+
+
Request body
// empty — action endpoint
+
Response · 200 OK
{
+  "id": "shl-01-...",
+  "status": "published",
+  "publishedAt": "2026-04-06T09:00:00Z"
+}
+// 422 if already published
+
+
+ +
+
+ PATCH +
/v1/shopping-lists/{listId}/items/{itemId}
Check/uncheck item. Both roles.
+ auth + J5 +
+
+
Request body
{
+  "isChecked": true
+}
+
Response · 200 OK
{
+  "id": "si-01-...",
+  "name": "spaghetti",
+  "isChecked": true,
+  "checkedBy": "661f-..."           // who checked it
+}
+// other members see this on next refresh
+
+
+ +
+
+ POST +
/v1/shopping-lists/{id}/items
Add custom item. Both roles.
+ auth + J5 +
+
+
Request body
{
+  "ingredientId": null,             // or existing id
+  "customName": "Paper towels",     // if no ingredientId
+  "quantity": 1,
+  "unit": ""                         // blank for countable
+}
+
Response · 201 Created
{
+  "id": "si-10-...",
+  "ingredientId": null,
+  "name": "Paper towels",
+  "quantity": 1, "unit": "",
+  "isChecked": false,
+  "sourceRecipes": []
+}
+
+
+ +
+
+ DELETE +
/v1/shopping-lists/{listId}/items/{itemId}
Remove item. Planner only, pre-publish.
+ planner + J5 +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// 422 if list is already published
+
+
+
+
+ + + +
+
Pantry endpoints
+
+

Pantry items

Pantry
+
MethodPath / DescriptionAuth
+ +
+
+ GET +
/v1/pantry-items
List items, expiring soonest first.
+ auth +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "id": "pi-01-...",
+    "ingredientId": "f1e2-...",
+    "name": "chicken breast",
+    "category": { "id": "cat-02-...", "name": "meat" },
+    "quantity": 500, "unit": "g",
+    "bestBefore": "2026-04-10",
+    "openedOn": null }
+]
+
+
+ +
+
+ POST +
/v1/pantry-items
Add item.
+ planner +
+
+
Request body
{
+  "ingredientId": "f1e2-...",      // or null
+  "customName": null,               // if no ingredientId
+  "quantity": 500,
+  "unit": "g",
+  "bestBefore": "2026-04-10",
+  "openedOn": null
+}
+
Response · 201 Created
{ "id": "pi-02-...",
+  "ingredientId": "f1e2-...",
+  "name": "chicken breast",
+  "quantity": 500, "unit": "g",
+  "bestBefore": "2026-04-10",
+  "openedOn": null }
+
+
+ +
+
+ PATCH +
/v1/pantry-items/{id}
Update quantity, bestBefore, openedOn.
+ planner +
+
+
Request body
{
+  "quantity": 250,
+  "openedOn": "2026-04-07"
+}
+
Response · 200 OK
{ "id": "pi-02-...", "quantity": 250,
+  "openedOn": "2026-04-07", ... }
+
+
+ +
+
+ DELETE +
/v1/pantry-items/{id}
Remove consumed/expired item.
+ planner +
+
+
Request
// no body
+
Response · 204 No Content
// empty
+
+
+
+
+ + + +
+
Admin endpoints
+
+

Admin user management

Admin
+
MethodPath / DescriptionAuth
+ +
+
+ GET +
/v1/admin/users
List all users. Paginated.
+ admin +
+
+
Query params
?limit=50&offset=0
+?search=jane               // by email or name
+?isActive=true
+
Response · 200 OK
{
+  "data": [
+    { "id": "550e-...", "email": "[email protected]",
+      "displayName": "Sarah", "systemRole": "user",
+      "isActive": true, "createdAt": "..." }
+  ],
+  "meta": { "pagination": { "total": 24, ... } }
+}
+
+
+ +
+
+ POST +
/v1/admin/users
Create user with temp password + audit log.
+ admin +
+
+
Request body
{
+  "email": "[email protected]",
+  "displayName": "New User",
+  "tempPassword": "Change1Me!",
+  "systemRole": "user"             // default "user"
+}
+
Response · 201 Created
{
+  "id": "new-uuid-...",
+  "email": "[email protected]",
+  "displayName": "New User",
+  "systemRole": "user",
+  "isActive": true,
+  "mustChangePassword": true
+}
+
+
+ +
+
+ PATCH +
/v1/admin/users/{id}
Update user. Audit logged.
+ admin +
+
+
Request body
{
+  "displayName": "Jane Smith",
+  "email": "[email protected]",
+  "systemRole": "admin",
+  "isActive": false                 // deactivate
+}
+
Response · 200 OK
{ "id": "...", "email": "[email protected]",
+  "displayName": "Jane Smith",
+  "systemRole": "admin", "isActive": false }
+
+
+ +
+
+ POST +
/v1/admin/users/{id}/reset-password
Reset to temp password. Audit logged.
+ admin +
+
+
Request body
{
+  "tempPassword": "Reset1Me!",
+  "reason": "user requested via support"
+}
+
Response · 200 OK
{
+  "message": "Password reset successfully",
+  "mustChangePassword": true
+}
+
+
+ +
+
+ GET +
/v1/admin/audit-log
View audit trail. Read-only.
+ admin +
+
+
Query params
?limit=50&offset=0
+?targetUserId=550e-...     // filter by user
+
Response · 200 OK
[
+  { "id": "al-01-...",
+    "adminId": "adm-...",
+    "adminEmail": "[email protected]",
+    "targetUserId": "550e-...",
+    "targetEmail": "[email protected]",
+    "action": "reset_password",
+    "detail": { "reason": "user requested" },
+    "performedAt": "2026-04-01T10:05:00Z" }
+]
+
+
+
+
+ + + +
+
Journey → API mapping
+
+
+
J1 — Add a recipe

3 requests

+
  • GET /v1/ingredients?search=...
  • GET /v1/tags
  • POST /v1/recipes
+
+
+
J2 — Plan the week

4–10 requests

+
  • GET /v1/week-plans?weekStart=...
  • GET .../suggestions?slotDate=...
  • POST .../slots
  • GET .../variety-score
  • POST .../confirm
+
+
+
J3 — Cook tonight

2 requests

+
  • GET /v1/recipes/{id}
  • POST /v1/cooking-logs
+
+
+
J4 — Adapt on the fly

2 requests (≤ 3 taps)

+
  • GET .../suggestions?slotDate=...
  • PATCH .../slots/{slotId}
+
+
+
J5 — Shopping list

3–5 requests

+
  • POST .../shopping-list
  • GET /v1/shopping-lists/{id}
  • POST .../publish
  • PATCH .../items/{id}
+
+
+
J6 — Household setup

4 requests

+
  • POST /v1/auth/signup
  • POST /v1/households
  • POST .../invites
  • POST /v1/invites/{code}/accept
+
+
+
+ + + +
+
Security architecture
+
+

Three layers

+

1. Authentication: Spring Security 7 session. HttpOnly + Secure + SameSite=Lax cookie. 24h expiry.
+ 2. Role authorization: @PreAuthorize on systemRole (admin) and householdRole (planner vs member). 403 on mismatch.
+ 3. Household isolation: HouseholdContext resolves householdId from session. Every query includes AND household_id = ?. Wrong household → 404.

+
+
+
Authorization matrix
+
Role        │ Recipes  │ Plan     │ Shopping list     │ Pantry   │ Admin
+────────────┼──────────┼──────────┼───────────────────┼──────────┼──────
+Planner     │ CRUD     │ CRUD     │ generate,publish  │ CRUD     │ —
+Member      │ —        │ READ     │ read,check,add    │ —        │ —
+Admin       │ —        │ —        │ —                 │ —        │ CRUD + audit
+Unauth      │ —        │ —        │ —                 │ —        │ —
+
+
+

Never exposed

+

password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)

+
+
+ + + +
+
Implementation phases
+
+

Phase 1 — Skeleton + Auth + CRUD (days 1–3)

+

Spring Initializr (Boot 4.0, Java 21). Flyway migrations. JPA entities. SecurityConfig with session auth + CSRF. Auth endpoints. Recipe CRUD + autocomplete + tags. Heavily AI-generatable.

+
+
+

Phase 2 — Household + Planning CRUD (days 4–5)

+

Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.

+
+
+

Phase 3 — Business logic (days 6–10)

+

SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.

+
+
+

Phase 4 — Admin + Polish (days 11–14)

+ \ No newline at end of file diff --git a/specs/backend/data-model.html b/specs/backend/data-model.html new file mode 100644 index 0000000..a6a1889 --- /dev/null +++ b/specs/backend/data-model.html @@ -0,0 +1,896 @@ + + + + + + Recipe App — Data Model v1.1 + + + + +
+ + +
+
+

Data model

+

Recipe app · PostgreSQL 16 · Normalized schema with audit trails

+
+
+ v1.1
+ Engine: PostgreSQL 16
+ Tables: 18
+ Domains: 6
+ Designed by: Atlas +
+
+ + +
+

v1.1 changes from v1.0

+
    +
  • Tag model fixed → proper M:N. Added tag reference table. recipe_tag is now a pure junction table with recipe_id FK + tag_id FK. Tags are reusable, renameable, and queryable from both directions.
  • +
  • Ingredient category normalized → 1:N. Added ingredient_category reference table. The category string on ingredient is replaced with category_id FK. Rename once, applies to all ingredients. Canonical list of aisle categories for shopping grouping.
  • +
  • Admin user management added. New system_role column on user_account (admin vs user). New admin_audit_log table tracks admin actions: account creation, updates, password resets.
  • +
  • Table count: 16 → 18. Domain count: 5 → 6 (added Admin domain).
  • +
+
+ + +
+
Overview
+
This schema covers all six user journeys (J1–J6), the suggestion/variety engine, the lightweight pantry tracker, recipe hero images, and platform-level admin user management. It is normalized by default, with computed fields (variety score) calculated at query time rather than stored. Every mutable table carries audit timestamps. Tags use a proper M:N relationship via a reference table + junction table.
+ +
+
4
Auth & household
+
7
Recipe domain
+
3
Planning domain
+
2
Shopping domain
+
1
Pantry domain
+
1
Admin domain
+
+ +
+

Design decisions

+

Variety score is computed, not stored — it's derived from cooking_log + recipe_ingredient + week_plan_slot.
+ Ingredients are a normalized reference table — enables merging, repetition tracking, and staple filtering.
+ Tags are a proper M:N: a tag reference table + recipe_tag junction. One recipe → many tags, one tag → many recipes. Rename once, applies everywhere.
+ Ingredient categories are a normalized 1:N reference table — one ingredient belongs to one category (e.g. "Produce", "Fish & Meat"). Rename a category once, applies to all ingredients. Powers the aisle-grouped shopping list (J5 variant V2).
+ Hero images store a URL/path reference to object storage (S3/R2).
+ Admin uses a system_role on user_account (not the household role). Admin actions are audit-logged in a dedicated table.
+ Pantry items link to the shared ingredient reference with best-before dates.

+
+
+ + + +
+
Entity-relationship diagram
+
Entities grouped by domain. Purple = auth, green = recipe, yellow = planning, blue = shopping, orange = pantry, red = admin. NEW marks v1.1 additions/changes.
+ +
+
+ + +
+
+
user_account CHANGED
+
+
PK iduuid
+
emailcitext UNIQUE
+
display_namevarchar(100)
+
password_hashvarchar(255)
+
system_roleenum(admin,user) NEW
+
is_activeboolean NEW
+
created_attimestamptz
+
updated_attimestamptz
+
+
+
+
household
+
+
PK iduuid
+
namevarchar(100)
+
FK created_by→ user_account
+
created_attimestamptz
+
+
+
+
household_member
+
+
PK iduuid
+
FK household_id→ household
+
FK user_id→ user_account UNIQUE
+
roleenum(planner,member)
+
joined_attimestamptz
+
+
+
+
household_invite
+
+
PK iduuid
+
FK household_id→ household
+
invite_codevarchar(20) UNIQUE
+
statusenum(pending,used,expired)
+
expires_attimestamptz
+
+
+
+ + +
+
+
recipe
+
+
PK iduuid
+
FK household_id→ household
+
namevarchar(200)
+
servessmallint
+
cook_time_minsmallint
+
effortenum(easy,medium,hard)
+
is_child_friendlyboolean
+
hero_image_urlvarchar(500) NULL
+
deleted_attimestamptz NULL
+
+
+
+
ingredient CHANGED
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
is_stapleboolean
+
FK category_id→ ingredient_category NULL NEW
+
+
+
+
ingredient_category NEW
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
sort_ordersmallint
+
+
+
+
recipe_ingredient
+
+
PK iduuid
+
FK recipe_id→ recipe
+
FK ingredient_id→ ingredient
+
quantitynumeric(8,2)
+
unitvarchar(20)
+
sort_ordersmallint
+
+
+
+
recipe_step
+
+
PK iduuid
+
FK recipe_id→ recipe
+
step_numbersmallint
+
instructiontext
+
+
+
+ + +
+
+
tag NEW
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
tag_typevarchar(20)
+
+
+
+
recipe_tag CHANGED
+
+
FK recipe_id→ recipe
+
FK tag_id→ tag NEW
+
PK (recipe_id, tag_id)composite
+
+
+
+
week_plan
+
+
PK iduuid
+
FK household_id→ household
+
week_startdate (Monday)
+
statusenum(draft,confirmed)
+
confirmed_attimestamptz NULL
+
+
+
+
week_plan_slot
+
+
PK iduuid
+
FK week_plan_id→ week_plan
+
FK recipe_id→ recipe
+
slot_datedate
+
+
+
+
cooking_log
+
+
PK iduuid
+
FK recipe_id→ recipe
+
FK household_id→ household
+
cooked_ondate
+
FK cooked_by→ user_account
+
+
+
+ + +
+
+
shopping_list
+
+
PK iduuid
+
FK household_id→ household
+
FK week_plan_id→ week_plan
+
statusenum(draft,published,done)
+
published_attimestamptz NULL
+
+
+
+
shopping_list_item
+
+
PK iduuid
+
FK shopping_list_id→ shopping_list
+
FK ingredient_id→ ingredient NULL
+
custom_namevarchar(200) NULL
+
quantity / unitnumeric / varchar
+
is_checkedboolean
+
source_recipesuuid[]
+
+
+
+
pantry_item
+
+
PK iduuid
+
FK household_id→ household
+
FK ingredient_id→ ingredient NULL
+
custom_namevarchar(200) NULL
+
quantity / unitnumeric / varchar
+
best_beforedate NULL
+
opened_ondate NULL
+
+
+
+
admin_audit_log NEW
+
+
PK iduuid
+
FK admin_id→ user_account
+
FK target_user_id→ user_account
+
actionvarchar(30)
+
detailjsonb NULL
+
performed_attimestamptz
+
+
+
+ +
+
+
+ + + +
+
Tag model — M:N via reference table
+
v1.0 stored tags as raw strings in recipe_tag. v1.1 fixes this to a proper many-to-many relationship. One recipe can have many tags. One tag can appear on many recipes. Tags are owned by a household and typed (protein, dietary, cuisine) to enable structured filtering.
+ +
+

What changed and why

+

Before (v1.0): recipe_tag(recipe_id, tag varchar(50)) — tag name stored as raw string per row. 30 recipes tagged "chicken" = 30 copies of the string "chicken". Renaming requires updating every row. No canonical list of available tags. No typed categorization.

+ After (v1.1): tag(id, household_id, name, tag_type) + recipe_tag(recipe_id, tag_id) — pure M:N junction. Rename a tag in one UPDATE. List available tags with a simple SELECT. Filter by tag_type (protein, dietary, cuisine) for the J2 suggestion engine. The junction PK is composite (recipe_id, tag_id) — no surrogate key needed.

+
+ + +
+
+

tag NEW in v1.1

+ Recipe +
+
Reference table for category tags. Scoped per household — each household can have its own tag vocabulary. tag_type classifies tags for the suggestion engine: "protein" tags trigger consecutive-day avoidance, "dietary" tags are informational.
+ + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADETags belong to a household
namecitextNOT NULL"Chicken", "Fish", "Vegetarian", "Pasta"
tag_typevarchar(20)NOT NULL, CHECK(tag_type IN ('protein','dietary','cuisine','other'))Classification. "protein" powers J2 consecutive-day filter.
created_attimestamptzNOT NULL, DEFAULT now()Creation time
+ + +
+ + +
+
+

recipe_tag CHANGED in v1.1

+ Recipe +
+
Pure M:N junction table. No surrogate key — the composite PK (recipe_id, tag_id) is the natural key. A recipe can have many tags; a tag can appear on many recipes. Both directions are indexed.
+ + + + + + +
ColumnTypeConstraintsPurpose
recipe_iduuidNOT NULL, FK → recipe ON DELETE CASCADE, part of composite PKWhich recipe
tag_iduuidNOT NULL, FK → tag ON DELETE CASCADE, part of composite PKWhich tag
+ + +
+ + +
+

J2 — Same-protein consecutive day check (updated for M:N)

+
Uses tag.tag_type = 'protein' to filter only protein tags from the M:N join
+
-- What protein tags are on adjacent planned days?
+SELECT wps.slot_date, t.name AS protein
+FROM week_plan_slot wps
+JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
+JOIN tag t ON t.id = rt.tag_id
+WHERE wps.week_plan_id = $1
+  AND t.tag_type = 'protein'
+ORDER BY wps.slot_date;
+
+
+ + + +
+
Ingredient category — 1:N reference table
+
v1.0 stored category as a raw string on ingredient. v1.1 normalizes this to a proper 1:N relationship. One ingredient belongs to one category. One category contains many ingredients. Categories are owned per household and ordered for shopping list grouping.
+ +
+

What changed and why

+

Before: ingredient.category varchar(30) — raw string. 15 ingredients labelled "Produce" = 15 copies. Rename requires updating every row. No canonical list. No display ordering for the aisle-grouped shopping list.

+ After: ingredient_category(id, household_id, name, sort_order) + ingredient.category_id FK. Rename once, applies everywhere. sort_order controls the display order on the aisle-grouped shopping list (J5 V2). Category is nullable on ingredient — uncategorized ingredients fall into an "Other" group in the UI.

+
+ +
+
+

ingredient_category NEW in v1.1

+ Recipe +
+
Reference table for ingredient aisle categories. Scoped per household — each household can customize their store layout. sort_order controls the display sequence in the aisle-grouped shopping list view (J5 screen D1 variant V2).
+ + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADECategories are per-household
namecitextNOT NULL"Produce", "Fish & Meat", "Dry Goods", "Dairy", "Sauces & Condiments"
sort_ordersmallintNOT NULL, DEFAULT 0Display order — matches supermarket aisle flow
created_attimestamptzNOT NULL, DEFAULT now()Creation time
+ + + +
+
+ + + +
+
Admin user management — new in v1.1
+
The system needs a platform-level admin who can create user accounts, update them, and reset passwords. This is separate from the household "planner" role — a planner manages meal plans, an admin manages the platform. The two role systems are orthogonal: system_role (admin vs user) lives on user_account; household_role (planner vs member) lives on household_member.
+ +
+

Two role systems — don't confuse them

+

system_role on user_account: platform-level. "admin" can manage all user accounts. "user" is a normal user. This is about platform administration.

+ household role on household_member: app-level. "planner" has full access to 18 screens. "member" sees C1 read-only + D1 collaborative. This is about what you can do within a household.

+ An admin can also be a planner in their own household. The roles are independent.

+
+ + +
+
+

user_account CHANGED in v1.1

+ Auth +
+
User identity for both app users and platform admins. system_role determines platform-level access. is_active allows admins to deactivate accounts without deleting them. Authentication handled here; household authorization in household_member.
+ + + + + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
emailcitextNOT NULL, UNIQUELogin identifier, case-insensitive
display_namevarchar(100)NOT NULLShown in UI (sidebar avatar initials)
password_hashvarchar(255)NOT NULLbcrypt/argon2 hash — never exposed via API
system_rolevarchar(10)NOT NULL, DEFAULT 'user', CHECK(system_role IN ('admin','user'))NEW — platform role. Admin can manage all accounts.
is_activebooleanNOT NULL, DEFAULT trueNEW — admin can deactivate accounts. Inactive users cannot log in.
created_attimestamptzNOT NULL, DEFAULT now()Account creation time
updated_attimestamptzNOT NULL, DEFAULT now()Last profile edit
+ + +
+ + +
+
+

admin_audit_log NEW in v1.1

+ Admin +
+
Immutable audit trail for all admin actions on user accounts. Every account creation, update, or password reset by an admin is logged here. Never updated or deleted. Used for compliance, debugging, and accountability.
+ + + + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
admin_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich admin performed the action
target_user_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich user was affected
actionvarchar(30)NOT NULL, CHECK(action IN ('create_account','update_account','reset_password','deactivate_account','reactivate_account','change_system_role'))What happened
detailjsonbNULLChanged fields snapshot: {"field":"email","old":"a@x.com","new":"b@x.com"}
ip_addressinetNULLAdmin's IP for security audit
performed_attimestamptzNOT NULL, DEFAULT now()When the action occurred
+ + + + +
+
+ + + +
+
Foreign key map (updated v1.1)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From tableColumnReferencesCardinalityOn delete
householdcreated_byuser_account.idN:1RESTRICT
household_memberhousehold_idhousehold.idN:1CASCADE
household_memberuser_iduser_account.idN:1CASCADE
household_invitehousehold_idhousehold.idN:1CASCADE
recipehousehold_idhousehold.idN:1CASCADE
ingredienthousehold_idhousehold.idN:1CASCADE
ingredient_categoryhousehold_idhousehold.idN:1CASCADE
ingredientcategory_idingredient_category.idN:1 (nullable)SET NULL
taghousehold_idhousehold.idN:1CASCADE
recipe_ingredientrecipe_idrecipe.idN:1CASCADE
recipe_ingredientingredient_idingredient.idN:1RESTRICT
recipe_steprecipe_idrecipe.idN:1CASCADE
recipe_tagrecipe_idrecipe.idM:N junctionCASCADE
recipe_tagtag_idtag.idM:N junctionCASCADE
week_planhousehold_idhousehold.idN:1CASCADE
week_plan_slotweek_plan_idweek_plan.idN:1CASCADE
week_plan_slotrecipe_idrecipe.idN:1RESTRICT
cooking_logrecipe_idrecipe.idN:1RESTRICT
cooking_logweek_plan_slot_idweek_plan_slot.idN:1 (nullable)SET NULL
shopping_listweek_plan_idweek_plan.idN:1RESTRICT
shopping_list_itemshopping_list_idshopping_list.idN:1CASCADE
shopping_list_itemingredient_idingredient.idN:1 (nullable)SET NULL
pantry_itemhousehold_idhousehold.idN:1CASCADE
pantry_itemingredient_idingredient.idN:1 (nullable)SET NULL
admin_audit_logadmin_iduser_account.idN:1RESTRICT
admin_audit_logtarget_user_iduser_account.idN:1RESTRICT
+
+
= new or changed in v1.1
+
+ + + +
+
Key query patterns
+ +
+

J2 — Ingredient repetition check (last 3 days)

+
Frequency: ~10×/week · Target: <50ms
+
WITH recent_meals AS (
+  SELECT recipe_id, cooked_on
+  FROM cooking_log
+  WHERE household_id = $1
+    AND cooked_on >= CURRENT_DATE - INTERVAL '3 days'
+)
+SELECT DISTINCT i.id, i.name
+FROM recent_meals rm
+JOIN recipe_ingredient ri ON ri.recipe_id = rm.recipe_id
+JOIN ingredient i ON i.id = ri.ingredient_id;
+
+ +
+

J2 — Protein tags on adjacent days (M:N join)

+
Frequency: ~10×/week · Target: <30ms
+
SELECT wps.slot_date, t.name AS protein
+FROM week_plan_slot wps
+JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
+JOIN tag t ON t.id = rt.tag_id
+WHERE wps.week_plan_id = $1
+  AND t.tag_type = 'protein'
+ORDER BY wps.slot_date;
+
+ +
+

J5 — Shopping list generation (merged + staples filtered)

+
Frequency: 1×/week · Target: <200ms
+
SELECT i.id, i.name,
+       SUM(ri.quantity) AS total_qty, ri.unit,
+       ARRAY_AGG(DISTINCT r.id) AS source_recipe_ids
+FROM week_plan_slot wps
+JOIN recipe r ON r.id = wps.recipe_id
+JOIN recipe_ingredient ri ON ri.recipe_id = r.id
+JOIN ingredient i ON i.id = ri.ingredient_id
+WHERE wps.week_plan_id = $1
+  AND i.is_staple = false
+GROUP BY i.id, i.name, ri.unit
+ORDER BY i.name;
+
+ +
+

Pantry — Items expiring within 3 days

+
Frequency: daily · Target: <20ms
+
SELECT pi.id, COALESCE(i.name, pi.custom_name) AS name,
+       pi.best_before, pi.quantity, pi.unit
+FROM pantry_item pi
+LEFT JOIN ingredient i ON i.id = pi.ingredient_id
+WHERE pi.household_id = $1
+  AND pi.best_before IS NOT NULL
+  AND pi.best_before <= CURRENT_DATE + INTERVAL '3 days'
+ORDER BY pi.best_before;
+
+ +
+

Admin — All actions on a user (audit trail)

+
Frequency: on-demand · Target: <50ms
+
SELECT aal.action, aal.detail, aal.performed_at,
+       admin.display_name AS admin_name, admin.email AS admin_email
+FROM admin_audit_log aal
+JOIN user_account admin ON admin.id = aal.admin_id
+WHERE aal.target_user_id = $1
+ORDER BY aal.performed_at DESC;
+
+ +
+

All tags for a recipe (M:N forward lookup)

+
Frequency: every recipe detail load · Target: <10ms
+
SELECT t.id, t.name, t.tag_type
+FROM recipe_tag rt
+JOIN tag t ON t.id = rt.tag_id
+WHERE rt.recipe_id = $1
+ORDER BY t.tag_type, t.name;
+
+ +
+

All recipes with a specific tag (M:N reverse lookup)

+
Frequency: J2 suggestion filter, B1 filter chips · Target: <30ms
+
SELECT r.id, r.name, r.effort, r.cook_time_min
+FROM recipe_tag rt
+JOIN recipe r ON r.id = rt.recipe_id
+WHERE rt.tag_id = $1
+  AND r.deleted_at IS NULL
+ORDER BY r.name;
+
+
+ + + +
+
Migration order (v1.1)
+
+

Migration 001 — Extensions & triggers

+
Run once before any table creation
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";   -- gen_random_uuid()
+CREATE EXTENSION IF NOT EXISTS "citext";     -- case-insensitive text
+
+CREATE OR REPLACE FUNCTION trigger_set_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+ +
+

Table creation order (respects FK dependencies)

+

1. user_account → 2. household → 3. household_member → 4. household_invite → 5. ingredient_category → 6. ingredient → 7. tag → 8. recipe → 9. recipe_ingredient → 10. recipe_step → 11. recipe_tag → 12. week_plan → 13. week_plan_slot → 14. cooking_log → 15. shopping_list → 16. shopping_list_item → 17. pantry_item → 18. admin_audit_log

+
+ +
+

Immutability rules for audit tables

+
Apply after admin_audit_log creation
+
-- Prevent accidental updates/deletes on audit log
+CREATE RULE no_update_audit AS ON UPDATE TO admin_audit_log
+  DO INSTEAD NOTHING;
+CREATE RULE no_delete_audit AS ON DELETE TO admin_audit_log
+  DO INSTEAD NOTHING;
+
+
+ + + +
+
Journey → table coverage matrix (v1.1)
+
+ + + + + + + + + + + + +
JourneyReadsWritesCritical path
J1 · Add recipeingredient, tag (autocomplete)recipe, recipe_ingredient, recipe_step, recipe_tag, ingredient, tagRecipe INSERT + child rows + tag associations in one transaction
J2 · Plan weekrecipe, recipe_ingredient, recipe_tag, tag, cooking_log, ingredientweek_plan, week_plan_slotVariety CTE joins tag (type=protein) for consecutive-day check
J3 · Cook tonightweek_plan_slot, recipe, recipe_ingredient, recipe_stepcooking_logcooking_log INSERT (immutable event)
J4 · Adapt on flyrecipe, recipe_tag, tag, cooking_logweek_plan_slot (UPDATE recipe_id)Slot UPDATE + variety recompute ≤ 3 taps
J5 · Shopping listweek_plan_slot, recipe_ingredient, ingredient, ingredient_categoryshopping_list, shopping_list_itemMerge query (GROUP BY ingredient, SUM quantity) + aisle grouping via category
J6 · Household setupuser_account, household, household_member, household_invite, ingredient (staples), tag (seed data), ingredient_category (seed data)Household creation + seed data in one transaction
Pantrypantry_item, ingredientpantry_itemExpiry notification query (daily)
Adminuser_account, admin_audit_loguser_account, admin_audit_logEvery admin action → audit log INSERT in same transaction
+
+
+ + + +
+
Pushback & trade-off log
+ +
+

v1.0 bug: recipe_tag was 1:N, not M:N

+

Fixed in v1.1. v1.0 stored tags as raw strings, making recipe_tag structurally a 1:N (one recipe → many string rows) rather than a true M:N. The tag string had no identity — "chicken" on recipe A and "chicken" on recipe B were unrelated rows. This prevented tag renaming, tag listing, and structured filtering. v1.1 adds a tag reference table, making recipe_tag a proper M:N junction with FK integrity in both directions.

+
+ +
+

v1.0 bug: ingredient.category was a raw string, not a FK

+

Fixed in v1.1. Same anti-pattern as the tag issue. 15 ingredients all storing the string "Produce" independently — no shared identity, no rename capability, no canonical list, no sort order. v1.1 extracts this to ingredient_category as a reference table with a 1:N FK from ingredient. The sort_order column enables the aisle-grouped shopping list (J5 D1 V2) to match supermarket layout. Category is nullable — uncategorized ingredients group into "Other."

+
+ +
+

source_recipes as uuid[] — trade-off accepted

+

Violates 1NF. But it's write-once display metadata, never joined against. A junction table would add ~60 rows/week for zero query benefit. Sunset plan: migrate to junction table if future requirements need "which list items came from recipe X?"

+
+ +
+

Variety score is computed, not materialized

+

At ~50 recipes × 7 slots, the CTE runs in <100ms. Materialized view adds staleness risk. At 100× scale, we add materialized view with refresh-on-mutation triggers.

+
+ +
+

admin_audit_log.detail uses JSONB — justified

+

This is the right use case for schemaless data. Each action type has a different shape (password reset has a "reason", email change has "old"+"new", creation has full profile). The data is write-once, append-only, and queried for display only. Normalizing it into typed columns would require a different schema per action type for no benefit.

+
+ +
+

Rejected: separate admin_user table

+

Having a separate table for admins would split identity across two tables. Login would need to check both. An admin who is also a household planner would need two accounts. Instead, system_role on user_account keeps one identity per person. The role check is a single column read.

+
+
+ +
+ + diff --git a/specs/e1-settings.html b/specs/e1-settings.html new file mode 100644 index 0000000..b866e6d --- /dev/null +++ b/specs/e1-settings.html @@ -0,0 +1,764 @@ + + + + + + E1 / D3 — Einstellungen & Vorräte · 5 Variationen + + + + + +
+ +
+
+

E1 / D3 — Einstellungen & Vorräte

+

5 Design-Variationen · Desktop-first · Routes /settings + /household/staples · Journey J8

+
+
+ Version: 1.0
+ Screens: E1, D3
+ Journey: J8
+ Actor: Planner
+ Last updated: 2026-04 +
+
+ +

Two pages, one journey. E1 is the settings hub at /settings — currently a placeholder. D3 is the StaplesManager component at /household/staples, rendered with context="settings". The component uses StapleChip — ingredient pills in flex-wrap grids per category, not a checklist. Five variations explore how the hub and staples editing relate: from navigating to a sub-page, to showing staples inline on the settings page itself. The recommended variation for v1 is V3 (Accordion) — one page, no navigation, staples always one tap away.

+ + + + + +
+
+
V1
+
+
Verknüpfte Abschnitte
+
Settings als Hub-Seite mit klickbaren Abschnitt-Zeilen. Jeder Bereich (Profil, Vorräte, Haushalt) ist eine Zeile mit Titel, Beschreibung, Stat und Pfeil. Tippen navigiert zur jeweiligen Unterseite. Klassisches iOS-Settings-Muster.
+ Maximal erweiterbar für künftige Settings +
+
+ + + + +
+
Desktop · D3 Vorräte-Unterseite (nach Navigation)
+
+
+
+ +
+ +
+

Vorräte

14 von 32 Zutaten als Vorrat markiert
+
+ +
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel + Paprika +
+
+
+
Getreide & Hülsenfrüchte
+
+ Pasta + Reis + Couscous + Linsen + Kichererbsen +
+
+
+
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Vorräte-Zeile hat einen grünen linken Rand (border-left: 3px solid --green-dark) — signalisiert "primäre Aktion" ohne ihn visuell zu überfrachten.
  • +
  • Die aktive Zutat-Zahl ("14") wird als Display-Schrift-Zahl dargestellt — schafft eine Verbindung zum Wert-Versprechen der App (Varietät-Scores sind ebenfalls Zahlen).
  • +
  • D3 Unterseite im settings-Kontext: kein Onboarding-Sidebar, kein "Weiter"-Button — nur die Kategorie-Chip-Liste. "← Einstellungen" Breadcrumb für die Rücknavigation.
  • +
  • StapleChip: Ausgewählt = --green-dark Hintergrund + weißer Text. Nicht ausgewählt = transparenter Hintergrund + --color-border Rahmen + --color-text-muted Text. Kein separater Speicher-Button — Auto-Save auf Toggle.
  • +
+
+
+ + + +
+
+
V2
+
+
Einstellungs-Kacheln
+
Jeder Einstellungsbereich als Kachel mit Titel, Beschreibung, Schlüsselstatistik und direktem Aktions-Button. Die Vorräte-Kachel zeigt "14 von 32 aktiv" und einen "Bearbeiten"-Button der direkt in den D3-Kontext führt.
+ Gute Übersicht auf großen Bildschirmen +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+

Einstellungen

+
+ +
+
+
Vorräte
+
14
von 32 aktiv
+
+
Zutaten, die immer im Haushalt vorhanden sind. Sie werden beim Generieren der Einkaufsliste automatisch herausgefiltert.
+ +
+ +
+
+
Haushalt
+
3
Mitglieder
+
+
Familie Raddatz. Haushaltsname und Mitgliederverwaltung.
+ +
+ +
+
Profil
+
Marcel Raddatz
marcel@email.com
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Einstellungen
+
+
+
Vorräte
14
von 32
+
Immer vorhandene Zutaten, automatisch aus Einkaufslisten gefiltert.
+ +
+
+
Haushalt
3 Mitglieder
+
Familie Raddatz
+ +
+
+
Profil
+
Marcel Raddatz · marcel@email.com
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Kacheln mit Display-Schrift-Zahlen (14, 3) als Schlüsselmetriken — konsistentes Designmuster mit der Varietätspunktzahl im Planer.
  • +
  • Der "Vorräte bearbeiten"-Button auf der Kachel führt direkt zur D3-Unterseite. Keine Zwischennavigation.
  • +
  • Nachteil gegenüber V3: erfordert einen Navigationswechsel für die häufigste Aufgabe (Vorräte bearbeiten). V3 macht das inline möglich.
  • +
+
+
+ + + +
+
+
V3
+
+
Akkordeon mit inline Vorräten
+
Die Settings-Seite zeigt alle Bereiche als aufklappbare Abschnitte. Der Vorräte-Bereich ist beim Öffnen der Seite standardmäßig aufgeklappt und zeigt direkt die StapleChip-Kategorien. Kein Seitenwechsel nötig.
+ Empfohlen · v1 +
+
+ +
+
+
Desktop · 1200px — Vorräte aufgeklappt (Standard)
+
+
+
+ +
+

Einstellungen

+
+ +
+ +
+
Tippe eine Zutat an um sie als Vorrat zu markieren oder zu entfernen. Änderungen werden sofort gespeichert.
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel +
+
+
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Einstellungen
+
+ +
+ +
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch +
+
Getreide
+
+ Pasta + Reis + Couscous +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Vorräte-Abschnitt ist beim Öffnen der Seite standardmäßig aufgeklappt (per URL-Hash oder initial state) — der häufigste Grund für diesen Seitenaufruf ist das Bearbeiten von Vorräten.
  • +
  • Kein Seitenwechsel zu /household/staples nötig — die StaplesManager-Komponente rendert direkt im Akkordeon-Bereich. Routing-Vorteil: die /settings URL bleibt beim Bearbeiten erhalten.
  • +
  • Akkordeon-Trigger zeigt den aktuellen Wert im eingeklappten Zustand (z. B. "14 aktiv", "Marcel Raddatz") — der Nutzer kann den Status scannen ohne aufzuklappen.
  • +
  • Auf Mobile perfekt: kein separater "Zurück"-Button nötig. Ein langer Scroll kann mit einem "Nach oben"-Link oder sticky Akkordeon-Headern optimiert werden.
  • +
  • Implementierungshinweis: Der aufgeklappte Bereich enthält direkt die StaplesManager-Komponente (<StaplesManager categories={data.categories} context="settings" />). Keine Seiten-Navigation erforderlich.
  • +
+
+
+ + + +
+
+
V4
+
+
Einstellungs-Sub-Navigation
+
Auf Desktop: eine zweite Navigationsleiste links neben dem Inhalt (Profil · Vorräte · Haushalt). Der Vorräte-Abschnitt ist der Standard-Inhalt. Auf Mobile: flache Liste als Einstieg, dann Drill-down.
+ Skaliert gut bei vielen Settings-Bereichen +
+
+ +
+
+
Desktop · 1200px — Vorräte als Standardansicht
+
+
+
+ + + +
+ +
+
Einstellungen
+ Vorräte + Profil + Haushalt +
+ +
+
+

Vorräte

+ 14 von 32 aktiv +
+
Tippe eine Zutat an um den Vorrats-Status zu ändern. Automatisch gespeichert.
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel + Paprika +
+
+
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+
+
+
+
+
+
+
+
Mobile · 390px (Drill-down)
+
+
+
+
+
+
Vorräte
+
14 aktiv
+
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch +
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Dreifache Navigationsstruktur auf Desktop (App-Sidebar → Settings-Sub-Nav → Inhalt) ist für 3 Einstellungsbereiche Overkill. Skaliert erst ab 6+ Bereichen.
  • +
  • Vorrat als Default-Selektion in der Sub-Nav sinnvoll — direktester Einstieg für J8. Verhindert eine "leere Hub"-Seite.
  • +
  • Mobile-Drill-down: Sub-Nav-Liste → Unterseite. Entspricht dem Standard-Mobile-Pattern (z. B. iOS Systemeinstellungen). Klare Navigation, aber erfordert eine Zurück-Navigation.
  • +
  • Nicht empfohlen für v1: zu viel Struktur für zu wenig Inhalt. V3 (Akkordeon) ist einfacher und erreicht dasselbe Ergebnis ohne Sub-Navigation.
  • +
+
+
+ + + +
+
+
V5
+
+
Schnellzugriff-Dashboard
+
Settings als kleines Dashboard: Haushalt-Zusammenfassung oben (Name + Mitgliederzahl), darunter ein prominenter "Vorräte bearbeiten" Einstieg mit aktuellem Zählstand und zuletzt bearbeiteten Chips als Vorschau. Alles auf einer Seite — kein Drill-down.
+ Informationsdicht · klarer Schwerpunkt +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+ +
+
+
MR
+
Marcel Raddatz
marcel@email.com · Planner
+
+ +
+ +
+ +
+
+
Vorräte
+
14
+
von 32 aktiv
+
+
Immer vorhandene Zutaten werden automatisch aus Einkaufslisten gefiltert.
+ +
+ Karotten + Pasta + Zwiebeln + Reis + +10 weitere +
+ +
+ +
+
Haushalt
+
3
+
Mitglieder · Familie Raddatz
+
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+ +
+
MR
+
Marcel Raddatz
Planner · Familie Raddatz
+
+
+ +
+
Vorräte
14
von 32
+
+ Karotten + Pasta + Zwiebeln + +11 +
+ +
+ +
+
Haushalt
Familie Raddatz · 3 Mitglieder
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Chip-Vorschau auf der Hub-Seite (4 aktive Vorräte + "+10 weitere") gibt dem Nutzer sofort Kontext über den aktuellen Zustand — kein Klick nötig um zu verstehen, was konfiguriert ist.
  • +
  • Profil-Leiste oben als Identitätsanker: Name, E-Mail, Rolle. Schafft eine klare "Wer bin ich in diesem Haushalt"-Aussage ohne eigene Profil-Seite besuchen zu müssen.
  • +
  • 2:1 Grid-Layout auf Desktop betont Vorräte als primäre Einstellung — genau das richtige Gewicht für J8.
  • +
  • Nachteil: Der "Alle Vorräte bearbeiten"-Button navigiert trotzdem zur D3-Unterseite (oder öffnet ein Modal). Keine wirklich inline-Bearbeitung wie bei V3. Funktioniert aber gut als Schnellzugriff-Übersicht.
  • +
+
+
+ + +
+ + + + +
+

Machine-readable spec — E1 Einstellungen / D3 Vorräte

+

Authoritative implementation reference for /settings and /household/staples?ctx=settings. Use before building either page.

+ +
/* E1 Settings + D3 Staples — implementation rules
+ * 1.  Recommended variation: V3 (Accordion). Vorräte section is open by default. No navigation to sub-page required.
+ * 2.  D3 = A3: StaplesManager is the same component. context="settings" removes the onboarding sidebar and "Weiter" button.
+ * 3.  StapleChip renders as a pill button, NOT a checkbox. Selected = --green-dark bg + white text. Unselected = transparent bg + --color-border border + --color-text-muted text.
+ * 4.  Auto-save on toggle. No explicit save button. The component already implements debounced PATCH to /household/staples.
+ * 5.  Category label: font-size 10px · font-weight 500 · letter-spacing 0.08em · text-transform uppercase · color --color-text-muted.
+ * 6.  Staple count display ("14 von 32 aktiv"): derive from categories prop — count isStaple=true vs total ingredients.
+ * 7.  Sidebar active item: "Einstellungen" (not "Vorräte" — there is no separate Vorräte sidebar item). Active style: --green-tint bg + --green-dark text.
+ * 8.  Mobile bottom nav active tab: "Einstellungen". Same for both /settings and /household/staples routes.
+ * 9.  Accordion trigger shows current stat in collapsed state: "14 aktiv" for Vorräte. Stat updates reactively as user toggles chips.
+ * 10. Changes to staples (J8) do NOT retroactively update an already-generated shopping list. If the current list should reflect changes, the planner must regenerate it via J5. Consider a note in the UI: "Gilt ab der nächsten Einkaufsliste."
+ * 11. Profile section: show name + email. Edit action navigates to /profile or opens an inline form. Not in scope for J8 — implement minimally.
+ */
+ + + + + + + + + + + + + + + + + + + + + + + + +
ElementValue / RuleNotes
StapleChip
Shapeborder-radius: --radius-full · padding: 6px 14pxfont-size: 13px (desktop) · 12px (mobile)
Selected statebackground: --green-dark · color: #fff · font-weight: 500Toggle off: PATCH ingredient isStaple=false
Unselected statebackground: transparent · border: 1px solid --color-border · color: --color-text-muted · font-weight: 400Toggle on: PATCH ingredient isStaple=true
Debounce300ms after last toggle before PATCH firesAlready implemented in StaplesManager. Do not add extra debounce layers.
Error stateRevert chip to previous state · show inline error messageStaplesManager already handles rollback on API error
Category section
Label10px · weight 500 · tracking 0.08em · uppercase · --color-text-mutedGerman category names from API
Chip griddisplay: flex · flex-wrap: wrap · gap: 7px (desktop) · 6px (mobile)No fixed column count — chips wrap naturally
Settings page (E1) — V3 Accordion
Vorräte sectionOpen by default on page loadUse Svelte derived state or URL hash to control. Default open state.
Collapsed statShow "N aktiv" reactively next to chevronDerive from stapleState in StaplesManager — count true values
Accordion trigger min-height48px (desktop) · 44px (mobile)WCAG: interactive controls must have 44px min touch target
Accordion chevron▲ (open) / ▼ (closed) · color: --color-text-mutedOr use CSS transform on a single chevron SVG
Responsive
Desktop (≥1024px)224px app sidebar + content area (max-width ~680px centered)Active sidebar: "Einstellungen" (Haushalt section)
Mobile (<768px)No sidebar · bottom nav "Einstellungen" active · accordion stacks full-widthChips wrap to multiple lines — no truncation
+
+ + + + diff --git a/specs/e2-members.html b/specs/e2-members.html new file mode 100644 index 0000000..ba3ef85 --- /dev/null +++ b/specs/e2-members.html @@ -0,0 +1,761 @@ + + + + + + E2 — Mitglieder · 5 Variationen + + + + + +
+ +
+
+

E2 — Mitglieder

+

5 Design-Variationen · Desktop-first · Route /members · Journey J7

+
+
+ Version: 1.0
+ Screen: E2
+ Journey: J7
+ Actor: Planner
+ Last updated: 2026-04 +
+
+ +

The members page is a rarely-visited, high-trust page. The planner opens it when the household changes — a new partner joins, a family member needs access removed. The household is typically 2–4 people. Five variations explore the range from a simple list to a panel-based layout. The recommended variation for v1 is V1 (Roster list) — fewest moving parts, matches the access frequency, household size, and task urgency.

+ + + + + +
+
+
V1
+
+
Roster-Liste
+
Lineares Listenformat. Alle Mitglieder als Zeilen mit Avatar, Name, Rolle und Beitrittsdatum. Ausstehende Einladungen darunter. Minimale kognitive Last für eine seltene Aufgabe.
+ Empfohlen · v1 +
+
+ +
+ +
+
Desktop · 1200px
+
+
+
+ + + +
+
+
+

Mitglieder

+ 3 +
+ +
+ +
+
+
MR
+
+
Marcel Raddatz
+
Beigetreten 14. Januar 2026
+
+ Planner +
+
+
+
SR
+
+
Sarah Raddatz
+
Beigetreten 15. Januar 2026
+
+ Mitglied +
+
+
+
TM
+
+
Tom Meier
+
Beigetreten 3. März 2026
+
+ Mitglied +
+
+
+ +
Ausstehende Einladungen · 1
+
+
inv_x8K2j
+
Läuft ab in 2 Tagen
+ + +
+
+
+
+
+
+ +
+
Mobile · 390px
+
+
+
+
+
Mitglieder
+ +
+
+
+
MR
+
Marcel Raddatz
Planner · Seit 14.1.26
+
+
+
+
SR
+
Sarah Raddatz
Mitglied · Seit 15.1.26
+
+
+
+
TM
+
Tom Meier
Mitglied · Seit 3.3.26
+
+
+
Einladungen · 1
+
+
inv_x8K2j2 Tage
+
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Planner-Avatar in Grün (--green-tint), Mitglieder-Avatar in Blau (--blue-tint) — Rollenfarben konsistent mit den Rollenbadges in der Spec.
  • +
  • Kebab-Menü (⋯) öffnet ein Kontextmenü mit "Zugang entziehen". Destructive action erfordert Bestätigung mit Mitgliedsnamen.
  • +
  • Ausstehende Einladungen haben einen gelben Ablauf-Badge wenn ≤ 3 Tage verbleiben, grau wenn mehr Zeit bleibt.
  • +
  • Auf Mobile: "Einladen"-Button als Kompakt-CTA im Seitenkopf (kein großes Hero-Element) — die Seite ist keine Onboarding-Seite, sondern eine Management-Seite.
  • +
+
+
+ + + +
+
+
V2
+
+
Kachelraster
+
Jedes Mitglied als eigenständige Kachel mit großem Avatar-Kreis, Name, Rolle und Datum. Eine "+" Kachel mit gestricheltem Rand fungiert als Invite-CTA. Visuelle, scannable Übersicht.
+ Geeignet ab 4+ Mitgliedern +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+

Mitglieder

+
+ +
+
MR
+
Marcel Raddatz
14. Jan 2026
+ Planner +
+
+
SR
+
Sarah Raddatz
15. Jan 2026
+ Mitglied +
+
+
TM
+
Tom Meier
3. Mär 2026
+ Mitglied +
+ +
+
+
+
Einladen
+
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel R.
+ Planner +
+
+
SR
+
Sarah R.
+ Mitglied +
+
+
TM
+
Tom M.
+ Mitglied +
+
+
+
+
Einladen
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Kachelgröße funktioniert nur bis ca. 6 Mitgliedern — ab 7+ muss auf Liste zurückgefallen werden. Für die typische Haushaltsgröße (2–4) unnötig visuell.
  • +
  • Die "+" Einladen-Kachel mit gestricheltem Rahmen ist ein etabliertes Muster für "leerer Slot zum Hinzufügen" — sofort verständlich ohne Label-Erklärung.
  • +
  • Schlechter als V1 für Remove-Aktionen: Die Entfernen-Aktion ist auf der Kachel nicht sichtbar — man muss sie irgendwo verstecken (Hover-State, Kontextmenü). Auf Mobile nicht erreichbar ohne Tap-Geste.
  • +
+
+
+ + + +
+
+
V3
+
+
Split-Panel
+
Geteilte Ansicht: links die Mitgliederliste, rechts das Einladungs-Management-Panel. Auf Desktop zeigt das rechte Panel immer den aktuellen Invite-Status. Auf Mobile: Tab-Navigation zwischen den beiden Bereichen.
+ Für Haushalte mit häufigen Mitgliederwechseln +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ + +
+ +
+
+ Mitglieder + 3 +
+
+
MR
+
Marcel Raddatz
Planner
+
+
+
SR
+
Sarah Raddatz
Mitglied
+ +
+
+
TM
+
Tom Meier
Mitglied
+ +
+
+ +
+
Mitglied einladen
+
Teile diesen Link per WhatsApp, SMS oder E-Mail. Der Empfänger erstellt ein Konto und tritt automatisch dem Haushalt bei.
+
+ https://mealprep.app/join/inv_x8K2j + +
+ +
Ausstehende Einladungen · 1
+
+ inv_x8K2j + 2 Tage + +
+
+
+
+
+
+
+
+
Mobile · 390px (Tab-Navigation)
+
+
+
+
+
Mitglieder
+
+ + +
+
+
+
+
MR
+
Marcel Raddatz
Planner
+
+
+
SR
+
Sarah Raddatz
Mitglied
+ +
+
+
TM
+
Tom Meier
Mitglied
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Das rechte Panel zeigt immer den Invite-Status — kein Modal, kein Navigationsschritt. Gut für Haushalte, die regelmäßig neue Mitglieder onboarden (z. B. Wohngemeinschaften).
  • +
  • "Entfernen" ist im linken Panel direkt sichtbar als Text-Link in Fehlerfarbe — kein verstecktes Kebab-Menü. Spart einen Schritt, erhöht aber das Risiko eines versehentlichen Taps auf Mobile.
  • +
  • Mobile Tab-Navigation: Tabs mit Unterstrich-Indikator. Die "Mitglieder"-Seite ist der Default. "Einladen"-Tab zeigt das Invite-Panel mit Link-Generator und ausstehendem Invite.
  • +
  • Nachteil: Mehr UI-Fläche für eine seltene Funktion (Invite). Für Haushalte mit 2 Personen wirkt das Panel überdimensioniert.
  • +
+
+
+ + + +
+
+
V4
+
+
Datentabelle
+
Tabellarisches Format mit sortierbaren Spalten: Name, Rolle, Beigetreten, Status, Aktionen. Inline-Aktionen statt Kontextmenü. Kompakt und informationsdicht.
+ Für Power-User · Viele Mitglieder +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+
+

Mitglieder

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name ↕RolleBeigetreten ↕StatusAktionen
MR
Marcel Raddatz
Planner14. Jan 2026Aktiv
SR
Sarah Raddatz
Mitglied15. Jan 2026Aktiv
TM
Tom Meier
Mitglied3. Mär 2026Aktiv
?
inv_x8K2j
Ausstehend · 2 Tage
+
+
+
+
+
+
+
Mobile · 390px (Karten-Fallback)
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel Raddatz
Planner · 14. Jan 2026
+
+
+
SR
+
Sarah Raddatz
Mitglied · 15. Jan 2026
+ +
+
+
TM
+
Tom Meier
Mitglied · 3. Mär 2026
+ +
+
+
inv_x8K2jAusstehend · 2 Tage
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Ausstehende Einladungen als Tabellenzeilen mit grauem Hintergrund und "?" Avatar — visuell klar von aktiven Mitgliedern getrennt, ohne eigenen Abschnitt zu benötigen.
  • +
  • Sortierbare Spalten (↕) nur auf Desktop sinnvoll — bei 2–4 Mitgliedern keine echte Notwendigkeit. Für Wohngemeinschaften mit 6+ Personen relevant.
  • +
  • Mobile Fallback: Karten statt Tabelle. Tabellen-Layouts kollabieren auf kleinen Bildschirmen schlecht — Karten sind das korrekte Muster für den selben Inhalt auf Mobile.
  • +
+
+
+ + + +
+
+
V5
+
+
Erweiterbare Zeilen
+
Liste mit progressiver Offenlegung. Jede Mitgliederzeile lässt sich aufklappen, um Detailinfos und Aktionen zu zeigen. Kompakte Standardansicht, Details bei Bedarf — kein Seitennavigation nötig.
+ Gute Balance zwischen V1 und V4 +
+
+ +
+
+
Desktop · 1200px (Sarah-Zeile aufgeklappt)
+
+
+
+ +
+
+

Mitglieder

3
+ +
+ +
+
MR
+
Marcel Raddatz
+ Planner +
Details ▼
+
+ +
+
+
SR
+
Sarah Raddatz
+ Mitglied +
Details ▲
+
+
+
Beigetreten 15. Januar 2026 · Zugang zu Planer (Lesen) und Einkauf
+ +
+
+ +
+
TM
+
Tom Meier
+ Mitglied +
Details ▼
+
+ +
Einladungen · 1
+
inv_x8K2j2 Tage
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel Raddatz
Planner
+
+
+ +
+
+
SR
+
Sarah Raddatz
Mitglied
+
+
+
Seit 15. Januar 2026 · Lesen + Einkauf
+
+
+
TM
+
Tom Meier
Mitglied
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Der "Zugang entziehen"-Button ist erst nach dem Aufklappen sichtbar — eine natürliche Bestätigungsbarriere ohne expliziten Bestätigungsdialog für das erste Tap.
  • +
  • Aufgeklappte Zeile erhält leichten Surface-Hintergrund (--color-surface) zur visuellen Abgrenzung vom Rest der Liste.
  • +
  • Besser als V1 wenn die Entfernen-Aktion prominent sein soll, aber nicht permanent sichtbar. Schlechter als V1 wenn maximale Einfachheit das Ziel ist.
  • +
  • Auf Mobile: "▼" Chevron als Tap-Target — muss mindestens 44×44px sein. Den gesamten Zeilenbereich tapbar machen ist vorzuziehen.
  • +
+
+
+ + +
+ + + + +
+

Machine-readable spec — E2 Mitglieder

+

Authoritative implementation reference for the /members page. Use before building any component for this route.

+ +
/* E2 Members page — implementation rules
+ * 1.  Recommended variation: V1 (Roster list). Simplest, lowest overhead, matches household size 2–4.
+ * 2.  Avatar colours: Planner = --green-tint bg + --green-dark text. Member = --blue-tint bg + --blue-dark text. Initials only.
+ * 3.  Role badges: same colour pairing as avatars. border-radius: --radius-full. font-size: 11px. font-weight: 500.
+ * 4.  The planner cannot remove themselves — no remove action on the Planner's row ever.
+ * 5.  Pending invites: show expiry in --yellow-tint + --yellow-text when ≤ 3 days remaining, --color-subtle + --color-text-muted otherwise.
+ * 6.  Remove action is destructive — requires a confirmation dialog with the member's name before execution.
+ * 7.  Invite mechanism: generate a link/code. Copy to clipboard. No email delivery system in v1.
+ * 8.  Expired invites: show "Abgelaufen" state. Provide one-tap regeneration — do not require re-opening an invite flow.
+ * 9.  Household members can view this page in read-only mode (no invite button, no remove actions, no pending invites section).
+ * 10. Desktop sidebar: "Mitglieder" item is active, under "Haushalt" section. Mobile: "Einstellungen" tab is active.
+ * 11. WCAG 2.2 AA: avatar initials need 4.5:1 contrast. Role badge text needs 4.5:1 contrast. Confirm before implementing.
+ */
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementValue / RuleNotes
Avatar
Size40px × 40px (desktop) · 36px × 36px (mobile)border-radius: 50%
Planner colourbg --green-tint · text --green-darkContrast OK: #2E6E39 on #E8F5EA ≈ 6.1:1
Member colourbg --blue-tint · text --blue-darkContrast OK: #0C447C on #E6F1FB ≈ 7.4:1
ContentFirst letter of first + last name (uppercase)Max 2 characters
Role badge
Shapeborder-radius: --radius-full · padding: 3px 10pxfont-size: 11px · font-weight: 500
Plannerbg --green-tint · color --green-darkLabel: "Planner"
Memberbg --blue-tint · color --blue-darkLabel: "Mitglied"
Invite / pending
Expiry badge — urgent (≤3d)bg --yellow-tint · color --yellow-text"Läuft ab in N Tagen"
Expiry badge — normalbg --color-subtle · color --color-text-muted"Läuft ab am DD. MMM"
Code fontfont-family: --font-mono · font-size: 13pxInvite codes are monospace
Interactions
Remove actionConfirmation dialog requiredDialog must show member name. Irreversible — member loses access immediately.
Copy invite linknavigator.clipboard.writeText()Show transient "Kopiert!" feedback (checkmark, 2s)
Regenerate invitePOST /household/invite — returns new codeOld code is immediately invalidated
Responsive
Desktop (≥1024px)224px sidebar + full content areaActive sidebar item: Mitglieder (Haushalt section)
Mobile (<768px)No sidebar · bottom nav · "Einstellungen" tab activeV3 mobile uses tab bar within page, not app bottom nav tabs
+
+ + + + diff --git a/specs/planner-c-e-combined.html b/specs/planner-c-e-combined.html new file mode 100644 index 0000000..7d3d294 --- /dev/null +++ b/specs/planner-c-e-combined.html @@ -0,0 +1,755 @@ + + + + + +Planner C+E — Drei Zustände + + + + + + +

Mealplan · Planer · Konzept

+

C + E Kombiniert — Drei Zustände

+

+ Der Hauptbereich unterhalb des Kalender-Grids zeigt immer nützlichen Inhalt — je nach Zustand. + Kein leerer Raum, kein Tab-Wechsel nötig. Das rechte Panel bleibt für den Rezept-Picker reserviert. +

+ +
+
Zustand 1 — Kein Tag ausgewählt
+
Zustand 2 — Tag mit Rezept angeklickt
+
Zustand 3 — Leerer Tag angeklickt
+
+ + + + + +
+
+ Zustand 1 + Kein Tag ausgewählt + Standard beim Laden der Seite +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
+
Di
8

Pasta Bolognese

45 Min

mittel
+
Mi
9

Gemüse-Stir-fry

20 Min

einfach
+
Do
10

Lachs mit Kartoffeln

30 Min

einfach
+
Fr
11

Pizza Margherita

50 Min

aufwändig
+
Sa
12
+wählen
+
So
13
+wählen
+
+ + +
+
+ Diese Woche +
+
+
+
Mo 7.4
+
Hähnchen-Curry
+
35 Min · mittel
+
+
+
+
Di 8.4
+
Pasta Bolognese
+
45 Min · mittel
+
+
+
Mi 9.4
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+
+
Do 10.4
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+
+
Fr 11.4
+
Pizza Margherita
+
50 Min · aufwändig
+
+
+
Sa 12.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+ + +
+
+ Vorschläge für ungeplante Tage + Alle Rezepte → +
+ + +
+
+ Samstag, 12. Apr + kein Gericht +
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+
+
Shakshuka
+
25 Min · einfach
+ Keine Überschneidung +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Aufwand: einfach +
+
+
+ + +
+
+ Sonntag, 13. Apr + kein Gericht +
+
+
+
Pho Bo
+
60 Min · aufwändig
+ Neues Protein +
+
+
Lachs-Avocado-Bowl
+
15 Min · einfach
+ Keine Überschneidung +
+
+
Kürbissuppe
+
35 Min · einfach
+ Keine Überschneidung +
+
+
+
+
+ + +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu wählen
+
+
+
+
+ +
+ Inhalt ohne Klick: Agenda aller 7 Tage (geplante + leere), darunter Vorschläge für die beiden + ungeplanten Tage — anstelle von Score-Delta zeigen die Karten kurze Begründungs-Tags + (Neues Protein / Keine Überschneidung / Aufwand-Hinweis). Das rechte Panel zeigt „Heute Abend" als + direkte Koch-Modus-Abkürzung. Kein leerer Raum, kein Klick nötig. +
+
+ + + + + +
+
+ Zustand 2 + Tag mit Rezept angeklickt + Klick auf Mi, 9. Apr → Gemüse-Stir-fry +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
+
Di
8

Pasta Bolognese

45 Min

mittel
+ +
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach +
+
+
+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+
Sa
12
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 4 Portionen
+
+ Tofu + Paprika + Brokkoli + Karotten + Ingwer + Zucchini + Sesamöl + Sojasauce + Knoblauch +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ + +
+
Restliche Woche
+
+
+
Do 10.4
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+
+
Fr 11.4
+
Pizza Margherita
+
50 Min · aufwändig
+
+
+
Sa 12.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+
+ + +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
Dieses Gericht
+
+
Kein Protein-Overlap
+
Neue Zutaten (Tofu, Paprika)
+
~Tofu 2× diese Woche
+
+
+
+
+ +
+ Nach Klick auf Mittwoch: Expansion öffnet sich direkt unter dem Grid mit Pfeil-Indikator. + Enthält Rezeptname groß, Zutaten-Tags (normale vs. Grundzutaten gedimmt), + Score-Impact-Badge und alle Aktionen. Unselektierte Kacheln werden leicht ausgeblendet (opacity 55%). + Darunter: kompakte Liste der restlichen Woche — geplante Tage + leere Tage mit „+ Hinzufügen". + Das rechte Panel wechselt auf einen Variety-Kontext: zeigt was dieses Gericht zur Woche beiträgt (positiv/neutral/negativ). +
+
+ + + + + +
+
+ Zustand 3 + Leerer Tag angeklickt + Klick auf Sa, 12. Apr → kein Gericht +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

+
Di
8

Pasta Bolognese

45 Min

+
Mi
9

Gemüse-Stir-fry

20 Min

+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+ +
+
Sa
+
12
+
+ + + wählen +
+
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+ +
+
Vorschläge für Samstag, 12. Apr
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+
+
Shakshuka
+
25 Min · einfach
+ Keine Überschneidung +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Keine Überschneidung +
+
+
Kürbissuppe
+
35 Min · einfach
+ Aufwand: einfach +
+
+
Tofu-Teriyaki
+
30 Min · einfach
+ Gleiche Zutaten +
+ +
Alle Rezepte →
+
+
+
+ + +
+
Noch diese Woche
+
+
+
Mo 7.4
+
Hähnchen-Curry
+
35 Min · mittel
+
+
+
+
Di 8.4
+
Pasta Bolognese
+
45 Min
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+
+ + +
+
Samstag, 12. Apr
+ +
+
+
Ramen mit Ei
40 Min · mittel
+ Top +
+
+
Shakshuka
25 Min · einfach
+ Top +
+
+
Kürbissuppe
35 Min · einfach
+
+
+
Tofu-Teriyaki
30 Min · einfach
+
+
+
Gemüse-Curry
40 Min · mittel
+
+
+
Linseneintopf
50 Min · einfach
+
+
+
Ofen-Lachs
35 Min · einfach
+
+
+
+
+
+ +
+ Nach Klick auf leeren Samstag: Expansion zeigt 5 Vorschläge (3×3 Grid + „Alle Rezepte" als letztes Feld) + mit Begründungs-Tags statt Score-Delta. Die Farbkodierung der Tags macht die Qualität der Empfehlung lesbar ohne Zahlenwert: + Grün = klar besser, Gelb = neutral/tradeoff. Das rechte Panel öffnet gleichzeitig den vollständigen Rezept-Picker mit Suche — + für Nutzer die keinen der Vorschläge wollen. Klick auf eine Karte (main) oder Picker-Item (rechts) führt dasselbe aus: Rezept eintragen. +

+ Tofu-Teriyaki erscheint mit „Gleiche Zutaten" (gelb) — das ist die sinnvolle Alternative zu einem Score-Delta von −0.2: + nicht versteckt, aber klar als Abwägung markiert. +
+
+ + + diff --git a/specs/planner-fullbleed-tiles.html b/specs/planner-fullbleed-tiles.html new file mode 100644 index 0000000..1e2cb6d --- /dev/null +++ b/specs/planner-fullbleed-tiles.html @@ -0,0 +1,773 @@ + + + + + +Planner — Full-Bleed Tiles + + + + + + +

Mealplan · Planer · Full-Bleed Tiles

+

Vollflächige Kacheln

+

+ Das Bild füllt die gesamte Kachelhöhe. Text und Tags werden über einen Gradienten am unteren Rand eingeblendet. + Kein leerer Bereich mehr — jeder Pixel der Kachel ist Bild. + Leere Kacheln behalten die Vorschlagsliste von innen. +

+ + + + + +
+
+ Nahaufnahme + Kachel-Varianten +
+ +
+ + +
+
Gefüllt — Standard
+
+
+
+ Mo + 7 +
+
+
Hähnchen-Curry
+
35 Min · mittel
+
+ Hähnchen + 4 Port. +
+
+
+
+ + +
+
Gefüllt — Heute
+
+
+
+ Di + 8 +
+
+
Pasta Bolognese
+
45 Min · mittel
+
+ Rind + 4 Port. +
+
+
+
+ + +
+
Gefüllt — Ausgewählt
+
+
+
+ Mi + 9 +
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ Tofu + 2 Port. +
+
+
+
+
+ + +
+
Gefüllt — Gedimmt
+
+
+
+ Do + 10 +
+
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
Fisch
+
+
+
+ + +
+
Leer — Vorschläge
+
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neu +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Leicht +
+
Alle →
+
+
+
+ + +
+
Leer — Ausgewählt
+
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neu +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Leicht +
+
Alle →
+
+
+
+ +
+ +
+ Gradient-Overlay: Dunkel oben (30% → 0%) für den Tages-Header, dunkel unten (0% → 55%) für den Text. + Der mittlere Bereich ist transparent — das Bild ist klar zu sehen. + Text-Shadow auf dem Rezeptnamen sichert Lesbarkeit auch bei hellen Bildern. + Tags nutzen rgba(255,255,255,.2) als Glasmorphismus-Hintergrund. + Zustands-Borders werden per box-shadow umgesetzt (kein Layout-Shift durch border:2px). +
+
+ + + + + +
+
+ 01 + Kein Tag ausgewählt — volle Seite + Kacheln füllen die Viewport-Höhe komplett +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ +
+ +
+ +
+
+
Mo7
+
+
Hähnchen-Curry
+
35 Min · mittel
+
Hähnchen4 Port.
+
+
+ +
+
+
Di8
+
+
Pasta Bolognese
+
45 Min · mittel
+
RindHeute
+
+
+ +
+
+
Mi9
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu2 Port.
+
+
+ +
+
+
Do10
+
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
Fisch2 Port.
+
+
+ +
+
+
Fr11
+
+
Pizza Margherita
+
50 Min · aufwändig
+
vegetarisch4 Port.
+
+
+ +
+
Sa12
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
Ramen mit EiNeues Protein
+
ShakshukaKein Overlap
+
TacosAufwand: leicht
+
Alle Rezepte →
+
+
+ +
+
So13
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
Pho BoNeues Protein
+
Avocado-BowlKein Overlap
+
KürbissuppeKein Overlap
+
Alle Rezepte →
+
+
+ +
+
+ +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu planen
+
+
+
+
+
+ Kein leerer Bereich. Das Grid nimmt die volle Höhe ein (height:100% auf Kacheln und Grid). + Gefüllte Kacheln: Bild von oben bis unten, Text per Overlay am unteren Rand. + Leere Kacheln: dieselbe Höhe, oben "+ Gericht wählen", darunter die Vorschlagsliste. + Da Mo–Fr alle Bilder haben, entsteht ein visuell abwechslungsreicher Kalender ohne Blank Space. +
+
+ + + + + +
+
+ 02 + Tag mit Rezept angeklickt + Mi — Expansion öffnet sich unter dem Grid +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
+
+ +
+ +
+ +
+
+
Mo7
+
Hähnchen-Curry
35 Min
+
+ +
+
+
Di8
+
Pasta Bolognese
45 Min
+
+ + +
+
+
Mi9
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu
+
+
+
+ +
+
+
Do10
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
+
Fr11
+
Pizza Margherita
50 Min
+
+ +
+
Sa12
+
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Portionen
+
+ TofuPaprikaBrokkoli + KarottenZucchiniIngwer + SesamölSojasauceKnoblauch +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+
+ +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
+
✓ Kein Protein-Overlap
+
✓ Neue Zutaten
+
~ Tofu zum 2. Mal
+
+
+
+
+
+ Beim Klick auf eine gefüllte Kachel werden alle anderen auf 42% Deckkraft gedimmt + (gefüllte und leere). Die Tiles bleiben in ihrer Höhe, die Expansion wächst darunter. + Da das Bild jetzt die volle Kachelhöhe einnimmt, wirkt das Dimmen als echter Fokus-Effekt — + wie eine Lupe auf das ausgewählte Gericht. +
+
+ + + diff --git a/specs/planner-layout-mockups.html b/specs/planner-layout-mockups.html new file mode 100644 index 0000000..4984f94 --- /dev/null +++ b/specs/planner-layout-mockups.html @@ -0,0 +1,1820 @@ + + + + + +Planner Layout Mockups — 5 Konzepte + + + + + + + + + + +

Mealplan · Wochenplaner

+

Planner Layout — 5 Konzepte

+

+ Problem: Desktop zeigt ~80 % leeren Platz. Die linke Sidebar hat den Variety-Score nur am unteren Rand. + Rechtes Panel beginnt mit „Kein Tag ausgewählt". Die Kacheln sind zu flach für die Datendichte die wir hätten. + Alle 5 Konzepte nutzen ausschließlich vorhandene API-Daten. +

+ + + + + +
+
+ 01 + Score Dashboard Sidebar + Einfachster Win — Sidebar von oben befüllen +
+ +
+
+ +
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ +
+ + + + +
+
+ +
+
Mo
+
7
+
+

Hähnchen-Curry

+

35 Min

+ mittel + Hähnchen +
+
+ +
+
Di
+
8
+
+

Pasta Bolognese

+

45 Min

+ mittel + Rind +
+
+ +
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach + Tofu +
+
+ +
+
Do
+
10
+
+

Lachsfilet mit Kartoffeln

+

30 Min

+ einfach + Fisch +
+
+ +
+
Fr
+
11
+
+

Pizza Margherita

+

50 Min

+ aufwändig + vegetarisch +
+
+ +
+
Sa
+
12
+
+ + + wählen +
+
+ +
+
So
+
13
+
+ + + wählen +
+
+
+
+ + +
+
+
Mittwoch, 9. Apr
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+ + + + +
+
+
+
+
+ +
+ Was ändert sich: Variety Score wird an den Anfang der Sidebar verschoben. Darunter folgen Teilwerte (3 Mini-Balken), + Aufwandverteilung (Farbbalken), Top-Warnungen, und ein 5/7-Fortschrittsindikator. + Kacheln werden auf 160 px min-height erhöht und zeigen Effort-Badge + Protein-Tag. +

+ Vorteile: Minimaler Aufwand, keine Layout-Änderung, alle Daten bereits vorhanden (varietyScore API liefert sub-scores + overlaps). + Die Sidebar ist jetzt von oben bis unten gefüllt. Kacheln vermitteln mehr Kontext auf einen Blick. +
+
+ + + + + +
+
+ 02 + Stats-Leiste + Fullscreen-Kalender + 2-Spalten-Layout, Kennzahlen als Kopfzeile +
+ +
+
+ +
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ + +
+
+
7.8
+
Abwechslungs-Score
+
+
+
5/7
+
Tage geplant
+
+
+
34 Min
+
Ø Kochzeit
+
+
+
Aufwand
+
+
+
+
+
+
+
+
Protein-Verteilung
+
+ Hähnchen ×2 + Rind ×1 + Fisch ×1 + Tofu ×1 +
+
+
+ +
+ +
+
+ +
+
Montag
+
7
+
+

Hähnchen-Curry

+

35 Min

+
+ mittel + Hähnchen +
+
+
+ +
+
Dienstag
+
8
+
+

Pasta Bolognese

+

45 Min

+
+ mittel + Rind +
+
+
+ +
+
Mittwoch
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+
+ einfach + Tofu +
+
+
+ +
+
Donnerstag
+
10
+
+

Lachs mit Kartoffeln

+

30 Min

+
+ einfach + Fisch +
+
+
+ +
+
Freitag
+
11
+
+

Pizza Margherita

+

50 Min

+
+ aufwändig + vegetarisch +
+
+
+ +
+
Samstag
+
12
+
+ + + Gericht wählen +
+
+ +
+
Sonntag
+
13
+
+ + + Gericht wählen +
+
+
+
+ + +
+
+
Mittwoch, 9. Apr
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+
+
Score-Vorschau
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
+ + + +
+
+
+
+
+ +
+ Was ändert sich: Die linke Sidebar entfällt — stattdessen gibt es eine horizontale Stats-Leiste direkt unter der Topbar. + Sie zeigt Score, geplante Tage, Ø Kochzeit, Aufwand-Farbbalken und Protein-Tags kompakt nebeneinander. + Der Kalender wächst auf die volle verbleibende Breite. Rechtes Panel zeigt beim ausgewählten Tag jetzt auch den aktuellen Score. +

+ Vorteile: Maximaler Platz für den Kalender. Stats auf einen Blick ohne Scrolling. Variety-Score immer sichtbar. + Schwäche: Keine persistente Sidebar für Warnungen. +
+
+ + + + + +
+
+ 03 + Rechtes Panel als Wochenübersicht + „Kein Tag ausgewählt" durch echte Daten ersetzen +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ +
+ + + + +
+
+
+
Mo
+
7
+
+

Hähnchen-Curry

+

35 Min

+ mittel +
+
+
+
Di
+
8
+
+

Pasta Bolognese

+

45 Min

+ mittel +
+
+
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach +
+
+
+
Do
+
10
+
+

Lachs mit Kartoffeln

+

30 Min

+ einfach +
+
+
+
Fr
+
11
+
+

Pizza Margherita

+

50 Min

+ aufwändig +
+
+
+
Sa
+
12
+
+
+
+
+
So
+
13
+
+
+
+
+
+ + +
+
Diese Woche
+ + +
+
+
5
+
geplant
+
+
+
2
+
offen
+
+
+
34
+
Ø Min
+
+
+ +
+ + +
Ungeplante Tage
+
+
+ Samstag, 12. Apr + Noch kein Gericht — Klicken zum Planen +
+
+ Sonntag, 13. Apr + Noch kein Gericht — Klicken zum Planen +
+
+ +
+ + +
Heute Abend
+
+

Pasta Bolognese

+

45 Min · mittel

+ +
+ + +
+
+
+
+ +
+ Was ändert sich: Nur das rechte Panel im Idle-Zustand. Statt „Kein Tag ausgewählt" zeigt es eine echte Wochenübersicht: + Geplant/Offen/Ø Kochzeit, ungeplante Tage als Einladung zum Klicken, und „Heute Abend" als schnellen Koch-Modus-Einstieg. +

+ Vorteile: Kein leerer Zustand mehr. Nutzer sehen sofort den Stand ihrer Woche. „Heute Abend" löst den häufigsten + Use-Case (abends schnell kochen starten) direkt. Minimale Änderungen am restlichen Layout. +
+
+ + + + + +
+
+ 04 + Variety rechts, Kalender breiter + Variety-Analyse ins rechte Panel, kein linkes Sidebar mehr +
+ +
+
+
+ Wochenplaner + 7.–13. Apr 2026 +
+
+ + +
+ +
+ +
+
+
+
Mo, 7.
+
+

Hähnchen-Curry

+

35 Min

+ mittel + Hähnchen +
+
+
+
Di, 8. ★
+
+

Pasta Bolognese

+

45 Min

+ mittel + Rind +
+
+
+
Mi, 9.
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach + Tofu +
+
+
+
Do, 10.
+
+

Lachs mit Kartoffeln

+

30 Min

+ einfach + Fisch +
+
+
+
Fr, 11.
+
+

Pizza Margherita

+

50 Min

+ aufwändig + vegetarisch +
+
+
+
Sa, 12.
+
+ + + Gericht wählen +
+
+
+
So, 13.
+
+ + + Gericht wählen +
+
+
+
+ + +
+ +
+
+ 7.8 +
+
+ +
+ Protein +
+ 8.0 +
+
+ Zutaten +
+ 7.2 +
+
+ Aufwand +
+ 8.2 +
+
+
+ +
+ + +
+
Aufwand diese Woche
+
+
3 einfach
+
2 mittel
+
1
+
+
+ + +
+
Überschneidungen
+
⚠ Hähnchen an Mo, Mi, Do
+
⚠ Tomaten an Di, Do
+ Variety-Analyse → +
+ +
+ + +
+
Heute Abend
+
+

Pasta Bolognese

+

Dienstag · 45 Min · mittel

+ +
+
+
+
+
+
+ +
+ Was ändert sich: Linke Sidebar komplett entfernt → Kalender gewinnt ~200 px Breite. + Das rechte Panel wird zum permanenten Variety-Dashboard: Score-Ring, Teilwerte, Aufwand-Balken, Warnungen. + Ganz unten: „Heute Abend" als direkter Koch-Modus-Einstieg. Beim Klick auf einen Tag ersetzt das Day-Detail-Panel diesen Inhalt. +

+ Vorteile: Variety-Score ist immer im Blick, nicht nur am unteren Sidebar-Rand. + Breiterer Kalender = mehr Platz für Rezeptnamen. Klarer Haupt-CTA (Koch-Modus) ohne Tab-Wechsel. +
+
+ + + + + +
+
+ 05 + Mobile — Wochengitter statt Tagesstreifen + Alle 7 Tage auf einmal sichtbar, Score im Header +
+ +
+
+ + +
+
Aktueller Zustand
+
+
9:41●●●
+
+ Diese Woche +
+ + + +
+
+ +
+
+ 7.8 + /10 Abwechslungs-Score +
+
+
⚠ Hähnchen in 3 Mahlzeiten
+
+ +
+
Mo
+
Di
8
+
Mi
9
+
Do
+
Fr
+
Sa
12
+
So
13
+
+ +
Mittwoch, 9. April
+
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+ + +
+
+
+
+ + +
+
Neuer Vorschlag — 2-Spalten-Gitter
+
+
9:41●●●
+ +
+ Diese Woche +
+
+ 7.8 + /10 +
+ + +
+
+ +
+
+ +
+
Montag · 7.
+
Hähnchen-Curry
+
35 Min · mittel
+
+ +
+
Dienstag · 8. ★
+
Pasta Bolognese
+
45 Min · mittel
+
+ +
+
+
+
Mittwoch · 9. — Ausgewählt
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+
+ + +
+
+
+ +
+
Donnerstag · 10.
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+ +
+
Freitag · 11.
+
Pizza Margherita
+
50 Min · aufwändig
+
+ +
+
Samstag · 12.
+
+
+
wählen
+
+ +
+
Sonntag · 13.
+
+
+
wählen
+
+
+
+
+
+ +
+
+ +
+ Was ändert sich (Mobile): Die gelbe Score-Banner wird aus dem Scroll-Bereich herausgelöst und als kompaktes Badge + in die Topbar integriert (spart ~80 px). Der horizontale Tagesstreifen + separate „Restliche Woche" Liste werden ersetzt durch ein + 2-Spalten-Gitter, das alle 7 Tage auf einmal zeigt. Der ausgewählte Tag expandiert zur vollen Breite mit integrierten Aktionen (kein separater großer Card mehr). +

+ Vorteile: Auf einem Blick sieht man die ganze Woche. Kein Scrollen nötig um alle Tage zu sehen. + Score-Badge bleibt jederzeit sichtbar ohne Platz zu fressen. Expandierter Tag ersetzt das separate Tagesdetail-Pattern. +
+
+ + + + + +
+ +
+ + + diff --git a/specs/planner-main-area-mockups.html b/specs/planner-main-area-mockups.html new file mode 100644 index 0000000..2b1d903 --- /dev/null +++ b/specs/planner-main-area-mockups.html @@ -0,0 +1,1064 @@ + + + + + +Planner Hauptbereich — 5 Ideen für den leeren Raum + + + + + + +

Mealplan · Wochenplaner · Hauptbereich

+

Was kommt unter das Kalender-Grid?

+

+ Das 7-Spalten-Grid nimmt ~150 px ein. Die Main-Area ist die volle Viewport-Höhe. + Fünf Konzepte, was den Raum darunter sinnvoll füllt — je mit dem gleichen Sidebar (Score oben) und rechtem Panel. +

+ + + + + +
+
+ A + Variety-Dashboard direkt im Planer + Kein separater /planner/variety Tab mehr nötig +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
Hähnchen
+
Di
8

Pasta Bolognese

45 Min

mittel
Rind
+
Mi
9

Gemüse-Stir-fry

20 Min

einfach
Tofu
+
Do
10

Lachs mit Kartoffeln

30 Min

einfach
Fisch
+
Fr
11

Pizza Margherita

50 Min

aufwändig
vegetarisch
+
Sa
12
+wählen
+
So
13
+wählen
+
+ + + +
+
+ Wochenanalyse + Detailansicht → +
+ + +
+
+
8.0/10
+
Protein-Vielfalt
+
+
+
+
7.2/10
+
Zutaten-Überlappung
+
+
+
+
8.2/10
+
Aufwands-Balance
+
+
+
+ + +
+ +
+
Mo
Di
Mi
Do
Fr
Sa
So
+ +
Hähnchen
+
HÄH
+
+
+
HÄH
+
+
+
+ +
Rind
+
+
RIND
+
+
+
+
+
+ +
Tofu / veg.
+
+
+
TOFU
+
+
VEG
+
+
+ +
Fisch
+
+
+
+
FISCH
+
+
+
+
+ + +
+
+
Aufwandsverteilung
+
+
3 × einfach
+
2 × mittel
+
1
+
+
+ ● einfach + ● mittel + ● aufwändig +
+
+
+
Hinweise
+
+
+
⚠ Hähnchen
+
an Mo + Do geplant
+ Tag tauschen → +
+
+
⚠ Tomaten
+
Di + Do wiederholt
+ Tag tauschen → +
+
+
+
+
+
+ + +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + + +
+
+
+
+ +
+ Was passiert: Die /planner/variety Seite existiert weiter als Deep-Link, aber ihre Kerninfos (Sub-Scores, + Protein-Grid, Aufwands-Balken, Warnungen) sind direkt im Planer sichtbar — kein Tab-Wechsel nötig. + Alle Daten kommen aus dem bereits geladenen varietyScore Objekt. Der „Detailansicht →" Link führt zur vollen Analyse-Seite. +

+ Gelber Ring im Protein-Grid = Protein wiederholt sich an mehreren Tagen (kommt von tagRepeats). +
+
+ + + + + +
+
+ B + Mehre Wochen gleichzeitig + Aktuelle + Folgewochen im selben Grid +
+ +
+
+
+ Wochenplaner +
+ Apr 2026 +
+ + +
+ +
+ + + + +
+ +
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+ + +
+
+ KW 15 + 7.–13. Apr + Aktuell + 7.8 / 10 +
+
+
7

Hähnchen-Curry

35 Min · mittel

Hähnchen
+
8

Pasta Bolognese

45 Min · mittel

Rind
+
9

Gemüse-Stir-fry

20 Min · einfach

Tofu
+
10

Lachs mit Kartoffeln

30 Min · einfach

Fisch
+
11

Pizza Margherita

50 Min · aufwändig

veg.
+
12
+wählen
+
13
+wählen
+
+
+ + +
+
+ KW 16 + 14.–20. Apr + 6.1 / 10 +
+
+
14

Hähnchen-Pfanne

25 Min · einfach

Hähnchen
+
15
+wählen
+
16

Linsensuppe

40 Min · einfach

veg.
+
17
+wählen
+
18
+wählen
+
19
+wählen
+
20
+wählen
+
+
+ + +
+
+ KW 17 + 21.–27. Apr + leer +
+
+
21
+wählen
+
22
+
23
+
24
+
25
+
26
+
27
+
+
+
+ + +
+
Mittwoch, 9. Apr · KW 15
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Die Main-Area zeigt 3 aufeinanderfolgende Wochen. Die Spaltenköpfe (Mo–So) sind geteilt. + Jede Woche hat ein Label mit KW-Nummer, Datumsbereich und Score. KW 17 ist gedimmt — noch kein Plan, aber klickbar. + Die Sidebar zeigt den Score der aktuell fokussierten Woche + eine kompakte Wochenliste. +

+ Was neu gebaut werden muss: API-Abruf für Folgewochen (gleicher Endpoint, andere weekStart Parameter). + Zwei zusätzliche Abrufe beim Laden der Seite. Score für Folgewochen wird mit kleinerem Datensatz berechnet. +
+
+ + + + + +
+
+ C + Empfehlungen unter dem Grid + Ungeplante Tage werden direkt mit Vorschlägen befüllt +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min · mittel

+
Di
8

Pasta Bolognese

45 Min · mittel

+
Mi
9

Gemüse-Stir-fry

20 Min · einfach

+
Do
10

Lachs mit Kartoffeln

30 Min · einfach

+
Fr
11

Pizza Margherita

50 Min · aufwändig

+
Sa
12
+
+
So
13
+
+
+ + +
+
+ Empfehlungen für ungeplante Tage + Alle Rezepte → +
+ + +
+
+ Samstag, 12. Apr + kein Gericht +
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ +0.9 Score +
+
+
Shakshuka
+
25 Min · einfach
+ +0.7 Score +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ +0.5 Score +
+
+
+ + +
+
+ Sonntag, 13. Apr + kein Gericht +
+
+
+
Pho Bo
+
60 Min · aufwändig
+ +1.1 Score +
+
+
Lachstartar auf Avocado
+
15 Min · einfach
+ +0.8 Score +
+
+
Hähnchen-Wrap
+
20 Min · einfach
+ −0.2 Score +
+
+
+ + +
+ Alle Tage sind geplant. Gute Woche! Score: 7.8/10 +
+
+
+ + +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Für jede Lücke in der Woche werden 3 Rezept-Vorschläge inline gezeigt — sortiert nach Score-Delta. + Klick auf eine Karte → setzt das Rezept direkt (kein Picker-Sheet nötig). Score-Delta (grün/rot) kommt bereits + aus der SuggestionResponse API, die der Planer schon abruft. +

+ Vorteil: Wenn alle 7 Tage geplant sind, verschwindet diese Sektion und wird durch eine + Bestätigungsmeldung ersetzt. Die Hauptfunktion des Planers (leere Tage füllen) passiert ohne Panel-Wechsel. +
+
+ + + + + +
+
+ D + Einkaufsliste unter dem Grid + Was diese Woche eingekauft werden muss +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

+
Di
8

Pasta Bolognese

45 Min

+
Mi
9

Gemüse-Stir-fry

20 Min

+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+
Sa
12
+
+
So
13
+
+
+ + +
+
+ Einkaufsliste diese Woche +
+ Grundzutaten ausblenden + Exportieren → +
+
+ +
+ +
+
Fleisch & Fisch
+
+
+
Hähnchenbrust
Mo — Hähnchen-Curry
+
+
+
+
Rinderhack 400 g
Di — Pasta Bolognese
+
+
+
+
Lachsfilet 2×
Do — Lachs mit Kartoffeln
+
+
+ + +
+
Gemüse
+
+
+
Paprika (rot + gelb)
Mi — Gemüse-Stir-fry
+
+
+
+
Tomaten, gehackt 2×
Di, Do
+
+
+
+
Zwiebeln
Mo, Di, Mi
+
+
+
+
Kartoffeln 600 g
Do — Lachs mit Kartoffeln
+
+
+ + +
+
Grundzutaten
+
+
+
Olivenöl
Alle Gerichte
+
+
+
+
Pasta 500 g
Di — Pasta Bolognese
+
+
+
+
Kokosmilch 400 ml
Mo — Hähnchen-Curry
+
+
+
+
Pizzateig (fertig)
Fr — Pizza Margherita
+
+
+
+
+
+ +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Alle geplanten Rezepte der Woche werden zusammengeführt und ihre Zutaten in Kategorien gruppiert. + Checkboxes ermöglichen das Abhaken beim Einkaufen. Mehrfach benötigte Zutaten (z.B. Tomaten Di+Do) werden gebündelt. +

+ Was neu gebaut werden muss: Backend-Endpoint zur Zutatenaggregation (oder Frontend-Zusammenführung aus den + bereits geladenen Rezeptdaten). Checkboxen-State wäre nur clientseitig (kein Speichern nötig). Grundzutat-Flag + ist bereits im Datenmodell vorhanden (RecipeIngredient.staple), damit man diese ein-/ausblenden kann. +
+
+ + + + + +
+
+ E + Klick-Expansion direkt im Grid + Kachel aufklappen statt rechtes Panel öffnen +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min · mittel

+
Di
8

Pasta Bolognese

45 Min · mittel

+ + +
+
Mi
9
+
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+
+ +
Do
10

Lachs mit Kartoffeln

30 Min · einfach

+
Fr
11

Pizza Margherita

50 Min · aufwändig

+
Sa
12
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 4 Portionen
+ +
+ Tofu + Paprika + Brokkoli + Karotten + Ingwer + Sesamöl + Sojasauce + Knoblauch +
+ +
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ + +
+
+ Restliche Woche +
+
+
+ Do 10.4 + Lachs mit Kartoffeln + 30 Min · einfach +
+
+ Fr 11.4 + Pizza Margherita + 50 Min · aufwändig +
+
+ Sa 12.4 + Noch kein Gericht + + Hinzufügen +
+
+ So 13.4 + Noch kein Gericht + + Hinzufügen +
+
+
+
+ + +
+
+
Detail-Bereich ist jetzt im Hauptbereich
+
Das rechte Panel könnte für den Rezept-Picker reserviert bleiben
+
+
+
Klicke einen leeren Tag um hier den Picker zu öffnen
+
+
+
+
+ +
+ Was passiert: Klick auf eine Kachel öffnet einen Expansion-Bereich direkt unter dem Grid + (volle Breite, mit Pfeil-Indikator zur aktiven Kachel). Gezeigt werden: Rezeptname groß, Metadaten, Zutaten als Tags + (normale vs. Grundzutaten farblich unterschieden), Score-Auswirkung und alle Aktionen. + Darunter folgt die restliche Woche als kompakte Agenda-Liste — ungeplante Tage als gestrichelte Zeilen mit „+ Hinzufügen". +

+ Konsequenz für rechtes Panel: Das Day-Detail zieht in die Main-Area um. Das rechte Panel bleibt + als reiner Rezept-Picker reserviert — es wäre nur offen wenn man aktiv ein Gericht auswählt. + Das eliminiert den Idle-Zustand „Kein Tag ausgewählt" komplett. +
+
+ + + diff --git a/specs/planner-tall-tiles.html b/specs/planner-tall-tiles.html new file mode 100644 index 0000000..ccdd658 --- /dev/null +++ b/specs/planner-tall-tiles.html @@ -0,0 +1,847 @@ + + + + + +Planner — Tall Tiles + + + + + + +

Mealplan · Planer · Tall Tiles

+

Hohe Kacheln — drei Zustände

+

+ Kein separater Agenda-Bereich. Die Kacheln selbst sind die Informationsschicht: + Bild-Placeholder oben, Rezeptname, Metadaten, Tags. Leere Kacheln nutzen die Höhe + für Vorschläge direkt inline. Unter dem Grid erscheint nur noch die Expansion beim Klick. +

+ + + + + +
+
+ 01 + Kein Tag ausgewählt + Standard beim Laden der Seite +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+ + +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+
+ + +
+
+ Mo + 7 +
+
+ +
+
+
+
+
Hähnchen-Curry
+
35 Min
+
+ mittel + Hähnchen +
+
4 Portionen
+
+
+ + +
+
+ Di + 8 +
+
+
+
+
Pasta Bolognese
+
45 Min
+
+ mittel + Rind +
+
4 Portionen
+
+
+ + +
+
+ Mi + 9 +
+
+
+
+
Gemüse-Stir-fry
+
20 Min
+
+ einfach + Tofu +
+
2 Portionen
+
+
+ + +
+
+ Do + 10 +
+
+
+
+
Lachs mit Kartoffeln
+
30 Min
+
+ einfach + Fisch +
+
2 Portionen
+
+
+ + +
+
+ Fr + 11 +
+
+
+
+
Pizza Margherita
+
50 Min
+
+ aufwändig + vegetarisch +
+
4 Portionen
+
+
+ + +
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neues Protein +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Aufwand: leicht +
+
Alle Rezepte →
+
+
+ + +
+
+ So + 13 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Pho Bo + Neues Protein +
+
+ Avocado-Bowl + Kein Overlap +
+
+ Kürbissuppe + Kein Overlap +
+
Alle Rezepte →
+
+
+ +
+
+ + +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu planen
+
+
+ +
+
+ +
+ Kein Agenda-Bereich. Die Kacheln füllen die volle Höhe des Hauptbereichs. + Geplante Tage zeigen: farbiges Bild-Placeholder (wird durch heroImageUrl ersetzt), + Rezeptname, Kochzeit, Effort-Badge, Protein-Tag, Portionenanzahl. + Leere Kacheln (Sa, So) zeigen direkt 3 Vorschläge mit Begründungs-Tags — kein Scrollen nötig. + Klick auf einen Vorschlag-Eintrag → Rezept wird direkt eingetragen. + Klick auf die Kachel selbst → öffnet Expansion unten (Zustand 3). +
+
+ + + + + +
+
+ 02 + Tag mit Rezept angeklickt + Klick auf Mi — Gemüse-Stir-fry +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ +
+ +
+ +
+
Mo7
+
+
Hähnchen-Curry
35 Min
+
+ +
+
Di8
+
+
Pasta Bolognese
45 Min
+
+ + +
+
Mi9
+
+
+
Gemüse-Stir-fry
+
20 Min
+
einfachTofu
+
+
+
+ +
+
Do10
+
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
Fr11
+
+
Pizza Margherita
50 Min
+
+ +
+
Sa12
+
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Portionen
+
+ Tofu + Paprika + Brokkoli + Karotten + Zucchini + Ingwer + Sesamöl + Sojasauce + Knoblauch + Salz, Pfeffer +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ +
+ + +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht auf die Woche?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
Bewertung
+
+
✓ Kein Protein-Overlap
+
✓ Neue Zutaten
+
~ Tofu zum 2. Mal
+
+
+ +
+
+ +
+ Nach Klick auf Mittwoch: Nicht-ausgewählte Kacheln werden auf 45% Deckkraft gedimmt. + Die Expansion erscheint direkt unter dem Grid (Pfeil zeigt zu Mi). + Die Kacheln bleiben auf ihrer Höhe — der Expansion-Bereich wächst zusätzlich darunter. + Zutaten zeigen normale Zutaten als Pills; Grundzutaten (Sesamöl, Sojasauce…) gedimmt. +
+
+ + + + + +
+
+ 03 + Leerer Tag angeklickt + Klick auf Sa — kein Gericht geplant +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+ +
+
+ +
+
Mo7
+
+
Hähnchen-Curry
35 Min
+
+ +
+
Di8
+
+
Pasta Bolognese
45 Min
+
+ +
+
Mi9
+
+
Gemüse-Stir-fry
20 Min
+
+ +
+
Do10
+
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
Fr11
+
+
Pizza Margherita
50 Min
+
+ + +
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neues Protein +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Aufwand: leicht +
+
Alle Rezepte →
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
+
Samstag, 12. Apr — Alle Vorschläge
+
+ +
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+ +
+
Shakshuka
+
25 Min · einfach
+ Kein Overlap +
+ +
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Gleiche Zutaten +
+ +
+
Kürbissuppe
+
35 Min · einfach
+ Kein Overlap +
+ +
+
+
+ +
+ + +
+
Samstag, 12. Apr
+ +
+
Ramen mit Ei
40 Min · mittel
Top
+
Shakshuka
25 Min · einfach
Top
+
Kürbissuppe
35 Min · einfach
+
Tofu-Teriyaki
30 Min · einfach
+
Gemüse-Curry
40 Min · mittel
+
Linseneintopf
50 Min · einfach
+
Ofen-Lachs
35 Min · einfach
+
+
+ +
+
+ +
+ Nach Klick auf leeren Samstag: Die Kachel selbst zeigt schon die 3 Inline-Vorschläge (sichtbar seit Zustand 1). + Der Pfeil-Indikator erscheint, die Expansion zeigt alle 4 Vorschläge nebeneinander als klickbare Karten. + Das rechte Panel öffnet gleichzeitig den vollständigen Rezept-Picker mit Suche — für alle anderen Optionen. + Klick auf eine Karte (main) oder Picker-Eintrag (rechts) trägt das Rezept ein und schließt die Expansion. +
+
+ + +