Compare commits

...

176 Commits

Author SHA1 Message Date
f97cf49bd0 feat(planner): overhaul desktop layout — flip tiles, no right panel
Replaces 3-panel layout with 2-panel (sidebar + full-width grid):
- Remove persistent right panel and toolbar + Gericht hinzufügen button
- grid-cols-7 tiles use DesktopDayTile (CSS 3D card flip)
- RecipePickerDrawer slides in on tile CTA / Gericht tauschen
- Page-owned activeSlotId + drawerOpen/drawerSlotId state
- Single Escape handler: drawer > flip priority
- Extend server load to forward recipe tags from /v1/recipes API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:04:26 +02:00
2cebf504f2 feat(planner): add RecipePickerDrawer slide-in drawer
Wraps RecipePicker in a fixed right-side drawer with backdrop.
Slide-in/out transition, backdrop click closes, purely presentational
(open + onclose props from parent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:52:56 +02:00
d20cd53be2 feat(planner): add DesktopDayTile flip-tile component
CSS 3D card flip with scene/card/front/back structure. Filled slots
show gradient/image front face and action back face (Koch-Modus,
tauschen, entfernen). Empty slots delegate to EmptyDayTile.
Sibling dimming and aria-expanded via activeSlotId prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:51:21 +02:00
2b7a7cceec feat(planner): add EmptyDayTile component
Dashed-border empty slot tile with + Gericht wählen CTA and lazy
reasoning tags (Neues Protein, Aufwand: leicht) derived from
topSuggestion prop via computeReasoningTags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:47:19 +02:00
f37f20d34e feat(planner): add computeReasoningTags pure helper
Derives ReasoningTag[] from slotMap + recipe. Covers Neues Protein
(protein not yet in week) and Aufwand: leicht (cookTimeMin < 30 or
effort einfach/leicht). No component dependency — Vitest-testable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:45:42 +02:00
f2071ca5d8 feat(planner): add flip-tile design tokens to app.css
Adds --color-ring-today, --color-ring-selected, --opacity-dimmed,
9 protein gradient tokens and 5 cuisine gradient tokens as @theme
custom properties, integrating into the existing token layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:44:46 +02:00
16e1539ac0 chore: merge master — adopt SlotResponse.SlotRecipe in SuggestionItem
Resolves conflict by keeping master's refactor: SuggestionItem now reuses
SlotResponse.SlotRecipe instead of the dedicated SuggestionRecipe record,
removing the duplication and adding heroImageUrl to suggestion responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:08:38 +02:00
e5cdce164a feat(recipes): give 'Bild entfernen' button persistent muted-red color
Was only red on hover — now always red at 60% opacity, full opacity on hover,
making the destructive intent immediately visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:40:51 +02:00
73b4fb84e7 feat(recipes): add (min) unit hint to Kochzeit label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:40:04 +02:00
932155c559 chore(backend): ignore application-dev.yml to prevent leaking local secrets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:39:01 +02:00
a5bb5d45a3 docs(config): annotate multipart limits explaining JSON body is not covered
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:38:48 +02:00
b2a798d90e docs(tests): clarify why fake base64 is acceptable in allowed-image-type test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:38:29 +02:00
23c821937f test(recipes): add JPEG input test for ImageCompressor
Confirms the compressor accepts JPEG data URIs as input (not just PNG).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:38:01 +02:00
9df6d6f0c6 test(recipes): verify null preview is stored when compressor returns null
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:37:24 +02:00
ebaf42d83d feat(recipes): return fail(422) when all ingredients filter to empty
Prevents a silent 400 from the backend when the user submits a form
where every ingredient row has quantity <= 0 or blank name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:36:41 +02:00
56e6143fd2 feat(recipes): validate image MIME type on file select
Rejects non-allowlisted types (only JPEG, PNG, GIF, WebP accepted) with
an inline error message. Uses image/bmp as test vector since it passes
accept="image/*" but is not in the allowed set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:33:39 +02:00
ed769b18a4 fix(recipe): add server-side image size limit and use .matches() for type check
- @Size(max=7_000_000) on heroImageUrl enforces ~5 MB cap at bean validation
- ALLOWED_IMAGE_PATTERN uses .matches() for unambiguous full-string check
- Tests: oversized image → 400, empty ingredients list → 400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:27:35 +02:00
f11cca534f feat(recipe): compress hero image to 400px preview on save
Adds Thumbnailator-based ImageCompressor that resizes uploaded images
to a 400px-wide JPEG preview stored in hero_image_preview. The recipe
list uses the preview instead of the full image URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:14:35 +02:00
822b34cd14 feat(recipe-form): reject files > 5 MB and show Max. 5 MB hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:11:57 +02:00
46f2ec45a3 feat(backend): limit multipart upload to 5 MB file / 6 MB request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:09:14 +02:00
90cff0c4d2 feat(recipe): validate heroImageUrl content type before persisting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:08:45 +02:00
b1eb9ed964 feat(recipes): send null instead of undefined for blank serves/cookTimeMin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:06:39 +02:00
44b3f06474 feat(recipes): filter ingredients with quantity <= 0 before API submission
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:05:19 +02:00
dbc78a1883 test(recipe): cover null serves/cookTimeMin and capitalised effort rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:00:16 +02:00
30ba53099c refactor(recipes): drop is_child_friendly column and remove from all layers
V025 migration drops the column. Removed from Recipe entity, RecipeDetailResponse,
RecipeSummaryResponse, RecipeRepository JPQL, RecipeService, and RecipeController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:56:57 +02:00
520dae5adf 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 <noreply@anthropic.com>
2026-04-09 20:23:28 +02:00
f139dce82c docs(specs): add planner desktop redesign spec — flip tiles
Final design spec for the planner desktop layout overhaul:
full-bleed color tiles, CSS 3D card flip for recipe detail,
no persistent right panel, inline suggestions on empty days.
Includes interactive mockup and written component spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:20:01 +02:00
0596fddcd3 refactor(planning): extract applyPenalties helper to unify score formula
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
008c725813 test(planner): verify mobile swap sheet triggers suggestion fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
1739b70d54 feat(planner): change neutral badge copy to Kein Einfluss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
3b829325f2 feat(planner): hide RecipePicker inner header in swap context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
d139e5e28c refactor(planner): delete orphaned SwapSuggestionList component and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
c9d6564fbe refactor(planner): remove dead SwapSuggestionList import and sortedRecipes derived
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
ba79cff4e7 feat(planner): show variety score in swap menu via RecipePicker
Replace SwapSuggestionList with RecipePicker in both mobile and desktop
swap contexts. RecipePicker now accepts excludeRecipeId, replacingRecipe,
and isDisabled props. Mobile swap sheet also triggers suggestion fetch
via activePickerDate so green/yellow/red score badges appear during swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
55285e7d5d feat(planner): show score badges for all recipes in RecipePicker
- +server.ts: pass topN=100 so all recipes are scored in one request
- RecipePicker: Empfohlen keeps top 5 with scoreDelta > 0; builds a
  scoreMap from all suggestions; shows green/yellow/red delta badge on
  every recipe in Alle Rezepte that has a score entry
- Extracted scoreBadge snippet to avoid duplication between sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
055ae11fa3 feat(planner): show yellow neutral badge for scoreDelta = 0 in RecipePicker
Neutral suggestions (no variety impact) now show "= 0.0 Punkte" in yellow
instead of no badge, making the three states explicit: green (improves),
yellow (neutral), red (worsens).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
bf18f2bd84 fix(planner): format variety score to one decimal place
Avoids floating-point display like 6.199999999999999 by using
score.toFixed(1) in VarietyScoreCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
da21a12222 feat(planner): replace Variationskonflikt with red delta badge
Shows the actual score delta (e.g. "↓ -1.5 Punkte") in red instead of a
generic ⚠ Variationskonflikt label, letting users compare the cost of each
recipe to make an informed swap decision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
e9dc04b2a5 feat(planner): add remove meal with undo; fix RecipePicker badge for neutral delta
- MealActionSheet: new onremove prop + Entfernen button (guarded by #if)
- +page.svelte: handleRemoveMeal submits delete form, shows undo bar;
  undo re-adds via addSlot form; refactored handleUndo to undoCallback
  pattern; desktop day-detail panel also gets Entfernen button
- RecipePicker: only show green +delta badge when scoreDelta > 0;
  neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt
- Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
8dfc3df06b fix(planning): hasConflict only when scoreDelta strictly negative
Neutral suggestions (scoreDelta = 0) are not conflicts — they simply
don't improve variety. Changing scoreDelta <= 0 to scoreDelta < 0
lets empty-plan additions and quality-neutral swaps show without a
misleading ⚠ Variationskonflikt warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
ea070b4760 fix(planning): replace existing slot in simulation instead of appending
simulateVarietyScore was adding the candidate recipe on top of the
existing slot for slotDate, keeping the old recipe's tag-repeat penalty
in the score. Now the existing slot is excluded before simulating, so
swapping a recipe for one with better variety correctly shows positive
scoreDelta and hasConflict=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
aecdf249d6 feat(planner): add onremove prop and Entfernen button to MealActionSheet
Button only renders when onremove callback is provided, keeping the
component usable in read-only contexts without the destructive action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
e4345350ad fix(planner): RecipePicker UI polish from review
- Badge font-size 8px → 9px (WCAG minimum)
- Score badge toFixed(1) to avoid misleading "+0 Punkte"
- Self-contain @keyframes pulse in component <style> block
- Wählen buttons use var(--green-dark) per design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
56decf155d test(planner): clarify server.test.ts error-branch test name
"when backend returns error" → "when data is undefined (error response
without data)" — documents that the guard is data?.suggestions ?? [],
not error field inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
1de4b15e34 refactor(planner): extract Suggestion type to $lib/planner/types.ts
Removes the inline interface from RecipePicker.svelte and replaces
any[] in +page.svelte with Suggestion[] — compile-time safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
ccec0baa99 feat(planner): add AbortController to suggestion fetch $effect
Cancels the inflight request when activePickerDate changes or picker
closes, preventing stale responses from overwriting suggestions.
Adds page.test.ts covering fetch trigger, suggestion rendering,
and AbortSignal presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
9928591b48 refactor(planner): extract computeCurrentScore helper in PlanningService
Eliminates duplicated currentSlots→score pattern that appeared in both
getSuggestions and getVarietyPreview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
89a549a1c8 test(planner): assert hasConflict=true for neutral scoreDelta on empty plan
Documents the surprising-but-correct behavior: recipes on an empty plan
get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
c24281dd4c test(planner): cover topN=0 and topN=-1 boundary in SuggestionsTest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
8051fcbe22 refactor(planner): extract MAX_VARIETY_SCORE constant in PlanningService
Replaces magic literal 10.0 with a named constant in all four
scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots,
and getVarietyScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
b45ab0fd46 fix(planner): guard scoreDelta against undefined in RecipePicker badge
Defensive null-coalescing prevents crash when suggestion data arrives
without scoreDelta (e.g. stale backend or mismatched schema).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
2bbc3762e2 feat(planner): lazy-fetch variety suggestions in RecipePicker for empty slots
Derives activePickerDate from mobile pickerOpen/selectedDay and desktop
recipe-picker panel state, then uses $effect to fetch /planner?planId&date
on demand — wires suggestions and isLoading into both RecipePicker instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
a751b0758a feat(planner): add server.test.ts for GET /planner, fix sort + add error handling
- Sort uses scoreDelta instead of removed simulatedScore
- try/catch degrades gracefully to suggestions=[] on backend errors
- 6 tests cover: missing params, success, backend error, network throw, empty result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
8234c2f162 feat(planner): RecipePicker uses scoreDelta/hasConflict, drop currentVarietyScore, add isLoading
- Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore)
- Badge renders from hasConflict directly — no client-side delta computation needed
- New isLoading prop shows skeleton rows while suggestions fetch is in flight
- currentVarietyScore prop removed from component and both call sites follow in next commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
257808016d chore(api): update SuggestionItem schema — scoreDelta + hasConflict replace simulatedScore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
cd7f4a1ea0 chore(planner): delete orphaned SuggestionCard component and test
Unused since the suggestions route was removed (commit 4333dc0).
RecipePicker.test.ts is the active coverage for suggestion rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
b673a466e9 feat(planner): replace simulatedScore with scoreDelta + hasConflict in SuggestionItem
SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and
hasConflict (scoreDelta ≤ 0) so the frontend can render badges without
needing to pass currentVarietyScore as a separate prop.

PlanningService.getSuggestions() computes currentScore once per request
and derives scoreDelta + hasConflict per candidate. Sorting is unchanged
(scoreDelta desc = simulatedScore desc since currentScore is constant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:33:12 +02:00
e3066ec3e5 docs(specs): add C3 variety page rework mockups and V1 implementation spec
Three mockup variations (c3-variety-rework.html) for /planner/variety page,
plus detailed implementation spec for the chosen V1 "Erweiterte Karten" approach:
recipe names + swap links inside warning cards, minimal layout changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:31:14 +02:00
bd1604fc1d docs(specs): add detailed implementation spec for E4 variety settings (V2 Kontext-Preset)
5 states: S0 E1 hub update, S1 default, S2 preset selection + score simulation,
S3 advanced settings + Individuell chip, S4 reset confirmation dialog.
Includes API contract, preset mappings, weight multipliers, and LLM agent region.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:13:17 +02:00
c297403506 docs(specs): add 3 mockup variations for E4 variety settings screen
V1: Structured sections (toggles + segmented weight controls, low effort)
V2: Context preset chips (Omnivor/Vegetarisch/Vegan) with live score preview — recommended
V3: Rule cards with inline examples showing exact penalty impact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:00:02 +02:00
fa4a4c9ef7 docs(specs): add J9 variety score config user journey and variety page rework spec
- Adds J9 (Configure variety score) to userjourneys.html — new journey for
  tuning the algorithm per household dietary context (e.g. disabling protein
  penalties for vegetarian households); introduces screen E4 (Variety settings)
- Adds specs/frontend/variety-page-rework.html with 3 design variations for
  the /planner/variety page rework: recipe-name pills, action rows (recommended),
  and week-grid with side panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:51:26 +02:00
116e400a91 refactor(planning): extract applyPenalties helper to unify score formula
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:08:49 +02:00
6dd0b7ac93 docs(specs): add final frontend specs for members and settings Kachel views
Finalised implementation specs for /members (E2) and /settings (E1)
pages using the chosen Kachel (card grid) variation. Members spec
covers 6 states including role-change inline control and remove
confirmation dialog; notes backend gaps (DELETE/PATCH member
endpoints). Settings spec covers hub layout, D3 staples sub-page,
hover and empty states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:06:11 +02:00
49ed75a989 test(planner): verify mobile swap sheet triggers suggestion fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:04:20 +02:00
813ddf8214 feat(planner): change neutral badge copy to Kein Einfluss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:03:39 +02:00
7359eba946 feat(planner): hide RecipePicker inner header in swap context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:02:56 +02:00
16162d80f4 refactor(planner): delete orphaned SwapSuggestionList component and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:02:07 +02:00
148f6a7b5b refactor(planner): remove dead SwapSuggestionList import and sortedRecipes derived
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:01:37 +02:00
f4503b0220 feat(planner): show variety score in swap menu via RecipePicker
Replace SwapSuggestionList with RecipePicker in both mobile and desktop
swap contexts. RecipePicker now accepts excludeRecipeId, replacingRecipe,
and isDisabled props. Mobile swap sheet also triggers suggestion fetch
via activePickerDate so green/yellow/red score badges appear during swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:40:17 +02:00
f4648cc382 feat(planner): show score badges for all recipes in RecipePicker
- +server.ts: pass topN=100 so all recipes are scored in one request
- RecipePicker: Empfohlen keeps top 5 with scoreDelta > 0; builds a
  scoreMap from all suggestions; shows green/yellow/red delta badge on
  every recipe in Alle Rezepte that has a score entry
- Extracted scoreBadge snippet to avoid duplication between sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:03:10 +02:00
081b8dcaf0 feat(planner): show yellow neutral badge for scoreDelta = 0 in RecipePicker
Neutral suggestions (no variety impact) now show "= 0.0 Punkte" in yellow
instead of no badge, making the three states explicit: green (improves),
yellow (neutral), red (worsens).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:54:31 +02:00
f33302e012 fix(planner): format variety score to one decimal place
Avoids floating-point display like 6.199999999999999 by using
score.toFixed(1) in VarietyScoreCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:52:56 +02:00
06bf567b90 feat(planner): replace Variationskonflikt with red delta badge
Shows the actual score delta (e.g. "↓ -1.5 Punkte") in red instead of a
generic ⚠ Variationskonflikt label, letting users compare the cost of each
recipe to make an informed swap decision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:50:03 +02:00
1de9dfc314 feat(planner): add remove meal with undo; fix RecipePicker badge for neutral delta
- MealActionSheet: new onremove prop + Entfernen button (guarded by #if)
- +page.svelte: handleRemoveMeal submits delete form, shows undo bar;
  undo re-adds via addSlot form; refactored handleUndo to undoCallback
  pattern; desktop day-detail panel also gets Entfernen button
- RecipePicker: only show green +delta badge when scoreDelta > 0;
  neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt
- Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:47:53 +02:00
77cdccb26c fix(planning): hasConflict only when scoreDelta strictly negative
Neutral suggestions (scoreDelta = 0) are not conflicts — they simply
don't improve variety. Changing scoreDelta <= 0 to scoreDelta < 0
lets empty-plan additions and quality-neutral swaps show without a
misleading ⚠ Variationskonflikt warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:47:41 +02:00
1611ddabf6 fix(planning): replace existing slot in simulation instead of appending
simulateVarietyScore was adding the candidate recipe on top of the
existing slot for slotDate, keeping the old recipe's tag-repeat penalty
in the score. Now the existing slot is excluded before simulating, so
swapping a recipe for one with better variety correctly shows positive
scoreDelta and hasConflict=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:31:24 +02:00
f55d938b32 feat(planner): add onremove prop and Entfernen button to MealActionSheet
Button only renders when onremove callback is provided, keeping the
component usable in read-only contexts without the destructive action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:25:22 +02:00
cb921b3c0f fix(planner): RecipePicker UI polish from review
- Badge font-size 8px → 9px (WCAG minimum)
- Score badge toFixed(1) to avoid misleading "+0 Punkte"
- Self-contain @keyframes pulse in component <style> block
- Wählen buttons use var(--green-dark) per design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:17:09 +02:00
8686f9eb9f test(planner): clarify server.test.ts error-branch test name
"when backend returns error" → "when data is undefined (error response
without data)" — documents that the guard is data?.suggestions ?? [],
not error field inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:16:29 +02:00
f7a239655a refactor(planner): extract Suggestion type to $lib/planner/types.ts
Removes the inline interface from RecipePicker.svelte and replaces
any[] in +page.svelte with Suggestion[] — compile-time safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:16:02 +02:00
539ca5d231 feat(planner): add AbortController to suggestion fetch $effect
Cancels the inflight request when activePickerDate changes or picker
closes, preventing stale responses from overwriting suggestions.
Adds page.test.ts covering fetch trigger, suggestion rendering,
and AbortSignal presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:15:17 +02:00
0a9e8032cf refactor(planner): extract computeCurrentScore helper in PlanningService
Eliminates duplicated currentSlots→score pattern that appeared in both
getSuggestions and getVarietyPreview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:11:44 +02:00
f84a647b8d test(planner): assert hasConflict=true for neutral scoreDelta on empty plan
Documents the surprising-but-correct behavior: recipes on an empty plan
get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:11:00 +02:00
e17e8d4630 test(planner): cover topN=0 and topN=-1 boundary in SuggestionsTest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:10:33 +02:00
482597bb6a refactor(planner): extract MAX_VARIETY_SCORE constant in PlanningService
Replaces magic literal 10.0 with a named constant in all four
scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots,
and getVarietyScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:09:08 +02:00
387d0705a4 fix(planner): guard scoreDelta against undefined in RecipePicker badge
Defensive null-coalescing prevents crash when suggestion data arrives
without scoreDelta (e.g. stale backend or mismatched schema).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:00:37 +02:00
ab66269131 feat(planner): lazy-fetch variety suggestions in RecipePicker for empty slots
Derives activePickerDate from mobile pickerOpen/selectedDay and desktop
recipe-picker panel state, then uses $effect to fetch /planner?planId&date
on demand — wires suggestions and isLoading into both RecipePicker instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:46:25 +02:00
59366b6e9c feat(planner): add server.test.ts for GET /planner, fix sort + add error handling
- Sort uses scoreDelta instead of removed simulatedScore
- try/catch degrades gracefully to suggestions=[] on backend errors
- 6 tests cover: missing params, success, backend error, network throw, empty result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:39:50 +02:00
4549e9a7fd feat(planner): RecipePicker uses scoreDelta/hasConflict, drop currentVarietyScore, add isLoading
- Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore)
- Badge renders from hasConflict directly — no client-side delta computation needed
- New isLoading prop shows skeleton rows while suggestions fetch is in flight
- currentVarietyScore prop removed from component and both call sites follow in next commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:38:47 +02:00
b6ad64ea53 chore(api): update SuggestionItem schema — scoreDelta + hasConflict replace simulatedScore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:35:57 +02:00
7e97d2dc58 chore(planner): delete orphaned SuggestionCard component and test
Unused since the suggestions route was removed (commit 4333dc0).
RecipePicker.test.ts is the active coverage for suggestion rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:35:33 +02:00
d008a17735 feat(planner): replace simulatedScore with scoreDelta + hasConflict in SuggestionItem
SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and
hasConflict (scoreDelta ≤ 0) so the frontend can render badges without
needing to pass currentVarietyScore as a separate prop.

