Hähnchen-Curry
+ + mittel + Hähnchen +Pasta Bolognese
+ + mittel + Rind +Gemüse-Stir-fry
+ + einfach + Tofu +Lachsfilet mit Kartoffeln
+ + einfach + Fisch +Pizza Margherita
+ + aufwändig + vegetarisch +- {warning.title} -
-- {warning.explanation} -
+ ++ {warning.title} +
+Bild
+ {#if heroImageUrl} +{imageError}
+ {:else} +Max. 5 MB
+ {/if} + +Zutaten
@@ -227,35 +306,42 @@Kategorien
-Kategorien
+ {#each groupedCategories as [type, tags] (type)} ++ {TAG_TYPE_LABELS[type] ?? type} +
+Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine
+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.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.
+Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.
+Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.
++ 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 +
+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+
// 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+
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.
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+
{
+ "email": "[email protected]", // required, valid email
+ "password": "s3cure!Pass", // required, min 8 chars
+ "displayName": "Sarah" // required, 1–100 chars
+}{
+ "id": "550e8400-...",
+ "email": "[email protected]",
+ "displayName": "Sarah",
+ "householdId": null, // no household yet
+ "householdRole": null
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax{
+ "email": "[email protected]",
+ "password": "s3cure!Pass"
+}{
+ "id": "550e8400-...",
+ "email": "[email protected]",
+ "displayName": "Sarah",
+ "householdId": "7c9e6679-...",
+ "householdRole": "planner",
+ "systemRole": "user"
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax// empty — no body needed// empty body
++ Set-Cookie: JSESSIONID=; Max-Age=0// no body — GET request
+// session cookie sent automatically{
+ "id": "550e8400-...",
+ "email": "[email protected]",
+ "displayName": "Sarah",
+ "householdId": "7c9e6679-...",
+ "householdName": "Smith family",
+ "householdRole": "planner",
+ "systemRole": "user"
+}{
+ "displayName": "Sarah S.", // optional
+ "currentPassword": "old...", // required if changing pw
+ "newPassword": "new..." // optional, min 8 chars
+}{
+ "id": "550e8400-...",
+ "email": "[email protected]",
+ "displayName": "Sarah S."
+}{
+ "name": "Smith family" // required, 1–100 chars
+}
+// Seeds ~20 default staple ingredients
+// Seeds default tags (protein types, dietary)
+// Adds current user as planner{
+ "id": "7c9e6679-...",
+ "name": "Smith family",
+ "members": [
+ {
+ "userId": "550e8400-...",
+ "displayName": "Sarah",
+ "role": "planner",
+ "joinedAt": "2026-04-01T10:00:00Z"
+ }
+ ]
+}// no body — GET{
+ "id": "7c9e6679-...",
+ "name": "Smith family",
+ "members": [
+ { "userId": "550e...", "displayName": "Sarah",
+ "role": "planner", "joinedAt": "..." },
+ { "userId": "661f...", "displayName": "Tom",
+ "role": "member", "joinedAt": "..." }
+ ]
+}// empty — server generates the code{
+ "inviteCode": "ABC12XYZ",
+ "shareUrl": "https://yourapp.com/join/ABC12XYZ",
+ "expiresAt": "2026-04-03T10:00:00Z"
+}// code is in the URL path
+// no request body needed{
+ "householdId": "7c9e6679-...",
+ "householdName": "Smith family",
+ "role": "member"
+}
+// 409 if code already used
+// 422 if code expired
+// 409 if user already in a household// no body — GET[
+ { "userId": "550e...", "displayName": "Sarah",
+ "role": "planner", "joinedAt": "..." },
+ { "userId": "661f...", "displayName": "Tom",
+ "role": "member", "joinedAt": "..." }
+]?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
{
+ "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
+ }
+ }
+}// no body — GET
+// {id} = recipe UUID{
+ "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" }
+ ]
+}{
+ "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)
+}// full RecipeDetail (same shape as GET /recipes/{id}) +{ + "id": "a1b2c3d4-...", + "name": "Spaghetti Bolognese", + ... +} ++ Location: /v1/recipes/a1b2c3d4-...
// 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": [ ... ]
+}// full RecipeDetail with updated data
+{
+ "id": "a1b2c3d4-...",
+ "name": "Spaghetti Bolognese (updated)",
+ ...
+}// no body — DELETE// empty body?search=chick // ILIKE '%chick%' +?isStaple=true // filter staples (A3/D3)
[
+ { "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 }
+]{
+ "isStaple": true,
+ "name": "olive oil",
+ "categoryId": "cat-03-..." // FK → ingredient_category
+}{ "id": "f1e2-...", "name": "olive oil",
+ "category": { "id": "cat-03-...", "name": "oil" },
+ "isStaple": true }// no body — GET[
+ { "id": "t1-...", "name": "chicken", "tagType": "protein" },
+ { "id": "t2-...", "name": "beef", "tagType": "protein" },
+ { "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
+ { "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
+]{
+ "name": "Thai",
+ "tagType": "cuisine" // protein|dietary|cuisine
+}{ "id": "t9-...", "name": "Thai",
+ "tagType": "cuisine" }// no body — GET
+// scoped to user's household automatically[
+ { "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{
+ "name": "frozen" // required, 1–50 chars
+}
+// 409 if name already exists in household{
+ "id": "cat-08-...",
+ "name": "frozen"
+}?weekStart=2026-04-06 // ISO date, must be Monday{
+ "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{
+ "weekStart": "2026-04-06" // must be a Monday
+}{
+ "id": "wp-1234-...",
+ "weekStart": "2026-04-06",
+ "status": "draft",
+ "slots": []
+}
+// 409 if plan already exists for that week{
+ "slotDate": "2026-04-07", // within plan week
+ "recipeId": "a1b2c3d4-..."
+}{
+ "id": "sl-03-...",
+ "slotDate": "2026-04-07",
+ "recipe": {
+ "id": "a1b2c3d4-...",
+ "name": "Spaghetti Bolognese",
+ "effort": "medium",
+ "cookTimeMin": 45,
+ "heroImageUrl": "..."
+ }
+}{
+ "recipeId": "x9y8z7-..." // new recipe
+}{
+ "id": "sl-03-...",
+ "slotDate": "2026-04-07",
+ "recipe": {
+ "id": "x9y8z7-...",
+ "name": "Quick Stir Fry",
+ "effort": "easy", "cookTimeMin": 15, ...
+ }
+}// no body — DELETE// empty body// empty — action endpoint{
+ "id": "wp-1234-...",
+ "status": "confirmed",
+ "confirmedAt": "2026-04-05T18:30:00Z"
+}
+// 422 if no slots filled
+// 422 if already confirmed?slotDate=2026-04-08 // target day{
+ "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"]
+ }
+ ]
+}// no body — GET{
+ "score": 7.5,
+ "ingredientOverlaps": [
+ { "ingredientName": "onion",
+ "days": ["2026-04-06", "2026-04-07"] }
+ ],
+ "proteinRepeats": [],
+ "effortBalance": {
+ "easy": 2, "medium": 3, "hard": 2
+ }
+}{
+ "recipeId": "a1b2c3d4-...",
+ "cookedOn": "2026-04-07" // default: today
+}{
+ "id": "cl-01-...",
+ "recipeId": "a1b2c3d4-...",
+ "cookedOn": "2026-04-07",
+ "cookedBy": "550e8400-..."
+}?limit=30 // default 30
+?offset=0[
+ { "id": "cl-01-...", "recipeId": "a1b2-...",
+ "recipeName": "Spaghetti Bolognese",
+ "cookedOn": "2026-04-07",
+ "cookedBy": "550e8400-..." }
+]// empty — generated from the week plan
+// server merges ingredients across meals,
+// sums quantities, filters staples{
+ "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-..."] }
+ ]
+}// no body — GET
+// this is the refresh action:
+// pull-to-refresh calls this endpoint// same shape as POST response above
+{
+ "id": "shl-01-...",
+ "status": "published",
+ "items": [ ... ]
+}// empty — action endpoint{
+ "id": "shl-01-...",
+ "status": "published",
+ "publishedAt": "2026-04-06T09:00:00Z"
+}
+// 422 if already published{
+ "isChecked": true
+}{
+ "id": "si-01-...",
+ "name": "spaghetti",
+ "isChecked": true,
+ "checkedBy": "661f-..." // who checked it
+}
+// other members see this on next refresh{
+ "ingredientId": null, // or existing id
+ "customName": "Paper towels", // if no ingredientId
+ "quantity": 1,
+ "unit": "" // blank for countable
+}{
+ "id": "si-10-...",
+ "ingredientId": null,
+ "name": "Paper towels",
+ "quantity": 1, "unit": "",
+ "isChecked": false,
+ "sourceRecipes": []
+}// no body — DELETE// 422 if list is already published// no body — GET[
+ { "id": "pi-01-...",
+ "ingredientId": "f1e2-...",
+ "name": "chicken breast",
+ "category": { "id": "cat-02-...", "name": "meat" },
+ "quantity": 500, "unit": "g",
+ "bestBefore": "2026-04-10",
+ "openedOn": null }
+]{
+ "ingredientId": "f1e2-...", // or null
+ "customName": null, // if no ingredientId
+ "quantity": 500,
+ "unit": "g",
+ "bestBefore": "2026-04-10",
+ "openedOn": null
+}{ "id": "pi-02-...",
+ "ingredientId": "f1e2-...",
+ "name": "chicken breast",
+ "quantity": 500, "unit": "g",
+ "bestBefore": "2026-04-10",
+ "openedOn": null }{
+ "quantity": 250,
+ "openedOn": "2026-04-07"
+}{ "id": "pi-02-...", "quantity": 250,
+ "openedOn": "2026-04-07", ... }// no body// empty?limit=50&offset=0
+?search=jane // by email or name
+?isActive=true{
+ "data": [
+ { "id": "550e-...", "email": "[email protected]",
+ "displayName": "Sarah", "systemRole": "user",
+ "isActive": true, "createdAt": "..." }
+ ],
+ "meta": { "pagination": { "total": 24, ... } }
+}{
+ "email": "[email protected]",
+ "displayName": "New User",
+ "tempPassword": "Change1Me!",
+ "systemRole": "user" // default "user"
+}{
+ "id": "new-uuid-...",
+ "email": "[email protected]",
+ "displayName": "New User",
+ "systemRole": "user",
+ "isActive": true,
+ "mustChangePassword": true
+}{
+ "displayName": "Jane Smith",
+ "email": "[email protected]",
+ "systemRole": "admin",
+ "isActive": false // deactivate
+}{ "id": "...", "email": "[email protected]",
+ "displayName": "Jane Smith",
+ "systemRole": "admin", "isActive": false }{
+ "tempPassword": "Reset1Me!",
+ "reason": "user requested via support"
+}{
+ "message": "Password reset successfully",
+ "mustChangePassword": true
+}?limit=50&offset=0
+?targetUserId=550e-... // filter by user[
+ { "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" }
+]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.
Role │ Recipes │ Plan │ Shopping list │ Pantry │ Admin +────────────┼──────────┼──────────┼───────────────────┼──────────┼────── +Planner │ CRUD │ CRUD │ generate,publish │ CRUD │ — +Member │ — │ READ │ read,check,add │ — │ — +Admin │ — │ — │ — │ — │ CRUD + audit +Unauth │ — │ — │ — │ — │ —+
password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)
+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.
+Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.
+SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.
+Recipe app · PostgreSQL 16 · Normalized schema with audit trails
+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 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.system_role column on user_account (admin vs user). New admin_audit_log table tracks admin actions: account creation, updates, password resets.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.
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.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
| id | uuid | PK, gen_random_uuid() | Surrogate PK |
| household_id | uuid | NOT NULL, FK → household ON DELETE CASCADE | Tags belong to a household |
| name | citext | NOT NULL | "Chicken", "Fish", "Vegetarian", "Pasta" |
| tag_type | varchar(20) | NOT NULL, CHECK(tag_type IN ('protein','dietary','cuisine','other')) | Classification. "protein" powers J2 consecutive-day filter. |
| created_at | timestamptz | NOT NULL, DEFAULT now() | Creation time |
| Column | Type | Constraints | Purpose |
|---|---|---|---|
| recipe_id | uuid | NOT NULL, FK → recipe ON DELETE CASCADE, part of composite PK | Which recipe |
| tag_id | uuid | NOT NULL, FK → tag ON DELETE CASCADE, part of composite PK | Which tag |
-- 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;+
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.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
| id | uuid | PK, gen_random_uuid() | Surrogate PK |
| household_id | uuid | NOT NULL, FK → household ON DELETE CASCADE | Categories are per-household |
| name | citext | NOT NULL | "Produce", "Fish & Meat", "Dry Goods", "Dairy", "Sauces & Condiments" |
| sort_order | smallint | NOT NULL, DEFAULT 0 | Display order — matches supermarket aisle flow |
| created_at | timestamptz | NOT NULL, DEFAULT now() | Creation time |
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.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
| id | uuid | PK, gen_random_uuid() | Surrogate PK |
| citext | NOT NULL, UNIQUE | Login identifier, case-insensitive | |
| display_name | varchar(100) | NOT NULL | Shown in UI (sidebar avatar initials) |
| password_hash | varchar(255) | NOT NULL | bcrypt/argon2 hash — never exposed via API |
| system_role | varchar(10) | NOT NULL, DEFAULT 'user', CHECK(system_role IN ('admin','user')) | NEW — platform role. Admin can manage all accounts. |
| is_active | boolean | NOT NULL, DEFAULT true | NEW — admin can deactivate accounts. Inactive users cannot log in. |
| created_at | timestamptz | NOT NULL, DEFAULT now() | Account creation time |
| updated_at | timestamptz | NOT NULL, DEFAULT now() | Last profile edit |
| Column | Type | Constraints | Purpose |
|---|---|---|---|
| id | uuid | PK, gen_random_uuid() | Surrogate PK |
| admin_id | uuid | NOT NULL, FK → user_account ON DELETE RESTRICT | Which admin performed the action |
| target_user_id | uuid | NOT NULL, FK → user_account ON DELETE RESTRICT | Which user was affected |
| action | varchar(30) | NOT NULL, CHECK(action IN ('create_account','update_account','reset_password','deactivate_account','reactivate_account','change_system_role')) | What happened |
| detail | jsonb | NULL | Changed fields snapshot: {"field":"email","old":"a@x.com","new":"b@x.com"} |
| ip_address | inet | NULL | Admin's IP for security audit |
| performed_at | timestamptz | NOT NULL, DEFAULT now() | When the action occurred |
| From table | Column | References | Cardinality | On delete |
|---|---|---|---|---|
| household | created_by | user_account.id | N:1 | RESTRICT |
| household_member | household_id | household.id | N:1 | CASCADE |
| household_member | user_id | user_account.id | N:1 | CASCADE |
| household_invite | household_id | household.id | N:1 | CASCADE |
| recipe | household_id | household.id | N:1 | CASCADE |
| ingredient | household_id | household.id | N:1 | CASCADE |
| ingredient_category | household_id | household.id | N:1 | CASCADE |
| ingredient | category_id | ingredient_category.id | N:1 (nullable) | SET NULL |
| tag | household_id | household.id | N:1 | CASCADE |
| recipe_ingredient | recipe_id | recipe.id | N:1 | CASCADE |
| recipe_ingredient | ingredient_id | ingredient.id | N:1 | RESTRICT |
| recipe_step | recipe_id | recipe.id | N:1 | CASCADE |
| recipe_tag | recipe_id | recipe.id | M:N junction | CASCADE |
| recipe_tag | tag_id | tag.id | M:N junction | CASCADE |
| week_plan | household_id | household.id | N:1 | CASCADE |
| week_plan_slot | week_plan_id | week_plan.id | N:1 | CASCADE |
| week_plan_slot | recipe_id | recipe.id | N:1 | RESTRICT |
| cooking_log | recipe_id | recipe.id | N:1 | RESTRICT |
| cooking_log | week_plan_slot_id | week_plan_slot.id | N:1 (nullable) | SET NULL |
| shopping_list | week_plan_id | week_plan.id | N:1 | RESTRICT |
| shopping_list_item | shopping_list_id | shopping_list.id | N:1 | CASCADE |
| shopping_list_item | ingredient_id | ingredient.id | N:1 (nullable) | SET NULL |
| pantry_item | household_id | household.id | N:1 | CASCADE |
| pantry_item | ingredient_id | ingredient.id | N:1 (nullable) | SET NULL |
| admin_audit_log | admin_id | user_account.id | N:1 | RESTRICT |
| admin_audit_log | target_user_id | user_account.id | N:1 | RESTRICT |
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;+
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;+
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;+
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;+
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;+
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;+
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;+
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;+
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
+-- 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 | Reads | Writes | Critical path |
|---|---|---|---|
| J1 · Add recipe | ingredient, tag (autocomplete) | recipe, recipe_ingredient, recipe_step, recipe_tag, ingredient, tag | Recipe INSERT + child rows + tag associations in one transaction |
| J2 · Plan week | recipe, recipe_ingredient, recipe_tag, tag, cooking_log, ingredient | week_plan, week_plan_slot | Variety CTE joins tag (type=protein) for consecutive-day check |
| J3 · Cook tonight | week_plan_slot, recipe, recipe_ingredient, recipe_step | cooking_log | cooking_log INSERT (immutable event) |
| J4 · Adapt on fly | recipe, recipe_tag, tag, cooking_log | week_plan_slot (UPDATE recipe_id) | Slot UPDATE + variety recompute ≤ 3 taps |
| J5 · Shopping list | week_plan_slot, recipe_ingredient, ingredient, ingredient_category | shopping_list, shopping_list_item | Merge query (GROUP BY ingredient, SUM quantity) + aisle grouping via category |
| J6 · Household setup | — | user_account, household, household_member, household_invite, ingredient (staples), tag (seed data), ingredient_category (seed data) | Household creation + seed data in one transaction |
| Pantry | pantry_item, ingredient | pantry_item | Expiry notification query (daily) |
| Admin | user_account, admin_audit_log | user_account, admin_audit_log | Every admin action → audit log INSERT in same transaction |
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.
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."
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?"
+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.
+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.
+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.
+5 Design-Variationen · Desktop-first · Routes /settings + /household/staples · Journey J8
+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.
<StaplesManager categories={data.categories} context="settings" />). Keine Seiten-Navigation erforderlich.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.
+ */
+
+ | Element | Value / Rule | Notes |
|---|---|---|
| StapleChip | ||
| Shape | border-radius: --radius-full · padding: 6px 14px | font-size: 13px (desktop) · 12px (mobile) |
| Selected state | background: --green-dark · color: #fff · font-weight: 500 | Toggle off: PATCH ingredient isStaple=false |
| Unselected state | background: transparent · border: 1px solid --color-border · color: --color-text-muted · font-weight: 400 | Toggle on: PATCH ingredient isStaple=true |
| Debounce | 300ms after last toggle before PATCH fires | Already implemented in StaplesManager. Do not add extra debounce layers. |
| Error state | Revert chip to previous state · show inline error message | StaplesManager already handles rollback on API error |
| Category section | ||
| Label | 10px · weight 500 · tracking 0.08em · uppercase · --color-text-muted | German category names from API |
| Chip grid | display: flex · flex-wrap: wrap · gap: 7px (desktop) · 6px (mobile) | No fixed column count — chips wrap naturally |
| Settings page (E1) — V3 Accordion | ||
| Vorräte section | Open by default on page load | Use Svelte derived state or URL hash to control. Default open state. |
| Collapsed stat | Show "N aktiv" reactively next to chevron | Derive from stapleState in StaplesManager — count true values |
| Accordion trigger min-height | 48px (desktop) · 44px (mobile) | WCAG: interactive controls must have 44px min touch target |
| Accordion chevron | ▲ (open) / ▼ (closed) · color: --color-text-muted | Or 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-width | Chips wrap to multiple lines — no truncation |
5 Design-Variationen · Desktop-first · Route /members · Journey J7
+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.
+ +| Name ↕ | +Rolle | +Beigetreten ↕ | +Status | +Aktionen | +
|---|---|---|---|---|
MR Marcel Raddatz |
+ Planner | +14. Jan 2026 | +Aktiv | +— | +
SR Sarah Raddatz |
+ Mitglied | +15. Jan 2026 | +Aktiv | ++ |
TM Tom Meier |
+ Mitglied | +3. Mär 2026 | +Aktiv | ++ |
? inv_x8K2j |
+ — | +— | +Ausstehend · 2 Tage | ++ |
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. + */+ +
| Element | Value / Rule | Notes |
|---|---|---|
| Avatar | ||
| Size | 40px × 40px (desktop) · 36px × 36px (mobile) | border-radius: 50% |
| Planner colour | bg --green-tint · text --green-dark | Contrast OK: #2E6E39 on #E8F5EA ≈ 6.1:1 |
| Member colour | bg --blue-tint · text --blue-dark | Contrast OK: #0C447C on #E6F1FB ≈ 7.4:1 |
| Content | First letter of first + last name (uppercase) | Max 2 characters |
| Role badge | ||
| Shape | border-radius: --radius-full · padding: 3px 10px | font-size: 11px · font-weight: 500 |
| Planner | bg --green-tint · color --green-dark | Label: "Planner" |
| Member | bg --blue-tint · color --blue-dark | Label: "Mitglied" |
| Invite / pending | ||
| Expiry badge — urgent (≤3d) | bg --yellow-tint · color --yellow-text | "Läuft ab in N Tagen" |
| Expiry badge — normal | bg --color-subtle · color --color-text-muted | "Läuft ab am DD. MMM" |
| Code font | font-family: --font-mono · font-size: 13px | Invite codes are monospace |
| Interactions | ||
| Remove action | Confirmation dialog required | Dialog must show member name. Irreversible — member loses access immediately. |
| Copy invite link | navigator.clipboard.writeText() | Show transient "Kopiert!" feedback (checkmark, 2s) |
| Regenerate invite | POST /household/invite — returns new code | Old code is immediately invalidated |
| Responsive | ||
| Desktop (≥1024px) | 224px sidebar + full content area | Active sidebar item: Mitglieder (Haushalt section) |
| Mobile (<768px) | No sidebar · bottom nav · "Einstellungen" tab active | V3 mobile uses tab bar within page, not app bottom nav tabs |
Mealplan · Planer · Konzept
++ 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. +
+ +Hähnchen-Curry
35 Min
mittelPasta Bolognese
45 Min
mittelGemüse-Stir-fry
20 Min
einfachLachs mit Kartoffeln
30 Min
einfachPizza Margherita
50 Min
aufwändigHähnchen-Curry
35 Min
mittelPasta Bolognese
45 Min
mittelGemüse-Stir-fry
+20 Min
+ einfach +Lachs mit Kartoffeln
30 Min
Pizza Margherita
50 Min
Hähnchen-Curry
35 Min
Pasta Bolognese
45 Min
Gemüse-Stir-fry
20 Min
Lachs mit Kartoffeln
30 Min
Pizza Margherita
50 Min
Mealplan · Planer · Full-Bleed Tiles
++ 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. +
+ + + + + +rgba(255,255,255,.2) als Glasmorphismus-Hintergrund.
+ Zustands-Borders werden per box-shadow umgesetzt (kein Layout-Shift durch border:2px).
+ 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.
+ Mealplan · Wochenplaner
++ 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. +
+ + + + + +Hähnchen-Curry
+ + mittel + Hähnchen +Pasta Bolognese
+ + mittel + Rind +Gemüse-Stir-fry
+ + einfach + Tofu +Lachsfilet mit Kartoffeln
+ + einfach + Fisch +Pizza Margherita
+ + aufwändig + vegetarisch +Gemüse-Stir-fry
+20 Min · einfach
+Hähnchen-Curry
+ +Pasta Bolognese
+ +Gemüse-Stir-fry
+ +Lachs mit Kartoffeln
+ +Pizza Margherita
+ +Gemüse-Stir-fry
+20 Min · einfach
+Hähnchen-Curry
+ + mittel +Pasta Bolognese
+ + mittel +Gemüse-Stir-fry
+ + einfach +Lachs mit Kartoffeln
+ + einfach +Pizza Margherita
+ + aufwändig +Pasta Bolognese
+45 Min · mittel
+ +Hähnchen-Curry
+ + mittel + Hähnchen +Pasta Bolognese
+ + mittel + Rind +Gemüse-Stir-fry
+ + einfach + Tofu +Lachs mit Kartoffeln
+ + einfach + Fisch +Pizza Margherita
+ + aufwändig + vegetarisch +Pasta Bolognese
+Dienstag · 45 Min · mittel
+ +Gemüse-Stir-fry
+20 Min · einfach
+Mealplan · Wochenplaner · Hauptbereich
++ 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. +
+ + + + + +Hähnchen-Curry
mittelPasta Bolognese
mittelGemüse-Stir-fry
einfachLachs mit Kartoffeln
einfachPizza Margherita
aufwändigvarietyScore Objekt. Der „Detailansicht →" Link führt zur vollen Analyse-Seite.
+ tagRepeats).
+ Hähnchen-Curry
HähnchenPasta Bolognese
RindGemüse-Stir-fry
TofuLachs mit Kartoffeln
FischPizza Margherita
veg.Hähnchen-Pfanne
HähnchenLinsensuppe
veg.weekStart Parameter).
+ Zwei zusätzliche Abrufe beim Laden der Seite. Score für Folgewochen wird mit kleinerem Datensatz berechnet.
+ Hähnchen-Curry
Pasta Bolognese
Gemüse-Stir-fry
Lachs mit Kartoffeln
Pizza Margherita
SuggestionResponse API, die der Planer schon abruft.
+ Hähnchen-Curry
Pasta Bolognese
Gemüse-Stir-fry
Lachs mit Kartoffeln
Pizza Margherita
RecipeIngredient.staple), damit man diese ein-/ausblenden kann.
+ Hähnchen-Curry
Pasta Bolognese
Gemüse-Stir-fry
+ +Lachs mit Kartoffeln
Pizza Margherita
Mealplan · Planer · Tall Tiles
++ 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. +
+ + + + + +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).
+