Compare commits

...

119 Commits

Author SHA1 Message Date
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
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
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
124 changed files with 17914 additions and 156 deletions

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()));
}
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<ApiError> handleForbidden(ForbiddenException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiError.of("FORBIDDEN", ex.getMessage()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
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
public class PlanningService {
private static final double MAX_VARIETY_SCORE = 10.0;
private final WeekPlanRepository weekPlanRepository;
private final WeekPlanSlotRepository weekPlanSlotRepository;
private final CookingLogRepository cookingLogRepository;
@@ -135,6 +137,8 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream()
@@ -145,11 +149,13 @@ public class PlanningService {
.filter(r -> !usedRecipeIds.contains(r.getId()))
.filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> {
double score = simulateVarietyScore(
double simulatedScore = simulateVarietyScore(
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)
.toList();
@@ -166,36 +172,65 @@ public class PlanningService {
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
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));
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();
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
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (SimulatedSlot slot : slots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.date);
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (SimulatedSlot slot : slots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
@@ -203,34 +238,35 @@ public class PlanningService {
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
// 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream()
long recentRepeatCount = slots.stream()
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the simulated plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
// 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)
.sum();
double score = 10.0;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
}
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)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
@@ -246,10 +282,6 @@ public class PlanningService {
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
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();
// 1. Tag-type repeats on consecutive days
@@ -317,13 +349,7 @@ public class PlanningService {
}
}
// Calculate score
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));
double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
}

View File

@@ -1,5 +1,6 @@
package com.recipeapp.planning;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.validation.Valid;
@@ -40,6 +41,7 @@ public class WeekPlanController {
}
@PostMapping("/{id}/slots")
@RequiresHouseholdRole("planner")
public ResponseEntity<SlotResponse> addSlot(
Principal principal,
@PathVariable UUID id,
@@ -50,6 +52,7 @@ public class WeekPlanController {
}
@PatchMapping("/{planId}/slots/{slotId}")
@RequiresHouseholdRole("planner")
public SlotResponse updateSlot(
Principal principal,
@PathVariable UUID planId,
@@ -61,6 +64,7 @@ public class WeekPlanController {
@DeleteMapping("/{planId}/slots/{slotId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequiresHouseholdRole("planner")
public void deleteSlot(
Principal principal,
@PathVariable UUID planId,
@@ -92,4 +96,15 @@ public class WeekPlanController {
UUID householdId = householdResolver.resolve(principal.getName());
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(
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();
}
public String resolveRole(String userEmail) {
return findMembership(userEmail).getRole();
}
private HouseholdMember findMembership(String userEmail) {
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));

View File

@@ -22,8 +22,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
FROM Recipe r
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
AND (:effort IS NULL OR r.effort = :effort)
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
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)
ORDER BY r.createdAt DESC
@@ -43,8 +43,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
FROM Recipe r
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
AND (:effort IS NULL OR r.effort = :effort)
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
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)
""")

View File

@@ -1,11 +1,15 @@
package com.recipeapp.shopping;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.UUID;
@RestController
@@ -19,8 +23,21 @@ public class ShoppingListController {
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")
@ResponseStatus(HttpStatus.CREATED)
@RequiresHouseholdRole("planner")
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.generateFromPlan(householdId, id);
@@ -45,7 +62,7 @@ public class ShoppingListController {
@PostMapping("/v1/shopping-lists/{id}/items")
@ResponseStatus(HttpStatus.CREATED)
public ShoppingListItemResponse addItem(@PathVariable UUID id,
@RequestBody AddItemRequest request,
@Valid @RequestBody AddItemRequest request,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.addItem(householdId, id, request);

View File

@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
import com.recipeapp.shopping.entity.ShoppingList;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
import java.util.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.entity.WeekPlan;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.Recipe;
import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.shopping.dto.*;
import com.recipeapp.shopping.entity.ShoppingList;
@@ -16,6 +18,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;
@@ -29,19 +34,34 @@ public class ShoppingService {
private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository;
private final UserAccountRepository userAccountRepository;
private final RecipeRepository recipeRepository;
public ShoppingService(ShoppingListRepository shoppingListRepository,
ShoppingListItemRepository shoppingListItemRepository,
WeekPlanRepository weekPlanRepository,
HouseholdRepository householdRepository,
IngredientRepository ingredientRepository,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
RecipeRepository recipeRepository) {
this.shoppingListRepository = shoppingListRepository;
this.shoppingListItemRepository = shoppingListItemRepository;
this.weekPlanRepository = weekPlanRepository;
this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository;
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");
}
var household = weekPlan.getHousehold();
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
shoppingList = shoppingListRepository.save(shoppingList);
// Find or create the shopping list
ShoppingList shoppingList = shoppingListRepository
.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
.orElseGet(() -> {
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
return shoppingListRepository.save(newList);
});
// Aggregate ingredients across all slots/recipes
// Key: ingredientId + unit -> merged data
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
for (var slot : weekPlan.getSlots()) {
var recipe = slot.getRecipe();
for (RecipeIngredient ri : recipe.getIngredients()) {
Ingredient ingredient = ri.getIngredient();
// Filter out staples
if (ingredient.isStaple()) {
continue;
}
String key = ingredient.getId().toString() + "|" + ri.getUnit();
String key = mergeKey(ingredient.getId(), ri.getUnit());
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
.addQuantity(ri.getQuantity())
.addRecipeId(recipe.getId());
}
}
// Create shopping list items
for (MergedIngredient mi : merged.values()) {
ShoppingListItem item = new ShoppingListItem(
shoppingList,
mi.ingredient,
null,
mi.totalQuantity,
mi.unit,
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
);
shoppingList.getItems().add(item);
// Build index of existing generated items by merge key
Map<String, ShoppingListItem> existingByKey = new HashMap<>();
List<ShoppingListItem> customItems = new ArrayList<>();
for (ShoppingListItem item : shoppingList.getItems()) {
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
// Generated item
String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit());
existingByKey.put(key, item);
} else {
customItems.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);
return toResponse(shoppingList);
@@ -121,7 +167,7 @@ public class ShoppingService {
}
shoppingListItemRepository.save(item);
return toItemResponse(item);
return toItemResponseWithNames(item);
}
@@ -146,7 +192,7 @@ public class ShoppingService {
item = shoppingListItemRepository.save(item);
list.getItems().add(item);
return toItemResponse(item);
return toItemResponseWithNames(item);
}
@@ -178,18 +224,53 @@ public class ShoppingService {
}
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()
.map(this::toItemResponse)
.map(item -> toItemResponse(item, recipeNames))
.toList();
// Count filtered staples from the week plan
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
return new ShoppingListResponse(
list.getId(),
list.getWeekPlan().getId(),
list.getGeneratedAt(),
filteredStaplesCount,
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;
ShoppingListItemResponse.CategoryRef categoryRef = null;
UUID ingredientId = null;
@@ -207,6 +288,14 @@ public class ShoppingService {
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(
item.getId(),
ingredientId,
@@ -216,10 +305,14 @@ public class ShoppingService {
item.getUnit(),
item.isChecked(),
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 {
final Ingredient ingredient;
final String unit;

View File

@@ -1,11 +1,15 @@
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.util.UUID;
public record AddItemRequest(
UUID ingredientId,
String customName,
BigDecimal quantity,
@NotBlank @Size(max = 255) String customName,
@Positive BigDecimal quantity,
String unit
) {}

View File

@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
String unit,
boolean isChecked,
UUID checkedBy,
List<UUID> sourceRecipes
List<RecipeRef> sourceRecipes
) {
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;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record ShoppingListResponse(
UUID id,
UUID weekPlanId,
Instant generatedAt,
int filteredStaplesCount,
List<ShoppingListItemResponse> items
) {}

View File

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

View File

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

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,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,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

@@ -443,4 +443,93 @@ class PlanningServiceTest {
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
.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

@@ -165,7 +165,7 @@ class SuggestionsTest {
}
@Test
void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() {
void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() {
var plan = createPlan();
var r1 = createRecipe("Pasta");
var r2 = createRecipe("Salad");
@@ -179,8 +179,12 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(3);
assertThat(result.suggestions()).allSatisfy(s ->
assertThat(s.simulatedScore()).isEqualTo(10.0));
// Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all
// hasConflict = (scoreDelta < 0) = false for neutral recipes
assertThat(result.suggestions()).allSatisfy(s -> {
assertThat(s.scoreDelta()).isEqualTo(0.0);
assertThat(s.hasConflict()).isFalse();
});
}
@Test
@@ -204,6 +208,28 @@ class SuggestionsTest {
.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
void singleCandidateShouldReturnOne() {
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
// ═══════════════════════════════════════════════════════════
@@ -402,8 +570,8 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(2);
// B should rank higher (no tag penalty)
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
assertThat(result.suggestions().get(0).simulatedScore())
.isGreaterThan(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).scoreDelta());
}
@Test
@@ -428,8 +596,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).simulatedScore())
.isEqualTo(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(0).scoreDelta())
.isEqualTo(result.suggestions().get(1).scoreDelta());
}
@Test
@@ -453,8 +621,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
assertThat(result.suggestions()).hasSize(1);
// No penalty — dietary not tracked
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
// No penalty — dietary not tracked → scoreDelta = 0.0
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
}
}
@@ -492,8 +660,8 @@ class SuggestionsTest {
assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto");
assertThat(result.suggestions().get(0).simulatedScore())
.isGreaterThan(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).scoreDelta());
}
@Test
@@ -519,7 +687,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
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().get(0).recipe().name()).isEqualTo("Stir Fry");
assertThat(result.suggestions().get(0).simulatedScore())
.isGreaterThan(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).scoreDelta());
}
@Test
@@ -566,7 +735,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
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
void rankingOrderShouldBeBySimulatedScoreDescending() {
void rankingOrderShouldBeByScoreDeltaDescending() {
var plan = createPlan();
var pastaTag = createTag("Pasta", "cuisine");
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(2).recipe().name()).isEqualTo("Tomato Pasta");
// Verify scores are strictly descending
assertThat(result.suggestions().get(0).simulatedScore())
.isGreaterThan(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(1).simulatedScore())
.isGreaterThan(result.suggestions().get(2).simulatedScore());
// Verify scoreDelta is strictly descending
assertThat(result.suggestions().get(0).scoreDelta())
.isGreaterThan(result.suggestions().get(1).scoreDelta());
assertThat(result.suggestions().get(1).scoreDelta())
.isGreaterThan(result.suggestions().get(2).scoreDelta());
}
@Test
@@ -688,8 +858,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
assertThat(result.suggestions()).hasSize(2);
assertThat(result.suggestions().get(0).simulatedScore())
.isEqualTo(result.suggestions().get(1).simulatedScore());
assertThat(result.suggestions().get(0).scoreDelta())
.isEqualTo(result.suggestions().get(1).scoreDelta());
}
}
@@ -726,7 +896,7 @@ class SuggestionsTest {
addTag(c1, pastaTag);
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");
addTag(c2, chickenTag);
@@ -745,7 +915,7 @@ class SuggestionsTest {
stubPlan(plan);
stubDefaultConfig();
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)));
// Slot date = Wednesday (adjacent to Tuesday)
@@ -754,19 +924,20 @@ class SuggestionsTest {
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);
assertThat(topThree).extracting(s -> s.recipe().name())
.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).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).simulatedScore()).isEqualTo(9.0);
assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0);
}
@Test
@@ -800,7 +971,7 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1),
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().get(0).recipe().name()).isEqualTo("Quick Salad");
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta");
@@ -815,7 +986,7 @@ class SuggestionsTest {
class EdgeCases {
@Test
void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() {
void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() {
var plan = createPlan();
var existingRecipe = createRecipe("Existing");
addSlot(plan, existingRecipe, MONDAY);
@@ -832,7 +1003,8 @@ class SuggestionsTest {
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
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

View File

@@ -3,9 +3,11 @@ package com.recipeapp.planning;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.common.ValidationException;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.setup.MockMvcBuilders;
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
.build();
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void getWeekPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
@@ -153,7 +162,7 @@ class WeekPlanControllerTest {
@Test
void getSuggestionsShouldReturn200() throws Exception {
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));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
@@ -166,7 +175,8 @@ class WeekPlanControllerTest {
.param("slotDate", "2026-04-08"))
.andExpect(status().isOk())
.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
@@ -182,4 +192,79 @@ class WeekPlanControllerTest {
.andExpect(status().isOk())
.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

@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.setup.MockMvcBuilders;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
.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
void generateFromPlanShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();
var item = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes",
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID()));
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item));
new BigDecimal("4.00"), "pcs", false, null,
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(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
@Test
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(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
void checkItemShouldReturn200() throws Exception {
var response = new ShoppingListItemResponse(
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.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
void addItemShouldReturn201() throws Exception {
var response = new ShoppingListItemResponse(
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(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
.principal(() -> "sarah@example.com"))
.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.WeekPlanSlot;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Recipe;
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
@Mock private HouseholdRepository householdRepository;
@Mock private IngredientRepository ingredientRepository;
@Mock private UserAccountRepository userAccountRepository;
@Mock private RecipeRepository recipeRepository;
@InjectMocks private ShoppingService shoppingService;
@@ -90,6 +92,46 @@ class ShoppingServiceTest {
} 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 ──
@Test
@@ -119,26 +161,84 @@ class ShoppingServiceTest {
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);
setId(sl, ShoppingList.class, UUID.randomUUID());
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
var tomatoItem = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
assertThat(tomatoItem.sourceRecipes()).hasSize(2);
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
var cheeseItem = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
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
void generateFromPlanShouldThrowWhenPlanNotFound() {
var planId = UUID.randomUUID();
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
assertThat(result.id()).isEqualTo(list.getId());
assertThat(result.generatedAt()).isNotNull();
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
}
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
.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 ──
@Test
@@ -376,9 +568,11 @@ class ShoppingServiceTest {
// no slots added
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);
setId(sl, ShoppingList.class, UUID.randomUUID());
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});

View File

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

View File

@@ -39,7 +39,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.benutzer = {
id: user.id!,
name: user.displayName!,
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied'
};
event.locals.haushalt = {
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;
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": {
parameters: {
query?: never;
@@ -624,6 +640,11 @@ export interface components {
/** Format: uuid */
recipeId: string;
};
RecipeRef: {
/** Format: uuid */
id?: string;
name?: string;
};
ShoppingListItemResponse: {
/** Format: uuid */
id?: string;
@@ -636,13 +657,17 @@ export interface components {
isChecked?: boolean;
/** Format: uuid */
checkedBy?: string;
sourceRecipes?: string[];
sourceRecipes?: components["schemas"]["RecipeRef"][];
};
ShoppingListResponse: {
/** Format: uuid */
id?: string;
/** Format: uuid */
weekPlanId?: string;
/** Format: date-time */
generatedAt?: string;
/** Format: int32 */
filteredStaplesCount?: number;
items?: components["schemas"]["ShoppingListItemResponse"][];
};
TagCreateRequest: {
@@ -889,7 +914,8 @@ export interface components {
SuggestionItem: {
recipe?: components["schemas"]["SlotRecipe"];
/** Format: double */
simulatedScore?: number;
scoreDelta?: number;
hasConflict?: boolean;
};
SuggestionResponse: {
suggestions?: components["schemas"]["SuggestionItem"][];
@@ -1902,6 +1928,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: {
parameters: {
query?: {

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

@@ -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,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,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,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,22 @@
<script lang="ts">
interface Warning {
title: string;
explanation: string;
}
let { warnings }: { warnings: Warning[] } = $props();
</script>
{#each warnings as warning}
<div
data-testid="warning-card"
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
>
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
{warning.title}
</p>
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{warning.explanation}
</p>
</div>
{/each}

View File

@@ -0,0 +1,32 @@
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', explanation: 'Mo, Mi — erwäge einen Tausch.' },
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
];
describe('VarietyWarningCards', () => {
it('renders one card per warning', () => {
render(VarietyWarningCards, { props: { warnings } });
const cards = screen.getAllByTestId('warning-card');
expect(cards.length).toBe(2);
});
it('renders warning titles', () => {
render(VarietyWarningCards, { props: { warnings } });
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
});
it('renders warning explanations', () => {
render(VarietyWarningCards, { props: { warnings } });
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
});
it('renders nothing when warnings is empty', () => {
render(VarietyWarningCards, { props: { warnings: [] } });
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,12 @@
export interface Recipe {
id: string;
name: string;
effort?: string;
cookTimeMin?: number;
}
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>

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import IngredientList from './IngredientList.svelte';
const mockIngredients = [
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
{ ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
];
describe('IngredientList', () => {
it('renders the section heading', () => {
render(IngredientList, { props: { ingredients: mockIngredients } });
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
});
it('renders a row for each ingredient', () => {
render(IngredientList, { props: { ingredients: mockIngredients } });
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
expect(screen.getByText('Salz')).toBeInTheDocument();
});
it('renders quantity and unit when present', () => {
render(IngredientList, { props: { ingredients: mockIngredients } });
expect(screen.getByText('200 g')).toBeInTheDocument();
expect(screen.getByText('400 g')).toBeInTheDocument();
});
it('renders no quantity when not present', () => {
render(IngredientList, { props: { ingredients: mockIngredients } });
expect(screen.queryByText('undefined')).not.toBeInTheDocument();
});
it('has no remove buttons (read-only)', () => {
render(IngredientList, { props: { ingredients: mockIngredients } });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('renders empty state when ingredients array is empty', () => {
render(IngredientList, { props: { ingredients: [] } });
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
});
it('renders ingredients sorted by sortOrder', () => {
const unsorted = [
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
];
render(IngredientList, { props: { ingredients: unsorted } });
const spans = document.querySelectorAll('li span:last-child');
expect(spans[0].textContent).toBe('Spaghetti');
expect(spans[1].textContent).toBe('Hackfleisch');
expect(spans[2].textContent).toBe('Oregano');
});
});

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { RecipeSummary } from './types';
let { recipe, compact = false, onplan }: {
recipe: RecipeSummary;
compact?: boolean;
onplan?: ((recipeId: string, recipeName: string) => void);
} = $props();
let metadata = $derived(
[
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
recipe.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
</script>
<div class="rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]">
<a href="/recipes/{recipe.id}" class="block">
<div
data-testid="image-area"
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
>
{#if recipe.heroImageUrl}
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
{:else}
<div
data-testid="image-placeholder"
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="text-[var(--color-text-muted)] opacity-50"
>
<!-- plate -->
<circle cx="12" cy="13" r="6" />
<path d="M12 7V5" />
<!-- fork tines -->
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
</svg>
</div>
{/if}
</div>
<div class="px-2 py-1.5">
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
{#if metadata}
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
{/if}
</div>
</a>
{#if onplan}
<div class="flex gap-[5px] px-2 pb-2">
<a
href="/cook/{recipe.id}"
class="flex-1 text-center font-[var(--font-sans)] text-[10px] font-[500] py-[5px] px-[6px] rounded-[var(--radius-md)] bg-[var(--green)] text-white"
>🍳 Jetzt kochen</a>
<button
type="button"
onclick={() => onplan!(recipe.id, recipe.name)}
class="flex-1 text-center font-[var(--font-sans)] text-[10px] font-[500] py-[5px] px-[6px] rounded-[var(--radius-md)] bg-[var(--green-tint)] text-[var(--green-dark)] border border-[var(--green-light)]"
>📅 Zur Woche +</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import RecipeCard from './RecipeCard.svelte';
const mockRecipe = {
id: 'recipe-1',
name: 'Spaghetti Bolognese',
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined
};
describe('RecipeCard', () => {
it('renders the recipe name', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
});
it('renders cook time when present', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
expect(screen.getByText(/30/)).toBeInTheDocument();
});
it('renders effort when present', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
expect(screen.getByText(/easy/i)).toBeInTheDocument();
});
it('shows placeholder when no heroImageUrl', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
});
it('shows image when heroImageUrl is provided', () => {
render(RecipeCard, {
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
});
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
});
it('has a link to the recipe detail page', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
const link = screen.getByRole('link', { name: /Spaghetti Bolognese/i });
expect(link).toHaveAttribute('href', '/recipes/recipe-1');
});
it('shows Jetzt kochen link when onplan provided', () => {
render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } });
const cookLink = screen.getByRole('link', { name: /Jetzt kochen/i });
expect(cookLink).toHaveAttribute('href', '/cook/recipe-1');
});
it('does not show Jetzt kochen when onplan not provided', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
});
it('shows Zur Woche + button when onplan provided', () => {
render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } });
expect(screen.getByRole('button', { name: /Zur Woche/i })).toBeTruthy();
});
it('calls onplan with recipeId and name when Zur Woche + clicked', async () => {
const onplan = vi.fn();
const user = userEvent.setup();
render(RecipeCard, { props: { recipe: mockRecipe, onplan } });
await user.click(screen.getByRole('button', { name: /Zur Woche/i }));
expect(onplan).toHaveBeenCalledWith('recipe-1', 'Spaghetti Bolognese');
});
it('applies compact image height when compact prop is true', () => {
render(RecipeCard, { props: { recipe: mockRecipe, compact: true } });
const imageArea = document.querySelector('[data-testid="image-area"]');
expect(imageArea?.className).toContain('h-[64px]');
});
it('applies full image height when compact prop is false', () => {
render(RecipeCard, { props: { recipe: mockRecipe, compact: false } });
const imageArea = document.querySelector('[data-testid="image-area"]');
expect(imageArea?.className).toContain('h-[100px]');
});
});

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
type Category = { id: string; name: string; tagType?: string };
type EditRecipe = {
id: string;
name: string;
serves?: number;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
ingredients: { name: string; quantity: number; unit: string }[];
steps: { instruction: string }[];
tagIds: string[];
} | null;
const { recipe, categories, action }: {
recipe: EditRecipe;
categories: Category[];
action: string;
} = $props();
const effortOptions = [
{ label: 'Leicht', value: 'Easy' },
{ label: 'Mittel', value: 'Medium' },
{ label: 'Schwer', value: 'Hard' }
];
const initial = (() => $state.snapshot(recipe))();
let name = $state(initial?.name ?? '');
let serves = $state<number | ''>(initial?.serves ?? '');
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
let effort = $state(initial?.effort ?? '');
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
let ingredients = $state(
initial?.ingredients.map((ing) => ({
name: ing.name,
quantity: ing.quantity as number | '',
unit: ing.unit
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
);
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
</script>
<form method="POST" {action} use:enhance>
<!-- Error banner -->
{#if $page.form?.error}
<div
role="alert"
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
>
{$page.form.error}
</div>
{/if}
<!-- Two-column layout -->
<div class="md:flex md:gap-[32px]">
<!-- Left column: main form fields -->
<div class="md:flex-1">
<!-- Basic info -->
<div class="mb-[24px]">
<div class="mb-[16px]">
<label
for="name"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Name
</label>
<input
id="name"
name="name"
type="text"
bind:value={name}
required
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<div class="mb-[16px]">
<label
for="serves"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Portionen
</label>
<input
id="serves"
name="serves"
type="number"
bind:value={serves}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<div class="mb-[16px]">
<label
for="cookTimeMin"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Kochzeit
</label>
<input
id="cookTimeMin"
name="cookTimeMin"
type="number"
bind:value={cookTimeMin}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
</div>
<!-- Effort chips -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
Schwierigkeitsgrad
</p>
<div class="flex flex-wrap gap-[8px]">
{#each effortOptions as opt (opt.value)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
effort === opt.value
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="radio"
name="effort"
value={opt.value}
bind:group={effort}
class="sr-only"
/>
{opt.label}
</label>
{/each}
</div>
</div>
<!-- Ingredients -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
<div class="flex flex-col gap-[8px]">
{#each ingredients as ing, i (i)}
<div class="flex items-center gap-[8px]">
<input
type="number"
bind:value={ing.quantity}
placeholder="Menge"
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<input
type="text"
bind:value={ing.unit}
placeholder="Einheit"
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<input
type="text"
bind:value={ing.name}
placeholder="Zutat"
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<button
type="button"
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Entfernen
</button>
</div>
{/each}
</div>
<button
type="button"
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Zutat hinzufügen
</button>
</div>
<!-- Steps -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
<div class="flex flex-col gap-[12px]">
{#each steps as _, i (i)}
<div class="flex items-start gap-[12px]">
<span
class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
>
{i + 1}
</span>
<div class="flex flex-1 flex-col gap-[6px]">
<textarea
bind:value={steps[i]}
placeholder="Schritt beschreiben…"
rows="3"
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
></textarea>
<button
type="button"
onclick={() => (steps = steps.filter((_, j) => j !== i))}
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Entfernen
</button>
</div>
</div>
{/each}
</div>
<button
type="button"
onclick={() => (steps = [...steps, ''])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Schritt hinzufügen
</button>
</div>
</div>
<!-- Right panel: categories -->
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
<div
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
>
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
<div class="flex flex-wrap gap-[8px]">
{#each categories as cat (cat.id)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
selectedTagIds.includes(cat.id)
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="checkbox"
name="tagIds"
value={cat.id}
checked={selectedTagIds.includes(cat.id)}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedTagIds = [...selectedTagIds, cat.id];
} else {
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
}
}}
class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
</div>
</div>
</div>
<!-- Hidden inputs for form submission -->
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
<!-- Footer -->
<div class="mt-[32px] flex items-center justify-between">
<a
href="/recipes"
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Abbrechen
</a>
<button
type="submit"
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
>
Speichern
</button>
</div>
</form>

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import { writable } from 'svelte/store';
import RecipeForm from './RecipeForm.svelte';
vi.mock('$app/stores', () => ({
page: writable({ form: null, url: new URL('http://localhost/recipes/new') })
}));
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
const mockCategories = [
{ id: 'c1', name: 'Pasta', tagType: 'category' },
{ id: 'c2', name: 'Fleisch', tagType: 'category' }
];
const emptyProps = {
recipe: null,
categories: mockCategories,
action: '?/create'
};
const editProps = {
recipe: {
id: 'r1',
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Medium',
heroImageUrl: undefined as string | undefined,
ingredients: [
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
],
steps: [
{ instruction: 'Wasser aufsetzen' }
],
tagIds: ['c1']
},
categories: mockCategories,
action: '?/update'
};
describe('RecipeForm', () => {
it('renders recipe name input', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
});
it('renders serves input', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument();
});
it('renders cook time input', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument();
});
it('prefills name when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese');
});
it('prefills serves when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByLabelText(/portionen/i)).toHaveValue(4);
});
it('renders effort chips', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument();
});
it('prefills effort when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked();
});
it('renders category chips', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument();
});
it('prefills selected categories when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked();
});
it('renders at least one ingredient row initially for empty form', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument();
});
it('prefills ingredient rows when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument();
});
it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const before = screen.getAllByPlaceholderText(/zutat/i).length;
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1);
});
it('removes ingredient row when remove button is clicked', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: editProps });
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
const before = screen.getAllByPlaceholderText(/zutat/i).length;
const removeButtons = screen.getAllByRole('button', { name: /entfernen/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1);
});
it('renders at least one step row initially for empty form', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument();
});
it('prefills step rows when editing', () => {
render(RecipeForm, { props: editProps });
expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument();
});
it('adds step row when "Schritt hinzufügen" is clicked', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const before = screen.getAllByPlaceholderText(/schritt/i).length;
await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i }));
expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1);
});
it('renders save button', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument();
});
it('renders cancel link back to /recipes', () => {
render(RecipeForm, { props: emptyProps });
const cancelLink = screen.getByRole('link', { name: /abbrechen/i });
expect(cancelLink).toHaveAttribute('href', '/recipes');
});
it('displays form error message when $page.form.error is set', async () => {
const { page } = await import('$app/stores');
(page as ReturnType<typeof writable>).set({ form: { error: 'Name ist erforderlich' }, url: new URL('http://localhost/recipes/new') });
render(RecipeForm, { props: emptyProps });
expect(screen.getByRole('alert')).toHaveTextContent('Name ist erforderlich');
(page as ReturnType<typeof writable>).set({ form: null, url: new URL('http://localhost/recipes/new') });
});
it('does not display error banner when form has no error', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import RecipeCard from './RecipeCard.svelte';
import type { RecipeSummary } from './types';
let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props();
</script>
{#if recipes.length > 0}
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
{#each recipes as recipe (recipe.id)}
<RecipeCard {recipe} compact={true} {onplan} />
{/each}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-[48px] px-[24px] text-center">
<p class="text-[var(--color-text-muted)] text-[14px] mb-[16px]">Noch keine Rezepte vorhanden.</p>
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">
Rezept hinzufügen
</a>
</div>
{/if}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import RecipeGrid from './RecipeGrid.svelte';
const mockRecipes = [
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
{ id: 'r3', name: 'Caesar Salad', cookTimeMin: 15, effort: 'Easy' }
];
describe('RecipeGrid', () => {
it('renders a card for each recipe', () => {
render(RecipeGrid, { props: { recipes: mockRecipes } });
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
expect(screen.getByText('Caesar Salad')).toBeInTheDocument();
});
it('renders 3 links for 3 recipes', () => {
render(RecipeGrid, { props: { recipes: mockRecipes } });
expect(screen.getAllByRole('link')).toHaveLength(3);
});
it('shows empty state when recipes array is empty', () => {
render(RecipeGrid, { props: { recipes: [] } });
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
});
it('empty state links to recipe creation', () => {
render(RecipeGrid, { props: { recipes: [] } });
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
expect(addLink).toHaveAttribute('href', '/recipes/new');
});
it('grid has 2-col mobile and 4-col desktop classes', () => {
render(RecipeGrid, { props: { recipes: mockRecipes } });
const grid = document.querySelector('[data-testid="recipe-grid"]');
expect(grid?.className).toContain('grid-cols-2');
expect(grid?.className).toContain('lg:grid-cols-4');
});
});

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { Tag } from './types';
type RecipeHeroData = {
id: string;
name: string;
serves?: number;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
tags: Tag[];
};
let { recipe }: { recipe: RecipeHeroData } = $props();
let hasImage = $derived(!!recipe.heroImageUrl);
let pillBase = $derived(
hasImage
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
);
let cookBtnClass = $derived(
hasImage
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
);
</script>
<div
data-testid="recipe-hero"
class="min-h-[200px] md:min-h-[240px] {hasImage
? 'relative text-white'
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
>
{#if hasImage}
<img
src={recipe.heroImageUrl}
alt={recipe.name}
class="object-cover w-full h-full absolute inset-0"
/>
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
{/if}
<div class="relative">
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
{recipe.name}
</h1>
<div class="flex gap-[8px] flex-wrap mt-[12px]">
{#if recipe.cookTimeMin != null}
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
{/if}
{#if recipe.effort}
<span class={pillBase}>{recipe.effort}</span>
{/if}
{#if recipe.serves != null}
<span class={pillBase}>{recipe.serves} Port.</span>
{/if}
{#each recipe.tags as tag (tag.id)}
<span class={pillBase}>{tag.name}</span>
{/each}
</div>
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
</div>
</div>

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import RecipeHero from './RecipeHero.svelte';
const baseRecipe = {
id: 'r1',
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined as string | undefined,
tags: [] as { id: string; name: string; tagType?: string }[]
};
describe('RecipeHero', () => {
it('renders the recipe name', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
});
it('renders green-tint hero when no image', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
const hero = document.querySelector('[data-testid="recipe-hero"]');
expect(hero?.className).toContain('bg-[var(--green-tint)]');
});
it('renders image when heroImageUrl is provided', () => {
render(RecipeHero, {
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
});
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
});
it('renders cook time pill', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
});
it('renders effort pill', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
expect(screen.getByText(/Easy/)).toBeInTheDocument();
});
it('renders serves pill', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
expect(screen.getByText(/4/)).toBeInTheDocument();
});
it('renders back link to /recipes', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
const backLink = screen.getByRole('link', { name: /zurück/i });
expect(backLink).toHaveAttribute('href', '/recipes');
});
it('renders cook now link to /cook/[id]', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
expect(cookLink).toHaveAttribute('href', '/cook/r1');
});
it('does not render img when no heroImageUrl', () => {
render(RecipeHero, { props: { recipe: baseRecipe } });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('renders tag pills', () => {
render(RecipeHero, {
props: {
recipe: {
...baseRecipe,
tags: [
{ id: 't1', name: 'Pasta' },
{ id: 't2', name: 'Italienisch' }
]
}
}
});
expect(screen.getByText('Pasta')).toBeInTheDocument();
expect(screen.getByText('Italienisch')).toBeInTheDocument();
});
it('renders no tag pills when tags array is empty', () => {
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { Step } from './types';
let { steps }: { steps: Step[] } = $props();
const sortedSteps = $derived(
[...steps].sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
);
</script>
<section>
<h2
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
>
Zubereitung
</h2>
<ol>
{#each sortedSteps as step (step.stepNumber)}
<li class="flex gap-[16px] items-start mb-[20px]">
<div
data-testid="step-circle"
aria-hidden="true"
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
>
{step.stepNumber}
</div>
<p class="text-[14px] text-[var(--color-text)] leading-[1.6] pt-[4px]">
{step.instruction}
</p>
</li>
{/each}
</ol>
</section>

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import StepList from './StepList.svelte';
const mockSteps = [
{ stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
{ stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
{ stepNumber: 3, instruction: 'Sauce bereiten' }
];
describe('StepList', () => {
it('renders the section heading', () => {
render(StepList, { props: { steps: mockSteps } });
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
});
it('renders each step instruction', () => {
render(StepList, { props: { steps: mockSteps } });
expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
});
it('renders step numbers', () => {
render(StepList, { props: { steps: mockSteps } });
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('renders numbered circles with step numbers', () => {
render(StepList, { props: { steps: mockSteps } });
const circles = document.querySelectorAll('[data-testid="step-circle"]');
expect(circles).toHaveLength(3);
});
it('renders steps in stepNumber order', () => {
const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
render(StepList, { props: { steps: shuffled } });
const circles = document.querySelectorAll('[data-testid="step-circle"]');
expect(circles[0].textContent).toBe('1');
expect(circles[1].textContent).toBe('2');
expect(circles[2].textContent).toBe('3');
});
it('renders empty state when no steps', () => {
render(StepList, { props: { steps: [] } });
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,38 @@
export type RecipeSummary = {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
};
export type Tag = {
id: string;
name: string;
tagType?: string;
};
export type Ingredient = {
ingredientId?: string;
name?: string;
quantity?: number;
unit?: string;
sortOrder?: number;
};
export type Step = {
stepNumber?: number;
instruction?: string;
};
export type RecipeDetail = {
id: string;
name: string;
serves?: number;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
ingredients: Ingredient[];
steps: Step[];
tags: Tag[];
};

View File

@@ -0,0 +1,65 @@
import { apiClient } from '$lib/server/api';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isValidUuid(value: string | null): value is string {
return typeof value === 'string' && UUID_RE.test(value);
}
export async function addSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
const formData = await request.formData();
const planId = formData.get('planId') as string | null;
const slotDate = formData.get('slotDate') as string | null;
const recipeId = formData.get('recipeId') as string | null;
if (!isValidUuid(planId) || !isValidUuid(recipeId) || !slotDate) {
return { success: false, error: 'Ungültige Eingabe.' };
}
const api = apiClient(fetch);
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
params: { path: { id: planId } },
body: { slotDate, recipeId }
});
if (error || !data) return { success: false };
return { success: true, slot: data };
}
export async function updateSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
const formData = await request.formData();
const planId = formData.get('planId') as string | null;
const slotId = formData.get('slotId') as string | null;
const recipeId = formData.get('recipeId') as string | null;
if (!isValidUuid(planId) || !isValidUuid(slotId) || !isValidUuid(recipeId)) {
return { success: false, error: 'Ungültige Eingabe.' };
}
const api = apiClient(fetch);
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
params: { path: { planId, slotId } },
body: { recipeId }
});
if (error || !data) return { success: false };
return { success: true, slot: data };
}
export async function deleteSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
const formData = await request.formData();
const planId = formData.get('planId') as string | null;
const slotId = formData.get('slotId') as string | null;
if (!isValidUuid(planId) || !isValidUuid(slotId)) {
return { success: false, error: 'Ungültige Eingabe.' };
}
const api = apiClient(fetch);
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
params: { path: { planId, slotId } }
});
if (error) return { success: false };
return { success: true };
}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
listId: string;
}
let { listId }: Props = $props();
let expanded = $state(false);
let customName = $state('');
let quantity = $state('1');
let unit = $state('');
</script>
{#if !expanded}
<button
type="button"
onclick={() => (expanded = true)}
class="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:border-[var(--green-light)] hover:text-[var(--color-text)]"
>
<span class="text-[16px]">+</span>
Artikel hinzufügen
</button>
{:else}
<form
method="POST"
action="?/addItem"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
await update();
customName = '';
quantity = '1';
unit = '';
expanded = false;
}
};
}}
class="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
>
<input type="hidden" name="listId" value={listId} />
<input
type="text"
name="customName"
bind:value={customName}
placeholder="Artikelname"
required
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
/>
<div class="flex gap-2">
<input
type="number"
name="quantity"
bind:value={quantity}
min="0"
step="any"
class="w-20 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] focus:border-[var(--green)] focus:outline-none"
/>
<input
type="text"
name="unit"
bind:value={unit}
placeholder="Einheit"
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (expanded = false)}
class="rounded-[var(--radius-md)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!customName.trim()}
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] font-medium text-white disabled:opacity-50"
>
Hinzufügen
</button>
</div>
</form>
{/if}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import AddCustomItem from './AddCustomItem.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('AddCustomItem', () => {
it('shows collapsed trigger button initially', () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
expect(screen.getByText(/Artikel hinzufügen/)).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
});
it('expands form when trigger button is clicked', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
});
it('collapses form when Abbrechen is clicked', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
await userEvent.click(screen.getByText('Abbrechen'));
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
});
it('submit button is disabled when name is empty', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByRole('button', { name: /Hinzufügen/i })).toBeDisabled();
});
it('submit button is enabled when name is entered', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
await userEvent.type(screen.getByPlaceholderText('Artikelname'), 'Papier');
expect(screen.getByRole('button', { name: /Hinzufügen/i })).not.toBeDisabled();
});
it('form submits to ?/addItem action', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
expect(form).toHaveAttribute('action', '?/addItem');
});
it('form includes listId as hidden input', async () => {
render(AddCustomItem, { props: { listId: 'list-42' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
expect(listIdInput.value).toBe('list-42');
});
});

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface RecipeRef {
id?: string;
name?: string;
}
interface Props {
listId: string;
itemId: string;
name: string;
quantity: number | null;
unit: string | null;
isChecked: boolean;
sourceRecipes: RecipeRef[];
}
let { listId, itemId, name, quantity, unit, isChecked, sourceRecipes }: Props = $props();
let recipeLabel = $derived(
sourceRecipes.length > 0
? sourceRecipes
.map((r) => r.name)
.filter(Boolean)
.join(', ')
: null
);
let quantityLabel = $derived(
quantity ? `${quantity}${unit ? ` ${unit}` : ''}` : null
);
</script>
<form method="POST" action="?/check" use:enhance={() => async ({ update }) => update({ reset: false })} class="group flex items-center gap-3 py-2">
<input type="hidden" name="listId" value={listId} />
<input type="hidden" name="itemId" value={itemId} />
<input type="hidden" name="isChecked" value={!isChecked} />
<button
type="submit"
role="checkbox"
aria-checked={isChecked}
aria-label="{isChecked ? 'Abhaken rückgängig' : 'Abhaken'}: {name}"
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--green)]
{isChecked
? 'border-[var(--green)] bg-[var(--green)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--green-light)]'}"
>
{#if isChecked}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 6l3 3 5-5" />
</svg>
{/if}
</button>
<div class="min-w-0 flex-1">
<p
class="font-[var(--font-sans)] text-[14px] {isChecked
? 'text-[var(--color-text-muted)] line-through'
: 'text-[var(--color-text)]'}"
>
{name}
</p>
{#if recipeLabel && !isChecked}
<p class="truncate font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
Für: {recipeLabel}
</p>
{/if}
</div>
{#if quantityLabel}
<span
class="flex-shrink-0 font-[var(--font-sans)] text-[13px] {isChecked
? 'text-[var(--color-text-muted)]'
: 'text-[var(--color-text)]'}"
>
{quantityLabel}
</span>
{/if}
</form>

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ChecklistItem from './ChecklistItem.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('ChecklistItem', () => {
const baseProps = {
listId: 'list-1',
itemId: 'item-1',
name: 'Tomaten',
quantity: null,
unit: null,
isChecked: false,
sourceRecipes: []
};
it('renders the item name', () => {
render(ChecklistItem, { props: baseProps });
expect(screen.getByText('Tomaten')).toBeInTheDocument();
});
it('renders quantity and unit when provided', () => {
render(ChecklistItem, { props: { ...baseProps, quantity: 3, unit: 'Stück' } });
expect(screen.getByText('3 Stück')).toBeInTheDocument();
});
it('renders quantity without unit when unit is null', () => {
render(ChecklistItem, { props: { ...baseProps, quantity: 2, unit: null } });
expect(screen.getByText('2')).toBeInTheDocument();
});
it('applies line-through style when checked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
const nameEl = screen.getByText('Tomaten');
expect(nameEl.className).toContain('line-through');
});
it('does not apply line-through when unchecked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
const nameEl = screen.getByText('Tomaten');
expect(nameEl.className).not.toContain('line-through');
});
it('shows recipe label for source recipes when unchecked', () => {
render(ChecklistItem, {
props: {
...baseProps,
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
}
});
expect(screen.getByText(/Für: Spaghetti/)).toBeInTheDocument();
});
it('hides recipe label when item is checked', () => {
render(ChecklistItem, {
props: {
...baseProps,
isChecked: true,
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
}
});
expect(screen.queryByText(/Für:/)).not.toBeInTheDocument();
});
it('sets aria-checked to false when unchecked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
const button = screen.getByRole('checkbox');
expect(button).toHaveAttribute('aria-checked', 'false');
});
it('sets aria-checked to true when checked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
const button = screen.getByRole('checkbox');
expect(button).toHaveAttribute('aria-checked', 'true');
});
it('passes listId and itemId as hidden inputs', () => {
render(ChecklistItem, { props: baseProps });
const form = screen.getByRole('checkbox').closest('form')!;
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
const itemIdInput = form.querySelector('input[name="itemId"]') as HTMLInputElement;
expect(listIdInput.value).toBe('list-1');
expect(itemIdInput.value).toBe('item-1');
});
});

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { formatDayAbbr } from '$lib/planner/week';
interface Slot {
slotDate?: string;
recipe?: {
id?: string;
name?: string;
};
}
interface Props {
slots: Slot[];
filteredStaplesCount: number;
}
let { slots, filteredStaplesCount }: Props = $props();
let filledSlots = $derived(slots.filter((s) => s.recipe));
</script>
<aside class="flex flex-col gap-4">
<div>
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Rezepte dieser Woche
</h2>
<div class="mt-2 space-y-1.5">
{#each filledSlots as slot}
<a
href="/recipes/{slot.recipe?.id}"
class="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2 hover:border-[var(--green-light)]"
>
<span class="min-w-[28px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : ''}
</span>
<span class="flex-1 truncate font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{slot.recipe?.name}
</span>
</a>
{/each}
{#if filledSlots.length === 0}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
Keine Gerichte geplant.
</p>
{/if}
</div>
</div>
{#if filteredStaplesCount > 0}
<div class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2">
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{filteredStaplesCount} Grundzutaten automatisch ausgeblendet
</p>
<a
href="/pantry"
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] hover:underline"
>
Vorrat bearbeiten
</a>
</div>
{/if}
</aside>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import ChecklistItem from '$lib/shopping/ChecklistItem.svelte';
import AddCustomItem from '$lib/shopping/AddCustomItem.svelte';
interface RecipeRef {
id?: string;
name?: string;
}
interface Item {
id?: string;
name?: string;
quantity?: number;
unit?: string;
isChecked?: boolean;
sourceRecipes?: RecipeRef[];
}
interface Props {
listId: string;
uncheckedItems: Item[];
checkedItems: Item[];
totalItems: number;
filteredStaplesCount?: number;
showFilteredStaples?: boolean;
}
let {
listId,
uncheckedItems,
checkedItems,
totalItems,
filteredStaplesCount = 0,
showFilteredStaples = false
}: Props = $props();
let checkedCount = $derived(checkedItems.length);
</script>
{#if uncheckedItems.length > 0}
<div class="divide-y divide-[var(--color-border)]">
{#each uncheckedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={false}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
{:else if totalItems > 0}
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Alles erledigt!
</p>
{/if}
<div class="mt-3">
<AddCustomItem {listId} />
</div>
{#if showFilteredStaples && filteredStaplesCount > 0}
<div class="mt-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{filteredStaplesCount} Grundzutaten ausgeblendet ·
<a href="/pantry" class="font-medium text-[var(--green-dark)] hover:underline">Vorrat bearbeiten</a>
</p>
</div>
{/if}
{#if checkedItems.length > 0}
<div class="mt-4">
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Abgehakt ({checkedCount})
</p>
<div class="divide-y divide-[var(--color-border)]">
{#each checkedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={true}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
totalItems: number;
checkedCount: number;
generatedAt: string | null;
weekPlanId: string | null;
isPlanner: boolean;
hasShoppingList: boolean;
}
let { totalItems, checkedCount, generatedAt, weekPlanId, isPlanner, hasShoppingList }: Props = $props();
let remainingCount = $derived(totalItems - checkedCount);
let generating = $state(false);
let formattedTime = $derived(
generatedAt
? new Date(generatedAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: null
);
</script>
<header class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
Einkaufsliste
</h1>
{#if isPlanner && weekPlanId}
<form method="POST" action="?/generate" use:enhance={() => {
generating = true;
return async ({ update }) => {
await update();
generating = false;
};
}}>
<input type="hidden" name="weekPlanId" value={weekPlanId} />
<button
type="submit"
disabled={generating}
class="rounded-[var(--radius-md)] {hasShoppingList
? 'border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]'
: 'bg-[var(--green-dark)] text-white'} px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] disabled:opacity-50"
>
{generating ? '…' : hasShoppingList ? 'Neu generieren' : 'Liste generieren'}
</button>
</form>
{/if}
</div>
{#if hasShoppingList}
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{remainingCount} Artikel übrig · {checkedCount} abgehakt
{#if formattedTime}
<span class="ml-1">· erstellt {formattedTime}</span>
{/if}
</p>
{/if}
</header>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ShoppingHeader from './ShoppingHeader.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('ShoppingHeader', () => {
const baseProps = {
totalItems: 0,
checkedCount: 0,
generatedAt: null,
weekPlanId: null,
isPlanner: false,
hasShoppingList: false
};
it('renders the heading', () => {
render(ShoppingHeader, { props: baseProps });
expect(screen.getByText('Einkaufsliste')).toBeInTheDocument();
});
it('shows counts when hasShoppingList is true', () => {
render(ShoppingHeader, {
props: { ...baseProps, totalItems: 5, checkedCount: 2, hasShoppingList: true }
});
expect(screen.getByText(/3 Artikel übrig/)).toBeInTheDocument();
expect(screen.getByText(/2 abgehakt/)).toBeInTheDocument();
});
it('does not show counts when hasShoppingList is false', () => {
render(ShoppingHeader, { props: { ...baseProps, hasShoppingList: false } });
expect(screen.queryByText(/Artikel übrig/)).not.toBeInTheDocument();
});
it('shows generate button for planner with weekPlanId', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1' }
});
expect(screen.getByRole('button', { name: /Liste generieren/i })).toBeInTheDocument();
});
it('shows regenerate button when planner already has a list', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1', hasShoppingList: true, totalItems: 3, checkedCount: 0 }
});
expect(screen.getByRole('button', { name: /Neu generieren/i })).toBeInTheDocument();
});
it('hides generate button for non-planner', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: false, weekPlanId: 'plan-1' }
});
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
});
it('hides generate button when weekPlanId is null', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: null }
});
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
});
it('shows formatted timestamp when generatedAt is provided', () => {
render(ShoppingHeader, {
props: { ...baseProps, hasShoppingList: true, generatedAt: '2026-04-06T10:30:00Z', totalItems: 1, checkedCount: 0 }
});
expect(screen.getByText(/erstellt/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week');
const weekStart = weekParam ?? getWeekStart(new Date());
const api = apiClient(fetch);
const [weekPlanResult, recipesResult] = await Promise.all([
api.GET('/v1/week-plans', { params: { query: { weekStart } } }),
api.GET('/v1/recipes', {})
]);
const recipes =
recipesResult.error || !recipesResult.data?.data
? []
: recipesResult.data.data.map((r: any) => ({
id: r.id!,
name: r.name!,
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl
}));
if (weekPlanResult.error || !weekPlanResult.data?.id) {
return { weekPlan: null, varietyScore: null, weekStart, recipes };
}
const weekPlan = weekPlanResult.data;
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
params: { path: { id: weekPlan.id! } }
});
return {
weekPlan,
varietyScore: varietyScore ?? null,
weekStart,
recipes
};
};
export const actions: Actions = {
addSlot: addSlotAction,
updateSlot: updateSlotAction,
deleteSlot: deleteSlotAction,
createPlan: async ({ fetch, request, locals }) => {
// Role guard: only planners may create week plans
if (locals.benutzer?.rolle !== 'planer') {
return { success: false, error: 'Keine Berechtigung.' };
}
const formData = await request.formData();
const weekStart = formData.get('weekStart') as string;
// Validate weekStart format: must be YYYY-MM-DD
if (!weekStart || !/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) {
return { success: false, error: 'Ungültiges Datum.' };
}
const api = apiClient(fetch);
const { data, error } = await api.POST('/v1/week-plans', {
body: { weekStart }
});
if (error || !data) {
return { success: false, error: 'Plan konnte nicht erstellt werden.' };
}
return { success: true };
}
};

View File

@@ -1 +1,720 @@
<h1 class="text-2xl font-medium p-6">Planer</h1>
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
import type { Suggestion } from '$lib/planner/types';
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
// Use UTC date string (YYYY-MM-DD) consistently
const today: string = new Date().toISOString().slice(0, 10);
let weekStart = $derived(data.weekStart);
let weekPlan = $derived(data.weekPlan);
let varietyScore = $derived(data.varietyScore);
let days = $derived(weekDays(weekStart));
let slots = $derived(weekPlan?.slots ?? []);
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
// Default selected day: today if in this week, else first day
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
let selectedDay = $state((() => {
const init = data.weekStart;
const d = weekDays(init);
return d.includes(today) ? today : d[0];
})());
// When week changes via navigation, reset selected day
$effect(() => {
const newDays = weekDays(weekStart);
if (!newDays.includes(selectedDay)) {
selectedDay = newDays.includes(today) ? today : newDays[0];
}
});
let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null });
let remainingSlots = $derived(days.filter((d: string) => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
let remainingSlotsWithMeal = $derived(remainingSlots.filter((s: any) => s.recipe));
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
let weekRange = $derived(formatWeekRange(weekStart));
// Desktop right panel state machine
type PanelState =
| { kind: 'idle' }
| { kind: 'day-detail'; date: string }
| { kind: 'recipe-picker'; date: string };
let panelState = $state<PanelState>({ kind: 'idle' });
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false);
let actionSheetOpen = $state(false);
let swapSheetOpen = $state(false);
let swapLoading = $state(false);
const activePickerDate = $derived(
pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date
: null
);
let suggestions: Suggestion[] = $state([]);
let isLoadingSuggestions = $state(false);
// Recipes already in any slot this week — used for ⚠ overlap warnings
let currentWeekRecipeIds = $derived(
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
);
// Hidden form field bindings
let addPlanId = $state('');
let addSlotDate = $state('');
let addRecipeId = $state('');
let addRecipeName = $state('');
let updPlanId = $state('');
let updSlotId = $state('');
let updRecipeId = $state('');
let updRecipeName = $state('');
let delPlanId = $state('');
let delSlotId = $state('');
let addSlotFormEl: HTMLFormElement;
let updateSlotFormEl: HTMLFormElement;
let deleteSlotFormEl: HTMLFormElement;
// UndoBar
let undoVisible = $state(false);
let undoMessage = $state('');
let undoCallback = $state<(() => void) | null>(null);
$effect(() => {
if (!activePickerDate || !weekPlan?.id) {
suggestions = [];
isLoadingSuggestions = false;
return;
}
const controller = new AbortController();
isLoadingSuggestions = true;
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
.then((r) => r.json())
.then((d) => { suggestions = d.suggestions ?? []; })
.catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
.finally(() => { isLoadingSuggestions = false; });
return () => controller.abort();
});
function handleSelectDay(day: string) {
selectedDay = day;
panelState = { kind: 'day-detail', date: day };
}
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
let newWeekStart: string;
if (direction === 'prev') newWeekStart = prevWeek(weekStart);
else if (direction === 'next') newWeekStart = nextWeek(weekStart);
else newWeekStart = getWeekStart(new Date());
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
}
async function handleRecipePick(recipeId: string, recipeName: string) {
// Capture date before modifying panel state
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
// Close pickers
pickerOpen = false;
if (panelState.kind === 'recipe-picker') {
panelState = { kind: 'idle' };
}
const existingSlot = slotMap[date];
if (existingSlot?.id) {
updPlanId = weekPlan!.id;
updSlotId = existingSlot.id;
updRecipeId = recipeId;
updRecipeName = recipeName;
await tick();
updateSlotFormEl.requestSubmit();
} else {
addPlanId = weekPlan!.id;
addSlotDate = date;
addRecipeId = recipeId;
addRecipeName = recipeName;
await tick();
addSlotFormEl.requestSubmit();
}
}
function handleUndo() {
undoVisible = false;
undoCallback?.();
}
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
// Capture primitive values immediately — slot may be a reactive proxy that
// becomes stale after the first await (tick flushes state + re-render).
const slotId = slot.id;
const slotDate = slot.slotDate;
const recipeName = slot.recipe?.name ?? '';
const recipeId = slot.recipe?.id ?? '';
if (!slotId || !recipeId) return;
actionSheetOpen = false;
undoCallback = async () => {
addPlanId = weekPlan!.id;
addSlotDate = slotDate;
addRecipeId = recipeId;
addRecipeName = recipeName;
await tick();
addSlotFormEl.requestSubmit();
};
delPlanId = weekPlan!.id;
delSlotId = slotId;
await tick();
deleteSlotFormEl.requestSubmit();
undoMessage = `${recipeName} entfernt`;
undoVisible = true;
}
async function handleSwapPick(recipeId: string, recipeName: string) {
swapLoading = true;
await handleRecipePick(recipeId, recipeName);
swapSheetOpen = false;
swapLoading = false;
}
function closePanelToIdle() {
panelState = { kind: 'idle' };
}
function closePanelToDayDetail() {
if (panelState.kind === 'recipe-picker') {
panelState = { kind: 'day-detail', date: panelState.date };
} else {
panelState = { kind: 'idle' };
}
}
</script>
<!-- Mobile & Tablet: vertical stack -->
<div class="flex h-full flex-col lg:hidden">
<!-- Top nav (sticky) -->
<header class="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Diese Woche</h1>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => navigateWeek('prev')}
aria-label="Vorherige Woche"
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
<button
type="button"
onclick={() => navigateWeek('next')}
aria-label="Nächste Woche"
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
{#if isPlanner}
<button
type="button"
onclick={() => (pickerOpen = true)}
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
+ Gericht
</button>
{/if}
</div>
</header>
<!-- Variety banner: sticky below the top nav so it's always visible (spec requirement) -->
{#if varietyScore}
<div class="sticky z-10 px-4 pt-3" style="top: 56px;">
<VarietyScoreCard
score={varietyScore.score ?? 0}
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
showReviewLink={false}
/>
</div>
{/if}
<!-- Day strip -->
<div class="px-4 pt-3">
<WeekStrip
{weekStart}
{slots}
{selectedDay}
{today}
onselectDay={handleSelectDay}
/>
</div>
<!-- Selected day card -->
<div class="px-4 pt-4">
<p class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(selectedDay)}
</p>
<DayMealCard
slot={selectedSlot}
isToday={selectedDay === today}
isSelected={true}
readonly={!isPlanner}
onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
/>
</div>
<!-- Remaining days list -->
{#if remainingSlotsWithMeal.length > 0}
<div class="px-4 pt-6 pb-4">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Restliche Woche
</h2>
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
{#each remainingSlotsWithMeal as slot (slot.slotDate)}
<button
type="button"
onclick={() => handleSelectDay(slot.slotDate)}
class="flex w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left hover:border-[var(--green-light)]"
>
<span class="min-w-[36px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{formatDayLabel(slot.slotDate).split(',')[0]}
</span>
<span class="flex-1 truncate font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)]">
{slot.recipe?.name}
</span>
{#if isPlanner}
<span class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
<!-- Empty week state -->
{#if !weekPlan}
<div class="flex flex-1 flex-col items-center justify-center px-4 py-8 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
{#if isPlanner}
<form method="POST" action="?/createPlan" class="mt-4">
<input type="hidden" name="weekStart" value={weekStart} />
<button
type="submit"
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
Wochenplan erstellen
</button>
</form>
{/if}
</div>
{/if}
<!-- Mobile: empty slot → RecipePicker -->
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
<RecipePicker
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
/>
</BottomSheet>
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
<MealActionSheet
open={actionSheetOpen}
slot={selectedSlot}
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
oncancel={() => (actionSheetOpen = false)}
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
/>
<!-- Mobile: swap suggestions sheet -->
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
{@const replacingMeta = [
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
selectedSlot.recipe?.effort ?? null
].filter(Boolean).join(' · ')}
<RecipePicker
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
isDisabled={swapLoading}
excludeRecipeId={selectedSlot.recipe?.id}
replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
onpick={handleSwapPick}
/>
</BottomSheet>
</div>
<!-- Desktop: 3-panel layout -->
<div class="hidden h-screen lg:flex lg:flex-col">
<!-- Topbar -->
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Wochenplaner</h1>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => navigateWeek('prev')}
aria-label="Vorherige Woche"
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{weekRange}</span>
<button
type="button"
onclick={() => navigateWeek('next')}
aria-label="Nächste Woche"
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
<button
type="button"
onclick={() => navigateWeek('today')}
class="flex min-h-[40px] items-center rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Heute
</button>
</div>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
+ Gericht hinzufügen
</button>
{/if}
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left sidebar -->
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<!-- Variety widget at bottom -->
{#if varietyScore}
<div class="mt-auto">
<VarietyScoreCard
score={varietyScore.score ?? 0}
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
showReviewLink={true}
/>
</div>
{/if}
</aside>
<!-- Main calendar (only scrollable panel) -->
<main class="flex-1 overflow-y-auto p-5">
{#if !weekPlan}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
{#if isPlanner}
<form method="POST" action="?/createPlan" class="mt-4">
<input type="hidden" name="weekStart" value={weekStart} />
<button type="submit" class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white">
Wochenplan erstellen
</button>
</form>
{/if}
</div>
{:else}
<div class="grid grid-cols-7 gap-[8px]">
{#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today}
{@const isSelectedDay = day === selectedDay}
{@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
<div class="flex flex-col">
<!-- Column header: day name + date badge -->
<div class="mb-2 flex flex-col items-center gap-1">
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
{dayAbbr}
</p>
<div
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
>
{dateNum}
</div>
</div>
<!-- Meal tile -->
<button
type="button"
onclick={() => {
handleSelectDay(day);
if (!slot.recipe && isPlanner) {
panelState = { kind: 'recipe-picker', date: day };
}
}}
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
>
{#if slot.recipe}
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
{slot.recipe.name}
</p>
{:else}
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
<span class="text-[18px]" aria-hidden="true">+</span>
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
</div>
{/if}
</button>
</div>
{/each}
</div>
{/if}
</main>
<!-- Right detail panel -->
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
{#if panelState.kind === 'idle'}
<div class="flex flex-1 flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
</div>
{:else if panelState.kind === 'day-detail'}
{@const detailDate = panelState.date}
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
<!-- Panel header with close button -->
<div class="mb-3 flex items-start justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(detailDate)} · Abendessen
</p>
<button
type="button"
onclick={closePanelToIdle}
aria-label="Panel schließen"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
{#if detailSlot.recipe}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
{detailSlot.recipe.name}
</h2>
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
</p>
{/if}
<div class="mt-4 space-y-2">
<a
href="/recipes/{detailSlot.recipe.id}"
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Rezept ansehen
</a>
<a
href="/recipes/{detailSlot.recipe.id}/cook"
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
Koch-Modus
</a>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Gericht tauschen
</button>
{#if detailSlot.id}
<button
type="button"
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
>
Entfernen
</button>
{/if}
{/if}
</div>
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
>
+ Gericht wählen
</button>
{/if}
{/if}
{:else if panelState.kind === 'recipe-picker'}
{@const pickerDate = panelState.date}
{@const pickerSlot = slotMap[pickerDate] ?? null}
{@const isSwapContext = !!pickerSlot?.recipe}
<!-- Panel header with back/close button -->
<div class="mb-3 flex items-center justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
</p>
<button
type="button"
onclick={closePanelToDayDetail}
aria-label="Zurück"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
{#if isSwapContext}
{@const replacingMeta = [
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
pickerSlot.recipe.effort ?? null
].filter(Boolean).join(' · ')}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
excludeRecipeId={pickerSlot.recipe.id}
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
onpick={handleRecipePick}
/>
</div>
{:else}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
/>
</div>
{/if}
{/if}
</aside>
</div>
</div>
<!-- Hidden forms for slot mutations -->
<div class="hidden">
<!-- Add slot -->
<form
method="POST"
action="?/addSlot"
bind:this={addSlotFormEl}
use:enhance={({ formData }) => {
formData.set('planId', addPlanId);
formData.set('slotDate', addSlotDate);
formData.set('recipeId', addRecipeId);
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.success) {
const slotId = (result.data as any)?.slot?.id ?? '';
delPlanId = addPlanId;
delSlotId = slotId;
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${addRecipeName} hinzugefügt`;
undoVisible = true;
}
await update({ reset: false });
await invalidateAll();
};
}}
>
<input type="hidden" name="planId" value={addPlanId} />
<input type="hidden" name="slotDate" value={addSlotDate} />
<input type="hidden" name="recipeId" value={addRecipeId} />
</form>
<!-- Update slot -->
<form
method="POST"
action="?/updateSlot"
bind:this={updateSlotFormEl}
use:enhance={({ formData }) => {
formData.set('planId', updPlanId);
formData.set('slotId', updSlotId);
formData.set('recipeId', updRecipeId);
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.success) {
delPlanId = updPlanId;
delSlotId = (result.data as any)?.slot?.id ?? '';
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${updRecipeName} eingetragen`;
undoVisible = true;
}
await update({ reset: false });
await invalidateAll();
};
}}
>
<input type="hidden" name="planId" value={updPlanId} />
<input type="hidden" name="slotId" value={updSlotId} />
<input type="hidden" name="recipeId" value={updRecipeId} />
</form>
<!-- Delete slot (for undo) -->
<form
method="POST"
action="?/deleteSlot"
bind:this={deleteSlotFormEl}
use:enhance={({ formData }) => {
formData.set('planId', delPlanId);
formData.set('slotId', delSlotId);
return async ({ update }) => {
await update({ reset: false });
await invalidateAll();
};
}}
>
<input type="hidden" name="planId" value={delPlanId} />
<input type="hidden" name="slotId" value={delSlotId} />
</form>
</div>
<!-- Undo toast -->
<UndoBar
visible={undoVisible}
message={undoMessage}
onundo={handleUndo}
ondismiss={() => (undoVisible = false)}
/>

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
// GET /planner?planId=&date= — returns suggestions JSON for C4 recipe picker
export const GET: RequestHandler = async ({ fetch, url }) => {
const planId = url.searchParams.get('planId');
const date = url.searchParams.get('date');
if (!planId || !date) {
return json({ suggestions: [] });
}
try {
const api = apiClient(fetch);
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
params: { path: { id: planId }, query: { slotDate: date, topN: 100 } }
});
const suggestions = (data?.suggestions ?? []).sort(
(a: any, b: any) => (b.scoreDelta ?? 0) - (a.scoreDelta ?? 0)
);
return json({ suggestions });
} catch {
return json({ suggestions: [] });
}
};

View File

@@ -0,0 +1,370 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPatch = vi.fn();
const mockDelete = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete })
}));
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
const SLOT_UUID = '22222222-2222-2222-2222-222222222222';
const RECIPE_UUID = '33333333-3333-3333-3333-333333333333';
const mockWeekPlan = {
id: PLAN_UUID,
weekStart: '2026-03-30',
status: 'draft',
slots: [
{ id: SLOT_UUID, slotDate: '2026-03-30', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } }
]
};
const mockRecipes = [{ id: RECIPE_UUID, name: 'Pasta', cookTimeMin: 30, effort: 'Easy' }];
const mockVarietyScore = { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] };
describe('planner page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('fetches week plan for the current week by default', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore
const url = new URL('http://localhost/planner');
await load({ fetch: vi.fn(), url });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) }));
});
it('uses weekStart from URL search params if provided', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner?week=2026-03-30');
await load({ fetch: vi.fn(), url });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) }));
});
it('returns weekPlan with slots in page data', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeDefined();
expect(result.weekPlan.id).toBe(PLAN_UUID);
expect(result.weekPlan.slots).toHaveLength(1);
});
it('returns variety score in page data', async () => {
const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] };
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: scoreWithOverlap, error: undefined });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.varietyScore.score).toBe(7.5);
expect(result.varietyScore.ingredientOverlaps).toHaveLength(1);
});
it('returns null weekPlan when API returns 404', async () => {
mockGet
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) // weekPlan 404
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); // recipes
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeNull();
expect(result.varietyScore).toBeNull();
});
it('returns the weekStart used for the query', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekStart).toBe('2026-03-30');
});
it('creates week plan if not found and fetches variety score after creation', async () => {
// When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load
mockGet
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeNull();
});
it('returns recipes in page data', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.recipes).toHaveLength(1);
expect(result.recipes[0].name).toBe('Pasta');
});
it('returns empty recipes array when recipes API fails', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) // recipes fail
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.recipes).toEqual([]);
});
});
describe('planner page — actions', () => {
let actions: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
it('createPlan action calls POST /v1/week-plans', async () => {
mockPost.mockResolvedValue({ data: { id: PLAN_UUID, weekStart: '2026-03-30', slots: [] }, error: undefined });
const formData = new FormData();
formData.set('weekStart', '2026-03-30');
const result = await actions.createPlan({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
});
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ body: { weekStart: '2026-03-30' } }));
expect(result).toEqual({ success: true });
});
it('createPlan action returns error when API fails', async () => {
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
const formData = new FormData();
formData.set('weekStart', '2026-03-30');
const result = await actions.createPlan({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
});
expect(result).toEqual({ success: false, error: expect.any(String) });
});
it('createPlan action returns error for invalid weekStart format', async () => {
const formData = new FormData();
formData.set('weekStart', 'not-a-date');
const result = await actions.createPlan({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
});
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('createPlan action returns error when weekStart is missing', async () => {
const formData = new FormData();
const result = await actions.createPlan({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
});
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
});
it('createPlan action returns permission error for member role', async () => {
const formData = new FormData();
formData.set('weekStart', '2026-03-30');
const result = await actions.createPlan({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' }, haushalt: { id: 'h1', name: 'Test' } }
});
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
expect(mockPost).not.toHaveBeenCalled();
});
});
describe('planner page — variety score partial failure', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('returns weekPlan even when variety score API fails', async () => {
mockGet
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); // variety score fails
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeDefined();
expect(result.weekPlan.id).toBe(PLAN_UUID);
expect(result.varietyScore).toBeNull();
});
});
describe('planner page — slot actions', () => {
let actions: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
mockPatch.mockReset();
mockDelete.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('slotDate', '2026-04-01');
formData.set('recipeId', RECIPE_UUID);
mockPost.mockResolvedValue({ data: { id: SLOT_UUID, slotDate: '2026-04-01' }, error: undefined });
const result = await actions.addSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(mockPost).toHaveBeenCalledWith(
'/v1/week-plans/{id}/slots',
expect.objectContaining({
params: { path: { id: PLAN_UUID } },
body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID }
})
);
expect(result.success).toBe(true);
expect(result.slot?.id).toBe(SLOT_UUID);
});
it('addSlot returns failure when API errors', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('slotDate', '2026-04-01');
formData.set('recipeId', RECIPE_UUID);
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
const result = await actions.addSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(result.success).toBe(false);
});
it('addSlot returns validation error when planId is not a UUID', async () => {
const formData = new FormData();
formData.set('planId', 'not-a-uuid');
formData.set('slotDate', '2026-04-01');
formData.set('recipeId', RECIPE_UUID);
const result = await actions.addSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(result.success).toBe(false);
expect(result.error).toBe('Ungültige Eingabe.');
expect(mockPost).not.toHaveBeenCalled();
});
it('addSlot returns validation error when slotDate is missing', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('recipeId', RECIPE_UUID);
const result = await actions.addSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(result.success).toBe(false);
expect(result.error).toBe('Ungültige Eingabe.');
expect(mockPost).not.toHaveBeenCalled();
});
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('slotId', SLOT_UUID);
formData.set('recipeId', RECIPE_UUID);
mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined });
const result = await actions.updateSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(mockPatch).toHaveBeenCalledWith(
'/v1/week-plans/{planId}/slots/{slotId}',
expect.objectContaining({
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } },
body: { recipeId: RECIPE_UUID }
})
);
expect(result.success).toBe(true);
});
it('updateSlot returns validation error when slotId is not a UUID', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('slotId', 'bad-id');
formData.set('recipeId', RECIPE_UUID);
const result = await actions.updateSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(result.success).toBe(false);
expect(result.error).toBe('Ungültige Eingabe.');
expect(mockPatch).not.toHaveBeenCalled();
});
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
const formData = new FormData();
formData.set('planId', PLAN_UUID);
formData.set('slotId', SLOT_UUID);
mockDelete.mockResolvedValue({ error: undefined });
const result = await actions.deleteSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(mockDelete).toHaveBeenCalledWith(
'/v1/week-plans/{planId}/slots/{slotId}',
expect.objectContaining({
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }
})
);
expect(result.success).toBe(true);
});
it('deleteSlot returns validation error when planId is missing', async () => {
const formData = new FormData();
formData.set('slotId', SLOT_UUID);
const result = await actions.deleteSlot({
fetch: vi.fn(),
request: { formData: async () => formData }
} as any);
expect(result.success).toBe(false);
expect(result.error).toBe('Ungültige Eingabe.');
expect(mockDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Page from './+page.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
vi.mock('$app/forms', () => ({
enhance: () => () => ({ destroy: () => {} })
}));
const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001';
// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday)
const DATE = '2025-01-06'; // Monday, January 6 2025
const mockData = {
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] },
varietyScore: null,
weekStart: DATE,
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
benutzer: { rolle: 'planer' }
};
const mockDataWithSlot = {
...mockData,
weekPlan: {
id: PLAN_ID,
weekStart: DATE,
status: 'draft',
slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }]
}
};
const mockSuggestions = [
{
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
scoreDelta: 1.5,
hasConflict: false
}
];
describe('+page.svelte — $effect suggestion fetch', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('calls fetch when picker opens with correct planId and date', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: mockSuggestions })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
});
it('shows suggestions in RecipePicker after fetch resolves', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: mockSuggestions })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
expect(await screen.findByText('Lachsfilet')).toBeTruthy();
});
it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: [] })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
const fetchOptions = (fetch as any).mock.calls[0][1];
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
});
});
describe('+page.svelte — swap sheet suggestion fetch', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('opening mobile swap sheet triggers fetch with planId and date', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
// Open action sheet, then swap sheet
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Gericht tauschen/i }));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
});
});
describe('+page.svelte — remove meal', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
const undoBar = screen.getByTestId('undo-bar');
expect(undoBar).toBeTruthy();
expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy();
});
it('clicking Rückgängig after remove hides the undo bar', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
expect(screen.queryByTestId('undo-bar')).toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
const DATE = '2026-04-09';
const mockSuggestions = [
{ recipe: { id: 'r1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 0.0, hasConflict: true },
{ recipe: { id: 'r2', name: 'Nudeln', effort: 'easy', cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
];
describe('GET /planner — suggestions route handler', () => {
let GET: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+server');
GET = mod.GET;
});
it('returns { suggestions: [] } when planId is missing', async () => {
const url = new URL('http://localhost/planner?date=' + DATE);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
expect(mockGet).not.toHaveBeenCalled();
});
it('returns { suggestions: [] } when date is missing', async () => {
const url = new URL('http://localhost/planner?planId=' + PLAN_UUID);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
expect(mockGet).not.toHaveBeenCalled();
});
it('returns sorted suggestions from backend on success', async () => {
mockGet.mockResolvedValueOnce({ data: { suggestions: mockSuggestions }, error: undefined });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
params: { path: { id: PLAN_UUID }, query: { slotDate: DATE, topN: 100 } }
}));
expect(body.suggestions).toHaveLength(2);
// sorted by scoreDelta desc: 0.0 before -1.5
expect(body.suggestions[0].recipe.name).toBe('Lachsfilet');
expect(body.suggestions[1].recipe.name).toBe('Nudeln');
});
it('returns { suggestions: [] } when data is undefined (error response without data)', async () => {
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
it('returns { suggestions: [] } when backend throws (network error)', async () => {
mockGet.mockRejectedValueOnce(new Error('Network error'));
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
it('returns empty suggestions when backend returns empty array', async () => {
mockGet.mockResolvedValueOnce({ data: { suggestions: [] }, error: undefined });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
});

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week');
const weekStart = weekParam ?? getWeekStart(new Date());
const api = apiClient(fetch);
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
params: { query: { weekStart } }
});
if (weekPlanError || !weekPlan?.id) {
return { weekPlan: null, varietyScore: null, weekStart };
}
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
params: { path: { id: weekPlan.id } }
});
return {
weekPlan,
varietyScore: varietyScore ?? null,
weekStart
};
};

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import VarietyScoreHero from '$lib/planner/VarietyScoreHero.svelte';
import ScoreBreakdownList from '$lib/planner/ScoreBreakdownList.svelte';
import VarietyWarningCards from '$lib/planner/VarietyWarningCards.svelte';
import EffortBar from '$lib/planner/EffortBar.svelte';
import { computeSubScores, computeWarnings } from '$lib/planner/variety';
let { data } = $props();
let weekPlan = $derived(data.weekPlan);
let varietyScore = $derived(data.varietyScore);
let weekStart = $derived(data.weekStart);
let score = $derived(varietyScore?.score ?? 0);
// Derive effort distribution from week plan slots
let effortCounts = $derived.by(() => {
const slots = weekPlan?.slots ?? [];
let easy = 0, medium = 0, hard = 0;
for (const slot of slots) {
const effort = slot.recipe?.effort?.toLowerCase() ?? '';
if (effort === 'easy' || effort === 'einfach') easy++;
else if (effort === 'medium' || effort === 'mittel') medium++;
else if (effort === 'hard' || effort === 'aufwändig') hard++;
}
return { easy, medium, hard };
});
// Derive sub-scores from API data
// TODO: replace with API-provided sub-scores once backend supports them.
let subScores = $derived.by(() => computeSubScores({
tagRepeats: varietyScore?.tagRepeats ?? [],
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
...effortCounts
}));
// Build warning list from API data
let warnings = $derived.by(() => computeWarnings({
tagRepeats: varietyScore?.tagRepeats ?? [],
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
duplicatesInPlan: varietyScore?.duplicatesInPlan ?? []
}));
// Protein grid: map protein tags to days of the week
let proteinByDay = $derived.by(() => {
const map: Record<string, string> = {};
for (const repeat of varietyScore?.tagRepeats ?? []) {
if (repeat.tagType === 'protein') {
for (const day of repeat.days ?? []) {
map[day] = repeat.tagName ?? '';
}
}
}
return map;
});
// Days of the week abbreviations for protein grid
const weekDayAbbrs = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const weekDayKeys = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
</script>
<svelte:head>
<title>Abwechslung überprüfen — Mealplan</title>
</svelte:head>
<!-- Mobile layout -->
<div class="flex h-full flex-col lg:hidden">
<!-- Topbar -->
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
<a
href="/planner?week={weekStart}"
aria-label="Zurück zum Planer"
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
</a>
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
Abwechslungs-Analyse
</h1>
</header>
<div class="flex-1 overflow-y-auto px-4 pb-8 pt-5">
{#if !varietyScore}
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant. Plane zuerst einige Mahlzeiten.
</p>
<a
href="/planner?week={weekStart}"
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Zum Wochenplaner →
</a>
</div>
{:else}
<!-- Big score -->
<div class="mb-6">
<VarietyScoreHero {score} />
</div>
<!-- Sub-scores -->
<div class="mb-6">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Bewertung im Detail
</h2>
<ScoreBreakdownList {subScores} />
</div>
<!-- Warnings -->
{#if warnings.length > 0}
<div class="space-y-3">
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Hinweise
</h2>
<VarietyWarningCards {warnings} />
</div>
{/if}
{/if}
</div>
</div>
<!-- Desktop layout -->
<div class="hidden h-screen lg:flex lg:flex-col">
<!-- Topbar with breadcrumb -->
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
<a
href="/planner?week={weekStart}"
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Planer
</a>
<span aria-hidden="true" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/</span>
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
Abwechslungs-Analyse
</h1>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar (224px) — nav placeholder for consistency with C1 -->
<aside class="hidden w-[224px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)] xl:block">
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-8 py-6">
{#if !varietyScore}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant.
</p>
<a
href="/planner?week={weekStart}"
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Zum Wochenplaner →
</a>
</div>
{:else}
<!-- Top section: 2 columns -->
<div class="flex gap-8">
<!-- Left: score + sub-scores -->
<div class="flex-1">
<VarietyScoreHero {score} />
<div class="mt-6">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Bewertung im Detail
</h2>
<ScoreBreakdownList {subScores} />
</div>
</div>
<!-- Right (320px): protein grid + effort bar -->
<div class="w-[320px] flex-shrink-0 space-y-6">
<!-- Protein grid -->
<div>
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Protein-Verteilung
</h2>
<div class="grid grid-cols-7 gap-[6px]">
{#each weekDayAbbrs as abbr, i (weekDayKeys[i])}
{@const key = weekDayKeys[i]}
{@const protein = proteinByDay[key]}
{@const isRepeated = protein && Object.values(proteinByDay).filter((p) => p === protein).length > 1}
<div class="flex flex-col items-center gap-1">
<span class="font-[var(--font-sans)] text-[10px] text-[var(--color-text-muted)]">{abbr}</span>
<div
data-testid="protein-cell"
data-protein={protein ?? 'none'}
class="flex h-[44px] w-full items-center justify-center rounded-[var(--radius-sm)] text-[10px] font-medium
{protein
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
: 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]'}
{isRepeated ? 'ring-2 ring-[var(--yellow)]' : ''}"
>
{protein ? protein.split(' ')[0].slice(0, 3).toUpperCase() : '—'}
</div>
</div>
{/each}
</div>
</div>
<!-- Effort bar -->
<div>
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Aufwandsverteilung
</h2>
{#if (effortCounts.easy + effortCounts.medium + effortCounts.hard) > 0}
<EffortBar
easy={effortCounts.easy}
medium={effortCounts.medium}
hard={effortCounts.hard}
/>
{:else}
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant.
</p>
{/if}
</div>
</div>
</div>
<!-- Bottom: warnings, full width -->
{#if warnings.length > 0}
<div class="mt-8 space-y-3">
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Hinweise
</h2>
<VarietyWarningCards {warnings} />
</div>
{/if}
{/if}
</main>
</div>
</div>

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const mockVarietyScore = {
score: 8.2,
tagRepeats: [
{ tagName: 'Chicken', tagType: 'protein', days: ['MON', 'WED'] }
],
ingredientOverlaps: [
{ ingredientName: 'Tomaten', days: ['MON', 'TUE', 'WED'] }
],
recentRepeats: ['Pasta Bolognese'],
duplicatesInPlan: ['Hühnchen Curry']
};
const mockWeekPlan = {
id: 'plan-1',
weekStart: '2026-03-30',
status: 'draft',
slots: [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 20 } },
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } },
{ id: 's3', slotDate: '2026-04-01', recipe: { id: 'r3', name: 'Steak', effort: 'Hard', cookTimeMin: 60 } }
]
};
describe('variety page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('fetches week plan and variety score', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.anything());
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/variety-score', expect.objectContaining({
params: { path: { id: 'plan-1' } }
}));
});
it('returns varietyScore and weekPlan in result', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
expect(result.varietyScore?.score).toBe(8.2);
expect(result.weekPlan?.id).toBe('plan-1');
});
it('returns weekStart from URL param', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url, locals: {} });
expect(result.weekStart).toBe('2026-03-30');
});
it('returns null data when week plan not found', async () => {
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url, locals: {} });
expect(result.weekPlan).toBeNull();
expect(result.varietyScore).toBeNull();
});
it('returns null varietyScore when score endpoint fails', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url, locals: {} });
expect(result.weekPlan?.id).toBe('plan-1');
expect(result.varietyScore).toBeNull();
});
it('uses current week when no week param provided', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner/variety');
const result = await load({ fetch: vi.fn(), url, locals: {} });
// weekStart should be a valid YYYY-MM-DD
expect(result.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});

View File

@@ -0,0 +1,36 @@
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
export const load: PageServerLoad = async ({ fetch }) => {
const api = apiClient(fetch);
const weekStart = getWeekStart(new Date());
const [recipesResult, weekPlanResult] = await Promise.all([
api.GET('/v1/recipes', {}),
api.GET('/v1/week-plans', { params: { query: { weekStart } } })
]);
const recipes =
recipesResult.error || !recipesResult.data?.data
? []
: recipesResult.data.data.map((r) => ({
id: r.id!,
name: r.name!,
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl
}));
const activePlan =
weekPlanResult.error || !weekPlanResult.data?.id ? null : weekPlanResult.data;
return { recipes, activePlan };
};
export const actions: Actions = {
addSlot: addSlotAction,
updateSlot: updateSlotAction,
deleteSlot: deleteSlotAction
};

View File

@@ -1 +1,224 @@
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { tick } from 'svelte';
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
import type { RecipeSummary } from '$lib/recipes/types';
import DayPicker from '$lib/planner/DayPicker.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
let { data, form = null }: { data: { recipes: RecipeSummary[]; activePlan: any }; form?: any } =
$props();
// ── Search / filter ──────────────────────────────────────────────────────
let searchQuery = $state('');
let activeFilter = $state('Alle');
const effortMap: Record<string, string> = {
Leicht: 'easy',
Mittel: 'medium',
Schwer: 'hard'
};
let filteredRecipes = $derived(
data.recipes
.filter((r) => {
if (activeFilter === 'Alle') return true;
return r.effort === effortMap[activeFilter];
})
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
// ── Today (computed once at module level) ─────────────────────────────────
const today = new Date().toISOString().slice(0, 10);
// ── DayPicker / BottomSheet state ─────────────────────────────────────────
let pickerOpen = $state(false);
let pickerRecipeId = $state('');
let pickerRecipeName = $state('');
let pickerPlan = $state<any>(null);
let pickerWeekStart = $state('');
// ── Undo bar state ────────────────────────────────────────────────────────
let undoVisible = $state(false);
let undoMessage = $state('');
let undoPlanId = $state('');
let undoSlotId = $state('');
// ── Hidden form field state ───────────────────────────────────────────────
let addPlanId = $state('');
let addSlotDate = $state('');
let addRecipeId = $state('');
let updPlanId = $state('');
let updSlotId = $state('');
let updRecipeId = $state('');
// ── Form element refs ─────────────────────────────────────────────────────
let addSlotFormEl: HTMLFormElement;
let updateSlotFormEl: HTMLFormElement;
let deleteSlotFormEl: HTMLFormElement;
// ── Handlers ──────────────────────────────────────────────────────────────
function openDayPicker(recipeId: string, recipeName: string) {
if (!data.activePlan) return;
pickerRecipeId = recipeId;
pickerRecipeName = recipeName;
pickerPlan = data.activePlan;
pickerWeekStart = data.activePlan.weekStart;
pickerOpen = true;
}
async function handleWeekChange(newWeekStart: string) {
const res = await fetch(`/recipes?week=${newWeekStart}`);
const { plan } = await res.json();
pickerPlan = plan;
pickerWeekStart = newWeekStart;
}
async function handleDayPickerConfirm({ date, slotId }: { date: string; slotId: string | null }) {
pickerOpen = false;
if (slotId) {
// Replace existing slot
updPlanId = pickerPlan?.id ?? '';
updSlotId = slotId;
updRecipeId = pickerRecipeId;
await tick();
updateSlotFormEl.requestSubmit();
} else {
// Add to empty slot
addPlanId = pickerPlan?.id ?? '';
addSlotDate = date;
addRecipeId = pickerRecipeId;
await tick();
addSlotFormEl.requestSubmit();
}
}
function handleUndo() {
undoVisible = false;
deleteSlotFormEl.requestSubmit();
}
</script>
<svelte:head>
<title>Rezepte — Mealplan</title>
</svelte:head>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">
Rezepte
</h1>
<a
href="/recipes/new"
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
>
Rezept hinzufügen
</a>
</div>
<input type="search" placeholder="Suchen…" class="input" bind:value={searchQuery} />
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
<RecipeGrid
recipes={filteredRecipes}
onplan={data.activePlan ? openDayPicker : undefined}
/>
</div>
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)} height="55vh">
{#if pickerPlan}
<DayPicker
recipeName={pickerRecipeName}
recipeId={pickerRecipeId}
planId={pickerPlan?.id ?? ''}
weekStart={pickerWeekStart}
{today}
slots={pickerPlan?.slots ?? []}
onconfirm={handleDayPickerConfirm}
onweekchange={handleWeekChange}
/>
{/if}
</BottomSheet>
<UndoBar
visible={undoVisible}
message={undoMessage}
onundo={handleUndo}
ondismiss={() => (undoVisible = false)}
/>
<!-- Hidden forms for slot mutations -->
<form
bind:this={addSlotFormEl}
method="POST"
action="?/addSlot"
class="hidden"
use:enhance={({ formData }) => {
formData.set('planId', addPlanId);
formData.set('slotDate', addSlotDate);
formData.set('recipeId', addRecipeId);
return async ({ result, update }) => {
if (result.type === 'success') {
undoPlanId = addPlanId;
undoSlotId = (result.data as any)?.slot?.id ?? '';
undoMessage = `${pickerRecipeName} hinzugefügt`;
undoVisible = true;
await invalidateAll();
}
await update({ reset: false });
};
}}
>
<input type="hidden" name="planId" value={addPlanId} />
<input type="hidden" name="slotDate" value={addSlotDate} />
<input type="hidden" name="recipeId" value={addRecipeId} />
</form>
<form
bind:this={updateSlotFormEl}
method="POST"
action="?/updateSlot"
class="hidden"
use:enhance={({ formData }) => {
formData.set('planId', updPlanId);
formData.set('slotId', updSlotId);
formData.set('recipeId', updRecipeId);
return async ({ result, update }) => {
if (result.type === 'success') {
undoPlanId = updPlanId;
undoSlotId = (result.data as any)?.slot?.id ?? '';
undoMessage = `${pickerRecipeName} hinzugefügt`;
undoVisible = true;
await invalidateAll();
}
await update({ reset: false });
};
}}
>
<input type="hidden" name="planId" value={updPlanId} />
<input type="hidden" name="slotId" value={updSlotId} />
<input type="hidden" name="recipeId" value={updRecipeId} />
</form>
<form
bind:this={deleteSlotFormEl}
method="POST"
action="?/deleteSlot"
class="hidden"
use:enhance={({ formData }) => {
formData.set('planId', undoPlanId);
formData.set('slotId', undoSlotId);
return async ({ update }) => {
await invalidateAll();
await update({ reset: false });
};
}}
>
<input type="hidden" name="planId" value={undoPlanId} />
<input type="hidden" name="slotId" value={undoSlotId} />
</form>

View File

@@ -0,0 +1,17 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation
export const GET: RequestHandler = async ({ fetch, url }) => {
const weekStart = url.searchParams.get('week');
if (!weekStart) return json({ plan: null });
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/week-plans', {
params: { query: { weekStart } }
});
if (error || !data?.id) return json({ plan: null });
return json({ plan: data });
};

View File

@@ -0,0 +1,41 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, params }) => {
const api = apiClient(fetch);
const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
params: { path: { id: params.id } }
});
if (apiError || !data) {
error(404, 'Recipe not found');
}
return {
recipe: {
id: data.id!,
name: data.name!,
serves: data.serves,
cookTimeMin: data.cookTimeMin,
effort: data.effort,
heroImageUrl: data.heroImageUrl,
ingredients: (data.ingredients ?? []).map((ing) => ({
ingredientId: ing.ingredientId,
name: ing.name,
quantity: ing.quantity,
unit: ing.unit,
sortOrder: ing.sortOrder
})),
steps: (data.steps ?? []).map((s) => ({
stepNumber: s.stepNumber,
instruction: s.instruction
})),
tags: (data.tags ?? []).map((t) => ({
id: t.id!,
name: t.name!,
tagType: t.tagType
}))
}
};
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
import IngredientList from '$lib/recipes/IngredientList.svelte';
import StepList from '$lib/recipes/StepList.svelte';
import type { RecipeDetail } from '$lib/recipes/types';
let { data }: { data: { recipe: RecipeDetail } } = $props();
</script>
<svelte:head>
<title>{data.recipe.name} — Mealplan</title>
</svelte:head>
<div>
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
<a
href="/recipes/{data.recipe.id}/edit"
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
>
Bearbeiten
</a>
</div>
<RecipeHero recipe={data.recipe} />
<div class="md:flex">
<div class="md:flex-1 md:border-r md:border-[var(--color-border)] p-[24px]">
<IngredientList ingredients={data.recipe.ingredients} />
</div>
<div class="md:flex-1 p-[24px]">
<StepList steps={data.recipe.steps} />
</div>
</div>
</div>

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