PlanningService.getSuggestions() computes currentScore once per request
and derives scoreDelta + hasConflict per candidate. Sorting is unchanged
(scoreDelta desc = simulatedScore desc since currentScore is constant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:33:52 +02:00
f0bbb3b009 fix(planner): exclude current recipe from swap suggestions
Adds excludeRecipeId prop to SwapSuggestionList so the meal being
replaced is not offered as a swap candidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:38:30 +02:00
b4fa3ca23e feat(planner): add isLoading prop to SwapSuggestionList — disables Pick buttons during PATCH
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:32:59 +02:00
9482ecbf36 fix(planner): add truncation and title attribute to replacing-name span
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:31:13 +02:00
278fda7d90 fix(planner): translate MealActionSheet button labels to German
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:30:39 +02:00
8e3256d960 fix(planner): translate SwapSuggestionList copy to German
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:29:54 +02:00
30722d9bcc refactor(planner): extract shared recipe info markup into DayMealCard snippet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:28:56 +02:00
dd9a86d4e9 feat(planner): wire J4 swap flow — mobile action sheet + desktop inline panel
Mobile: DayMealCard tap opens MealActionSheet; Swap → SwapSuggestionsSheet
(BottomSheet + SwapSuggestionList, easiest-first). Empty slots still open
RecipePicker directly.

Desktop: recipe-picker panel detects swap context (slot has recipe) and
renders SwapSuggestionList; empty slots continue to show RecipePicker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:13:45 +02:00
c8c2605f31 feat(planner): add SwapSuggestionList component for J4 swap context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:10:44 +02:00
1b2a02881d feat(planner): add MealActionSheet component for mobile swap trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:07:46 +02:00
8756bf93d9 feat(planner): add sortEasiestFirst utility for J4 swap context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:03:47 +02:00
dac83c70ea feat(planner): DayMealCard gains onactionsheet prop for full-card mobile tap target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:02:47 +02:00
5b8d336d21 fix(planner): map backend role 'planner' to 'planer' and enlarge nav buttons to 40px touch targets
- hooks.server.ts: replace type-cast with actual mapping so isPlanner works
- planner page: set min-h/min-w 40px on prev/next/heute week buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:51:32 +02:00
e5d96cd85a fix(frontend): address all PR review concerns
- Fix 7px → 11px font-size on section headers in RecipePicker
- Extract shared slotActions.ts with UUID validation for planId/slotId/recipeId
- Load full recipe list in planner page load (was placeholder current-week slots)
- Update planner/+page.svelte to pass data.recipes as allRecipes to RecipePicker
- Update planner and recipes page.server.ts to use shared slot action helpers
- Fix planner page.server tests: add recipes mock for parallel GET load
- Update action tests to use valid UUIDs (were 'plan-1'/'r1' style strings)
- Add validation-path tests for blank/invalid input on all slot actions
- Add tests for recipes/+server.ts GET endpoint (DayPicker week navigation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:19:37 +02:00
ea7113ec53 fix(backend): add role guard to variety-preview and extract shared scoring method
- Add @RequiresHouseholdRole("member") to GET /{planId}/variety-preview endpoint
  to require household membership (was accessible to any authenticated user)
- Extract scoreFromSimulatedSlots() private method eliminating duplicate logic
  between simulateVarietyScore() and the old computeCurrentScore()
- Fix loose variety preview test assertions (isBetween → exact assertEquals)
- Add test verifying negative scoreDelta when candidate is a duplicate recipe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:11:45 +02:00
4333dc0d84 refactor(planner): remove C2 suggestions route, replace with callback-based DayMealCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 23:25:35 +02:00
cbafe783e9 feat(planner): integrate C4 RecipePicker with PanelState machine + slot actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 23:23:26 +02:00
178c888635 feat(recipes): add C6 day-picker flow — week plan load + slot actions + DayPicker sheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 23:09:40 +02:00
f5adc051e8 feat(recipes): add C5 quick action buttons to RecipeCard
Always-visible "Jetzt kochen" and "Zur Woche +" buttons shown
when onplan prop is provided. Restructured card to avoid nested
interactive elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:58:50 +02:00
90c9ea1894 feat(planner): add UndoBar component with 4s auto-dismiss
Shows undo notification after slot add/replace. Rückgängig button
calls onundo, auto-dismisses after 4s via ondismiss callback.
Also patches test-setup for userEvent + fake timers compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:57:05 +02:00
ba41f6984b feat(planner): add DayPicker component (C6)
7-chip week strip with 5 slot states, inline replace warning,
confirm button, and prev/next week navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:44:29 +02:00
25c575c167 feat(planner): add RecipePicker component (C4) and suggestions API endpoint
C4 sheet content: Empfohlen section with variety delta badges,
Alle Rezepte with client-side search filter. GET /planner endpoint
proxies suggestions to backend for lazy client-side loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:42:10 +02:00
36ae82af5d feat(ui): add BottomSheet.svelte shared wrapper component
Shared wrapper for C4, C6, and future sheet flows. Handles dim overlay,
drag handle, focus trap, Escape dismiss, and backdrop click dismiss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:38:59 +02:00
7175b56833 feat(planning): add GET /v1/week-plans/{planId}/variety-preview endpoint
Returns currentScore, projectedScore, and scoreDelta when a recipe
would be added on a given date. Used by C6 desktop day picker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:36:03 +02:00
a52b0a9d24 feat(planning): enforce planner role on slot mutation endpoints
PATCH, DELETE, and POST slot endpoints now return 403 Forbidden
when called by a household member.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:34:28 +02:00
f6265efa92 test(shopping): add component tests for ShoppingHeader, ChecklistItem, AddCustomItem
25 tests covering counts, planner guard, aria-checked, strikethrough,
recipe labels, expand/collapse, and form submission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:58:28 +02:00
3cd9154550 refactor(shopping): extract ShoppingChecklist.svelte to eliminate mobile/desktop duplication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:55:54 +02:00
be43fe94b6 fix(shopping): address frontend review concerns
- ChecklistItem: use:enhance with reset:false, role=checkbox, aria-checked, focus ring
- RecipeReferencePanel: day abbreviation text-[12px] (was 11px)
- ShoppingHeader: generating pending state disables button during submit
- AddCustomItem: only collapse form on successful submission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:54:25 +02:00
e3afe1b4f2 test(shopping): add HTTP-level role guard test and blank customName validation test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:52:09 +02:00
eb5ee1ab5a test(shopping): add missing service tests for stale items, dedup, and household isolation
- generateFromPlan removes stale generated items
- sourceRecipes deduplicates when same recipe appears in two slots
- checkItem throws ResourceNotFoundException on household mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:50:27 +02:00
9d210befa1 fix(security): add @Valid constraints on AddItemRequest to prevent oversized input
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:49:06 +02:00
40a6a0e92d fix(security): use generic forbidden message to avoid leaking required role
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:46:24 +02:00
40ee4dad53 refactor(shopping): extract mergeKey helper to eliminate duplicate key construction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:44:44 +02:00
741141168b feat(shopping): build main +page.svelte with responsive layout and empty states
Mobile/desktop responsive shopping list page with:
- Three empty states (no plan, no list, all checked)
- Unchecked/checked item sections with divider
- Add custom item form
- Desktop right panel with recipe references
- Filtered staples info

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:56:42 +02:00
6cc79836d5 feat(shopping): add RecipeReferencePanel.svelte component
Desktop right panel showing this week's recipe cards with day
abbreviation, filtered staples count, and link to edit pantry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:55:31 +02:00
5ac8f1768f feat(shopping): add AddCustomItem.svelte component
Expandable inline form for adding custom items to the shopping list.
Includes name, quantity, and unit fields with cancel/submit actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:54:47 +02:00
7bdadbe962 feat(shopping): add ChecklistItem.svelte component
Checkbox row with name, quantity/unit, recipe source label, and
strikethrough styling when checked. Each toggle submits a form action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:54:12 +02:00
2151dff4db feat(shopping): add ShoppingHeader.svelte component
Displays title, eyebrow counts (remaining/checked), generation
timestamp, and planner-only generate/regenerate button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:53:36 +02:00
e831480860 feat(shopping): add +page.server.ts with load function and form actions
Load function fetches shopping list and week plan for the current week.
Form actions: check (toggle item), addItem (custom item), generate
(planner-only shopping list generation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:52:44 +02:00
92922533ac feat(shopping): finalize GET /v1/shopping-list endpoint and regenerate OpenAPI types
Renamed endpoint to /v1/shopping-list to avoid Springdoc path conflict.
Added @RequiresHouseholdRole("planner") on generate. Regenerated
frontend OpenAPI schema with all new shopping list endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:49:08 +02:00
16b70bd818 feat(shopping): add GET /v1/shopping-lists endpoint and planner-only guard
New week-based lookup endpoint with optional weekStart param (defaults
to current week). Generate endpoint now enforced with @RequiresHouseholdRole.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:35:31 +02:00
5325f4827e feat(shopping): refactor generateFromPlan to merge strategy
When a shopping list already exists for the week plan, regeneration
now merges: custom items and check states are preserved, existing
generated items are updated, removed recipes' items are deleted,
and new ingredients are added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:33:15 +02:00
c26c2e1973 feat(shopping): add getByWeekStart to ShoppingService
Returns the shopping list for a given week, defaulting to the current
week's Monday when no weekStart is provided. Returns null when no
list exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:30:41 +02:00
93e8bf9e41 feat(shopping): extend response with generatedAt, filteredStaplesCount, RecipeRef
Shopping list response now includes generatedAt timestamp, count of
filtered staples, and recipe names (not just UUIDs) in source references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:29:07 +02:00
7e254fc280 feat(shopping): add week-based shopping list repository query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:23:43 +02:00
3be9f502c6 feat(auth): add @RequiresHouseholdRole annotation with interceptor
Reusable annotation for planner-only endpoints. Uses a
HandlerInterceptor that resolves the household role from the
authenticated user and throws 403 if the role doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:22:47 +02:00
2f690eb3cb feat(common): add ForbiddenException with 403 handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:18:27 +02:00
2253c76287 feat(shopping): add generated_at column to shopping_list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:17:12 +02:00
e12fb72fc2 refactor(specs): remove real-time sync from D1 shopping list spec
Replace WebSocket/SSE liveness model with server-authoritative sync
(form actions + page refresh). Remove "members online" indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:45:30 +02:00
693ec2b997 feat(specs): Add-to-Plan flows — screens C4, C5, C6
Specifies three missing interaction surfaces for adding a recipe to
the weekly plan (Issue #42):

- C4: Recipe picker bottom sheet / desktop panel transformation
  triggered from the weekly planner's "+" button or empty slot
- C5: Recipe card quick actions ("Jetzt kochen" + "Zur Woche +")
- C6: Day picker sheet / panel for adding a known recipe to a chosen day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:43:18 +02:00
e73a84af5f fix(recipes): correct effort map casing to match backend values
effortMap had 'Easy'/'Medium'/'Hard' but the API returns 'easy'/'medium'/'hard',
so filtering by difficulty always returned nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:16:04 +02:00
27e09a77d6 fix(recipes): cast JPQL null params to avoid lower(bytea) error on PostgreSQL
When :search or :effort are null, Hibernate passes untyped bind parameters
that PostgreSQL infers as bytea, causing `lower(bytea) does not exist`.
Explicit CAST(… AS string) tells Hibernate to bind them as varchar.

Also fixes the bcrypt hash in V100 dev seed (was wrong, dev/dev now works).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:10:59 +02:00
6d76da5542 Merge pull request 'feat: C3 — Variety review screen (Issue #28)' (#41) from feat/issue-28-variety-review into master
feat(variety): C3 — Variety review screen (Issue #28) (#41)
2026-04-03 11:37:53 +02:00
8e82213d1e fix(variety): remove unused total, add warning border, fix abbreviation, aria
- EffortBar: remove unused \`total\` derived variable
- VarietyWarningCards: add border border-[var(--yellow-light)] to cards
- variety page: protein abbreviation uses split(' ')[0].slice(0,3).toUpperCase()
- variety page: breadcrumb separator span gets aria-hidden="true"

Addresses Kai blocker: unused total. Atlas blockers: yellow-light border,
protein abbreviation, breadcrumb aria.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:37:26 +02:00
cb15143c30 refactor(variety): fix \$derived.by pattern, remove dead import, use pure functions
- Change all \$derived(() => {...}) to \$derived.by(() => {...}) — values not functions
- Remove unused formatDayLabel import
- Delegate subScores to computeSubScores(), warnings to computeWarnings()
- Remove () call syntax from all template reactive references

Addresses Kai blockers: anti-pattern derived, dead import.
Addresses QA blocker: logic now exercised by unit tests in variety.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:36:00 +02:00
9adf786b8f test(variety): extract and test sub-score/warnings pure functions
- Extract computeSubScores() and computeWarnings() to variety.ts
- 18 unit tests covering formulas, boundaries, clamping, edge cases:
  - proteinDiversity: repeats × 2 penalty, clamped to 0
  - ingredientOverlap: overlaps × 1.5 penalty, clamped to 0
  - effortBalance: easy-hard diff × 1.5, total=0 → 10
  - warnings: repeat≥2 days, overlap≥2 days, duplicates

Addresses QA blockers: untested business logic in sub-score derivations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:32:20 +02:00
1bf929280b test(variety): add all-zero edge case test for EffortBar
Addresses QA concern: renders no segments when easy=0, medium=0, hard=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:30:19 +02:00
75c860a62b test(variety): add boundary tests for VarietyScoreHero (score=0,4,7,10)
Addresses QA concern: boundary values (0, 4, 7, 9, 10) now have
explicit tests covering description labels and aria-valuenow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:29:26 +02:00
8ad636f825 feat(variety): implement C3 variety review screen (Issue #28)
- Add /planner/variety route with mobile stacked + desktop 2-column layout
- Implement VarietyScoreHero: Fraunces score display + progress bar + color-coded description
- Implement ScoreBreakdownList: 3 sub-score rows (protein diversity, ingredient overlap, effort balance)
- Implement VarietyWarningCards: yellow-tint warning cards derived from API tagRepeats/ingredientOverlaps
- Implement EffortBar: proportional colored segments (Easy/Medium/Hard) with ×N labels
- Desktop: protein grid (7 columns, repeat highlight with yellow ring) + effort bar in right panel
- Client-side sub-score derivation from VarietyScoreResponse (tagged for TODO to move to API)
- 26 new tests across 5 components + server load function; 455 tests total, 0 type errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:23:29 +02:00
7c07bc443b feat(suggestions): C2 — Meal suggestions (variety-aware) (#40)
feat(suggestions): implement C2 meal suggestion screen (Issue #27)

Co-authored-by: Marcel Raddatz <marcel@raddatz.cloud>
Co-committed-by: Marcel Raddatz <marcel@raddatz.cloud>
2026-04-03 11:18:45 +02:00
05e47c3dac feat(planner): C1 — Weekly planner home screen 2026-04-03 11:07:56 +02:00
5d2bb9e84e fix(planner): address all PR review blockers
- Fix logic bug `{#if !isPlanner === false}` - view/cook buttons now visible for all roles, swap only for planner
- Convert Tauschen from dead button to link with suggestions href
- Add week.ts unit tests (23 tests covering getWeekStart Sunday edge case, prevWeek/nextWeek, weekDays, isToday, formatWeekRange)
- Fix isToday to use UTC consistently (.toISOString().slice(0,10)) instead of local date
- Add server-side role guard to createPlan action (403 for members)
- Add weekStart format validation in createPlan action
- Add isSelected prop to DayMealCard with green treatment
- Make variety banner sticky on mobile (always visible per spec)
- Add day name abbreviation above date badge in desktop column headers
- Remove placeholder Navigation text from desktop sidebar
- Add aria-label to desktop empty tile buttons
- Add variety score partial failure test, multiple overlaps test, WeekStrip today+selected test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:07:47 +02:00
e3f8d8ad73 feat(planner): implement C1 weekly planner home screen (#26)
Three-breakpoint layout (mobile/tablet/desktop) with VarietyScoreCard,
WeekStrip, DayMealCard components. Server loads week plan and variety
score via API; read-only role behavior derived from benutzer.rolle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:01:17 +02:00
0511a735a5 Merge pull request 'feat(recipes): B3 — Add/edit recipe form with dynamic ingredients, steps, tag chips' (#38) from feat/issue-23-recipe-form into master
feat(recipes): B3 — Add/Edit Recipe Form (#38)
2026-04-03 10:36:19 +02:00
33f3b30cb4 feat(recipes): style RecipeForm with design system + split-panel layout
- Full design system tokens: inputs, labels, chips, buttons
- Effort and category chips as pill-style radio/checkbox
- Desktop two-column split-panel: form left, categories right (280px)
- Ingredient rows: quantity/unit/name flex layout with remove ghost button
- Steps with numbered circle indicator
- Add use:enhance for SPA experience without full page reload
- Footer: cancel link left, primary save button right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:35:35 +02:00
e4d3008139 feat(recipes): display form error from \$page.form in RecipeForm
- Import page store and render role="alert" error banner
- Add mock for \$app/stores and \$app/forms in RecipeForm tests
- Add tests: error banner shown when form.error set, hidden when null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:31:18 +02:00
6505cb4251 test(recipes): add action tests and harden create/update form actions
- Add try-catch around JSON.parse with fail(400) for malformed input
- Validate effort against allowed values ['Easy','Medium','Hard']
- Fix NaN risk: Number(serves)||undefined instead of Number(serves)
- Add action tests for create/update: validation, JSON.parse crash, success, API error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:27:54 +02:00
3d49e6b7bf feat(recipes): add /recipes/[id]/edit route with update action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:20:45 +02:00
4e2b0b5727 feat(recipes): add /recipes/new route with create action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:19:27 +02:00
2cef8a1169 feat(recipes): add RecipeForm component — add/edit two-state form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:17:17 +02:00
fcf0f297bb Merge pull request 'feat(recipes): B2 — Recipe detail view with hero, ingredients, steps' (#37) from feat/issue-24-recipe-detail into master 2026-04-03 10:07:27 +02:00
0256b4360b fix(recipes): address B2 review — tags, sort, edit link, types, a11y, tests
- RecipeHero: render tag pills, min-h-[200px/240px], fix back link styling, remove font-[400]
- IngredientList: sort by sortOrder ascending
- StepList: aria-hidden on step circles
- types.ts: add Tag, Ingredient, Step, RecipeDetail shared types
- +page.svelte: add Edit link → /recipes/[id]/edit (desktop topbar)
- Tests: tag pills, sortOrder sort, edit link, image variant, 403-as-404 documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:07:19 +02:00
00c48a7c96 feat(recipes): implement B2 recipe detail page with mobile/desktop layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:02:20 +02:00
ce860d68e4 feat(recipes): add recipe detail load function with 404 handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:00:02 +02:00
b39d04acce feat(recipes): add StepList component with numbered circles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:58:39 +02:00
c7e56a173d feat(recipes): add IngredientList component (read-only)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:57:36 +02:00
86a25eb038 feat(recipes): add RecipeHero component with image/no-image variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:56:35 +02:00
a34c6f30f2 Merge pull request 'feat(recipes): B1 — Recipe Library page with search and effort filtering' (#36) from feat/issue-22-recipe-library into master 2026-04-03 09:53:38 +02:00
9bb6293d9f fix(recipes): address review feedback — shared type, design system tokens, test coverage
- Extract RecipeSummary type to $lib/recipes/types.ts (was duplicated in 3 files)
- Fix +page.svelte header link: replace Skeleton UI classes with design system tokens
- Fix h1: use font-[var(--font-display)] and correct size
- Fix FilterChipRow: text-[11px] → text-[13px] + tracking-[0.04em] per design system
- Fix RecipeCard metadata: text-[11px] → text-[12px] for readability
- Remove unused imports (vi, beforeEach, afterEach) from page.test.ts
- Add combined search + effort filter test
- Add reset-to-Alle filter test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:53:32 +02:00
47c748145d feat(recipes): implement recipe library page with search and effort filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:49:39 +02:00
a25286e385 feat(recipes): load recipe list from API in page server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:45:43 +02:00
a733e8dd66 feat(recipes): add RecipeGrid with 2/4-col responsive grid and empty state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:44:05 +02:00
35ed6ca878 feat(recipes): add FilterChipRow with effort filter chips
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:43:06 +02:00
dc99459a2e feat(recipes): add RecipeCard component with compact/full image variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:41:56 +02:00
021d308a71 feat(staples): A3/D3 — Pantry staples toggle UI 2026-04-03 09:35:03 +02:00
172 changed files with 30299 additions and 219 deletions

3
backend/.gitignore vendored
View File

@@ -31,3 +31,6 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
### Local dev config (may contain secrets / local DB credentials) ###
src/main/resources/application-dev.yml

View File

@@ -55,6 +55,16 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.21</version>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.13.1</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,7 @@
package com.recipeapp.common;
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}

View File

@@ -32,6 +32,12 @@ public class GlobalExceptionHandler {
.body(ApiError.of("CONFLICT", ex.getMessage())); .body(ApiError.of("CONFLICT", ex.getMessage()));
} }
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<ApiError> handleForbidden(ForbiddenException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiError.of("FORBIDDEN", ex.getMessage()));
}
@ExceptionHandler(ValidationException.class) @ExceptionHandler(ValidationException.class)
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) { public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)

View File

@@ -0,0 +1,43 @@
package com.recipeapp.common;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class HouseholdRoleInterceptor implements HandlerInterceptor {
private final HouseholdResolver householdResolver;
public HouseholdRoleInterceptor(HouseholdResolver householdResolver) {
this.householdResolver = householdResolver;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class);
if (annotation == null) {
return true;
}
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
throw new ForbiddenException("Not authenticated");
}
String actualRole = householdResolver.resolveRole(auth.getName());
if (!annotation.value().equals(actualRole)) {
throw new ForbiddenException("Insufficient permissions");
}
return true;
}
}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresHouseholdRole {
String value();
}

View File

@@ -0,0 +1,20 @@
package com.recipeapp.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final HouseholdRoleInterceptor householdRoleInterceptor;
public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) {
this.householdRoleInterceptor = householdRoleInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(householdRoleInterceptor);
}
}

View File

@@ -26,6 +26,8 @@ import java.util.stream.Collectors;
@Service @Service
public class PlanningService { public class PlanningService {
private static final double MAX_VARIETY_SCORE = 10.0;
private final WeekPlanRepository weekPlanRepository; private final WeekPlanRepository weekPlanRepository;
private final WeekPlanSlotRepository weekPlanSlotRepository; private final WeekPlanSlotRepository weekPlanSlotRepository;
private final CookingLogRepository cookingLogRepository; private final CookingLogRepository cookingLogRepository;
@@ -135,6 +137,8 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId()) .map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream() Set<String> lowerTagFilters = tagFilters.stream()
@@ -145,11 +149,13 @@ public class PlanningService {
.filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> !usedRecipeIds.contains(r.getId()))
.filter(r -> matchesAllTags(r, lowerTagFilters)) .filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> { .map(candidate -> {
double score = simulateVarietyScore( double simulatedScore = simulateVarietyScore(
plan, candidate, slotDate, config, recentlyCookedIds); plan, candidate, slotDate, config, recentlyCookedIds);
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); double scoreDelta = simulatedScore - currentScore;
boolean hasConflict = scoreDelta < 0;
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
}) })
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
.limit(limit) .limit(limit)
.toList(); .toList();
@@ -166,36 +172,65 @@ public class PlanningService {
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate, private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) { VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>(); List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) { for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); if (!slot.getSlotDate().equals(slotDate)) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
} }
simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
}
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
.toList();
return currentSlots.isEmpty() ? MAX_VARIETY_SCORE
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@Transactional(readOnly = true)
public VarietyPreviewResponse getVarietyPreview(UUID householdId, UUID planId, UUID recipeId, LocalDate date) {
WeekPlan plan = findPlan(planId, householdId);
Recipe candidate = findRecipe(recipeId, householdId);
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
Set<UUID> recentlyCookedIds = cookingLogRepository
.findByHouseholdIdAndCookedOnAfter(householdId,
plan.getWeekStart().minusDays(config.getHistoryDays()))
.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
}
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
Set<UUID> recentlyCookedIds) {
List<String> checkedTagTypes = config.getRepeatTagTypes(); List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days // 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>(); Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) { for (SimulatedSlot slot : slots) {
for (Tag tag : slot.recipe.getTags()) { for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) { if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
.add(slot.date);
} }
} }
} }
long tagRepeatCount = tagDays.values().stream() long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days // 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>(); Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) { for (SimulatedSlot slot : slots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) { for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) { if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
@@ -203,34 +238,35 @@ public class PlanningService {
} }
} }
} }
long ingredientOverlapCount = ingredientDays.values().stream() long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log // 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream() long recentRepeatCount = slots.stream()
.map(s -> s.recipe.getId()) .map(s -> s.recipe.getId())
.distinct() .distinct()
.filter(recentlyCookedIds::contains) .filter(recentlyCookedIds::contains)
.count(); .count();
// 4. Duplicate recipes within the simulated plan // 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream() Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting())); .collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream() long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1) .filter(c -> c > 1)
.mapToLong(c -> c - 1) .mapToLong(c -> c - 1)
.sum(); .sum();
double score = 10.0; return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
} }
private record SimulatedSlot(Recipe recipe, LocalDate date) {} private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats,
long duplicates, VarietyScoreConfig config) {
double score = MAX_VARIETY_SCORE;
score -= tagRepeats * config.getWTagRepeat().doubleValue();
score -= ingredientOverlaps * config.getWIngredientOverlap().doubleValue();
score -= recentRepeats * config.getWRecentRepeat().doubleValue();
score -= duplicates * config.getWPlanDuplicate().doubleValue();
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
@@ -246,10 +282,6 @@ public class PlanningService {
.orElse(VarietyScoreConfig.defaults(plan.getHousehold())); .orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
List<String> checkedTagTypes = config.getRepeatTagTypes(); List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
int historyDays = config.getHistoryDays(); int historyDays = config.getHistoryDays();
// 1. Tag-type repeats on consecutive days // 1. Tag-type repeats on consecutive days
@@ -317,13 +349,7 @@ public class PlanningService {
} }
} }
// Calculate score double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
double score = 10.0;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
} }

View File

@@ -1,5 +1,6 @@
package com.recipeapp.planning; package com.recipeapp.planning;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.planning.dto.*; import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -40,6 +41,7 @@ public class WeekPlanController {
} }
@PostMapping("/{id}/slots") @PostMapping("/{id}/slots")
@RequiresHouseholdRole("planner")
public ResponseEntity<SlotResponse> addSlot( public ResponseEntity<SlotResponse> addSlot(
Principal principal, Principal principal,
@PathVariable UUID id, @PathVariable UUID id,
@@ -50,6 +52,7 @@ public class WeekPlanController {
} }
@PatchMapping("/{planId}/slots/{slotId}") @PatchMapping("/{planId}/slots/{slotId}")
@RequiresHouseholdRole("planner")
public SlotResponse updateSlot( public SlotResponse updateSlot(
Principal principal, Principal principal,
@PathVariable UUID planId, @PathVariable UUID planId,
@@ -61,6 +64,7 @@ public class WeekPlanController {
@DeleteMapping("/{planId}/slots/{slotId}") @DeleteMapping("/{planId}/slots/{slotId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequiresHouseholdRole("planner")
public void deleteSlot( public void deleteSlot(
Principal principal, Principal principal,
@PathVariable UUID planId, @PathVariable UUID planId,
@@ -92,4 +96,15 @@ public class WeekPlanController {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getVarietyScore(householdId, id); return planningService.getVarietyScore(householdId, id);
} }
@GetMapping("/{planId}/variety-preview")
@RequiresHouseholdRole("member")
public VarietyPreviewResponse getVarietyPreview(
Principal principal,
@PathVariable UUID planId,
@RequestParam UUID recipeId,
@RequestParam LocalDate date) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getVarietyPreview(householdId, planId, recipeId, date);
}
} }

View File

@@ -6,6 +6,7 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionItem( public record SuggestionItem(
SlotResponse.SlotRecipe recipe, SlotResponse.SlotRecipe recipe,
double simulatedScore double scoreDelta,
boolean hasConflict
) {} ) {}
} }

View File

@@ -0,0 +1,7 @@
package com.recipeapp.planning.dto;
public record VarietyPreviewResponse(
double currentScore,
double projectedScore,
double scoreDelta
) {}

View File

@@ -24,6 +24,10 @@ public class HouseholdResolver {
return findMembership(userEmail).getUser().getId(); return findMembership(userEmail).getUser().getId();
} }
public String resolveRole(String userEmail) {
return findMembership(userEmail).getRole();
}
private HouseholdMember findMembership(String userEmail) { private HouseholdMember findMembership(String userEmail) {
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail) return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household")); .orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));

View File

@@ -0,0 +1,60 @@
package com.recipeapp.recipe;
import net.coobird.thumbnailator.Thumbnails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
@Component
public class ImageCompressor {
private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class);
private static final int PREVIEW_WIDTH = 400;
private static final double PREVIEW_QUALITY = 0.6;
private static final String DATA_URI_PREFIX = "data:image/";
private static final String BASE64_MARKER = ";base64,";
private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,";
public String compressToPreview(String dataUri) {
if (dataUri == null || dataUri.isBlank()) return null;
if (!dataUri.startsWith(DATA_URI_PREFIX)) return null;
int markerIdx = dataUri.indexOf(BASE64_MARKER);
if (markerIdx < 0) return null;
byte[] imageBytes;
try {
imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length()));
} catch (IllegalArgumentException e) {
return null;
}
try {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
if (original == null) {
log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})",
dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40)));
return null;
}
int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Thumbnails.of(original)
.width(targetWidth)
.outputFormat("jpeg")
.outputQuality(PREVIEW_QUALITY)
.toOutputStream(bos);
return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray());
} catch (Exception e) {
log.warn("Failed to generate image preview", e);
return null;
}
}
}

View File

