Compare commits

...

105 Commits

Author SHA1 Message Date
f33302e012 fix(planner): format variety score to one decimal place
Avoids floating-point display like 6.199999999999999 by using
score.toFixed(1) in VarietyScoreCard.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:53:32 +02:00
47c748145d feat(recipes): implement recipe library page with search and effort filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:49:39 +02:00
a25286e385 feat(recipes): load recipe list from API in page server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:45:43 +02:00
a733e8dd66 feat(recipes): add RecipeGrid with 2/4-col responsive grid and empty state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:44:05 +02:00
35ed6ca878 feat(recipes): add FilterChipRow with effort filter chips
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:43:06 +02:00
dc99459a2e feat(recipes): add RecipeCard component with compact/full image variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:41:56 +02:00
021d308a71 feat(staples): A3/D3 — Pantry staples toggle UI 2026-04-03 09:35:03 +02:00
118 changed files with 10385 additions and 140 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,13 +172,49 @@ 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();
@@ -181,21 +223,18 @@ public class PlanningService {
// 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,35 +242,31 @@ 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;
double score = MAX_VARIETY_SCORE;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@Transactional(readOnly = true)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
WeekPlan plan = findPlan(planId, householdId);
@@ -318,12 +353,12 @@ public class PlanningService {
}
// Calculate score
double score = 10.0;
double score = MAX_VARIETY_SCORE;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
score = Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
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,184 @@
<script lang="ts">
import type { Recipe, Suggestion } from '$lib/planner/types';
let {
planId,
date,
dateLabel,
suggestions = [],
allRecipes = [],
isLoading = false,
onpick
}: {
planId: string;
date: string;
dateLabel: string;
suggestions: Suggestion[];
allRecipes: Recipe[];
isLoading?: boolean;
onpick: (recipeId: string, recipeName: string) => void;
} = $props();
let searchQuery = $state('');
let filteredRecipes = $derived(
searchQuery.trim() === ''
? allRecipes
: allRecipes.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>
<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;">
Rezept wählen
</p>
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
{dateLabel}
</p>
</div>
<!-- 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 suggestions.length > 0}
<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 suggestions 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}
{#if (suggestion.scoreDelta ?? 0) > 0}
<span
data-testid="badge-{suggestion.recipe.id}"
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);"
>
↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
</span>
{:else if suggestion.hasConflict}
<span
data-testid="badge-{suggestion.recipe.id}"
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);"
>
{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
</span>
{/if}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
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: pointer;"
>
+ Wählen
</button>
</div>
{/each}
{/if}
<!-- 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)}
<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}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(recipe.id, recipe.name)}
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: pointer;"
>
+ Wählen
</button>
</div>
{/each}
{/if}
</div>
<style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } 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 all suggestion recipe names', () => {
render(RecipePicker, { props: baseProps });
expect(screen.getByText('Lachsfilet')).toBeTruthy();
expect(screen.getByText('Hähnchen-Curry')).toBeTruthy();
});
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 when hasConflict is true', () => {
render(RecipePicker, { props: baseProps });
// Hähnchen-Curry: hasConflict = true, scoreDelta = -1.5 → red badge with delta
const badge = screen.getByTestId('badge-s2');
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 2 are suggestions, rest are allRecipes
await userEvent.click(buttons[2]);
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 no badge when scoreDelta is zero (neutral, no improvement)', () => {
const neutralSuggestions = [
{ recipe: { id: 'sn', name: 'Neutrales Rezept', effort: 'easy', cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false }
];
render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } });
expect(screen.queryByTestId('badge-sn')).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();
});
});

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,126 @@
<script lang="ts">
interface Recipe {
id: string;
name: string;
effort?: string | null;
cookTimeMin?: number | null;
}
let {
replacingName,
replacingMeta,
recipes,
currentWeekRecipeIds,
excludeRecipeId,
isLoading = false,
onpick,
oncancel
}: {
replacingName: string;
replacingMeta?: string;
recipes: Recipe[];
currentWeekRecipeIds: Set<string>;
excludeRecipeId?: string;
isLoading?: boolean;
onpick: (recipeId: string, recipeName: string) => void;
oncancel?: () => void;
} = $props();
let visibleRecipes = $derived(
excludeRecipeId ? recipes.filter((r) => r.id !== excludeRecipeId) : recipes
);
function recipeMeta(recipe: Recipe): string {
return [
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} min` : null,
recipe.effort ?? null
]
.filter(Boolean)
.join(' · ');
}
</script>
<!-- Replacing banner -->
<div
style="background: var(--orange-tint); border: 1px solid #FBCDA4; border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 14px;"
>
<p
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 4px 0; font-family: var(--font-sans);"
>
Wird ersetzt
</p>
<span
data-testid="replacing-name"
title={replacingName}
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 14px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
>
{replacingName}{#if replacingMeta} · {replacingMeta}{/if}
</span>
</div>
<!-- Eyebrow label -->
<p
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); margin: 0 0 6px 0; font-family: var(--font-sans);"
>
Ersetzen durch (einfachste zuerst)
</p>
<!-- Recipe list -->
{#if visibleRecipes.length === 0}
<p
data-testid="swap-empty-state"
style="text-align: center; color: var(--color-text-muted); font-family: var(--font-sans); margin: 0;"
>
Keine Rezepte verfügbar.
</p>
{:else}
{#each visibleRecipes as recipe (recipe.id)}
{@const meta = recipeMeta(recipe)}
{@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
<div
style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 6px; display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 13px; color: var(--color-text); margin: 0;"
>
{recipe.name}
</p>
{#if meta}
<p
style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans); margin: 1px 0 0;"
>
{meta}
</p>
{/if}
{#if alreadyPlanned}
<p
data-testid="already-planned-{recipe.id}"
style="font-size: 9px; color: var(--yellow-text); font-family: var(--font-sans); margin: 1px 0 0;"
>
⚠ Bereits diese Woche
</p>
{/if}
</div>
<button
type="button"
onclick={() => onpick(recipe.id, recipe.name)}
disabled={isLoading}
style="background: none; border: none; cursor: {isLoading ? 'default' : 'pointer'}; font-size: 11px; font-weight: 500; color: var(--green); font-family: var(--font-sans); flex-shrink: 0; opacity: {isLoading ? '0.4' : '1'};"
>
Wählen
</button>
</div>
{/each}
{/if}
<!-- Cancel button (optional) -->
{#if oncancel}
<button
type="button"
onclick={oncancel}
style="width: 100%; background: none; border: none; cursor: pointer; color: var(--color-text-muted); font-size: 13px; text-align: center; padding: 8px 0; font-family: var(--font-sans);"
>
Abbrechen
</button>
{/if}

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import SwapSuggestionList from './SwapSuggestionList.svelte';
const recipes = [
{ id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 },
{ id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 },
{ id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 }
];
const baseProps = {
replacingName: 'Tomato pasta',
replacingMeta: '45 min · Easy',
recipes,
currentWeekRecipeIds: new Set<string>(),
onpick: vi.fn()
};
describe('SwapSuggestionList', () => {
it('renders the Replacing banner', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
});
it('renders old meal name with strikethrough', () => {
render(SwapSuggestionList, { props: baseProps });
const struck = screen.getByTestId('replacing-name');
expect(struck.textContent).toContain('Tomato pasta');
expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through');
});
it('replacing-name span has title attribute for full name', () => {
render(SwapSuggestionList, { props: baseProps });
const struck = screen.getByTestId('replacing-name');
expect(struck.getAttribute('title')).toBe('Tomato pasta');
});
it('renders the easiest-first eyebrow label', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
});
it('renders all recipe names', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('clicking Wählen calls onpick with recipeId and name', async () => {
const onpick = vi.fn();
const user = userEvent.setup();
render(SwapSuggestionList, { props: { ...baseProps, onpick } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
await user.click(buttons[0]);
expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
});
it('shows already-planned warning for recipes in currentWeekRecipeIds', () => {
render(SwapSuggestionList, {
props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) }
});
expect(screen.getByTestId('already-planned-r2')).toBeTruthy();
});
it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.queryByTestId('already-planned-r1')).toBeNull();
});
it('shows empty state when no recipes', () => {
render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } });
expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
});
it('excludes the recipe being replaced when excludeRecipeId is provided', () => {
render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } });
expect(screen.queryByText('Chicken stir-fry')).toBeNull();
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('shows all recipes when excludeRecipeId is not provided', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('disables all Wählen buttons when isLoading is true', () => {
render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
});
it('Wählen buttons are enabled when isLoading is false', () => {
render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
});
it('renders optional Abbrechen button when oncancel provided', () => {
render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
});
it('does not render Abbrechen button when oncancel not provided', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
});
it('clicking Abbrechen calls oncancel', async () => {
const oncancel = vi.fn();
const user = userEvent.setup();
render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
});

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 SwapSuggestionList from '$lib/planner/SwapSuggestionList.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } 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
: 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))
);
// Recipes sorted easiest-first for the swap suggestion list
let sortedRecipes = $derived(sortEasiestFirst(data.recipes));
// 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(' · ')}
<div style="padding: 16px;">
<SwapSuggestionList
replacingName={selectedSlot.recipe?.name ?? ''}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
excludeRecipeId={selectedSlot.recipe?.id}
isLoading={swapLoading}
onpick={handleSwapPick}
oncancel={() => (swapSheetOpen = false)}
/>
</div>
</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 px-4">
<SwapSuggestionList
replacingName={pickerSlot.recipe.name}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
excludeRecipeId={pickerSlot.recipe.id}
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 } }
});
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,126 @@
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 — 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 } }
}));
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 });
};

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