@@ -29,7 +29,6 @@ public class RecipeController {
Principal principal, Principal principal,
@RequestParam(required = false) String search, @RequestParam(required = false) String search,
@RequestParam(required = false) String effort, @RequestParam(required = false) String effort,
@RequestParam(required = false) Boolean isChildFriendly,
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin, @RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
@RequestParam(required = false) String sort, @RequestParam(required = false) String sort,
@RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "20") int limit,
@@ -37,9 +36,9 @@ public class RecipeController {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
List<RecipeSummaryResponse> recipes = recipeService.listRecipes( List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset); householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
long total = recipeService.countRecipes( long total = recipeService.countRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin); householdId, search, effort, cookTimeMaxMin);
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total); var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
var meta = new ApiResponse.Meta(pagination); var meta = new ApiResponse.Meta(pagination);

View File

@@ -18,13 +18,12 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
@Query(""" @Query("""
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse( SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl) r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview)
FROM Recipe r FROM Recipe r
WHERE r.household.id = :householdId WHERE r.household.id = :householdId
AND r.deletedAt IS NULL AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
AND (:effort IS NULL OR r.effort = :effort) AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
ORDER BY r.createdAt DESC ORDER BY r.createdAt DESC
""") """)
@@ -32,7 +31,6 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
@Param("householdId") UUID householdId, @Param("householdId") UUID householdId,
@Param("search") String search, @Param("search") String search,
@Param("effort") String effort, @Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin, @Param("cookTimeMaxMin") Integer cookTimeMaxMin,
@Param("sort") String sort, @Param("sort") String sort,
@Param("limit") int limit, @Param("limit") int limit,
@@ -43,15 +41,13 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
FROM Recipe r FROM Recipe r
WHERE r.household.id = :householdId WHERE r.household.id = :householdId
AND r.deletedAt IS NULL AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
AND (:effort IS NULL OR r.effort = :effort) AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
""") """)
long countFiltered( long countFiltered(
@Param("householdId") UUID householdId, @Param("householdId") UUID householdId,
@Param("search") String search, @Param("search") String search,
@Param("effort") String effort, @Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin); @Param("cookTimeMaxMin") Integer cookTimeMaxMin);
} }

View File

@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
import com.recipeapp.common.ConflictException; import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository; import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.dto.*; import com.recipeapp.recipe.dto.*;
@@ -22,31 +23,31 @@ public class RecipeService {
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final IngredientCategoryRepository ingredientCategoryRepository; private final IngredientCategoryRepository ingredientCategoryRepository;
private final HouseholdRepository householdRepository; private final HouseholdRepository householdRepository;
private final ImageCompressor imageCompressor;
public RecipeService(RecipeRepository recipeRepository, public RecipeService(RecipeRepository recipeRepository,
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
TagRepository tagRepository, TagRepository tagRepository,
IngredientCategoryRepository ingredientCategoryRepository, IngredientCategoryRepository ingredientCategoryRepository,
HouseholdRepository householdRepository) { HouseholdRepository householdRepository,
ImageCompressor imageCompressor) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository; this.ingredientCategoryRepository = ingredientCategoryRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.imageCompressor = imageCompressor;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort, public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin, Integer cookTimeMaxMin, String sort, int limit, int offset) {
String sort, int limit, int offset) { return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
cookTimeMaxMin, sort, limit, offset);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public long countRecipes(UUID householdId, String search, String effort, public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
Boolean isChildFriendly, Integer cookTimeMaxMin) { return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -60,9 +61,14 @@ public class RecipeService {
Household household = householdRepository.findById(householdId) Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found")); .orElseThrow(() -> new ResourceNotFoundException("Household not found"));
Recipe recipe = new Recipe(household, request.name(), request.serves(), validateHeroImageUrl(request.heroImageUrl());
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());
recipe.setHeroImageUrl(request.heroImageUrl()); recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
addIngredients(recipe, household, request.ingredients()); addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps()); addSteps(recipe, request.steps());
@@ -77,12 +83,14 @@ public class RecipeService {
Recipe recipe = findRecipe(householdId, recipeId); Recipe recipe = findRecipe(householdId, recipeId);
Household household = recipe.getHousehold(); Household household = recipe.getHousehold();
validateHeroImageUrl(request.heroImageUrl());
recipe.setName(request.name()); recipe.setName(request.name());
recipe.setServes(request.serves()); recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
recipe.setCookTimeMin(request.cookTimeMin()); recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
recipe.setEffort(request.effort()); recipe.setEffort(request.effort());
recipe.setChildFriendly(request.isChildFriendly());
recipe.setHeroImageUrl(request.heroImageUrl()); recipe.setHeroImageUrl(request.heroImageUrl());
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
recipe.getIngredients().clear(); recipe.getIngredients().clear();
recipe.getSteps().clear(); recipe.getSteps().clear();
@@ -180,6 +188,18 @@ public class RecipeService {
return new IngredientCategoryResponse(category.getId(), category.getName()); return new IngredientCategoryResponse(category.getId(), category.getName());
} }
// ── Image validation ──
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*");
private void validateHeroImageUrl(String heroImageUrl) {
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) {
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
}
}
// ── Private helpers ── // ── Private helpers ──
private Recipe findRecipe(UUID householdId, UUID recipeId) { private Recipe findRecipe(UUID householdId, UUID recipeId) {
@@ -238,7 +258,7 @@ public class RecipeService {
return new RecipeDetailResponse( return new RecipeDetailResponse(
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(), recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(), recipe.getEffort(), recipe.getHeroImageUrl(),
ingredients, steps, tags); ingredients, steps, tags);
} }

View File

@@ -6,13 +6,13 @@ import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public record RecipeCreateRequest( public record RecipeCreateRequest(
@NotBlank @Size(max = 200) String name, @NotBlank @Size(max = 200) String name,
@Min(1) @Max(20) short serves, Integer serves,
@Min(0) short cookTimeMin, Integer cookTimeMin,
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort, @NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
boolean isChildFriendly, @Size(max = 7_000_000) String heroImageUrl,
@Size(max = 500) String heroImageUrl,
@NotEmpty @Valid List<IngredientEntry> ingredients, @NotEmpty @Valid List<IngredientEntry> ingredients,
@Valid List<StepEntry> steps, @Valid List<StepEntry> steps,
@NotEmpty List<UUID> tagIds @NotEmpty List<UUID> tagIds

View File

@@ -10,7 +10,6 @@ public record RecipeDetailResponse(
short serves, short serves,
short cookTimeMin, short cookTimeMin,
String effort, String effort,
boolean isChildFriendly,
String heroImageUrl, String heroImageUrl,
List<IngredientItem> ingredients, List<IngredientItem> ingredients,
List<StepItem> steps, List<StepItem> steps,

View File

@@ -8,6 +8,5 @@ public record RecipeSummaryResponse(
short serves, short serves,
short cookTimeMin, short cookTimeMin,
String effort, String effort,
boolean isChildFriendly, String heroImagePreview
String heroImageUrl
) {} ) {}

View File

@@ -33,12 +33,12 @@ public class Recipe {
@Column(nullable = false, length = 10) @Column(nullable = false, length = 10)
private String effort; private String effort;
@Column(name = "is_child_friendly", nullable = false) @Column(name = "hero_image_url", columnDefinition = "text")
private boolean isChildFriendly;
@Column(name = "hero_image_url", length = 500)
private String heroImageUrl; private String heroImageUrl;
@Column(name = "hero_image_preview", columnDefinition = "text")
private String heroImagePreview;
@Column(name = "deleted_at") @Column(name = "deleted_at")
private Instant deletedAt; private Instant deletedAt;
@@ -64,14 +64,12 @@ public class Recipe {
protected Recipe() {} protected Recipe() {}
public Recipe(Household household, String name, short serves, short cookTimeMin, public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) {
String effort, boolean isChildFriendly) {
this.household = household; this.household = household;
this.name = name; this.name = name;
this.serves = serves; this.serves = serves;
this.cookTimeMin = cookTimeMin; this.cookTimeMin = cookTimeMin;
this.effort = effort; this.effort = effort;
this.isChildFriendly = isChildFriendly;
} }
@PrePersist @PrePersist
@@ -95,10 +93,10 @@ public class Recipe {
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; } public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
public String getEffort() { return effort; } public String getEffort() { return effort; }
public void setEffort(String effort) { this.effort = effort; } public void setEffort(String effort) { this.effort = effort; }
public boolean isChildFriendly() { return isChildFriendly; }
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
public String getHeroImageUrl() { return heroImageUrl; } public String getHeroImageUrl() { return heroImageUrl; }
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; } public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
public String getHeroImagePreview() { return heroImagePreview; }
public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; }
public Instant getDeletedAt() { return deletedAt; } public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
public Instant getCreatedAt() { return createdAt; } public Instant getCreatedAt() { return createdAt; }

View File

@@ -1,11 +1,15 @@
package com.recipeapp.shopping; package com.recipeapp.shopping;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@@ -19,8 +23,21 @@ public class ShoppingListController {
this.householdResolver = householdResolver; this.householdResolver = householdResolver;
} }
@GetMapping("/v1/shopping-list")
public ShoppingListResponse getByWeekStart(
@RequestParam(required = false) LocalDate weekStart,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart);
if (response == null) {
throw new ResourceNotFoundException("No shopping list for this week");
}
return response;
}
@PostMapping("/v1/week-plans/{id}/shopping-list") @PostMapping("/v1/week-plans/{id}/shopping-list")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@RequiresHouseholdRole("planner")
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) { public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.generateFromPlan(householdId, id); return shoppingService.generateFromPlan(householdId, id);
@@ -45,7 +62,7 @@ public class ShoppingListController {
@PostMapping("/v1/shopping-lists/{id}/items") @PostMapping("/v1/shopping-lists/{id}/items")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public ShoppingListItemResponse addItem(@PathVariable UUID id, public ShoppingListItemResponse addItem(@PathVariable UUID id,
@RequestBody AddItemRequest request, @Valid @RequestBody AddItemRequest request,
Principal principal) { Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.addItem(householdId, id, request); return shoppingService.addItem(householdId, id, request);

View File

@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
import com.recipeapp.shopping.entity.ShoppingList; import com.recipeapp.shopping.entity.ShoppingList;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> { public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
Optional<ShoppingList> findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart);
} }

View File

@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.planning.WeekPlanRepository; import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.Recipe;
import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import com.recipeapp.shopping.entity.ShoppingList; import com.recipeapp.shopping.entity.ShoppingList;
@@ -16,6 +18,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -29,19 +34,34 @@ public class ShoppingService {
private final HouseholdRepository householdRepository; private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository; private final IngredientRepository ingredientRepository;
private final UserAccountRepository userAccountRepository; private final UserAccountRepository userAccountRepository;
private final RecipeRepository recipeRepository;
public ShoppingService(ShoppingListRepository shoppingListRepository, public ShoppingService(ShoppingListRepository shoppingListRepository,
ShoppingListItemRepository shoppingListItemRepository, ShoppingListItemRepository shoppingListItemRepository,
WeekPlanRepository weekPlanRepository, WeekPlanRepository weekPlanRepository,
HouseholdRepository householdRepository, HouseholdRepository householdRepository,
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
UserAccountRepository userAccountRepository) { UserAccountRepository userAccountRepository,
RecipeRepository recipeRepository) {
this.shoppingListRepository = shoppingListRepository; this.shoppingListRepository = shoppingListRepository;
this.shoppingListItemRepository = shoppingListItemRepository; this.shoppingListItemRepository = shoppingListItemRepository;
this.weekPlanRepository = weekPlanRepository; this.weekPlanRepository = weekPlanRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.userAccountRepository = userAccountRepository; this.userAccountRepository = userAccountRepository;
this.recipeRepository = recipeRepository;
}
@Transactional(readOnly = true)
public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) {
if (weekStart == null) {
weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart)
.map(this::toResponse)
.orElse(null);
} }
@@ -53,45 +73,71 @@ public class ShoppingService {
throw new ResourceNotFoundException("Week plan not found"); throw new ResourceNotFoundException("Week plan not found");
} }
var household = weekPlan.getHousehold(); // Find or create the shopping list
ShoppingList shoppingList = shoppingListRepository
ShoppingList shoppingList = new ShoppingList(household, weekPlan); .findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
shoppingList = shoppingListRepository.save(shoppingList); .orElseGet(() -> {
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
return shoppingListRepository.save(newList);
});
// Aggregate ingredients across all slots/recipes // Aggregate ingredients across all slots/recipes
// Key: ingredientId + unit -> merged data
Map<String, MergedIngredient> merged = new LinkedHashMap<>(); Map<String, MergedIngredient> merged = new LinkedHashMap<>();
for (var slot : weekPlan.getSlots()) { for (var slot : weekPlan.getSlots()) {
var recipe = slot.getRecipe(); var recipe = slot.getRecipe();
for (RecipeIngredient ri : recipe.getIngredients()) { for (RecipeIngredient ri : recipe.getIngredients()) {
Ingredient ingredient = ri.getIngredient(); Ingredient ingredient = ri.getIngredient();
// Filter out staples
if (ingredient.isStaple()) { if (ingredient.isStaple()) {
continue; continue;
} }
String key = mergeKey(ingredient.getId(), ri.getUnit());
String key = ingredient.getId().toString() + "|" + ri.getUnit();
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit())) merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
.addQuantity(ri.getQuantity()) .addQuantity(ri.getQuantity())
.addRecipeId(recipe.getId()); .addRecipeId(recipe.getId());
} }
} }
// Create shopping list items // Build index of existing generated items by merge key
for (MergedIngredient mi : merged.values()) { Map<String, ShoppingListItem> existingByKey = new HashMap<>();
ShoppingListItem item = new ShoppingListItem( List<ShoppingListItem> customItems = new ArrayList<>();
shoppingList, for (ShoppingListItem item : shoppingList.getItems()) {
mi.ingredient, if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
null, // Generated item
mi.totalQuantity, String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit());
mi.unit, existingByKey.put(key, item);
mi.recipeIds.stream().distinct().toArray(UUID[]::new) } else {
); customItems.add(item);
shoppingList.getItems().add(item); }
} }
// Merge: update existing, add new, collect keys to keep
Set<String> mergedKeys = new HashSet<>();
for (MergedIngredient mi : merged.values()) {
String key = mergeKey(mi.ingredient.getId(), mi.unit);
mergedKeys.add(key);
ShoppingListItem existing = existingByKey.get(key);
if (existing != null) {
// Update quantity and sources, preserve check state
existing.setQuantity(mi.totalQuantity);
existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new));
} else {
// New item
ShoppingListItem item = new ShoppingListItem(
shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit,
mi.recipeIds.stream().distinct().toArray(UUID[]::new));
shoppingList.getItems().add(item);
}
}
// Remove generated items no longer in the plan
shoppingList.getItems().removeIf(item ->
item.getSourceRecipes() != null && item.getSourceRecipes().length > 0
&& !mergedKeys.contains(mergeKey(
item.getIngredient() != null ? item.getIngredient().getId() : null,
item.getUnit())));
shoppingList.setGeneratedAt(java.time.Instant.now());
shoppingListRepository.save(shoppingList); shoppingListRepository.save(shoppingList);
return toResponse(shoppingList); return toResponse(shoppingList);
@@ -121,7 +167,7 @@ public class ShoppingService {
} }
shoppingListItemRepository.save(item); shoppingListItemRepository.save(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -146,7 +192,7 @@ public class ShoppingService {
item = shoppingListItemRepository.save(item); item = shoppingListItemRepository.save(item);
list.getItems().add(item); list.getItems().add(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -178,18 +224,53 @@ public class ShoppingService {
} }
private ShoppingListResponse toResponse(ShoppingList list) { private ShoppingListResponse toResponse(ShoppingList list) {
// Batch-fetch recipe names for source references
Set<UUID> allRecipeIds = list.getItems().stream()
.filter(i -> i.getSourceRecipes() != null)
.flatMap(i -> Arrays.stream(i.getSourceRecipes()))
.collect(Collectors.toSet());
Map<UUID, String> recipeNames = allRecipeIds.isEmpty()
? Map.of()
: recipeRepository.findAllById(allRecipeIds).stream()
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
List<ShoppingListItemResponse> items = list.getItems().stream() List<ShoppingListItemResponse> items = list.getItems().stream()
.map(this::toItemResponse) .map(item -> toItemResponse(item, recipeNames))
.toList(); .toList();
// Count filtered staples from the week plan
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
return new ShoppingListResponse( return new ShoppingListResponse(
list.getId(), list.getId(),
list.getWeekPlan().getId(), list.getWeekPlan().getId(),
list.getGeneratedAt(),
filteredStaplesCount,
items items
); );
} }
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) { private int countFilteredStaples(WeekPlan weekPlan) {
return (int) weekPlan.getSlots().stream()
.flatMap(slot -> slot.getRecipe().getIngredients().stream())
.map(RecipeIngredient::getIngredient)
.filter(Ingredient::isStaple)
.map(Ingredient::getId)
.distinct()
.count();
}
private ShoppingListItemResponse toItemResponseWithNames(ShoppingListItem item) {
Map<UUID, String> recipeNames = Map.of();
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
recipeNames = recipeRepository.findAllById(Arrays.asList(item.getSourceRecipes())).stream()
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
}
return toItemResponse(item, recipeNames);
}
private ShoppingListItemResponse toItemResponse(ShoppingListItem item, Map<UUID, String> recipeNames) {
String name; String name;
ShoppingListItemResponse.CategoryRef categoryRef = null; ShoppingListItemResponse.CategoryRef categoryRef = null;
UUID ingredientId = null; UUID ingredientId = null;
@@ -207,6 +288,14 @@ public class ShoppingService {
name = item.getCustomName(); name = item.getCustomName();
} }
List<ShoppingListItemResponse.RecipeRef> sourceRefs = item.getSourceRecipes() != null
? Arrays.stream(item.getSourceRecipes())
.distinct()
.filter(recipeNames::containsKey)
.map(id -> new ShoppingListItemResponse.RecipeRef(id, recipeNames.get(id)))
.toList()
: List.of();
return new ShoppingListItemResponse( return new ShoppingListItemResponse(
item.getId(), item.getId(),
ingredientId, ingredientId,
@@ -216,10 +305,14 @@ public class ShoppingService {
item.getUnit(), item.getUnit(),
item.isChecked(), item.isChecked(),
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() sourceRefs
); );
} }
private static String mergeKey(UUID ingredientId, String unit) {
return (ingredientId != null ? ingredientId.toString() : "") + "|" + unit;
}
private static class MergedIngredient { private static class MergedIngredient {
final Ingredient ingredient; final Ingredient ingredient;
final String unit; final String unit;

View File

@@ -1,11 +1,15 @@
package com.recipeapp.shopping.dto; package com.recipeapp.shopping.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
public record AddItemRequest( public record AddItemRequest(
UUID ingredientId, UUID ingredientId,
String customName, @NotBlank @Size(max = 255) String customName,
BigDecimal quantity, @Positive BigDecimal quantity,
String unit String unit
) {} ) {}

View File

@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
String unit, String unit,
boolean isChecked, boolean isChecked,
UUID checkedBy, UUID checkedBy,
List<UUID> sourceRecipes List<RecipeRef> sourceRecipes
) { ) {
public record CategoryRef(UUID id, String name) {} public record CategoryRef(UUID id, String name) {}
public record RecipeRef(UUID id, String name) {}
} }

View File

@@ -1,10 +1,13 @@
package com.recipeapp.shopping.dto; package com.recipeapp.shopping.dto;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public record ShoppingListResponse( public record ShoppingListResponse(
UUID id, UUID id,
UUID weekPlanId, UUID weekPlanId,
Instant generatedAt,
int filteredStaplesCount,
List<ShoppingListItemResponse> items List<ShoppingListItemResponse> items
) {} ) {}

View File

@@ -3,6 +3,7 @@ package com.recipeapp.shopping.entity;
import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.Household;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -23,6 +24,9 @@ public class ShoppingList {
@JoinColumn(name = "week_plan_id", nullable = false) @JoinColumn(name = "week_plan_id", nullable = false)
private WeekPlan weekPlan; private WeekPlan weekPlan;
@Column(name = "generated_at", nullable = false)
private Instant generatedAt = Instant.now();
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ShoppingListItem> items = new ArrayList<>(); private List<ShoppingListItem> items = new ArrayList<>();
@@ -36,5 +40,7 @@ public class ShoppingList {
public UUID getId() { return id; } public UUID getId() { return id; }
public Household getHousehold() { return household; } public Household getHousehold() { return household; }
public WeekPlan getWeekPlan() { return weekPlan; } public WeekPlan getWeekPlan() { return weekPlan; }
public Instant getGeneratedAt() { return generatedAt; }
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
public List<ShoppingListItem> getItems() { return items; } public List<ShoppingListItem> getItems() { return items; }
} }

View File

@@ -0,0 +1,3 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/seed

View File

@@ -0,0 +1,4 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/seed
out-of-order: true

View File

@@ -19,5 +19,14 @@ spring:
enabled: true enabled: true
locations: classpath:db/migration locations: classpath:db/migration
servlet:
multipart:
# NOTE: these limits only apply to multipart/form-data uploads.
# Images sent as base64 inside a JSON body (Content-Type: application/json)
# are NOT constrained here — the @Size(max=7_000_000) annotation on
# RecipeCreateRequest.heroImageUrl enforces the limit for that path.
max-file-size: 5MB
max-request-size: 6MB
server: server:
port: 8080 port: 8080

View File

@@ -0,0 +1,2 @@
ALTER TABLE shopping_list
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();

View File

@@ -0,0 +1 @@
ALTER TABLE recipe ALTER COLUMN hero_image_url TYPE text;

View File

@@ -0,0 +1 @@
ALTER TABLE recipe ADD COLUMN hero_image_preview text;

View File

@@ -0,0 +1 @@
ALTER TABLE recipe DROP COLUMN is_child_friendly;

View File

@@ -0,0 +1,182 @@
-- Dev seed: German household with Italian-leaning staples
-- Fixed UUIDs so the migration is idempotent and references are stable.
-- ─── User & Household ────────────────────────────────────────────────────────
INSERT INTO user_account (id, email, password_hash, display_name, created_at)
VALUES (
'aaaaaaaa-0000-0000-0000-000000000001',
'dev@mealprep.local',
-- bcrypt of "dev" — never expose this outside local dev
'$2a$10$IK233Yyc62EHt2hL5fw9F.0fBlEdoERr75LldZD35VFAAYfnkaOuK',
'Dev User',
now()
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO household (id, name, created_by, created_at)
VALUES (
'bbbbbbbb-0000-0000-0000-000000000001',
'Musterhaushalt',
'aaaaaaaa-0000-0000-0000-000000000001',
now()
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO household_member (household_id, user_id, role, joined_at)
VALUES (
'bbbbbbbb-0000-0000-0000-000000000001',
'aaaaaaaa-0000-0000-0000-000000000001',
'planner',
now()
)
ON CONFLICT (user_id) DO NOTHING;
-- ─── Ingredient Categories ───────────────────────────────────────────────────
INSERT INTO ingredient_category (id, household_id, name, sort_order) VALUES
('cc000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüse', 1),
('cc000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Obst', 2),
('cc000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fleisch & Fisch', 3),
('cc000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Milchprodukte & Eier', 4),
('cc000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getreide & Nudeln', 5),
('cc000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 6),
('cc000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Konserven', 7),
('cc000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürze & Kräuter', 8),
('cc000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Öle & Essig', 9),
('cc000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Saucen & Pasten', 10),
('cc000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nüsse & Samen', 11),
('cc000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Backzutaten', 12),
('cc000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tiefkühl', 13),
('cc000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getränke', 14)
ON CONFLICT (household_id, name) DO NOTHING;
-- ─── Staple Ingredients ──────────────────────────────────────────────────────
-- is_staple = true means "always keep in stock"
-- Gemüse (frisch → kein Staple)
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zwiebeln', true, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauch', true, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Karotten', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Staudensellerie', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucchini', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Aubergine', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spinat', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Brokkoli', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Süßkartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lauch', false, 'cc000001-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- Obst (frisch → kein Staple)
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zitronen', false, 'cc000001-0000-0000-0000-000000000002'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Limetten', false, 'cc000001-0000-0000-0000-000000000002')
ON CONFLICT DO NOTHING;
-- Fleisch & Fisch (frisches Fleisch → kein Staple; Konserven/Gepökeltes → Staple)
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hähnchenbrust', false, 'cc000001-0000-0000-0000-000000000003'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hackfleisch (gemischt)', false, 'cc000001-0000-0000-0000-000000000003'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pancetta', true, 'cc000001-0000-0000-0000-000000000003'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thunfisch (Dose)', true, 'cc000001-0000-0000-0000-000000000003')
ON CONFLICT DO NOTHING;
-- Milchprodukte & Eier (frisch → kein Staple)
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Butter', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Parmesan', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Mozzarella', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sahne', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schmand', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Ricotta', false, 'cc000001-0000-0000-0000-000000000004'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Milch', false, 'cc000001-0000-0000-0000-000000000004')
ON CONFLICT DO NOTHING;
-- Getreide & Nudeln (Pasta → kein Staple; Trockenvorräte wie Reis/Mehl → Staple)
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spaghetti', false, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Penne', false, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tagliatelle', false, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lasagneplatten', false, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Risottoreis (Arborio)', true, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basmati-Reis', true, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weizenmehl (Type 405)', true, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paniermehl', true, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Polenta', true, 'cc000001-0000-0000-0000-000000000005'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Haferflocken', true, 'cc000001-0000-0000-0000-000000000005')
ON CONFLICT DO NOTHING;
-- Hülsenfrüchte
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kichererbsen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Linsen', true, 'cc000001-0000-0000-0000-000000000006'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Cannellini-Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006')
ON CONFLICT DO NOTHING;
-- Konserven
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gehackte Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'San-Marzano-Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenmark', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüsebrühe', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hühnerbrühe', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kapern', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oliven (schwarz)', true, 'cc000001-0000-0000-0000-000000000007'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sardellen (Dose)', true, 'cc000001-0000-0000-0000-000000000007')
ON CONFLICT DO NOTHING;
-- Gewürze & Kräuter
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Salz', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarzer Pfeffer', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oregano (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thymian (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosmarin (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lorbeerblätter', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprikapulver (edelsüß)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Chiliflocken', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Muskat (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zimt (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kümmel (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucker', true, 'cc000001-0000-0000-0000-000000000008'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauchpulver', true, 'cc000001-0000-0000-0000-000000000008')
ON CONFLICT DO NOTHING;
-- Öle & Essig
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Olivenöl (extra vergine)', true, 'cc000001-0000-0000-0000-000000000009'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rapsöl', true, 'cc000001-0000-0000-0000-000000000009'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamico-Essig', true, 'cc000001-0000-0000-0000-000000000009'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weißweinessig', true, 'cc000001-0000-0000-0000-000000000009')
ON CONFLICT DO NOTHING;
-- Saucen & Pasten
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum-Pesto', true, 'cc000001-0000-0000-0000-000000000010'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenpassata', true, 'cc000001-0000-0000-0000-000000000010'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sojasauce', true, 'cc000001-0000-0000-0000-000000000010'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Worcestershiresauce', true, 'cc000001-0000-0000-0000-000000000010'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Senf (mittelscharf)', true, 'cc000001-0000-0000-0000-000000000010')
ON CONFLICT DO NOTHING;
-- Nüsse & Samen
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pinienkerne', true, 'cc000001-0000-0000-0000-000000000011'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Walnüsse', true, 'cc000001-0000-0000-0000-000000000011'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sonnenblumenkerne', true, 'cc000001-0000-0000-0000-000000000011'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosinen', true, 'cc000001-0000-0000-0000-000000000011')
ON CONFLICT DO NOTHING;
-- Backzutaten
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Backpulver', true, 'cc000001-0000-0000-0000-000000000012'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Trockenhefe', true, 'cc000001-0000-0000-0000-000000000012'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Natron', true, 'cc000001-0000-0000-0000-000000000012'),
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Vanilleextrakt', true, 'cc000001-0000-0000-0000-000000000012')
ON CONFLICT DO NOTHING;

View File

@@ -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 23 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 510 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 1820 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 34 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 1315 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 23 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. 2025 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 79 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 67 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 23 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 2530 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 89 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 12 cm Würfel schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Olivenöl in einer großen Pfanne stark erhitzen. Aubergine 34 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 1012 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 510 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 34 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;

View File

@@ -0,0 +1,84 @@
package com.recipeapp.common;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.method.HandlerMethod;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class HouseholdRoleInterceptorTest {
@Mock private HouseholdResolver householdResolver;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@InjectMocks private HouseholdRoleInterceptor interceptor;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(String email) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(email, null));
}
@Test
void shouldAllowWhenUserHasRequiredRole() throws Exception {
authenticateAs("planner@example.com");
when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldThrowForbiddenWhenUserLacksRequiredRole() {
authenticateAs("member@example.com");
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod))
.isInstanceOf(ForbiddenException.class)
.hasMessage("Insufficient permissions");
}
@Test
void shouldPassThroughWhenNoAnnotation() throws Exception {
var handlerMethod = mock(HandlerMethod.class);
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldPassThroughWhenNotHandlerMethod() throws Exception {
boolean result = interceptor.preHandle(request, response, new Object());
assertThat(result).isTrue();
}
}

View File

@@ -55,7 +55,7 @@ class PlanningServiceTest {
} }
private Recipe testRecipe(Household household, String name) { private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }
@@ -443,4 +443,93 @@ class PlanningServiceTest {
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
// ── Variety preview ──
@Test
void getVarietyPreviewShouldReturnScoreDeltaForDifferentRecipe() {
var household = testHousehold();
var plan = testWeekPlan(household);
var planId = plan.getId();
// Plan already has one slot (Mon) with Spaghetti
var existingRecipe = testRecipe(household, "Spaghetti");
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot);
// Candidate is Lachsfilet (different recipe, no shared tags/ingredients)
var candidate = testRecipe(household, "Lachsfilet");
var candidateId = candidate.getId();
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(candidateId, HOUSEHOLD_ID))
.thenReturn(Optional.of(candidate));
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
.thenReturn(List.of());
var result = planningService.getVarietyPreview(HOUSEHOLD_ID, planId, candidateId, WEEK_START.plusDays(1));
// 1 existing slot with no conflicts → currentScore = 10.0
// Adding a different recipe with no tags/ingredients → projectedScore = 10.0, delta = 0
assertThat(result.currentScore()).isEqualTo(10.0);
assertThat(result.projectedScore()).isEqualTo(10.0);
assertThat(result.scoreDelta()).isEqualTo(0.0);
}
@Test
void getVarietyPreviewShouldReturnNegativeDeltaForDuplicateRecipe() {
var household = testHousehold();
var plan = testWeekPlan(household);
var planId = plan.getId();
// Plan already has Spaghetti on Mon
var existingRecipe = testRecipe(household, "Spaghetti");
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot);
// Candidate is the same Spaghetti recipe → triggers duplicate penalty (wPlanDuplicate = 2.0)
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(existingRecipe.getId(), HOUSEHOLD_ID))
.thenReturn(Optional.of(existingRecipe));
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
.thenReturn(List.of());
var result = planningService.getVarietyPreview(
HOUSEHOLD_ID, planId, existingRecipe.getId(), WEEK_START.plusDays(1));
// currentScore = 10.0 (1 slot, no conflicts)
// projectedScore = 10.0 - 1 * 2.0 (duplicate penalty) = 8.0
assertThat(result.currentScore()).isEqualTo(10.0);
assertThat(result.projectedScore()).isEqualTo(8.0);
assertThat(result.scoreDelta()).isEqualTo(-2.0);
}
@Test
void getVarietyPreviewShouldThrowWhenPlanNotFound() {
var planId = UUID.randomUUID();
when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> planningService.getVarietyPreview(
HOUSEHOLD_ID, planId, UUID.randomUUID(), WEEK_START))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getVarietyPreviewShouldThrowWhenRecipeNotFound() {
var household = testHousehold();
var plan = testWeekPlan(household);
var recipeId = UUID.randomUUID();
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, HOUSEHOLD_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> planningService.getVarietyPreview(
HOUSEHOLD_ID, plan.getId(), recipeId, WEEK_START))
.isInstanceOf(ResourceNotFoundException.class);
}
} }

View File

@@ -69,7 +69,7 @@ class SuggestionsTest {
} }
private Recipe createRecipe(String name) { private Recipe createRecipe(String name) {
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }
@@ -165,7 +165,7 @@ class SuggestionsTest {
} }
@Test @Test
void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() { void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() {
var plan = createPlan(); var plan = createPlan();
var r1 = createRecipe("Pasta"); var r1 = createRecipe("Pasta");
var r2 = createRecipe("Salad"); var r2 = createRecipe("Salad");
@@ -179,8 +179,12 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(3); assertThat(result.suggestions()).hasSize(3);
assertThat(result.suggestions()).allSatisfy(s -> // Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all
assertThat(s.simulatedScore()).isEqualTo(10.0)); // hasConflict = (scoreDelta < 0) = false for neutral recipes
assertThat(result.suggestions()).allSatisfy(s -> {
assertThat(s.scoreDelta()).isEqualTo(0.0);
assertThat(s.hasConflict()).isFalse();
});
} }
@Test @Test
@@ -204,6 +208,28 @@ class SuggestionsTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test
void topNZeroShouldReturnEmptyList() {
var plan = createPlan();
stubPlan(plan);
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0);
assertThat(result.suggestions()).isEmpty();
}
@Test
void topNNegativeShouldReturnEmptyList() {
var plan = createPlan();
stubPlan(plan);
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), -1);
assertThat(result.suggestions()).isEmpty();
}
@Test @Test
void singleCandidateShouldReturnOne() { void singleCandidateShouldReturnOne() {
var plan = createPlan(); var plan = createPlan();
@@ -221,6 +247,148 @@ class SuggestionsTest {
} }
} }
// ═══════════════════════════════════════════════════════════
// Category 1b: scoreDelta and hasConflict
// ═══════════════════════════════════════════════════════════
@Nested
class ScoreDeltaAndHasConflict {
@Test
void recipeWithZeroDeltaOnEmptyPlanShouldNotHaveConflict() {
// Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0.
// scoreDelta = 0.0. No worsening → hasConflict = false.
var plan = createPlan();
var recipe = createRecipe("Clean Recipe");
stubPlan(plan);
stubDefaultConfig();
stubRecipes(recipe);
stubNoCookingLogs();
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(1);
var item = result.suggestions().getFirst();
assertThat(item.scoreDelta()).isEqualTo(0.0);
assertThat(item.hasConflict()).isFalse();
}
@Test
void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() {
// Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5).
// currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5.
// scoreDelta = -1.5, hasConflict = true.
var plan = createPlan();
var pastaTag = createTag("Pasta", "cuisine");
var existingRecipe = createRecipe("Monday Pasta");
addTag(existingRecipe, pastaTag);
addSlot(plan, existingRecipe, MONDAY);
var candidate = createRecipe("More Pasta");
addTag(candidate, pastaTag);
stubPlan(plan);
stubDefaultConfig();
stubRecipes(existingRecipe, candidate);
stubNoCookingLogs();
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1);
var item = result.suggestions().getFirst();
assertThat(item.scoreDelta()).isEqualTo(-1.5);
assertThat(item.hasConflict()).isTrue();
}
@Test
void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() {
// Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3).
// currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true.
var plan = createPlan();
var tomato = createIngredient("Tomatoes", false);
var existingRecipe = createRecipe("Tomato Soup");
addIngredient(existingRecipe, tomato);
addSlot(plan, existingRecipe, MONDAY);
var candidate = createRecipe("Tomato Pasta");
addIngredient(candidate, tomato);
stubPlan(plan);
stubDefaultConfig();
stubRecipes(existingRecipe, candidate);
stubNoCookingLogs();
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1);
var item = result.suggestions().getFirst();
assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001));
assertThat(item.hasConflict()).isTrue();
}
@Test
void swappingExistingSlotForCleanRecipeShouldHavePositiveDelta() {
// Plan has Mon=ItalianA, Tue=ItalianB → consecutive cuisine tag repeat → currentScore = 8.5
// Asking for suggestions for Mon (swap scenario).
// CleanRecipe (no Italian tag) → correct simulation: [Mon:CleanRecipe, Tue:ItalianB] → no repeat → 10.0
// scoreDelta = +1.5 → hasConflict = false
var plan = createPlan();
var italianTag = createTag("Italienisch", "cuisine");
var italianA = createRecipe("Spaghetti Carbonara");
addTag(italianA, italianTag);
addSlot(plan, italianA, MONDAY);
var italianB = createRecipe("Penne Arrabiata");
addTag(italianB, italianTag);
addSlot(plan, italianB, MONDAY.plusDays(1));
var cleanRecipe = createRecipe("Grillhähnchen");
stubPlan(plan);
stubDefaultConfig();
stubRecipes(italianA, italianB, cleanRecipe);
stubNoCookingLogs();
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(1);
var item = result.suggestions().getFirst();
assertThat(item.recipe().name()).isEqualTo("Grillhähnchen");
assertThat(item.scoreDelta()).isCloseTo(1.5, within(0.001));
assertThat(item.hasConflict()).isFalse();
}
@Test
void scoreDeltaIsSortedDescendingCleanBeforeConflicting() {
// Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0).
var plan = createPlan();
var pastaTag = createTag("Pasta", "cuisine");
var existingRecipe = createRecipe("Monday Pasta");
addTag(existingRecipe, pastaTag);
addSlot(plan, existingRecipe, MONDAY);
var cleanRecipe = createRecipe("Plain Rice");
var conflictingRecipe = createRecipe("More Pasta");
addTag(conflictingRecipe, pastaTag);
stubPlan(plan);
stubDefaultConfig();
stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe);
stubNoCookingLogs();
SuggestionResponse result = planningService.getSuggestions(
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0);
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta");
assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5);
}
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Category 2: Exclusion of In-Plan Recipes // Category 2: Exclusion of In-Plan Recipes
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -402,8 +570,8 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
// B should rank higher (no tag penalty) // B should rank higher (no tag penalty)
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).simulatedScore()); .isGreaterThan(result.suggestions().get(1).scoreDelta());
} }
@Test @Test
@@ -428,8 +596,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isEqualTo(result.suggestions().get(1).simulatedScore()); .isEqualTo(result.suggestions().get(1).scoreDelta());
} }
@Test @Test
@@ -453,8 +621,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1); assertThat(result.suggestions()).hasSize(1);
// No penalty — dietary not tracked // No penalty — dietary not tracked → scoreDelta = 0.0
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
} }
} }
@@ -492,8 +660,8 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto"); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto");
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).simulatedScore()); .isGreaterThan(result.suggestions().get(1).scoreDelta());
} }
@Test @Test
@@ -519,7 +687,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1); assertThat(result.suggestions()).hasSize(1);
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); // Staples ignored → scoreDelta = 0.0
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
} }
} }
@@ -547,8 +716,8 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry"); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry");
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).simulatedScore()); .isGreaterThan(result.suggestions().get(1).scoreDelta());
} }
@Test @Test
@@ -566,7 +735,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(1); assertThat(result.suggestions()).hasSize(1);
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); // No penalty → scoreDelta = 0.0
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
} }
} }
@@ -631,7 +801,7 @@ class SuggestionsTest {
} }
@Test @Test
void rankingOrderShouldBeBySimulatedScoreDescending() { void rankingOrderShouldBeByScoreDeltaDescending() {
var plan = createPlan(); var plan = createPlan();
var pastaTag = createTag("Pasta", "cuisine"); var pastaTag = createTag("Pasta", "cuisine");
var tomato = createIngredient("Tomatoes", false); var tomato = createIngredient("Tomatoes", false);
@@ -666,11 +836,11 @@ class SuggestionsTest {
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta"); assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta");
assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta"); assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta");
// Verify scores are strictly descending // Verify scoreDelta is strictly descending
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).simulatedScore()); .isGreaterThan(result.suggestions().get(1).scoreDelta());
assertThat(result.suggestions().get(1).simulatedScore()) assertThat(result.suggestions().get(1).scoreDelta())
.isGreaterThan(result.suggestions().get(2).simulatedScore()); .isGreaterThan(result.suggestions().get(2).scoreDelta());
} }
@Test @Test
@@ -688,8 +858,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).simulatedScore()) assertThat(result.suggestions().get(0).scoreDelta())
.isEqualTo(result.suggestions().get(1).simulatedScore()); .isEqualTo(result.suggestions().get(1).scoreDelta());
} }
} }
@@ -726,7 +896,7 @@ class SuggestionsTest {
addTag(c1, pastaTag); addTag(c1, pastaTag);
addIngredient(c1, tomato); addIngredient(c1, tomato);
// Candidate 2: Chicken only → protein repeat with Mon // Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive)
var c2 = createRecipe("Chicken Salad"); var c2 = createRecipe("Chicken Salad");
addTag(c2, chickenTag); addTag(c2, chickenTag);
@@ -745,7 +915,7 @@ class SuggestionsTest {
stubPlan(plan); stubPlan(plan);
stubDefaultConfig(); stubDefaultConfig();
stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5); stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5);
// c1 was cooked recently // c1 was cooked recently (within 14-day window)
stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3))); stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3)));
// Slot date = Wednesday (adjacent to Tuesday) // Slot date = Wednesday (adjacent to Tuesday)
@@ -754,19 +924,20 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(5); assertThat(result.suggestions()).hasSize(5);
// c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive) // currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots)
// c2, c4, c5: no additional conflicts → scoreDelta = 0.0
var topThree = result.suggestions().subList(0, 3); var topThree = result.suggestions().subList(0, 3);
assertThat(topThree).extracting(s -> s.recipe().name()) assertThat(topThree).extracting(s -> s.recipe().name())
.containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup"); .containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup");
assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0)); assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0));
// c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3 // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3
assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette"); assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette");
assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001)); assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001));
// c1 (Tomato Spaghetti) has recent repeat: -1.0 // c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0
assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti"); assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti");
assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0); assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0);
} }
@Test @Test
@@ -800,7 +971,7 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1),
List.of("Quick meal"), 5); List.of("Quick meal"), 5);
// Only quick recipes, ranked by variety // Only quick recipes, ranked by scoreDelta desc
assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad"); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad");
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta"); assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta");
@@ -815,7 +986,7 @@ class SuggestionsTest {
class EdgeCases { class EdgeCases {
@Test @Test
void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() {
var plan = createPlan(); var plan = createPlan();
var existingRecipe = createRecipe("Existing"); var existingRecipe = createRecipe("Existing");
addSlot(plan, existingRecipe, MONDAY); addSlot(plan, existingRecipe, MONDAY);
@@ -832,7 +1003,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1); assertThat(result.suggestions()).hasSize(1);
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); // No conflicts → scoreDelta = 0.0
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
} }
@Test @Test

View File

@@ -69,7 +69,7 @@ class VarietyScoreTest {
} }
private Recipe createRecipe(String name) { private Recipe createRecipe(String name) {
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

View File

@@ -3,9 +3,11 @@ package com.recipeapp.planning;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.common.ValidationException; import com.recipeapp.common.ValidationException;
import com.recipeapp.planning.dto.*; import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
.build(); .build();
} }
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test @Test
void getWeekPlanShouldReturn200() throws Exception { void getWeekPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of()); var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
@@ -153,7 +162,7 @@ class WeekPlanControllerTest {
@Test @Test
void getSuggestionsShouldReturn200() throws Exception { void getSuggestionsShouldReturn200() throws Exception {
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
var response = new SuggestionResponse(List.of(item)); var response = new SuggestionResponse(List.of(item));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
@@ -166,7 +175,8 @@ class WeekPlanControllerTest {
.param("slotDate", "2026-04-08")) .param("slotDate", "2026-04-08"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry"))
.andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); .andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5))
.andExpect(jsonPath("$.suggestions[0].hasConflict").value(false));
} }
@Test @Test
@@ -182,4 +192,79 @@ class WeekPlanControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(7.5)); .andExpect(jsonPath("$.score").value(7.5));
} }
@Test
void getVarietyPreviewShouldReturn200() throws Exception {
var recipeId = UUID.randomUUID();
var response = new VarietyPreviewResponse(8.0, 9.0, 1.0);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(planningService.getVarietyPreview(HOUSEHOLD_ID, PLAN_ID, recipeId, WEEK_START.plusDays(2)))
.thenReturn(response);
mockMvc.perform(get("/v1/week-plans/{planId}/variety-preview", PLAN_ID)
.principal(() -> "sarah@example.com")
.param("recipeId", recipeId.toString())
.param("date", "2026-04-08"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.currentScore").value(8.0))
.andExpect(jsonPath("$.projectedScore").value(9.0))
.andExpect(jsonPath("$.scoreDelta").value(1.0));
}
@Test
void addSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
.andExpect(status().isForbidden());
}
@Test
void updateSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
.andExpect(status().isForbidden());
}
@Test
void deleteSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com"))
.andExpect(status().isForbidden());
}
} }

View File

@@ -0,0 +1,120 @@
package com.recipeapp.recipe;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import static org.assertj.core.api.Assertions.*;
class ImageCompressorTest {
private final ImageCompressor compressor = new ImageCompressor();
@Test
void compressToPreview_returnsJpegDataUri() throws Exception {
String dataUri = makePngDataUri(800, 600);
String result = compressor.compressToPreview(dataUri);
assertThat(result).startsWith("data:image/jpeg;base64,");
}
@Test
void compressToPreview_outputIsDecodableJpeg() throws Exception {
String dataUri = makePngDataUri(800, 600);
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
byte[] bytes = Base64.getDecoder().decode(base64);
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
assertThat(img).isNotNull();
assertThat(img.getWidth()).isLessThanOrEqualTo(400);
}
@Test
void compressToPreview_preservesAspectRatio() throws Exception {
String dataUri = makePngDataUri(800, 400); // 2:1 ratio
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
assertThat(img).isNotNull();
double ratio = (double) img.getWidth() / img.getHeight();
assertThat(ratio).isCloseTo(2.0, within(0.1));
}
@Test
void compressToPreview_doesNotUpscaleSmallImages() throws Exception {
String dataUri = makePngDataUri(200, 150); // smaller than 400px
String result = compressor.compressToPreview(dataUri);
String base64 = result.substring("data:image/jpeg;base64,".length());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
assertThat(img).isNotNull();
assertThat(img.getWidth()).isLessThanOrEqualTo(200);
}
@Test
void compressToPreview_returnsNullForNull() {
assertThat(compressor.compressToPreview(null)).isNull();
}
@Test
void compressToPreview_returnsNullForBlankString() {
assertThat(compressor.compressToPreview(" ")).isNull();
}
@Test
void compressToPreview_returnsNullForNonDataUri() {
assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull();
}
@Test
void compressToPreview_returnsNullForInvalidBase64() {
assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull();
}
@Test
void compressToPreview_acceptsJpegInput() throws Exception {
String dataUri = makeJpegDataUri(800, 600);
String result = compressor.compressToPreview(dataUri);
assertThat(result).startsWith("data:image/jpeg;base64,");
String base64 = result.substring("data:image/jpeg;base64,".length());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
assertThat(img).isNotNull();
assertThat(img.getWidth()).isLessThanOrEqualTo(400);
}
// ── helpers ──
private String makePngDataUri(int width, int height) throws Exception {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
// draw gradient so PNG and JPEG both have non-trivial content
for (int x = 0; x < width; x++) {
g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128));
g.drawLine(x, 0, x, height);
}
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
}
private String makeJpegDataUri(int width, int height) throws Exception {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = img.createGraphics();
g.setColor(java.awt.Color.ORANGE);
g.fillRect(0, 0, width, height);
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "jpeg", bos);
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
}
}

View File

@@ -47,13 +47,13 @@ class RecipeControllerTest {
@Test @Test
void listRecipesShouldReturn200WithPagination() throws Exception { void listRecipesShouldReturn200WithPagination() throws Exception {
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese", var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
(short) 4, (short) 45, "medium", true, null); (short) 4, (short) 45, "medium", null);
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(), when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
isNull(), eq(20), eq(0))) isNull(), eq(20), eq(0)))
.thenReturn(List.of(summary)); .thenReturn(List.of(summary));
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull())) when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull()))
.thenReturn(1L); .thenReturn(1L);
mockMvc.perform(get("/v1/recipes") mockMvc.perform(get("/v1/recipes")
@@ -69,17 +69,16 @@ class RecipeControllerTest {
@Test @Test
void listRecipesWithFiltersShouldPassParams() throws Exception { void listRecipesWithFiltersShouldPassParams() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"),
eq(30), eq("-cookTimeMin"), eq(10), eq(5))) eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
.thenReturn(List.of()); .thenReturn(List.of());
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30))) when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30)))
.thenReturn(0L); .thenReturn(0L);
mockMvc.perform(get("/v1/recipes") mockMvc.perform(get("/v1/recipes")
.principal(() -> "sarah@example.com") .principal(() -> "sarah@example.com")
.param("search", "pasta") .param("search", "pasta")
.param("effort", "easy") .param("effort", "easy")
.param("isChildFriendly", "true")
.param("cookTimeMin.lte", "30") .param("cookTimeMin.lte", "30")
.param("sort", "-cookTimeMin") .param("sort", "-cookTimeMin")
.param("limit", "10") .param("limit", "10")
@@ -162,10 +161,50 @@ class RecipeControllerTest {
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
} }
@Test
void createRecipeWithOversizedHeroImageShouldReturn400() throws Exception {
String heroImageUrl = "data:image/jpeg;base64," + "A".repeat(7_000_000);
String body = "{\"name\":\"Test\",\"effort\":\"easy\",\"tagIds\":[\"" + UUID.randomUUID() + "\"]," +
"\"ingredients\":[{\"quantity\":1,\"unit\":\"g\",\"newIngredientName\":\"x\",\"sortOrder\":0}]," +
"\"heroImageUrl\":\"" + heroImageUrl + "\"}";
mockMvc.perform(post("/v1/recipes")
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void createRecipeWithEmptyIngredientsListShouldReturn400() throws Exception {
var body = """
{"name":"Test","effort":"easy","tagIds":["%s"],"ingredients":[]}
""".formatted(UUID.randomUUID());
mockMvc.perform(post("/v1/recipes")
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
var body = """
{"name":"Test","effort":"Easy","tagIds":["%s"],"ingredients":[{"quantity":1,"unit":"g","newIngredientName":"x","sortOrder":0}]}
""".formatted(UUID.randomUUID());
mockMvc.perform(post("/v1/recipes")
.principal(() -> "sarah@example.com")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
private RecipeCreateRequest sampleCreateRequest() { private RecipeCreateRequest sampleCreateRequest() {
var ingredientId = UUID.randomUUID(); var ingredientId = UUID.randomUUID();
return new RecipeCreateRequest( return new RecipeCreateRequest(
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, "Spaghetti Bolognese", 4, 45, "medium", null,
List.of(new RecipeCreateRequest.IngredientEntry( List.of(new RecipeCreateRequest.IngredientEntry(
ingredientId, null, new BigDecimal("400"), "g", (short) 1)), ingredientId, null, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
@@ -175,7 +214,7 @@ class RecipeControllerTest {
private RecipeDetailResponse sampleDetail() { private RecipeDetailResponse sampleDetail() {
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta"); var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
return new RecipeDetailResponse( return new RecipeDetailResponse(
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null,
List.of(new RecipeDetailResponse.IngredientItem( List.of(new RecipeDetailResponse.IngredientItem(
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)), UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")), List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),

View File

@@ -27,6 +27,7 @@ class RecipeServiceTest {
@Mock private TagRepository tagRepository; @Mock private TagRepository tagRepository;
@Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private HouseholdRepository householdRepository; @Mock private HouseholdRepository householdRepository;
@Mock private ImageCompressor imageCompressor;
@InjectMocks private RecipeService recipeService; @InjectMocks private RecipeService recipeService;
@@ -43,7 +44,7 @@ class RecipeServiceTest {
} }
private Recipe testRecipe(Household household) { private Recipe testRecipe(Household household) {
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true); var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium");
try { try {
var field = Recipe.class.getDeclaredField("id"); var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true); field.setAccessible(true);
@@ -126,7 +127,7 @@ class RecipeServiceTest {
}); });
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, "Spaghetti Bolognese", 4, 45, "medium", null,
List.of(new RecipeCreateRequest.IngredientEntry( List.of(new RecipeCreateRequest.IngredientEntry(
ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)), ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)),
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
@@ -166,7 +167,7 @@ class RecipeServiceTest {
}); });
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Carbonara", (short) 2, (short) 30, "medium", false, null, "Carbonara", 2, 30, "medium", null,
List.of(new RecipeCreateRequest.IngredientEntry( List.of(new RecipeCreateRequest.IngredientEntry(
null, "pancetta", new BigDecimal("100"), "g", (short) 1)), null, "pancetta", new BigDecimal("100"), "g", (short) 1)),
List.of(), List.of(),
@@ -192,7 +193,7 @@ class RecipeServiceTest {
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0)); when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Chicken Rice", (short) 3, (short) 25, "easy", true, null, "Chicken Rice", 3, 25, "easy", null,
List.of(new RecipeCreateRequest.IngredientEntry( List.of(new RecipeCreateRequest.IngredientEntry(
ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)), ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)),
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")), List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")),
@@ -450,7 +451,7 @@ class RecipeServiceTest {
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty()); when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty());
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Test", (short) 2, (short) 15, "easy", false, null, "Test", 2, 15, "easy", null,
List.of(), List.of(), List.of()); List.of(), List.of(), List.of());
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)) assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
@@ -466,7 +467,7 @@ class RecipeServiceTest {
when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty()); when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty());
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Test", (short) 2, (short) 15, "easy", false, null, "Test", 2, 15, "easy", null,
List.of(new RecipeCreateRequest.IngredientEntry( List.of(new RecipeCreateRequest.IngredientEntry(
ingredientId, null, new BigDecimal("100"), "g", (short) 1)), ingredientId, null, new BigDecimal("100"), "g", (short) 1)),
List.of(), List.of()); List.of(), List.of());
@@ -491,7 +492,7 @@ class RecipeServiceTest {
}); });
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Simple", (short) 1, (short) 5, "easy", false, null, "Simple", 1, 5, "easy", null,
null, null, null); null, null, null);
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
@@ -518,13 +519,36 @@ class RecipeServiceTest {
.thenReturn(Optional.empty()); .thenReturn(Optional.empty());
var request = new RecipeCreateRequest( var request = new RecipeCreateRequest(
"Updated", (short) 2, (short) 20, "easy", false, null, "Updated", 2, 20, "easy", null,
List.of(), List.of(), List.of()); List.of(), List.of(), List.of());
assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request)) assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test
void createRecipeWithNullServesAndCookTimeShouldStoreZero() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
Recipe r = i.getArgument(0);
try {
var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true);
field.set(r, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return r;
});
var request = new RecipeCreateRequest("Soup", null, null, "easy", null,
List.of(), List.of(), List.of());
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
assertThat(result.serves()).isEqualTo((short) 0);
assertThat(result.cookTimeMin()).isEqualTo((short) 0);
}
// ── Tag/Category edge cases ── // ── Tag/Category edge cases ──
@Test @Test
@@ -547,6 +571,33 @@ class RecipeServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test
void createRecipeWithDisallowedImageTypeShouldThrowValidationException() {
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold()));
var request = new RecipeCreateRequest(
"Test", null, null, "easy", "data:application/pdf;base64,abc",
List.of(), List.of(), List.of());
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
.isInstanceOf(com.recipeapp.common.ValidationException.class);
}
@Test
void createRecipeWithAllowedImageTypeShouldNotThrow() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
// "abc" is not valid base64 for a real image; ImageCompressor will return null for the
// preview, but validateHeroImageUrl() should pass for a well-formed data URI prefix.
var request = new RecipeCreateRequest(
"Test", null, null, "easy", "data:image/jpeg;base64,abc",
List.of(), List.of(), List.of());
assertThatNoException().isThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request));
}
@Test @Test
void listTagsShouldReturnEmptyList() { void listTagsShouldReturnEmptyList() {
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of()); when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());
@@ -555,4 +606,30 @@ class RecipeServiceTest {
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
@Test
void createRecipeShouldStoreNullPreviewWhenCompressorReturnsNull() {
var household = testHousehold();
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
when(imageCompressor.compressToPreview(any())).thenReturn(null);
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
Recipe r = i.getArgument(0);
try {
var field = Recipe.class.getDeclaredField("id");
field.setAccessible(true);
field.set(r, UUID.randomUUID());
} catch (Exception e) { throw new RuntimeException(e); }
return r;
});
var request = new RecipeCreateRequest(
"Soup", null, null, "easy", "data:image/jpeg;base64,abc",
List.of(), List.of(), List.of());
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
assertThat(result.id()).isNotNull();
// verify the recipe was saved without a preview (compressor returned null)
verify(recipeRepository).save(argThat(r -> r.getHeroImagePreview() == null));
}
} }

View File

@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
.build(); .build();
} }
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void getByWeekStartShouldReturn200() throws Exception {
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response);
mockMvc.perform(get("/v1/shopping-list")
.param("weekStart", "2026-04-06")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(LIST_ID.toString()))
.andExpect(jsonPath("$.filteredStaplesCount").value(3));
}
@Test
void getByWeekStartShouldReturn404WhenNoListExists() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null);
mockMvc.perform(get("/v1/shopping-list")
.param("weekStart", "2026-04-06")
.principal(() -> "sarah@example.com"))
.andExpect(status().isNotFound());
}
@Test @Test
void generateFromPlanShouldReturn201() throws Exception { void generateFromPlanShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();
var item = new ShoppingListItemResponse( var item = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", ITEM_ID, UUID.randomUUID(), "Tomatoes",
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"), new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID())); new BigDecimal("4.00"), "pcs", false, null,
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item)); List.of(new ShoppingListItemResponse.RecipeRef(recipeId, "Spaghetti")));
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 2, List.of(item));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
@Test @Test
void getShoppingListShouldReturn200() throws Exception { void getShoppingListShouldReturn200() throws Exception {
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of()); var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 0, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
void checkItemShouldReturn200() throws Exception { void checkItemShouldReturn200() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", null, ITEM_ID, UUID.randomUUID(), "Tomatoes", null,
new BigDecimal("4.00"), "pcs", true, USER_ID, List.of()); new BigDecimal("4.00"), "pcs", true, USER_ID,
List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID); when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
void addItemShouldReturn201() throws Exception { void addItemShouldReturn201() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, null, "Paper towels", null, ITEM_ID, null, "Paper towels", null,
new BigDecimal("1"), "", false, null, List.of()); new BigDecimal("1"), "", false, null,
List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class))) when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
.principal(() -> "sarah@example.com")) .principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@Test
void addItemShouldReturn400WhenCustomNameIsBlank() throws Exception {
mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new AddItemRequest(null, " ", new BigDecimal("1"), ""))))
.andExpect(status().isBadRequest());
}
@Test
void generateFromPlanShouldReturn403ForNonPlanner() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(shoppingListController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID)
.principal(() -> "member@example.com"))
.andExpect(status().isForbidden());
}
} }

View File

@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.planning.entity.WeekPlanSlot; import com.recipeapp.planning.entity.WeekPlanSlot;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.Recipe;
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
@Mock private HouseholdRepository householdRepository; @Mock private HouseholdRepository householdRepository;
@Mock private IngredientRepository ingredientRepository; @Mock private IngredientRepository ingredientRepository;
@Mock private UserAccountRepository userAccountRepository; @Mock private UserAccountRepository userAccountRepository;
@Mock private RecipeRepository recipeRepository;
@InjectMocks private ShoppingService shoppingService; @InjectMocks private ShoppingService shoppingService;
@@ -58,7 +60,7 @@ class ShoppingServiceTest {
} }
private Recipe testRecipe(Household household, String name) { private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }
@@ -90,6 +92,46 @@ class ShoppingServiceTest {
} catch (Exception e) { throw new RuntimeException(e); } } catch (Exception e) { throw new RuntimeException(e); }
} }
// ── Get by week start ──
@Test
void getByWeekStartShouldReturnListForGivenWeek() {
var household = testHousehold();
var plan = testWeekPlan(household);
var list = testShoppingList(household, plan);
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.of(list));
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
assertThat(result.id()).isEqualTo(list.getId());
}
@Test
void getByWeekStartShouldDefaultToCurrentWeekWhenNull() {
var household = testHousehold();
var plan = testWeekPlan(household);
var list = testShoppingList(household, plan);
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class)))
.thenReturn(Optional.of(list));
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null);
assertThat(result).isNotNull();
}
@Test
void getByWeekStartShouldReturnNullWhenNoListExists() {
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
assertThat(result).isNull();
}
// ── Generate ── // ── Generate ──
@Test @Test
@@ -119,26 +161,84 @@ class ShoppingServiceTest {
plan.getSlots().add(slot2); plan.getSlots().add(slot2);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0); ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID()); if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl; return sl;
}); });
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
var tomatoItem = result.items().stream() var tomatoItem = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3 assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
assertThat(tomatoItem.sourceRecipes()).hasSize(2); assertThat(tomatoItem.sourceRecipes()).hasSize(2);
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
var cheeseItem = result.items().stream() var cheeseItem = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
} }
@Test
void generateFromPlanShouldMergeWhenListAlreadyExists() {
var household = testHousehold();
var plan = testWeekPlan(household);
var existingList = testShoppingList(household, plan);
// Existing generated item: 2 tomatoes
var tomato = testIngredient(household, "Tomatoes", false);
var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(existingItem);
// Existing custom item (should be preserved)
var customItem = new ShoppingListItem(existingList, null, "Paper towels",
new BigDecimal("1"), "", new UUID[0]);
setId(customItem, ShoppingListItem.class, UUID.randomUUID());
customItem.setChecked(true);
existingList.getItems().add(customItem);
// New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added)
var recipe = testRecipe(household, "Pasta");
var cheese = testIngredient(household, "Cheese", false);
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1));
recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2));
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart()))
.thenReturn(Optional.of(existingList));
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
// Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom)
assertThat(result.items()).hasSize(3);
var tomatoResult = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00"));
var cheeseResult = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
// Custom item preserved with check state
var customResult = result.items().stream()
.filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow();
assertThat(customResult.isChecked()).isTrue();
}
@Test @Test
void generateFromPlanShouldThrowWhenPlanNotFound() { void generateFromPlanShouldThrowWhenPlanNotFound() {
var planId = UUID.randomUUID(); var planId = UUID.randomUUID();
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
assertThat(result.id()).isEqualTo(list.getId()); assertThat(result.id()).isEqualTo(list.getId());
assertThat(result.generatedAt()).isNotNull();
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
} }
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
// ── Generate removes stale items ──
@Test
void generateFromPlanShouldRemoveStaleGeneratedItems() {
var household = testHousehold();
var plan = testWeekPlan(household);
var existingList = testShoppingList(household, plan);
var tomato = testIngredient(household, "Tomatoes", false);
var onion = testIngredient(household, "Onions", false);
// Existing list has both tomatoes and onions (generated)
var tomatoItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
tomatoItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(tomatoItem);
var onionItem = testItem(existingList, onion, new BigDecimal("1.00"), "pcs");
onionItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(onionItem);
// New plan only has tomatoes — onions removed from recipes
var recipe = testRecipe(household, "Sauce");
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("3.00"), "pcs", (short) 1));
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.of(existingList));
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
}
// ── Source recipes deduplication ──
@Test
void generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots() {
var household = testHousehold();
var plan = testWeekPlan(household);
var recipe = testRecipe(household, "Pasta");
var tomato = testIngredient(household, "Tomatoes", false);
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("2.00"), "pcs", (short) 1));
// Same recipe in two slots
var slot1 = new WeekPlanSlot(plan, recipe, WEEK_START);
setId(slot1, WeekPlanSlot.class, UUID.randomUUID());
var slot2 = new WeekPlanSlot(plan, recipe, WEEK_START.plusDays(2));
setId(slot2, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot1);
plan.getSlots().add(slot2);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0);
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().sourceRecipes()).hasSize(1); // deduplicated
}
// ── checkItem household isolation ──
@Test
void checkItemShouldThrowWhenHouseholdMismatch() {
var otherHousehold = new Household("Other family", null);
setId(otherHousehold, Household.class, UUID.randomUUID());
var plan = new WeekPlan(otherHousehold, WEEK_START);
setId(plan, WeekPlan.class, UUID.randomUUID());
var list = new ShoppingList(otherHousehold, plan);
setId(list, ShoppingList.class, UUID.randomUUID());
when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list));
assertThatThrownBy(() -> shoppingService.checkItem(
HOUSEHOLD_ID, list.getId(), UUID.randomUUID(), new CheckItemRequest(true), UUID.randomUUID()))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── Generate from plan with empty slots ── // ── Generate from plan with empty slots ──
@Test @Test
@@ -376,9 +568,11 @@ class ShoppingServiceTest {
// no slots added // no slots added
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0); ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID()); if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl; return sl;
}); });

23
frontend/.gitignore vendored Normal file
View File

@@ -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-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -86,4 +86,27 @@
--btn-font-size: 13px; --btn-font-size: 13px;
--btn-font-weight: 500; --btn-font-weight: 500;
--btn-letter-spacing: 0.04em; --btn-letter-spacing: 0.04em;
/* ── Planner flip-tile semantic tokens ──────────────────────────── */
--color-ring-today: var(--yellow-text);
--color-ring-selected: var(--green-dark);
--opacity-dimmed: 0.38;
/* ── Protein gradient tokens ────────────────────────────────────── */
--gradient-protein-haehnchen: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
--gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
--gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
--gradient-protein-veg: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
--gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
--gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%);
--gradient-protein-ei: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
--gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%);
/* ── Cuisine gradient tokens ────────────────────────────────────── */
--gradient-cuisine-italienisch: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
--gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%);
--gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%);
--gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
--gradient-cuisine-mediterran: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
} }

View File

@@ -79,7 +79,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
displayName: 'Max', displayName: 'Max',
householdId: 'h1', householdId: 'h1',
householdName: 'Familie Müller', householdName: 'Familie Müller',
householdRole: 'planer', householdRole: 'planner',
email: 'max@example.com', email: 'max@example.com',
systemRole: 'user' systemRole: 'user'
} }

View File

@@ -39,7 +39,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.benutzer = { event.locals.benutzer = {
id: user.id!, id: user.id!,
name: user.displayName!, name: user.displayName!,
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied' rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied'
}; };
event.locals.haushalt = { event.locals.haushalt = {
id: user.householdId ?? undefined, id: user.householdId ?? undefined,

File diff suppressed because one or more lines are too long

View File

@@ -452,6 +452,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/shopping-list": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getByWeekStart"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/ingredients": { "/v1/ingredients": {
parameters: { parameters: {
query?: never; query?: never;
@@ -536,7 +552,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort: string; effort: string;
isChildFriendly?: boolean;
heroImageUrl?: string; heroImageUrl?: string;
ingredients: components["schemas"]["IngredientEntry"][]; ingredients: components["schemas"]["IngredientEntry"][];
steps?: components["schemas"]["StepEntry"][]; steps?: components["schemas"]["StepEntry"][];
@@ -571,7 +586,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
isChildFriendly?: boolean;
heroImageUrl?: string; heroImageUrl?: string;
ingredients?: components["schemas"]["IngredientItem"][]; ingredients?: components["schemas"]["IngredientItem"][];
steps?: components["schemas"]["StepItem"][]; steps?: components["schemas"]["StepItem"][];
@@ -624,6 +638,11 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
recipeId: string; recipeId: string;
}; };
RecipeRef: {
/** Format: uuid */
id?: string;
name?: string;
};
ShoppingListItemResponse: { ShoppingListItemResponse: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
@@ -636,13 +655,17 @@ export interface components {
isChecked?: boolean; isChecked?: boolean;
/** Format: uuid */ /** Format: uuid */
checkedBy?: string; checkedBy?: string;
sourceRecipes?: string[]; sourceRecipes?: components["schemas"]["RecipeRef"][];
}; };
ShoppingListResponse: { ShoppingListResponse: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
/** Format: uuid */ /** Format: uuid */
weekPlanId?: string; weekPlanId?: string;
/** Format: date-time */
generatedAt?: string;
/** Format: int32 */
filteredStaplesCount?: number;
items?: components["schemas"]["ShoppingListItemResponse"][]; items?: components["schemas"]["ShoppingListItemResponse"][];
}; };
TagCreateRequest: { TagCreateRequest: {
@@ -889,7 +912,8 @@ export interface components {
SuggestionItem: { SuggestionItem: {
recipe?: components["schemas"]["SlotRecipe"]; recipe?: components["schemas"]["SlotRecipe"];
/** Format: double */ /** Format: double */
simulatedScore?: number; scoreDelta?: number;
hasConflict?: boolean;
}; };
SuggestionResponse: { SuggestionResponse: {
suggestions?: components["schemas"]["SuggestionItem"][]; suggestions?: components["schemas"]["SuggestionItem"][];
@@ -908,8 +932,7 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
isChildFriendly?: boolean; heroImagePreview?: string;
heroImageUrl?: string;
}; };
ApiResponseListAdminUserResponse: { ApiResponseListAdminUserResponse: {
status?: string; status?: string;
@@ -1902,6 +1925,28 @@ export interface operations {
}; };
}; };
}; };
getByWeekStart: {
parameters: {
query?: {
weekStart?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ShoppingListResponse"];
};
};
};
};
searchIngredients: { searchIngredients: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
open = false,
onclose,
height = '75vh',
children
}: {
open: boolean;
onclose: () => void;
height?: string;
children?: Snippet;
} = $props();
$effect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onclose();
}
}
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
});
</script>
{#if open}
<div
data-testid="bottom-sheet"
aria-hidden={open ? 'false' : 'true'}
class="fixed inset-0 z-50 flex items-end"
>
<!-- Backdrop -->
<div
data-testid="sheet-backdrop"
class="absolute inset-0"
style="background: rgba(28,28,24,0.4);"
onclick={onclose}
role="presentation"
></div>
<!-- Sheet panel -->
<div
class="relative z-10 w-full flex flex-col overflow-hidden"
style="
background: var(--color-page);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
box-shadow: var(--shadow-overlay);
max-height: {height};
"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Header row: drag handle + close button -->
<div class="relative flex items-center justify-center pt-3 pb-2 px-4">
<!-- Drag handle -->
<div
data-testid="drag-handle"
aria-hidden="true"
class="absolute"
style="
width: 32px;
height: 4px;
background: var(--color-border);
border-radius: 9999px;
"
></div>
<!-- Close button -->
<button
type="button"
aria-label="Schließen"
class="ml-auto text-xl leading-none"
onclick={onclose}
>
&times;
</button>
</div>
<!-- Body content -->
<div class="overflow-y-auto flex-1">
{@render children?.()}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import BottomSheet from './BottomSheet.svelte';
describe('BottomSheet', () => {
it('is not mounted in DOM when open is false', () => {
render(BottomSheet, { props: { open: false, onclose: vi.fn() } });
expect(screen.queryByTestId('bottom-sheet')).toBeNull();
});
it('is mounted in DOM when open is true', () => {
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
expect(screen.getByTestId('bottom-sheet')).toBeTruthy();
});
it('calls onclose when close button is clicked', async () => {
const onclose = vi.fn();
render(BottomSheet, { props: { open: true, onclose } });
const closeBtn = screen.getByRole('button', { name: /schließen/i });
await userEvent.click(closeBtn);
expect(onclose).toHaveBeenCalledOnce();
});
it('calls onclose when backdrop is clicked', async () => {
const onclose = vi.fn();
render(BottomSheet, { props: { open: true, onclose } });
const backdrop = screen.getByTestId('sheet-backdrop');
await userEvent.click(backdrop);
expect(onclose).toHaveBeenCalledOnce();
});
it('calls onclose when Escape is pressed', async () => {
const onclose = vi.fn();
render(BottomSheet, { props: { open: true, onclose } });
await userEvent.keyboard('{Escape}');
expect(onclose).toHaveBeenCalledOnce();
});
it('drag handle has aria-hidden', () => {
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
const handle = screen.getByTestId('drag-handle');
expect(handle.getAttribute('aria-hidden')).toBe('true');
});
it('does not call onclose when Escape is pressed while closed', async () => {
const onclose = vi.fn();
render(BottomSheet, { props: { open: false, onclose } });
await userEvent.keyboard('{Escape}');
expect(onclose).not.toHaveBeenCalled();
});
});

View File

@@ -47,7 +47,27 @@ const requiredTokens = [
// Shadows // Shadows
'--shadow-card', '--shadow-card',
'--shadow-raised', '--shadow-raised',
'--shadow-overlay' '--shadow-overlay',
// Planner flip-tile semantic tokens
'--color-ring-today',
'--color-ring-selected',
'--opacity-dimmed',
// Protein gradient tokens
'--gradient-protein-haehnchen',
'--gradient-protein-rind',
'--gradient-protein-fisch',
'--gradient-protein-tofu',
'--gradient-protein-veg',
'--gradient-protein-schwein',
'--gradient-protein-lamm',
'--gradient-protein-ei',
'--gradient-protein-huelsenfruechte',
// Cuisine gradient tokens
'--gradient-cuisine-italienisch',
'--gradient-cuisine-asiatisch',
'--gradient-cuisine-indisch',
'--gradient-cuisine-mexikanisch',
'--gradient-cuisine-mediterran'
]; ];
describe('design token completeness', () => { describe('design token completeness', () => {

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,113 @@
<script lang="ts">
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: SlotRecipe | null;
}
let {
slot,
isToday = false,
isSelected = false,
readonly = false,
onaddrecipe,
onactionsheet
}: {
slot: Slot;
isToday?: boolean;
isSelected?: boolean;
readonly?: boolean;
onaddrecipe?: () => void;
onactionsheet?: () => void;
} = $props();
let actionSheetMode = $derived(!!onactionsheet && !!slot.recipe);
let metadata = $derived(
[
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
slot.recipe?.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
let borderClass = $derived(
isToday
? 'border-[var(--yellow)] bg-[var(--yellow-tint)]'
: isSelected
? 'border-[var(--green)] bg-[var(--green-tint)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
);
</script>
{#snippet recipeInfo()}
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
{slot.recipe?.name ?? ''}
</h3>
{#if metadata}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
{/if}
{/snippet}
{#if actionSheetMode}
<button
type="button"
data-testid="day-meal-card"
data-today={isToday}
data-selected={isSelected}
onclick={onactionsheet}
class="w-full text-left rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
>
{@render recipeInfo()}
</button>
{:else}
<div
data-testid="day-meal-card"
data-today={isToday}
data-selected={isSelected}
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
>
{#if slot.recipe}
{@render recipeInfo()}
{#if !readonly}
<div class="mt-3 flex gap-2">
<a
href="/recipes/{slot.recipe.id}/cook"
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
Jetzt kochen
</a>
{#if onaddrecipe}
<button
type="button"
onclick={onaddrecipe}
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
>
Tauschen
</button>
{/if}
</div>
{/if}
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if !readonly && onaddrecipe}
<button
type="button"
onclick={onaddrecipe}
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
>
+ Gericht hinzufügen
</button>
{/if}
{/if}
</div>
{/if}

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DayMealCard from './DayMealCard.svelte';
const slot = {
id: 's1',
slotDate: '2026-03-30',
recipe: { id: 'r1', name: 'Pasta Bolognese', effort: 'Easy', cookTimeMin: 30 }
};
describe('DayMealCard', () => {
it('renders recipe name', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
});
it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } });
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
});
it('Tauschen button calls onaddrecipe when clicked', async () => {
const onaddrecipe = vi.fn();
const user = userEvent.setup();
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe } });
await user.click(screen.getByRole('button', { name: /Tauschen/i }));
expect(onaddrecipe).toHaveBeenCalledOnce();
});
it('hides Tauschen button when onaddrecipe not provided', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
});
it('hides action links when readonly', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } });
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
});
it('applies today styling when isToday is true', () => {
render(DayMealCard, { props: { slot, isToday: true, readonly: false } });
const card = screen.getByTestId('day-meal-card');
expect(card.getAttribute('data-today')).toBe('true');
});
it('applies selected styling when isSelected is true and not today', () => {
render(DayMealCard, { props: { slot, isToday: false, isSelected: true, readonly: false } });
const card = screen.getByTestId('day-meal-card');
expect(card.getAttribute('data-selected')).toBe('true');
});
it('renders empty state when slot has no recipe', () => {
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
expect(screen.getByText(/Kein Gericht/i)).toBeTruthy();
});
it('shows cook time and effort metadata', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
expect(screen.getByText(/30 Min/)).toBeTruthy();
expect(screen.getByText(/Easy/)).toBeTruthy();
});
it('empty state shows add button when onaddrecipe provided', () => {
const onaddrecipe = vi.fn();
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
expect(screen.getByRole('button', { name: /Gericht hinzufügen/i })).toBeTruthy();
});
it('add button calls onaddrecipe when clicked', async () => {
const onaddrecipe = vi.fn();
const user = userEvent.setup();
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
await user.click(screen.getByRole('button', { name: /Gericht hinzufügen/i }));
expect(onaddrecipe).toHaveBeenCalledOnce();
});
it('empty state hides add button when onaddrecipe not provided', () => {
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
});
describe('onactionsheet prop (mobile full-card tap target)', () => {
it('card renders as a button when onactionsheet provided and recipe exists', () => {
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
const card = screen.getByRole('button', { name: /Pasta Bolognese/i });
expect(card).toBeTruthy();
});
it('clicking card calls onactionsheet', async () => {
const onactionsheet = vi.fn();
const user = userEvent.setup();
render(DayMealCard, { props: { slot, onactionsheet } });
await user.click(screen.getByRole('button', { name: /Pasta Bolognese/i }));
expect(onactionsheet).toHaveBeenCalledOnce();
});
it('inline Jetzt kochen and Tauschen buttons are hidden when onactionsheet provided', () => {
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
});
it('falls back to normal rendering when onactionsheet not provided', () => {
render(DayMealCard, { props: { slot, readonly: false, onaddrecipe: vi.fn() } });
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
});
it('empty slot does not render card as button even when onactionsheet provided', () => {
const emptySlot = { id: 's2', slotDate: '2026-03-31', recipe: null };
render(DayMealCard, { props: { slot: emptySlot, onactionsheet: vi.fn(), onaddrecipe: vi.fn() } });
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
});
});
});

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
interface Slot {
id: string;
slotDate: string;
recipe: { id: string; name: string } | null;
}
let {
recipeName,
recipeId,
planId,
weekStart,
today,
slots = [],
onconfirm,
onweekchange
}: {
recipeName: string;
recipeId: string;
planId: string;
weekStart: string;
today: string;
slots: Slot[];
onconfirm: (result: { date: string; slotId: string | null }) => void;
onweekchange: (newWeekStart: string) => void;
} = $props();
let selectedDate = $state<string | null>(null);
const slotMap = $derived(
new Map(slots.map((s) => [s.slotDate, s]))
);
const days = $derived(weekDays(weekStart));
function chipState(date: string): string {
const isSelected = selectedDate === date;
const slot = slotMap.get(date);
const hasFilled = slot?.recipe != null;
if (isSelected) {
return hasFilled ? 'sel-filled' : 'sel-empty';
}
if (date === today) return 'today';
return hasFilled ? 'filled' : 'empty';
}
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
const existingSlotId = $derived(selectedSlot?.id ?? null);
function chipStyle(state: string): string {
switch (state) {
case 'empty':
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
case 'filled':
return 'border-color: var(--color-border); background: var(--color-surface);';
case 'today':
return 'border-color: var(--yellow); background: var(--yellow-tint);';
case 'sel-empty':
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
case 'sel-filled':
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
default:
return '';
}
}
function handleChipClick(date: string) {
selectedDate = date;
}
function handleConfirm() {
if (!selectedDate) return;
onconfirm({ date: selectedDate, slotId: existingSlotId });
}
function dayNumber(date: string): string {
return date.slice(-2).replace(/^0/, '');
}
</script>
<div style="background: var(--color-page); font-family: var(--font-sans);">
<!-- Header -->
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
<p
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
>
Tag wählen
</p>
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
{recipeName}
</p>
</div>
<!-- Week navigation -->
<div
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
>
<button
type="button"
aria-label="Vorherige Woche"
onclick={() => onweekchange(prevWeek(weekStart))}
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
>
</button>
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
{formatWeekRange(weekStart)}
</span>
<button
type="button"
aria-label="Nächste Woche"
onclick={() => onweekchange(nextWeek(weekStart))}
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
>
</button>
</div>
<!-- Day chips -->
<div
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
>
{#each days as date (date)}
{@const state = chipState(date)}
<button
type="button"
data-testid="chip-{date}"
data-state={state}
onclick={() => handleChipClick(date)}
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
>
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
{formatDayAbbr(date, 'narrow')}
</span>
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
{dayNumber(date)}
</span>
</button>
{/each}
</div>
<!-- Replace warning -->
{#if selectedDate && existingRecipeName}
<div
data-testid="replace-warning"
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
>
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
</div>
{/if}
<!-- Confirm button -->
<div style="padding: 0 12px 12px;">
<button
type="button"
data-testid="confirm-btn"
disabled={!selectedDate}
onclick={handleConfirm}
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
>
Einplanen
</button>
</div>
</div>

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import DayPicker from './DayPicker.svelte';
const weekStart = '2026-03-30'; // Monday
const today = '2026-04-01'; // Wednesday
// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled
const slots = [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } },
{ id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } },
{ id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } },
{ id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } },
{ id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } }
];
const baseProps = {
recipeName: 'Mushroom Risotto',
recipeId: 'recipe-42',
planId: 'plan-1',
weekStart,
today,
slots,
onconfirm: vi.fn(),
onweekchange: vi.fn()
};
describe('DayPicker', () => {
it('shows recipe name in header', () => {
render(DayPicker, { props: baseProps });
expect(screen.getByText('Mushroom Risotto')).toBeTruthy();
});
it('shows 7 day chips', () => {
render(DayPicker, { props: baseProps });
const chips = screen.getAllByTestId(/^chip-/);
expect(chips).toHaveLength(7);
});
it('marks empty slot chips with data-state="empty"', () => {
render(DayPicker, { props: baseProps });
// Do (2026-04-03) and Sa (2026-04-05) are empty
const doChip = screen.getByTestId('chip-2026-04-03');
expect(doChip.getAttribute('data-state')).toBe('empty');
});
it('marks filled slot chips with data-state="filled"', () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
expect(moChip.getAttribute('data-state')).toBe('filled');
});
it('marks today chip with data-state="today"', () => {
render(DayPicker, { props: baseProps });
const todayChip = screen.getByTestId('chip-2026-04-01');
expect(todayChip.getAttribute('data-state')).toBe('today');
});
it('selecting an empty chip changes its state to sel-empty', async () => {
render(DayPicker, { props: baseProps });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
expect(doChip.getAttribute('data-state')).toBe('sel-empty');
});
it('selecting a filled chip changes its state to sel-filled', async () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
expect(moChip.getAttribute('data-state')).toBe('sel-filled');
});
it('shows replace warning when filled chip is selected', async () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
expect(screen.getByTestId('replace-warning')).toBeTruthy();
expect(screen.getByText(/Pasta/)).toBeTruthy();
});
it('does not show replace warning when empty chip is selected', async () => {
render(DayPicker, { props: baseProps });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
expect(screen.queryByTestId('replace-warning')).toBeNull();
});
it('confirm button is disabled when no chip is selected', () => {
render(DayPicker, { props: baseProps });
const btn = screen.getByTestId('confirm-btn');
expect(btn.hasAttribute('disabled')).toBe(true);
});
it('calls onconfirm with date and null slotId when empty chip confirmed', async () => {
const onconfirm = vi.fn();
render(DayPicker, { props: { ...baseProps, onconfirm } });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
const btn = screen.getByTestId('confirm-btn');
await userEvent.click(btn);
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null });
});
it('calls onconfirm with date and slotId when filled chip confirmed', async () => {
const onconfirm = vi.fn();
render(DayPicker, { props: { ...baseProps, onconfirm } });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
const btn = screen.getByTestId('confirm-btn');
await userEvent.click(btn);
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' });
});
it('shows prev/next week navigation buttons', () => {
render(DayPicker, { props: baseProps });
expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy();
});
it('calls onweekchange with prev week when prev button clicked', async () => {
const onweekchange = vi.fn();
render(DayPicker, { props: { ...baseProps, onweekchange } });
await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ }));
expect(onweekchange).toHaveBeenCalledWith('2026-03-23');
});
it('calls onweekchange with next week when next button clicked', async () => {
const onweekchange = vi.fn();
render(DayPicker, { props: { ...baseProps, onweekchange } });
await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ }));
expect(onweekchange).toHaveBeenCalledWith('2026-04-06');
});
});

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import EmptyDayTile from './EmptyDayTile.svelte';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
}
interface Slot {
id?: string | null;
slotDate?: string;
recipe?: SlotRecipe | null;
}
interface Suggestion {
recipe: any;
scoreDelta: number;
hasConflict: boolean;
}
let {
slot,
isToday,
activeSlotId,
isPlanner,
slotMap,
suggestions,
topSuggestion,
onflip,
onclose,
onswap,
onremove,
onaddrecipe
}: {
slot: Slot;
isToday: boolean;
activeSlotId: string | null;
isPlanner: boolean;
slotMap: Record<string, any>;
suggestions: Suggestion[];
topSuggestion?: Suggestion;
onflip?: (slotId: string) => void;
onclose?: () => void;
onswap?: () => void;
onremove?: () => void;
onaddrecipe?: () => void;
} = $props();
const slotId = $derived(slot.id ?? '');
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
function handleFlip() {
onflip?.(slotId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onflip?.(slotId);
}
}
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${proteinTag.name.toLowerCase()})`;
}
return 'var(--color-surface)';
})());
</script>
{#if slot.recipe}
<div
data-testid="day-meal-card-{slot.slotDate ?? ''}"
role="button"
tabindex="0"
aria-label={slot.recipe?.name ?? 'Gericht'}
aria-expanded={isFlipped}
data-today={isToday}
data-flipped={isFlipped}
data-dimmed={isDimmed}
class="scene"
onclick={handleFlip}
onkeydown={handleKeydown}
>
<div class="card" class:flipped={isFlipped}>
<div
class="card-front"
style="background: {gradientBackground}; background-size: cover; background-position: center;"
>
<p style="font-family: var(--font-display); font-size: 13px; padding: 8px; margin: 0; color: var(--color-text);">
{slot.recipe.name}
</p>
</div>
<div class="card-back" aria-hidden={!isFlipped}>
<button
type="button"
aria-label="Schließen"
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
>
×
</button>
{#if slot.recipe.cookTimeMin}
<span style="font-size: 12px;">{slot.recipe.cookTimeMin} min</span>
{/if}
{#if slot.recipe.effort}
<span style="font-size: 12px; margin-left: 4px;">{slot.recipe.effort}</span>
{/if}
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;">
<a href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
</div>
{#if isPlanner}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
style="margin-top: 8px; display: block;"
>
Gericht tauschen
</button>
{/if}
{#if isPlanner && slot.id}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
style="margin-top: 4px; display: block;"
>
Entfernen
</button>
{/if}
</div>
</div>
</div>
{:else}
<EmptyDayTile
slotDate={slot.slotDate ?? ''}
slotId={slot.id ?? ''}
{isPlanner}
{slotMap}
{topSuggestion}
{onaddrecipe}
/>
{/if}
<style>
.scene {
perspective: 900px;
height: 100%;
width: 100%;
cursor: pointer;
}
.card {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 10px;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 10px;
overflow: hidden;
}
.card-back {
transform: rotateY(180deg);
background: var(--color-page);
border: 1px solid var(--color-border);
padding: 10px;
overflow-y: auto;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DesktopDayTile from './DesktopDayTile.svelte';
const filledSlot = {
id: 's1',
slotDate: '2026-04-14',
recipe: {
id: 'r1',
name: 'Pasta Bolognese',
cookTimeMin: 45,
effort: 'mittel',
heroImageUrl: null,
tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }]
}
};
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
describe('DesktopDayTile — filled slot', () => {
describe('front face', () => {
it('renders recipe name on front face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
});
it('has data-testid="day-meal-card" on the scene element', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
});
it('applies today ring when isToday', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-today')).toBe('true');
});
it('applies selected ring when activeSlotId matches slot id', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-flipped')).toBe('true');
});
it('dims tile when another slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('true');
});
it('is not dimmed when no slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('false');
});
});
describe('flip interaction', () => {
it('calls onflip with slot id when scene is clicked', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
expect(onflip).toHaveBeenCalledWith('s1');
});
it('calls onflip when Enter key is pressed on scene', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard('{Enter}');
expect(onflip).toHaveBeenCalledWith('s1');
});
it('calls onflip when Space key is pressed on scene', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard(' ');
expect(onflip).toHaveBeenCalledWith('s1');
});
});
describe('back face (flipped state)', () => {
it('shows recipe name on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
// Back face should also show recipe name
const names = screen.getAllByText('Pasta Bolognese');
expect(names.length).toBeGreaterThanOrEqual(1);
});
it('shows Koch-Modus link on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy();
});
it('shows Rezept ansehen link on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
});
it('shows close button on back face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy();
});
it('calls onclose when close button clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } });
await user.click(screen.getByRole('button', { name: /Schließen/i }));
expect(onclose).toHaveBeenCalledOnce();
});
it('shows Gericht tauschen button for planner on back face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
});
it('hides Gericht tauschen for non-planner', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } });
expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull();
});
it('calls onswap when Gericht tauschen clicked', async () => {
const onswap = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } });
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
expect(onswap).toHaveBeenCalledOnce();
});
it('shows Entfernen button for planner when slot has id', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
});
it('calls onremove when Entfernen clicked', async () => {
const onremove = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } });
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
expect(onremove).toHaveBeenCalledOnce();
});
it('aria-expanded is true when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('true');
});
it('aria-expanded is false when not flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('false');
});
});
});
describe('DesktopDayTile — empty slot', () => {
it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => {
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
});
it('does not render Koch-Modus for empty slot', () => {
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
<script lang="ts">
let {
easy,
medium,
hard
}: {
easy: number;
medium: number;
hard: number;
} = $props();
</script>
<!-- Labels below the bar -->
<div class="space-y-2">
<!-- Bar segments -->
<div class="flex h-[10px] overflow-hidden rounded-full">
{#if easy > 0}
<div
class="h-full bg-[var(--green)]"
style="flex: {easy}"
></div>
{/if}
{#if medium > 0}
<div
class="h-full bg-[var(--yellow)]"
style="flex: {medium}"
></div>
{/if}
{#if hard > 0}
<div
class="h-full bg-[var(--color-error)]"
style="flex: {hard}"
></div>
{/if}
</div>
<!-- Labels -->
<div class="flex gap-4">
{#if easy > 0}
<span
data-testid="effort-easy"
class="font-[var(--font-sans)] text-[12px] text-[var(--green-dark)]"
>
Einfach ×{easy}
</span>
{/if}
{#if medium > 0}
<span
data-testid="effort-medium"
class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]"
>
Mittel ×{medium}
</span>
{/if}
{#if hard > 0}
<span
data-testid="effort-hard"
class="font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
>
Aufwändig ×{hard}
</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import EffortBar from './EffortBar.svelte';
describe('EffortBar', () => {
it('renders segment for easy effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-easy').textContent).toContain('3');
});
it('renders segment for medium effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-medium').textContent).toContain('3');
});
it('renders segment for hard effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-hard').textContent).toContain('1');
});
it('hides zero-count segments', () => {
render(EffortBar, { props: { easy: 7, medium: 0, hard: 0 } });
expect(screen.queryByTestId('effort-medium')).toBeNull();
expect(screen.queryByTestId('effort-hard')).toBeNull();
});
it('renders label with ×N count', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-easy').textContent).toContain('×3');
});
it('renders no segments when all counts are zero', () => {
render(EffortBar, { props: { easy: 0, medium: 0, hard: 0 } });
expect(screen.queryByTestId('effort-easy')).toBeNull();
expect(screen.queryByTestId('effort-medium')).toBeNull();
expect(screen.queryByTestId('effort-hard')).toBeNull();
});
});

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { computeReasoningTags } from './reasoningTags';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SuggestionRecipe {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
tags?: TagItem[];
}
interface TopSuggestion {
recipe: SuggestionRecipe;
scoreDelta: number;
hasConflict: boolean;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: any | null;
}
let {
slotDate,
slotId,
isPlanner,
slotMap,
topSuggestion,
onaddrecipe
}: {
slotDate: string;
slotId: string;
isPlanner: boolean;
slotMap: Record<string, Slot>;
topSuggestion?: TopSuggestion;
onaddrecipe?: () => void;
} = $props();
let reasoningTags = $derived(
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
);
</script>
<div
data-testid="empty-day-tile"
role="group"
class="h-full flex flex-col gap-2 p-3"
style="border: 1px dashed var(--color-border);"
>
{#if isPlanner}
<button
type="button"
aria-label="Gericht wählen"
onclick={() => onaddrecipe?.()}
class="self-start font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
>
+ Gericht wählen
</button>
{/if}
{#if topSuggestion}
<p class="font-[var(--font-display)] text-[12px] text-[var(--color-text-muted)] leading-snug">
{topSuggestion.recipe.name}
</p>
{#if reasoningTags.length > 0}
<div class="flex flex-wrap gap-1">
{#each reasoningTags as tag (tag.id)}
<span
data-testid="reasoning-tag"
class="inline-block rounded px-1.5 py-0.5 font-[var(--font-sans)] text-[11px] font-medium"
style={tag.color === 'green'
? 'background: var(--green-tint); color: var(--green-dark);'
: 'background: var(--yellow-tint); color: var(--yellow-text);'}
>
{tag.label}
</span>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import EmptyDayTile from './EmptyDayTile.svelte';
const slotDate = '2026-04-14';
const slotId = 'slot-1';
const topSuggestionNewProtein = {
recipe: {
id: 'r1',
name: 'Lachs mit Gemüse',
cookTimeMin: 20,
effort: 'einfach',
tags: [{ id: 't1', name: 'Fisch', tagType: 'protein' }]
},
scoreDelta: 3.2,
hasConflict: false
};
const slotMapEmpty = {};
describe('EmptyDayTile', () => {
describe('base render', () => {
it('shows + CTA for planner', () => {
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
});
it('hides + CTA for non-planner', () => {
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: false, slotMap: slotMapEmpty } });
expect(screen.queryByRole('button', { name: /Gericht wählen/i })).toBeNull();
});
it('calls onaddrecipe when + CTA clicked', async () => {
const onaddrecipe = vi.fn();
const user = userEvent.setup();
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, onaddrecipe } });
await user.click(screen.getByRole('button', { name: /Gericht wählen/i }));
expect(onaddrecipe).toHaveBeenCalledOnce();
});
it('has data-testid="empty-day-tile"', () => {
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
expect(screen.getByTestId('empty-day-tile')).toBeTruthy();
});
});
describe('reasoning tags', () => {
it('shows no tags when no topSuggestion', () => {
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
});
it('shows Neues Protein tag when topSuggestion has new protein', () => {
render(EmptyDayTile, {
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
});
expect(screen.getByText('Neues Protein')).toBeTruthy();
});
it('shows Aufwand tag for easy suggestion', () => {
render(EmptyDayTile, {
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
});
expect(screen.getByText('Aufwand: leicht')).toBeTruthy();
});
it('shows suggestion recipe name when topSuggestion provided', () => {
render(EmptyDayTile, {
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
});
expect(screen.getByText('Lachs mit Gemüse')).toBeTruthy();
});
it('does not show tags when suggestion has no matching conditions', () => {
const heavySuggestion = {
recipe: { id: 'r2', name: 'Roulade', cookTimeMin: 120, effort: 'aufwändig', tags: [] },
scoreDelta: 1.0,
hasConflict: false
};
render(EmptyDayTile, {
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: heavySuggestion }
});
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
});
});
});

View File

@@ -0,0 +1,122 @@
<script lang="ts">
interface SlotRecipe {
id: string;
name: string;
effort?: string;
cookTimeMin?: number;
}
interface Slot {
id?: string;
slotDate?: string;
recipe: SlotRecipe | null;
}
interface Props {
open: boolean;
slot: Slot;
onswap: () => void;
oncancel: () => void;
onremove?: () => void;
}
let { open, slot, onswap, oncancel, onremove }: Props = $props();
const meta = $derived.by(() => {
const parts: string[] = [];
if (slot.recipe?.cookTimeMin != null) parts.push(`${slot.recipe.cookTimeMin} min`);
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
return parts.join(' · ');
});
$effect(() => {
if (!open) return;
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') oncancel();
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="presentation"
data-testid="sheet-backdrop"
style="position:fixed;inset:0;z-index:50;background:rgba(28,28,24,0.4)"
onclick={oncancel}
>
<div
role="dialog"
aria-modal="true"
tabindex="-1"
style="position:absolute;bottom:0;left:0;right:0;background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:var(--shadow-overlay)"
onclick={(e) => e.stopPropagation()}
>
<!-- Drag handle -->
<div style="display:flex;justify-content:center;margin-top:12px">
<div style="width:32px;height:4px;background:var(--color-border);border-radius:9999px"></div>
</div>
<!-- Meal title -->
<p style="font-family:var(--font-display);font-size:15px;font-weight:500;color:var(--color-text);padding:0 16px;margin:12px 0 4px">
{slot.recipe?.name ?? ''}
</p>
<!-- Metadata -->
{#if meta}
<p style="font-family:var(--font-sans);font-size:11px;color:var(--color-text-muted);padding:0 16px 12px;margin:0">
{meta}
</p>
{/if}
<!-- Actions -->
<div style="padding:0 16px 16px;display:flex;flex-direction:column;gap:6px">
<button
type="button"
style="width:100%;background:var(--orange-tint);border:1px solid #FBCDA4;color:var(--orange-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
onclick={onswap}
>
↻ Gericht tauschen
</button>
{#if onremove}
<button
type="button"
style="width:100%;background:var(--color-error, #d9534f);border:1px solid var(--color-error, #d9534f);color:#fff;font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
onclick={onremove}
>
✕ Gericht entfernen
</button>
{/if}
{#if slot.recipe}
<a
href="/recipes/{slot.recipe.id}/cook"
style="display:block;width:100%;background:var(--green-tint);border:1px solid var(--green-light);color:var(--green-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
>
🍳 Jetzt kochen
</a>
<a
href="/recipes/{slot.recipe.id}"
style="display:block;width:100%;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
>
👁 Rezept ansehen
</a>
{/if}
<button
type="button"
style="width:100%;background:none;border:none;color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;text-align:center;cursor:pointer;padding:12px"
onclick={oncancel}
>
Abbrechen
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import MealActionSheet from './MealActionSheet.svelte';
const slot = {
id: 's1',
slotDate: '2026-04-08',
recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 }
};
const baseProps = {
open: true,
slot,
onswap: vi.fn(),
oncancel: vi.fn(),
onremove: vi.fn()
};
describe('MealActionSheet', () => {
it('renders meal title', () => {
render(MealActionSheet, { props: baseProps });
expect(screen.getByText('Tomato pasta')).toBeTruthy();
});
it('renders meal metadata', () => {
render(MealActionSheet, { props: baseProps });
expect(screen.getByText(/45 min/i)).toBeTruthy();
expect(screen.getByText(/easy/i)).toBeTruthy();
});
it('renders all 5 action buttons', () => {
render(MealActionSheet, { props: baseProps });
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
});
it('clicking Entfernen calls onremove', async () => {
const onremove = vi.fn();
const user = userEvent.setup();
render(MealActionSheet, { props: { ...baseProps, onremove } });
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
expect(onremove).toHaveBeenCalledOnce();
});
it('does not render Entfernen button when onremove is not provided', () => {
const { onremove: _, ...propsWithoutRemove } = baseProps;
render(MealActionSheet, { props: propsWithoutRemove });
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
});
it('Jetzt kochen links to the cook route', () => {
render(MealActionSheet, { props: baseProps });
const link = screen.getByRole('link', { name: /Jetzt kochen/i });
expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
});
it('Rezept ansehen links to the recipe detail route', () => {
render(MealActionSheet, { props: baseProps });
const link = screen.getByRole('link', { name: /Rezept ansehen/i });
expect(link.getAttribute('href')).toBe('/recipes/r1');
});
it('clicking Gericht tauschen calls onswap', async () => {
const onswap = vi.fn();
const user = userEvent.setup();
render(MealActionSheet, { props: { ...baseProps, onswap } });
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
expect(onswap).toHaveBeenCalledOnce();
});
it('clicking Abbrechen calls oncancel', async () => {
const oncancel = vi.fn();
const user = userEvent.setup();
render(MealActionSheet, { props: { ...baseProps, oncancel } });
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
it('clicking backdrop calls oncancel', async () => {
const oncancel = vi.fn();
const user = userEvent.setup();
render(MealActionSheet, { props: { ...baseProps, oncancel } });
await user.click(screen.getByTestId('sheet-backdrop'));
expect(oncancel).toHaveBeenCalledOnce();
});
it('does not render when open is false', () => {
render(MealActionSheet, { props: { ...baseProps, open: false } });
expect(screen.queryByText('Tomato pasta')).toBeNull();
});
});

View File

@@ -0,0 +1,244 @@
<script lang="ts">
import type { Recipe, Suggestion } from '$lib/planner/types';
let {
planId,
date,
dateLabel,
suggestions = [],
allRecipes = [],
isLoading = false,
isDisabled = false,
excludeRecipeId,
replacingRecipe,
onpick
}: {
planId: string;
date: string;
dateLabel: string;
suggestions: Suggestion[];
allRecipes: Recipe[];
isLoading?: boolean;
isDisabled?: boolean;
excludeRecipeId?: string;
replacingRecipe?: { name: string; meta?: string };
onpick: (recipeId: string, recipeName: string) => void;
} = $props();
let searchQuery = $state('');
let topRecommendations = $derived(
suggestions
.filter((s) => s.scoreDelta > 0 && s.recipe.id !== excludeRecipeId)
.slice(0, 5)
);
let scoreMap = $derived(
new Map(suggestions.map((s) => [s.recipe.id, s]))
);
let baseRecipes = $derived(
excludeRecipeId ? allRecipes.filter((r) => r.id !== excludeRecipeId) : allRecipes
);
let filteredRecipes = $derived(
searchQuery.trim() === ''
? baseRecipes
: baseRecipes.filter((r) =>
r.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
function recipeMetadata(recipe: Recipe): string {
return [
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
recipe.effort ?? null
]
.filter(Boolean)
.join(' · ');
}
</script>
{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)}
{#if delta > 0}
<span
data-testid="badge-{recipeId}"
data-type="good"
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
>
↑ +{delta.toFixed(1)} Punkte
</span>
{:else if hasConflict}
<span
data-testid="badge-{recipeId}"
data-type="bad"
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red-tint, #fdecea); color: var(--color-error, #d9534f);"
>
{delta.toFixed(1)} Punkte
</span>
{:else}
<span
data-testid="badge-{recipeId}"
data-type="neutral"
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
>
Kein Einfluss
</span>
{/if}
{/snippet}
<div style="background: var(--color-page); font-family: var(--font-sans);">
<!-- Header (hidden in swap context — the panel/sheet title already provides context) -->
{#if !replacingRecipe}
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
<p style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;">
Rezept wählen
</p>
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
{dateLabel}
</p>
</div>
{/if}
<!-- Wird ersetzt banner (swap context) -->
{#if replacingRecipe}
<div style="background: var(--orange-tint); border-bottom: 1px solid #FBCDA4; padding: 8px 12px;">
<p style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 2px 0; font-family: var(--font-sans);">
Wird ersetzt
</p>
<span
data-testid="replacing-name"
title={replacingRecipe.name}
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 13px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
>
{replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if}
</span>
</div>
{/if}
<!-- Search -->
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
<input
type="search"
bind:value={searchQuery}
placeholder="Rezept suchen…"
style="width: 100%; box-sizing: border-box; padding: 5px 8px; font-size: 11px; font-family: var(--font-sans); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text);"
/>
</div>
<!-- Empfohlen section -->
{#if isLoading}
<div data-testid="suggestions-loading">
{#each [1, 2, 3] as i (i)}
<div
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<div
style="height: 12px; width: 60%; border-radius: 3px; background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
></div>
<div
style="height: 9px; width: 35%; border-radius: 3px; background: var(--color-subtle); margin-top: 4px; animation: pulse 1.5s ease-in-out infinite;"
></div>
</div>
<div
style="height: 26px; width: 56px; border-radius: var(--radius-md); background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
></div>
</div>
{/each}
</div>
{:else if topRecommendations.length > 0}
<div data-testid="empfohlen-section">
<div
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
>
Empfohlen · Beste Abwechslung
</div>
{#each topRecommendations as suggestion (suggestion.recipe.id)}
{@const meta = recipeMetadata(suggestion.recipe)}
<div
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
>
{suggestion.recipe.name}
</p>
{#if meta}
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
{meta}
</p>
{/if}
{@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
disabled={isDisabled}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
>
+ Wählen
</button>
</div>
{/each}
</div>
{/if}
<!-- Alle Rezepte section -->
<div data-testid="alle-rezepte-section">
<div
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
>
Alle Rezepte
</div>
{#if filteredRecipes.length === 0}
<p style="padding: 10px 12px; font-size: 11px; color: var(--color-text-muted); margin: 0;">
Keine Treffer
</p>
{:else}
{#each filteredRecipes as recipe (recipe.id)}
{@const meta = recipeMetadata(recipe)}
{@const score = scoreMap.get(recipe.id)}
<div
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
>
{recipe.name}
</p>
{#if meta}
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
{meta}
</p>
{/if}
{#if score}
{@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)}
{/if}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(recipe.id, recipe.name)}
disabled={isDisabled}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
>
+ Wählen
</button>
</div>
{/each}
{/if}
</div>
</div>
<style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RecipePicker from './RecipePicker.svelte';
const suggestions = [
{ recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 1.5, hasConflict: false },
{ recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, scoreDelta: -1.5, hasConflict: true }
];
const allRecipes = [
{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 },
{ id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy', cookTimeMin: 20 },
{ id: 'r3', name: 'Tomatensuppe', effort: 'easy', cookTimeMin: 30 }
];
const baseProps = {
planId: 'plan-1',
date: '2026-04-05',
dateLabel: 'Samstag, 5. April',
suggestions,
allRecipes,
onpick: vi.fn()
};
describe('RecipePicker', () => {
it('shows date label in header', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText('Samstag, 5. April')).toBeTruthy();
});
it('shows Empfohlen section', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
});
it('shows only positive-delta suggestions in Empfohlen', () => {
render(RecipePicker, { props: baseProps });
// s1 (scoreDelta=1.5) appears in Empfohlen
expect(screen.getByText('Lachsfilet')).toBeTruthy();
// s2 (scoreDelta=-1.5) is excluded from Empfohlen; not in allRecipes either → absent
expect(screen.queryByText('Hähnchen-Curry')).toBeNull();
});
it('shows green badge when hasConflict is false', () => {
render(RecipePicker, { props: baseProps });
// Lachsfilet: hasConflict = false → green badge
const badge = screen.getByTestId('badge-s1');
expect(badge.getAttribute('data-type')).toBe('good');
});
it('shows red delta badge in Alle Rezepte when hasConflict is true', () => {
// r2 is in allRecipes; scoring it negative via suggestions → red badge in Alle Rezepte
const withR2Scored = [
...suggestions,
{ recipe: { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
];
render(RecipePicker, { props: { ...baseProps, suggestions: withR2Scored } });
const alleRezepte = screen.getByTestId('alle-rezepte-section');
const badge = within(alleRezepte).getByTestId('badge-r2');
expect(badge.getAttribute('data-type')).toBe('bad');
expect(badge.textContent).toContain('-1.5');
});
it('shows Alle Rezepte section', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText(/Alle Rezepte/i)).toBeTruthy();
});
it('shows all recipe names in Alle Rezepte', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
});
it('filters recipes by search query', async () => {
render(RecipePicker, { props: baseProps });
const input = screen.getByRole('searchbox');
await userEvent.type(input, 'Spaghetti');
expect(screen.queryByText('Beef Bourguignon')).toBeNull();
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
});
it('calls onpick with recipeId and name when Wählen clicked for suggestion', async () => {
const onpick = vi.fn();
render(RecipePicker, { props: { ...baseProps, onpick } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
await userEvent.click(buttons[0]);
expect(onpick).toHaveBeenCalledWith('s1', 'Lachsfilet');
});
it('calls onpick when Wählen clicked for all-recipes item', async () => {
const onpick = vi.fn();
render(RecipePicker, { props: { ...baseProps, onpick } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
// First 1 is the positive-delta suggestion (s1), rest are allRecipes
await userEvent.click(buttons[1]);
expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon');
});
it('shows empty state when search has no results', async () => {
render(RecipePicker, { props: baseProps });
const input = screen.getByRole('searchbox');
await userEvent.type(input, 'xyznotfound');
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
});
it('shows yellow neutral badge in Alle Rezepte when scoreDelta is zero', () => {
// r1 is in allRecipes; scoring it neutral via suggestions → yellow badge in Alle Rezepte
const neutralSuggestions = [
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: 0.0, hasConflict: false }
];
render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } });
const alleRezepte = screen.getByTestId('alle-rezepte-section');
const badge = within(alleRezepte).getByTestId('badge-r1');
expect(badge.getAttribute('data-type')).toBe('neutral');
expect(badge.textContent).toContain('Kein Einfluss');
});
it('Empfohlen shows only positive-delta suggestions, capped at 5', () => {
const sixImproving = Array.from({ length: 6 }, (_, i) => ({
recipe: { id: `imp${i}`, name: `Improving ${i}`, effort: 'easy' as const, cookTimeMin: 20 },
scoreDelta: 1.0,
hasConflict: false
}));
render(RecipePicker, { props: { ...baseProps, suggestions: sixImproving } });
const empfohlen = screen.getByTestId('empfohlen-section');
const buttons = empfohlen.querySelectorAll('button');
expect(buttons).toHaveLength(5);
});
it('Empfohlen excludes neutral and negative suggestions', () => {
const mixed = [
{ recipe: { id: 'pos', name: 'Positiv', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 1.0, hasConflict: false },
{ recipe: { id: 'neu', name: 'Neutral', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false },
{ recipe: { id: 'neg', name: 'Negativ', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.0, hasConflict: true }
];
render(RecipePicker, { props: { ...baseProps, suggestions: mixed } });
const empfohlen = screen.getByTestId('empfohlen-section');
expect(empfohlen.textContent).toContain('Positiv');
expect(empfohlen.textContent).not.toContain('Neutral');
expect(empfohlen.textContent).not.toContain('Negativ');
});
it('shows score badge inside Alle Rezepte for a recipe that has a matching suggestion', () => {
// r1 is in allRecipes; scoreDelta=-0.3 → not in Empfohlen (needs >0), but scoreMap provides badge
const withR1Scored = [
...suggestions,
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: -0.3, hasConflict: true }
];
render(RecipePicker, { props: { ...baseProps, suggestions: withR1Scored } });
const alleRezepte = screen.getByTestId('alle-rezepte-section');
const badge = within(alleRezepte).getByTestId('badge-r1');
expect(badge.getAttribute('data-type')).toBe('bad');
});
it('shows no badge in Alle Rezepte for recipes with no suggestion score', () => {
// r2 and r3 have no suggestion entry
render(RecipePicker, { props: baseProps });
const alleRezepte = screen.getByTestId('alle-rezepte-section');
expect(within(alleRezepte).queryByTestId('badge-r2')).toBeNull();
expect(within(alleRezepte).queryByTestId('badge-r3')).toBeNull();
});
it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => {
render(RecipePicker, { props: { ...baseProps, isLoading: true } });
expect(screen.getByTestId('suggestions-loading')).toBeTruthy();
expect(screen.queryByText(/Empfohlen/i)).toBeNull();
});
it('hides loading skeleton when isLoading is false and suggestions are present', () => {
render(RecipePicker, { props: { ...baseProps, isLoading: false } });
expect(screen.queryByTestId('suggestions-loading')).toBeNull();
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
});
it('shows Wird ersetzt banner when replacingRecipe is provided', () => {
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta', meta: '20 Min · easy' } } });
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
expect(screen.getByTestId('replacing-name').textContent).toContain('Pasta');
});
it('hides Wird ersetzt banner when replacingRecipe is not provided', () => {
render(RecipePicker, { props: baseProps });
expect(screen.queryByText(/Wird ersetzt/i)).toBeNull();
});
it('hides Rezept wählen header when replacingRecipe is set', () => {
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta' } } });
expect(screen.queryByText(/Rezept wählen/i)).toBeNull();
});
it('shows Rezept wählen header when replacingRecipe is not set', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText(/Rezept wählen/i)).toBeTruthy();
});
it('excludes recipe from Alle Rezepte when excludeRecipeId is set', () => {
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 'r2' } });
expect(screen.queryByText('Spaghetti Carbonara')).toBeNull();
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
});
it('excludes recipe from Empfohlen when excludeRecipeId matches a positive-delta suggestion', () => {
// s1 (Lachsfilet, scoreDelta=1.5) would normally appear in Empfohlen
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 's1' } });
expect(screen.queryByText('Lachsfilet')).toBeNull();
});
it('disables Wählen buttons when isDisabled is true', () => {
render(RecipePicker, { props: { ...baseProps, isDisabled: true } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
});
it('enables Wählen buttons when isDisabled is false', () => {
render(RecipePicker, { props: { ...baseProps, isDisabled: false } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
});
});

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import type { Recipe, Suggestion } from '$lib/planner/types';
import RecipePicker from './RecipePicker.svelte';
let {
open,
slotDate,
planId,
suggestions,
allRecipes,
isLoading,
onpick,
onclose,
excludeRecipeId,
replacingRecipe
}: {
open: boolean;
slotDate: string;
planId: string;
suggestions: Suggestion[];
allRecipes: Recipe[];
isLoading: boolean;
onpick: (recipeId: string, recipeName: string) => void;
onclose: () => void;
excludeRecipeId?: string;
replacingRecipe?: { name: string; meta?: string };
} = $props();
let drawerTransform = $derived(open ? 'translateX(0)' : 'translateX(100%)');
let backdropVisibility = $derived(open ? 'visible' : 'hidden');
let backdropOpacity = $derived(open ? '1' : '0');
</script>
<!-- Backdrop -->
<div
data-testid="drawer-backdrop"
aria-hidden="true"
onclick={onclose}
style="position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 40; visibility: {backdropVisibility}; opacity: {backdropOpacity}; transition: opacity 0.2s, visibility 0.2s;"
></div>
<!-- Drawer panel -->
<div
data-testid="recipe-picker-drawer"
aria-hidden={!open}
style="position: fixed; right: 0; top: 0; height: 100%; width: min(480px, 90vw); background: var(--color-page); border-left: 1px solid var(--color-border); z-index: 50; transform: {drawerTransform}; transition: transform 0.25s ease; display: flex; flex-direction: column;"
>
<!-- Header -->
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;">
<p style="margin: 0; font-family: var(--font-display); font-size: 15px; font-weight: 500; color: var(--color-text);">
Rezept wählen
</p>
<button
type="button"
aria-label="Schließen"
onclick={onclose}
style="background: none; border: none; cursor: pointer; font-size: 20px; line-height: 1; color: var(--color-text-muted); padding: 4px 8px;"
>
&times;
</button>
</div>
<!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
<div style="overflow-y: auto; flex: 1;">
{#if open}
<RecipePicker
{planId}
date={slotDate}
dateLabel={slotDate}
{suggestions}
{allRecipes}
{isLoading}
{onpick}
{excludeRecipeId}
{replacingRecipe}
/>
{/if}
</div>
</div>

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import RecipePickerDrawer from './RecipePickerDrawer.svelte';
const baseProps = {
open: true,
slotDate: '2026-04-14',
planId: 'plan-1',
suggestions: [],
allRecipes: [
{ id: 'r1', name: 'Pasta Bolognese', cookTimeMin: 45, effort: 'mittel' },
{ id: 'r2', name: 'Lachs', cookTimeMin: 20, effort: 'einfach' }
],
isLoading: false,
onpick: vi.fn(),
onclose: vi.fn()
};
describe('RecipePickerDrawer', () => {
describe('visibility', () => {
it('renders drawer content when open=true', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByTestId('recipe-picker-drawer')).toBeTruthy();
});
it('drawer is not visible when open=false', () => {
render(RecipePickerDrawer, { props: { ...baseProps, open: false } });
const drawer = screen.getByTestId('recipe-picker-drawer');
// Drawer exists in DOM but should be off-screen / aria-hidden
expect(drawer.getAttribute('aria-hidden')).toBe('true');
});
it('renders recipe list inside drawer', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
});
});
describe('backdrop', () => {
it('renders backdrop when open', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByTestId('drawer-backdrop')).toBeTruthy();
});
it('calls onclose when backdrop is clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
await user.click(screen.getByTestId('drawer-backdrop'));
expect(onclose).toHaveBeenCalledOnce();
});
});
describe('close button', () => {
it('renders a close button inside the drawer', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByRole('button', { name: /schließen|close/i })).toBeTruthy();
});
it('calls onclose when close button clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
await user.click(screen.getByRole('button', { name: /schließen|close/i }));
expect(onclose).toHaveBeenCalledOnce();
});
});
describe('recipe picking', () => {
it('calls onpick when a recipe is selected', async () => {
const onpick = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onpick } });
const pickButtons = screen.getAllByRole('button', { name: /Wählen/i });
await user.click(pickButtons[0]);
expect(onpick).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,39 @@
<script lang="ts">
interface SubScores {
proteinDiversity: number;
ingredientOverlap: number;
effortBalance: number;
}
let { subScores }: { subScores: SubScores } = $props();
</script>
<ul class="divide-y divide-[var(--color-border)] rounded-[var(--radius-md)] border border-[var(--color-border)]">
<li
data-testid="sub-protein"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Protein-Vielfalt</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.proteinDiversity}/10
</span>
</li>
<li
data-testid="sub-ingredient"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Zutaten-Überlappung</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.ingredientOverlap}/10
</span>
</li>
<li
data-testid="sub-effort"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Aufwandsbalance</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.effortBalance}/10
</span>
</li>
</ul>

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ScoreBreakdownList from './ScoreBreakdownList.svelte';
const subScores = {
proteinDiversity: 9,
ingredientOverlap: 7,
effortBalance: 8
};
describe('ScoreBreakdownList', () => {
it('renders protein diversity row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-protein').textContent).toContain('9');
});
it('renders ingredient overlap row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-ingredient').textContent).toContain('7');
});
it('renders effort balance row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-effort').textContent).toContain('8');
});
it('renders all rows with /10 suffix', () => {
render(ScoreBreakdownList, { props: { subScores } });
const items = screen.getAllByTestId(/^sub-/);
expect(items.length).toBe(3);
items.forEach((item) => {
expect(item.textContent).toContain('/10');
});
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { formatDayLabel } from './week';
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: SlotRecipe | null;
}
interface WeekPlan {
id?: string;
weekStart?: string;
slots?: Slot[];
}
let {
selectedDay,
weekPlan
}: {
selectedDay: string;
weekPlan: WeekPlan | null;
} = $props();
let expanded = $state(false);
let slotsWithMeal = $derived(
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)
);
function toggle() {
expanded = !expanded;
}
</script>
<div
data-testid="context-banner"
class="rounded-[var(--radius-md)] border border-[var(--green-light)] bg-[var(--green-tint)] px-4 py-3"
>
<div class="flex items-center justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]">
Vorschläge für <strong>{formatDayLabel(selectedDay)}</strong>
</p>
<button
type="button"
onclick={toggle}
aria-expanded={expanded}
aria-controls="context-detail"
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
{expanded ? 'Filter ausblenden ▲' : 'Filter einblenden ▼'}
</button>
</div>
<div
id="context-detail"
data-testid="context-detail"
aria-hidden={!expanded}
{...expanded ? {} : { hidden: true }}
>
{#if slotsWithMeal.length > 0}
<div class="mt-3">
<p class="mb-1 font-[var(--font-sans)] text-[11px] uppercase tracking-wide text-[var(--color-text-muted)]">
Diese Woche bisher
</p>
<ul class="space-y-1">
{#each slotsWithMeal as slot}
<li class="flex gap-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text)]">
<span class="text-[var(--color-text-muted)]">{formatDayLabel(slot.slotDate!).split(',')[0]}</span>
<span>{slot.recipe?.name}</span>
</li>
{/each}
</ul>
</div>
{:else}
<p class="mt-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Noch keine Gerichte diese Woche geplant
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import SuggestionContextBanner from './SuggestionContextBanner.svelte';
const weekPlan = {
id: 'plan-1',
weekStart: '2026-03-30',
slots: [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Hard' } }
]
};
describe('SuggestionContextBanner', () => {
it('renders the selected day label', () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
// Day label should be visible
expect(screen.getByTestId('context-banner')).toBeTruthy();
});
it('renders meals from the current week after expanding', async () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
// Banner starts collapsed — expand it first
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
await fireEvent.click(toggle);
expect(screen.getByText(/Pasta/)).toBeTruthy();
expect(screen.getByText(/Curry/)).toBeTruthy();
});
it('starts collapsed and expands on toggle', async () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
const detail = screen.getByTestId('context-detail');
// Initially collapsed
expect(detail.hasAttribute('hidden')).toBe(true);
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
await fireEvent.click(toggle);
// After toggle: expanded
expect(detail.hasAttribute('hidden')).toBe(false);
await fireEvent.click(toggle);
// After second toggle: collapsed again
expect(detail.hasAttribute('hidden')).toBe(true);
});
it('renders with no slots gracefully', () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } });
expect(screen.getByTestId('context-banner')).toBeTruthy();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
let {
visible,
message,
onundo,
ondismiss
}: {
visible: boolean;
message: string;
onundo: () => void;
ondismiss: () => void;
} = $props();
$effect(() => {
if (!visible) return;
const timer = setTimeout(() => {
ondismiss();
}, 4000);
return () => {
clearTimeout(timer);
};
});
</script>
{#if visible}
<div
data-testid="undo-bar"
role="status"
style="
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--color-text);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
"
>
<span style="color: #E8E8E2; font-family: var(--font-sans); font-size: 14px;">
{message}
</span>
<button
type="button"
onclick={onundo}
style="
background: none;
border: none;
cursor: pointer;
color: var(--green-dark);
font-family: var(--font-sans);
font-size: 14px;
padding: 0;
text-decoration: none;
"
onmouseenter={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'underline')}
onmouseleave={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'none')}
>
Rückgängig
</button>
</div>
{/if}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import UndoBar from './UndoBar.svelte';
describe('UndoBar', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('is not mounted when visible is false', () => {
render(UndoBar, { props: { visible: false, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
expect(screen.queryByTestId('undo-bar')).toBeNull();
});
it('is mounted and shows message when visible is true', () => {
render(UndoBar, { props: { visible: true, message: 'Gericht hinzugefügt', onundo: vi.fn(), ondismiss: vi.fn() } });
expect(screen.getByTestId('undo-bar')).toBeTruthy();
expect(screen.getByText('Gericht hinzugefügt')).toBeTruthy();
});
it('shows Rückgängig button', () => {
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
expect(screen.getByRole('button', { name: /Rückgängig/i })).toBeTruthy();
});
it('calls onundo when Rückgängig is clicked', async () => {
const onundo = vi.fn();
render(UndoBar, { props: { visible: true, message: 'Test', onundo, ondismiss: vi.fn() } });
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
expect(onundo).toHaveBeenCalledOnce();
});
it('calls ondismiss after 4 seconds', async () => {
const ondismiss = vi.fn();
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
await act(() => { vi.advanceTimersByTime(4000); });
expect(ondismiss).toHaveBeenCalledOnce();
});
it('does not call ondismiss before 4 seconds', async () => {
const ondismiss = vi.fn();
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
await act(() => { vi.advanceTimersByTime(3999); });
expect(ondismiss).not.toHaveBeenCalled();
});
it('has role="status" for accessibility', () => {
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
expect(screen.getByRole('status')).toBeTruthy();
});
});

View File

@@ -0,0 +1,62 @@
<script lang="ts">
interface IngredientOverlap {
ingredientName?: string;
days?: string[];
}
let {
score,
ingredientOverlaps = [],
showReviewLink = false
}: {
score: number;
ingredientOverlaps?: IngredientOverlap[];
showReviewLink?: boolean;
} = $props();
let percentage = $derived(Math.round((score / 10) * 100));
</script>
<div class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] p-4">
<div class="flex items-baseline gap-1">
<span class="font-[var(--font-display)] text-[28px] font-[300] text-[var(--color-text)] md:text-[40px]">
{score.toFixed(1)}
</span>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/10</span>
<span class="ml-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">Abwechslungs-Score</span>
</div>
<!-- Progress bar -->
<div
class="mt-2 h-[4px] w-full overflow-hidden rounded-full bg-[var(--yellow-light)]"
>
<div
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={10}
class="h-full rounded-full bg-[var(--yellow)] transition-all"
style="width: {percentage}%"
></div>
</div>
<!-- Ingredient overlap warnings -->
{#if ingredientOverlaps.length > 0}
<ul class="mt-3 space-y-1">
{#each ingredientOverlaps as overlap}
<li class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]">
<span>{overlap.ingredientName}</span> in <span>{overlap.days?.length ?? 0} Mahlzeiten</span>
</li>
{/each}
</ul>
{/if}
{#if showReviewLink}
<a
href="/planner/variety"
class="mt-3 block font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] hover:underline"
>
Variety überprüfen →
</a>
{/if}
</div>

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import VarietyScoreCard from './VarietyScoreCard.svelte';
const baseProps = {
score: 7.5,
ingredientOverlaps: [],
showReviewLink: false
};
describe('VarietyScoreCard', () => {
it('renders the variety score', () => {
render(VarietyScoreCard, { props: baseProps });
expect(screen.getByText('7.5')).toBeTruthy();
});
it('renders "/10" denominator', () => {
render(VarietyScoreCard, { props: baseProps });
expect(screen.getByText('/10')).toBeTruthy();
});
it('renders a progress bar with correct aria attributes', () => {
render(VarietyScoreCard, { props: baseProps });
const bar = screen.getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('7.5');
expect(bar.getAttribute('aria-valuemin')).toBe('0');
expect(bar.getAttribute('aria-valuemax')).toBe('10');
});
it('renders ingredient overlap warnings', () => {
render(VarietyScoreCard, {
props: {
...baseProps,
ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }]
}
});
expect(screen.getByText(/Tomate/)).toBeTruthy();
expect(screen.getByText(/2 Mahlzeiten/)).toBeTruthy();
});
it('shows review link when showReviewLink is true', () => {
render(VarietyScoreCard, { props: { ...baseProps, showReviewLink: true } });
const link = screen.getByRole('link', { name: /Variety.*überprüfen|Review variety/i });
expect(link).toBeTruthy();
});
it('hides review link by default', () => {
render(VarietyScoreCard, { props: baseProps });
expect(screen.queryByRole('link', { name: /variety/i })).toBeNull();
});
it('renders with score 0', () => {
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
expect(screen.getByText('0.0')).toBeTruthy();
});
it('rounds floating-point scores to one decimal place', () => {
render(VarietyScoreCard, { props: { ...baseProps, score: 6.199999999999999 } });
expect(screen.getByText('6.2')).toBeTruthy();
expect(screen.queryByText('6.199999999999999')).toBeNull();
});
it('renders multiple ingredient overlap warnings', () => {
render(VarietyScoreCard, {
props: {
...baseProps,
ingredientOverlaps: [
{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] },
{ ingredientName: 'Zwiebel', days: ['2026-03-30', '2026-04-01', '2026-04-02'] },
{ ingredientName: 'Knoblauch', days: ['2026-03-31', '2026-04-01'] }
]
}
});
expect(screen.getByText(/Tomate/)).toBeTruthy();
expect(screen.getByText(/Zwiebel/)).toBeTruthy();
expect(screen.getByText(/Knoblauch/)).toBeTruthy();
expect(screen.getByText(/3 Mahlzeiten/)).toBeTruthy();
});
});

View File

@@ -0,0 +1,56 @@
<script lang="ts">
let {
score
}: {
score: number;
} = $props();
let percentage = $derived(Math.round((score / 10) * 100));
let description = $derived(
score >= 9
? { label: 'Ausgezeichnet', colorClass: 'text-[var(--green-dark)]' }
: score >= 7
? { label: 'Gut', colorClass: 'text-[var(--color-text)]' }
: score >= 4
? { label: 'Verbesserbar', colorClass: 'text-[var(--yellow-text)]' }
: { label: 'Unzureichend', colorClass: 'text-[var(--color-error)]' }
);
</script>
<div>
<!-- Score number + out of 10 -->
<div class="flex items-baseline gap-2">
<span
data-testid="score-value"
class="font-[var(--font-display)] text-[56px] font-[300] leading-none text-[var(--color-text)] lg:text-[72px]"
>
{score}
</span>
<span
data-testid="score-label"
class="font-[var(--font-sans)] text-[16px] text-[var(--color-text-muted)]"
>
/ 10
</span>
<span
data-testid="score-description"
class="ml-1 font-[var(--font-sans)] text-[14px] font-medium {description.colorClass}"
>
{description.label}
</span>
</div>
<!-- Progress bar -->
<div class="mt-3 h-[6px] w-[120px] overflow-hidden rounded-full bg-[var(--color-border)] lg:w-[200px]">
<div
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={10}
aria-label="Abwechslungs-Score"
class="h-full rounded-full bg-[var(--yellow)] transition-all"
style="width: {percentage}%"
></div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import VarietyScoreHero from './VarietyScoreHero.svelte';
describe('VarietyScoreHero', () => {
it('renders the score number', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
expect(screen.getByTestId('score-value').textContent).toContain('8.2');
});
it('renders "out of 10" label', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
expect(screen.getByTestId('score-label').textContent).toContain('10');
});
it('renders a progressbar with correct aria attributes', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
const bar = screen.getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('8.2');
expect(bar.getAttribute('aria-valuemin')).toBe('0');
expect(bar.getAttribute('aria-valuemax')).toBe('10');
});
it('shows "Excellent variety" description for score >= 9', () => {
render(VarietyScoreHero, { props: { score: 9.5 } });
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
});
it('shows "Good variety" description for score 7-8.9', () => {
render(VarietyScoreHero, { props: { score: 7.5 } });
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
});
it('shows "Getting there" description for score 4-6.9', () => {
render(VarietyScoreHero, { props: { score: 5.0 } });
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
});
it('shows "Needs improvement" description for score < 4', () => {
render(VarietyScoreHero, { props: { score: 2.1 } });
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
});
it('shows "Unzureichend" for score = 0 (boundary)', () => {
render(VarietyScoreHero, { props: { score: 0 } });
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
});
it('renders score 0 in score-value for score = 0', () => {
render(VarietyScoreHero, { props: { score: 0 } });
expect(screen.getByTestId('score-value').textContent).toContain('0');
});
it('renders 0-width progress bar for score = 0', () => {
render(VarietyScoreHero, { props: { score: 0 } });
const bar = screen.getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('0');
});
it('shows "Ausgezeichnet" for score = 10 (boundary)', () => {
render(VarietyScoreHero, { props: { score: 10 } });
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
});
it('shows "Verbesserbar" for score = 4 (boundary)', () => {
render(VarietyScoreHero, { props: { score: 4 } });
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
});
it('shows "Gut" for score = 7 (boundary)', () => {
render(VarietyScoreHero, { props: { score: 7 } });
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
});
});

View File

@@ -0,0 +1,51 @@
<script lang="ts">
interface WarningItem {
dayShort: string;
recipeName: string;
slotId: number;
}
interface ActionWarning {
title: string;
items: WarningItem[];
}
let { warnings, weekStart }: { warnings: ActionWarning[]; weekStart: string } = $props();
</script>
{#each warnings as warning (warning.title)}
<div
data-testid="warning-card"
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] overflow-hidden"
>
<!-- Header row -->
<div class="px-4 py-2.5 border-b border-[var(--yellow-light)]">
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
{warning.title}
</p>
</div>
<!-- Item rows -->
{#each warning.items as item (item.slotId)}
<div class="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--yellow-light)] last:border-b-0">
<!-- Left: day label + recipe name -->
<div class="flex items-center gap-2 min-w-0">
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--yellow-text)] w-6 flex-shrink-0">
{item.dayShort}
</span>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
{item.recipeName}
</span>
</div>
<!-- Right: swap link -->
<a
href="/planner?week={weekStart}&swap={item.slotId}"
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] flex-shrink-0 hover:underline"
>
Tauschen →
</a>
</div>
{/each}
</div>
{/each}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import VarietyWarningCards from './VarietyWarningCards.svelte';
const warnings = [
{
title: 'Chicken zweimal diese Woche',
items: [
{ dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 },
{ dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 }
]
},
{
title: 'Tomaten in 3 Gerichten',
items: [
{ dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 },
{ dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 },
{ dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 }
]
}
];
describe('VarietyWarningCards', () => {
it('renders one card per warning', () => {
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
const cards = screen.getAllByTestId('warning-card');
expect(cards.length).toBe(2);
});
it('renders warning titles', () => {
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
});
it('renders warning explanations', () => {
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
expect(screen.getByText('Chicken Tikka')).toBeTruthy();
expect(screen.getByText('Chicken Curry')).toBeTruthy();
});
it('renders nothing when warnings is empty', () => {
render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } });
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
});
});

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { weekDays, formatDayAbbr } from './week';
interface Slot {
id?: string;
slotDate?: string;
recipe?: { id?: string; name?: string } | null;
}
let {
weekStart,
slots = [],
selectedDay,
today,
onselectDay
}: {
weekStart: string;
slots?: Slot[];
selectedDay: string;
today: string;
onselectDay?: (day: string) => void;
} = $props();
let days = $derived(weekDays(weekStart));
let slotMap = $derived(
Object.fromEntries(slots.map((s) => [s.slotDate!, s]))
);
function selectDay(day: string) {
onselectDay?.(day);
}
</script>
<div class="grid grid-cols-7 gap-[2px] md:gap-[6px]">
{#each days as day}
{@const isSelected = day === selectedDay}
{@const isTodayDay = day === today}
{@const hasMeal = !!slotMap[day]?.recipe}
{@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const abbr = formatDayAbbr(day, 'narrow')}
<button
type="button"
data-testid="day-chip-{day}"
data-selected={isSelected}
data-today={isTodayDay}
onclick={() => selectDay(day)}
class="flex flex-col items-center rounded-[10px] px-1 py-2 transition-colors
{isTodayDay ? 'border border-[var(--yellow-light)] bg-[var(--yellow-tint)]' : ''}
{isSelected && !isTodayDay ? 'border border-[var(--green-light)] bg-[var(--green-tint)]' : ''}
{!isTodayDay && !isSelected ? 'border border-transparent' : ''}"
>
<span class="font-[var(--font-sans)] text-[7px] uppercase tracking-wide text-[var(--color-text-muted)] md:text-[10px]">
{abbr}
</span>
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--color-text)] md:text-[14px]">
{dateNum}
</span>
<!-- Dot indicator -->
<span
data-testid="dot-{day}"
data-has-meal={hasMeal}
class="mt-1 h-[3px] w-[3px] rounded-full
{hasMeal ? 'bg-[var(--green)]' : ''}
{!hasMeal && isTodayDay ? 'bg-[var(--yellow-text)]' : ''}
{!hasMeal && !isTodayDay ? 'bg-[var(--color-border)]' : ''}"
></span>
</button>
{/each}
</div>

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import WeekStrip from './WeekStrip.svelte';
const weekStart = '2026-03-30'; // Monday
const slots = [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium' } }
];
describe('WeekStrip', () => {
it('renders 7 day chips', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
const chips = screen.getAllByRole('button');
expect(chips).toHaveLength(7);
});
it('marks today chip with data-today attribute', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
const todayChip = screen.getByTestId('day-chip-2026-04-03');
expect(todayChip.getAttribute('data-today')).toBe('true');
});
it('marks selected chip with data-selected attribute', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
const selectedChip = screen.getByTestId('day-chip-2026-03-30');
expect(selectedChip.getAttribute('data-selected')).toBe('true');
});
it('shows meal indicator dot for days with a meal', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
const dot = screen.getByTestId('dot-2026-03-30');
expect(dot.getAttribute('data-has-meal')).toBe('true');
});
it('shows empty dot for days without a meal', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
// 2026-04-01 has no meal
const dot = screen.getByTestId('dot-2026-04-01');
expect(dot.getAttribute('data-has-meal')).toBe('false');
});
it('when today equals selected day, both data-today and data-selected are true', () => {
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-04-03', today: '2026-04-03' } });
const chip = screen.getByTestId('day-chip-2026-04-03');
expect(chip.getAttribute('data-today')).toBe('true');
expect(chip.getAttribute('data-selected')).toBe('true');
});
it('calls onselectDay callback when chip is clicked', async () => {
let emitted: string | null = null;
render(WeekStrip, {
props: {
weekStart,
slots,
selectedDay: '2026-03-30',
today: '2026-04-03',
onselectDay: (day: string) => { emitted = day; }
}
});
const chip = screen.getByTestId('day-chip-2026-03-31');
chip.click();
expect(emitted).toBe('2026-03-31');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
export interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
export interface Recipe {
id: string;
name: string;
effort?: string;
cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
}
export interface Suggestion {
recipe: Recipe;
scoreDelta: number;
hasConflict: boolean;
}

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { computeSubScores, computeWarnings } from './variety';
describe('computeSubScores', () => {
it('returns proteinDiversity=10 when no protein repeats', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 2, hard: 1 });
expect(result.proteinDiversity).toBe(10);
});
it('reduces proteinDiversity by 2 per protein repeat', () => {
const tagRepeats = [
{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] },
{ tagType: 'protein', tagName: 'Beef', days: ['WED', 'THU'] }
];
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
// 2 protein repeat entries → 10 - 2*2 = 6
expect(result.proteinDiversity).toBe(6);
});
it('clamps proteinDiversity to minimum 0', () => {
const tagRepeats = Array.from({ length: 6 }, (_, i) => ({
tagType: 'protein', tagName: `P${i}`, days: ['MON', 'TUE']
}));
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
expect(result.proteinDiversity).toBe(0);
});
it('returns ingredientOverlap=10 when no overlaps', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
expect(result.ingredientOverlap).toBe(10);
});
it('reduces ingredientOverlap by 1.5 per overlap (rounded)', () => {
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'TUE'] }];
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
// 1 overlap → 10 - 1*1.5 = 8.5 → round = 9 (Math.round rounds .5 up)
expect(result.ingredientOverlap).toBe(9);
});
it('clamps ingredientOverlap to minimum 0', () => {
const ingredientOverlaps = Array.from({ length: 8 }, (_, i) => ({
ingredientName: `Ing${i}`, days: ['MON', 'TUE']
}));
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
expect(result.ingredientOverlap).toBe(0);
});
it('returns effortBalance=10 when no meals (total=0)', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
expect(result.effortBalance).toBe(10);
});
it('returns effortBalance=10 when easy and hard are equal', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 3, medium: 0, hard: 3 });
// |3-3| = 0 → 10 - 0 = 10
expect(result.effortBalance).toBe(10);
});
it('reduces effortBalance by 1.5 per unit of easy-hard difference', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 0, hard: 0 });
// |4-0| = 4 → 10 - 4*1.5 = 4 → round(4) = 4
expect(result.effortBalance).toBe(4);
});
it('clamps effortBalance to minimum 0', () => {
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 10, medium: 0, hard: 0 });
// |10-0| = 10 → 10 - 10*1.5 = -5 → clamp to 0
expect(result.effortBalance).toBe(0);
});
it('ignores non-protein tag repeats for proteinDiversity', () => {
const tagRepeats = [{ tagType: 'category', tagName: 'Pasta', days: ['MON', 'TUE'] }];
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
expect(result.proteinDiversity).toBe(10);
});
});
describe('computeWarnings', () => {
it('returns empty array when no repeats or overlaps', () => {
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: [] });
expect(result).toHaveLength(0);
});
it('generates warning for protein appearing on 2+ days', () => {
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
expect(result).toHaveLength(1);
expect(result[0].title).toContain('Chicken');
});
it('does not generate warning for protein appearing on only 1 day', () => {
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON'] }];
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
expect(result).toHaveLength(0);
});
it('generates warning for ingredient overlap on 2+ days', () => {
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
expect(result).toHaveLength(1);
expect(result[0].title).toContain('Rice');
});
it('does not generate warning for ingredient appearing on only 1 day', () => {
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON'] }];
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
expect(result).toHaveLength(0);
});
it('generates warning for each duplicate recipe in plan', () => {
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: ['Pasta Bolognese', 'Risotto'] });
expect(result).toHaveLength(2);
expect(result[0].title).toContain('Pasta Bolognese');
expect(result[1].title).toContain('Risotto');
});
it('combines all warning types', () => {
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
const result = computeWarnings({ tagRepeats, ingredientOverlaps, duplicatesInPlan: ['Pasta'] });
expect(result).toHaveLength(3);
});
});

View File

@@ -0,0 +1,88 @@
interface TagRepeat {
tagType?: string;
tagName?: string;
days?: string[];
}
interface IngredientOverlap {
ingredientName?: string;
days?: string[];
}
interface SubScoreInput {
tagRepeats: TagRepeat[];
ingredientOverlaps: IngredientOverlap[];
easy: number;
medium: number;
hard: number;
}
export interface SubScores {
proteinDiversity: number;
ingredientOverlap: number;
effortBalance: number;
}
export function computeSubScores(input: SubScoreInput): SubScores {
const { tagRepeats, ingredientOverlaps, easy, medium, hard } = input;
const proteinRepeats = tagRepeats.filter((t) => t.tagType === 'protein').length;
const ingredientOverlapCount = ingredientOverlaps.length;
const total = easy + medium + hard;
const effortBalance =
total === 0
? 10
: Math.min(10, Math.round(Math.max(0, 10 - Math.abs(easy - hard) * 1.5)));
return {
proteinDiversity: Math.max(0, Math.round(10 - proteinRepeats * 2)),
ingredientOverlap: Math.max(0, Math.round(10 - ingredientOverlapCount * 1.5)),
effortBalance
};
}
interface WarningInput {
tagRepeats: TagRepeat[];
ingredientOverlaps: IngredientOverlap[];
duplicatesInPlan: string[];
}
export interface Warning {
title: string;
explanation: string;
}
export function computeWarnings(input: WarningInput): Warning[] {
const { tagRepeats, ingredientOverlaps, duplicatesInPlan } = input;
const result: Warning[] = [];
for (const repeat of tagRepeats) {
if ((repeat.days?.length ?? 0) > 1) {
const days = (repeat.days ?? []).join(', ');
result.push({
title: `${repeat.tagName} mehrfach diese Woche`,
explanation: `${days} — erwäge einen Tausch für mehr Protein-Abwechslung.`
});
}
}
for (const overlap of ingredientOverlaps) {
if ((overlap.days?.length ?? 0) > 1) {
const days = (overlap.days ?? []).join(', ');
result.push({
title: `${overlap.ingredientName} in mehreren Gerichten`,
explanation: `${days} — sorge für Zutaten-Abwechslung.`
});
}
}
for (const name of duplicatesInPlan) {
result.push({
title: `${name} doppelt geplant`,
explanation: 'Dasselbe Rezept erscheint mehrfach — tausche eines aus.'
});
}
return result;
}

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
getWeekStart,
prevWeek,
nextWeek,
weekDays,
isToday,
formatWeekRange,
formatDayLabel,
sortEasiestFirst
} from './week';
describe('getWeekStart', () => {
it('returns Monday for a Monday input', () => {
// 2026-03-30 is a Monday
const d = new Date('2026-03-30T12:00:00Z');
expect(getWeekStart(d)).toBe('2026-03-30');
});
it('returns Monday for a Wednesday input', () => {
// 2026-04-01 is a Wednesday → week starts 2026-03-30
const d = new Date('2026-04-01T12:00:00Z');
expect(getWeekStart(d)).toBe('2026-03-30');
});
it('returns Monday for a Sunday input (edge case — goes back 6 days)', () => {
// 2026-04-05 is a Sunday → week starts 2026-03-30
const d = new Date('2026-04-05T12:00:00Z');
expect(getWeekStart(d)).toBe('2026-03-30');
});
it('returns Monday for a Saturday input', () => {
// 2026-04-04 is a Saturday → week starts 2026-03-30
const d = new Date('2026-04-04T12:00:00Z');
expect(getWeekStart(d)).toBe('2026-03-30');
});
it('handles year boundary correctly (Dec 28 2025 → week starts Dec 22 2025)', () => {
const d = new Date('2025-12-28T12:00:00Z');
expect(getWeekStart(d)).toBe('2025-12-22');
});
});
describe('prevWeek', () => {
it('returns the Monday 7 days before', () => {
expect(prevWeek('2026-03-30')).toBe('2026-03-23');
});
it('handles month boundary', () => {
expect(prevWeek('2026-04-06')).toBe('2026-03-30');
});
it('handles year boundary', () => {
expect(prevWeek('2026-01-05')).toBe('2025-12-29');
});
});
describe('nextWeek', () => {
it('returns the Monday 7 days after', () => {
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
});
it('handles month boundary', () => {
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
});
it('handles year boundary', () => {
expect(nextWeek('2025-12-29')).toBe('2026-01-05');
});
});
describe('weekDays', () => {
it('returns exactly 7 dates', () => {
expect(weekDays('2026-03-30')).toHaveLength(7);
});
it('starts on the given weekStart', () => {
const days = weekDays('2026-03-30');
expect(days[0]).toBe('2026-03-30');
});
it('ends 6 days after weekStart', () => {
const days = weekDays('2026-03-30');
expect(days[6]).toBe('2026-04-05');
});
it('has consecutive daily dates', () => {
const days = weekDays('2026-03-30');
for (let i = 1; i < 7; i++) {
const prev = new Date(days[i - 1] + 'T00:00:00Z');
const curr = new Date(days[i] + 'T00:00:00Z');
expect(curr.getTime() - prev.getTime()).toBe(86400000);
}
});
it('handles month boundary correctly', () => {
const days = weekDays('2026-03-30');
expect(days[1]).toBe('2026-03-31');
expect(days[2]).toBe('2026-04-01');
});
});
describe('isToday', () => {
afterEach(() => {
vi.useRealTimers();
});
it('returns true for today (UTC)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
expect(isToday('2026-04-03')).toBe(true);
});
it('returns false for yesterday', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
expect(isToday('2026-04-02')).toBe(false);
});
it('returns false for tomorrow', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
expect(isToday('2026-04-04')).toBe(false);
});
});
describe('formatWeekRange', () => {
it('returns a non-empty string', () => {
expect(formatWeekRange('2026-03-30')).toBeTruthy();
});
it('contains both start and end month info', () => {
const range = formatWeekRange('2026-03-30');
// Start is March 30, end is April 5 — range should span both months
expect(range).toContain('');
});
});
describe('formatDayLabel', () => {
it('returns a non-empty string', () => {
expect(formatDayLabel('2026-03-30')).toBeTruthy();
});
it('contains a comma separator', () => {
expect(formatDayLabel('2026-03-30')).toContain(',');
});
});
describe('sortEasiestFirst', () => {
it('sorts easy before medium before hard', () => {
const recipes = [
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 },
{ id: '3', name: 'Medium', effort: 'medium', cookTimeMin: 10 }
];
const sorted = sortEasiestFirst(recipes);
expect(sorted.map((r) => r.effort)).toEqual(['easy', 'medium', 'hard']);
});
it('sorts by cookTimeMin ascending within same effort', () => {
const recipes = [
{ id: '1', name: 'Slow Easy', effort: 'easy', cookTimeMin: 60 },
{ id: '2', name: 'Fast Easy', effort: 'easy', cookTimeMin: 15 }
];
const sorted = sortEasiestFirst(recipes);
expect(sorted[0].name).toBe('Fast Easy');
});
it('treats missing effort as after hard', () => {
const recipes = [
{ id: '1', name: 'No effort', effort: undefined, cookTimeMin: 5 },
{ id: '2', name: 'Hard', effort: 'hard', cookTimeMin: 5 }
];
const sorted = sortEasiestFirst(recipes);
expect(sorted[0].effort).toBe('hard');
});
it('treats missing cookTimeMin as after defined values', () => {
const recipes = [
{ id: '1', name: 'No time', effort: 'easy', cookTimeMin: undefined },
{ id: '2', name: 'Has time', effort: 'easy', cookTimeMin: 30 }
];
const sorted = sortEasiestFirst(recipes);
expect(sorted[0].name).toBe('Has time');
});
it('does not mutate the original array', () => {
const recipes = [
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 }
];
const original = [...recipes];
sortEasiestFirst(recipes);
expect(recipes[0].effort).toBe(original[0].effort);
});
});

View File

@@ -0,0 +1,107 @@
/**
* Returns the ISO Monday (YYYY-MM-DD) for the week containing `date`.
*/
export function getWeekStart(date: Date): string {
const d = new Date(date);
const day = d.getUTCDay(); // 0=Sun, 1=Mon, …
const diff = day === 0 ? -6 : 1 - day; // shift to Monday
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
/**
* Returns the Monday of the previous week relative to `weekStart`.
*/
export function prevWeek(weekStart: string): string {
const d = new Date(weekStart + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 7);
return d.toISOString().slice(0, 10);
}
/**
* Returns the Monday of the next week relative to `weekStart`.
*/
export function nextWeek(weekStart: string): string {
const d = new Date(weekStart + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 7);
return d.toISOString().slice(0, 10);
}
/**
* Formats a date string (YYYY-MM-DD) as a localized day abbreviation.
*/
export function formatDayAbbr(dateStr: string, length: 'narrow' | 'short' = 'narrow'): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('de-DE', { weekday: length, timeZone: 'UTC' });
}
/**
* Returns an array of 7 date strings for the week starting on `weekStart`.
*/
export function weekDays(weekStart: string): string[] {
const days: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(weekStart + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
/**
* Formats a date string as "Mo, 30.03." style label.
*/
export function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
const day = d.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' });
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: 'UTC' });
return `${day}, ${date}`;
}
/**
* Formats a date string as "30. März" style label.
*/
export function formatDayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', timeZone: 'UTC' });
}
/**
* Returns true if dateStr is today (UTC date).
* Uses UTC consistently with all other date functions in this module.
*/
export function isToday(dateStr: string): boolean {
const todayStr = new Date().toISOString().slice(0, 10);
return dateStr === todayStr;
}
const EFFORT_ORDER: Record<string, number> = { easy: 0, medium: 1, hard: 2 };
/**
* Returns a new array of recipes sorted easiest first (effort ASC, cookTimeMin ASC).
* Used for the J4 mid-week swap context — different from variety-first sorting in J2.
*/
export function sortEasiestFirst<T extends { effort?: string | null; cookTimeMin?: number | null }>(
recipes: T[]
): T[] {
return [...recipes].sort((a, b) => {
const ea = a.effort != null ? (EFFORT_ORDER[a.effort] ?? 99) : 99;
const eb = b.effort != null ? (EFFORT_ORDER[b.effort] ?? 99) : 99;
if (ea !== eb) return ea - eb;
const ta = a.cookTimeMin ?? Infinity;
const tb = b.cookTimeMin ?? Infinity;
return ta - tb;
});
}
/**
* Formats a week range: "30. Mär 5. Apr 2026".
*/
export function formatWeekRange(weekStart: string): string {
const start = new Date(weekStart + 'T00:00:00Z');
const end = new Date(weekStart + 'T00:00:00Z');
end.setUTCDate(end.getUTCDate() + 6);
const startStr = start.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', timeZone: 'UTC' });
const endStr = end.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
return `${startStr} ${endStr}`;
}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let { activeFilter, onFilter }: { activeFilter: string; onFilter: (filter: string) => void } = $props();
const chips = ['Alle', 'Leicht', 'Mittel', 'Schwer'];
</script>
<div class="flex gap-[8px] overflow-x-auto px-[16px] py-[12px] scrollbar-none">
{#each chips as label (label)}
<button
type="button"
aria-pressed={activeFilter === label}
onclick={() => onFilter(label)}
class="font-sans text-[13px] font-medium tracking-[0.04em] px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label
? 'bg-[var(--green-tint)] text-[var(--green-dark)] border-[var(--green-light)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)]'}"
>
{label}
</button>
{/each}
</div>

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import FilterChipRow from './FilterChipRow.svelte';
describe('FilterChipRow', () => {
it('renders all effort filter chips', () => {
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mittel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Schwer' })).toBeInTheDocument();
});
it('marks active chip with aria-pressed=true', () => {
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter: vi.fn() } });
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Alle' })).toHaveAttribute('aria-pressed', 'false');
});
it('marks inactive chips with aria-pressed=false', () => {
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Mittel' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Schwer' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onFilter with the chip label when clicked', async () => {
const user = userEvent.setup();
const onFilter = vi.fn();
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter } });
await user.click(screen.getByRole('button', { name: 'Leicht' }));
expect(onFilter).toHaveBeenCalledWith('Leicht');
});
it('calls onFilter with Alle when reset chip clicked', async () => {
const user = userEvent.setup();
const onFilter = vi.fn();
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter } });
await user.click(screen.getByRole('button', { name: 'Alle' }));
expect(onFilter).toHaveBeenCalledWith('Alle');
});
it('active chip has green-tint styling', () => {
render(FilterChipRow, { props: { activeFilter: 'Mittel', onFilter: vi.fn() } });
const btn = screen.getByRole('button', { name: 'Mittel' });
expect(btn.className).toContain('bg-[var(--green-tint)]');
});
});

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Ingredient } from './types';
let { ingredients }: { ingredients: Ingredient[] } = $props();
const sortedIngredients = $derived(
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
);
</script>
<section>
<h2
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
>
Zutaten
</h2>
<ul>
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
{#if ingredient.quantity != null}
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
{ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
</span>
{/if}
<span class="text-[14px] text-[var(--color-text)]">{ingredient.name}</span>
</li>
{/each}
</ul>
</section>

Some files were not shown because too many files have changed in this diff Show More