Compare commits

...

36 Commits

Author SHA1 Message Date
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
63 changed files with 6110 additions and 6 deletions

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

@@ -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,86 @@
<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
}: {
slot: Slot;
isToday?: boolean;
isSelected?: boolean;
readonly?: boolean;
} = $props();
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>
<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}
<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}
{#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>
<a
href="/planner/suggestions?day={slot.slotDate}"
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
</a>
</div>
{/if}
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if !readonly}
<a
href="/planner/suggestions?day={slot.slotDate}"
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
</a>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
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 Cook now and Tauschen links when not readonly', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy();
});
it('Tauschen link navigates to suggestions for the slot day', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
const link = screen.getByRole('link', { name: /Tauschen/i });
expect(link.getAttribute('href')).toContain('2026-03-30');
});
it('hides action links when readonly', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: true } });
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
expect(screen.queryByRole('link', { 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 link with suggestions href', () => {
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
const link = screen.getByRole('link', { name: /Gericht hinzufügen/i });
expect(link.getAttribute('href')).toContain('2026-03-31');
});
});

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,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,83 @@
<script lang="ts">
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
}
interface Suggestion {
recipe?: SlotRecipe;
simulatedScore?: number;
reasoningType?: 'good' | 'warning';
reasoningLabel?: string;
}
let {
suggestion,
rank,
planId,
slotDate,
weekStart
}: {
suggestion: Suggestion;
rank: number;
planId: string;
slotDate: string;
weekStart: string;
} = $props();
let metadata = $derived(
[
suggestion.recipe?.cookTimeMin != null ? `${suggestion.recipe.cookTimeMin} Min` : null,
suggestion.recipe?.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
</script>
<div class="flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 shadow-[var(--shadow-card)]">
<!-- Rank number -->
<div class="w-10 flex-shrink-0 self-start text-right">
<span class="font-[var(--font-display)] text-[32px] font-[300] leading-none text-[var(--color-text-muted)]">{rank}</span>
</div>
<!-- Card content -->
<div class="flex-1 min-w-0">
<p class="font-[var(--font-sans)] text-[15px] font-medium text-[var(--color-text)] line-clamp-2">
{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
</p>
{#if metadata}
<p class="mt-0.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
{/if}
<!-- Reasoning badge -->
{#if suggestion.reasoningType && suggestion.reasoningLabel}
<div
data-testid="reasoning-badge"
data-type={suggestion.reasoningType}
class="mt-2 inline-flex items-center rounded-full px-2 py-0.5 font-[var(--font-sans)] text-[11px] font-medium
{suggestion.reasoningType === 'good'
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
: 'bg-[var(--yellow-tint)] text-[var(--yellow-text)]'}"
>
{suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel}
</div>
{/if}
</div>
<!-- Pick action -->
<form method="POST" action="?/pickSuggestion" class="flex-shrink-0">
<input type="hidden" name="planId" value={planId} />
<input type="hidden" name="recipeId" value={suggestion.recipe?.id} />
<input type="hidden" name="slotDate" value={slotDate} />
<input type="hidden" name="weekStart" value={weekStart} />
<button
type="submit"
class="font-[var(--font-sans)] text-[13px] font-medium tracking-[0.04em] text-[var(--green-dark)] hover:underline"
>
Wählen
</button>
</form>
</div>

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SuggestionCard from './SuggestionCard.svelte';
const goodSuggestion = {
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
simulatedScore: 9.2,
reasoningType: 'good' as const,
reasoningLabel: 'Frisches Protein · Aufwandsbalance'
};
const warningSuggestion = {
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
simulatedScore: 6.1,
reasoningType: 'warning' as const,
reasoningLabel: 'Hähnchen schon 2 Tage dabei'
};
describe('SuggestionCard', () => {
it('renders recipe name', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
});
it('renders rank number', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('1')).toBeTruthy();
});
it('renders cook time and effort metadata', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText(/25 Min/)).toBeTruthy();
expect(screen.getByText(/Easy/)).toBeTruthy();
});
it('renders green reasoning badge for good suggestions', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
const badge = screen.getByTestId('reasoning-badge');
expect(badge.getAttribute('data-type')).toBe('good');
expect(badge.textContent).toContain('Frisches Protein');
});
it('renders yellow reasoning badge for warnings', () => {
render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
const badge = screen.getByTestId('reasoning-badge');
expect(badge.getAttribute('data-type')).toBe('warning');
expect(badge.textContent).toContain('Hähnchen');
});
it('renders a pick button/form', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy();
});
it('card without reasoning renders without crashing', () => {
const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined };
render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
});
});

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,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}
</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,73 @@
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')).toBeTruthy();
});
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,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,146 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
getWeekStart,
prevWeek,
nextWeek,
weekDays,
isToday,
formatWeekRange,
formatDayLabel
} 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(',');
});
});

View File

@@ -0,0 +1,88 @@
/**
* 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;
}
/**
* 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,60 @@
<script lang="ts">
import type { RecipeSummary } from './types';
let { recipe, compact = false }: { recipe: RecipeSummary; compact?: boolean } = $props();
let metadata = $derived(
[
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
recipe.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
</script>
<a
href="/recipes/{recipe.id}"
class="block rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]"
>
<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>

View File

@@ -0,0 +1,62 @@
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('wraps in a link to the recipe detail page', () => {
render(RecipeCard, { props: { recipe: mockRecipe } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/recipes/recipe-1');
});
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 }: { recipes: RecipeSummary[] } = $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} />
{/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,55 @@
import type { PageServerLoad, Actions } 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 } = await api.GET('/v1/week-plans', {
params: { query: { weekStart } }
});
if (error || !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
};
};
export const actions: Actions = {
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,349 @@
<h1 class="text-2xl font-medium p-6">Planer</h1>
<script lang="ts">
import { goto } from '$app/navigation';
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte';
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
let { data } = $props();
// Capture initial weekStart before reactivity for $state initialization
const initialWeekStart: string = data.weekStart;
// 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
let selectedDay = $state(weekDays(initialWeekStart).includes(today) ? today : weekDays(initialWeekStart)[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));
function handleSelectDay(day: string) {
selectedDay = 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 });
}
</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="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
<button
type="button"
onclick={() => navigateWeek('next')}
aria-label="Nächste Woche"
class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
{#if isPlanner}
<a
href="/planner/suggestions?day={selectedDay}"
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
</a>
{/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}
/>
</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}
<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}
</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="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 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="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 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="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 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}
<a
href="/planner/suggestions?day={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
</a>
{/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}
{@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)}
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">
<div class="mb-3">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(selectedDay)} · Abendessen
</p>
</div>
{#if selectedSlot?.recipe}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
{selectedSlot.recipe.name}
</h2>
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{[selectedSlot.recipe.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe.effort].filter(Boolean).join(' · ')}
</p>
{/if}
<!-- View and cook actions shown to all roles -->
<div class="mt-4 space-y-2">
<a
href="/recipes/{selectedSlot.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/{selectedSlot.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>
<!-- Swap action: planner only -->
{#if isPlanner}
<a
href="/planner/suggestions?day={selectedDay}"
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)]"
>
Gericht tauschen
</a>
{/if}
</div>
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if isPlanner}
<a
href="/planner/suggestions?day={selectedDay}"
class="mt-3 block 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
</a>
{/if}
{/if}
</aside>
</div>
</div>

View File

@@ -0,0 +1,195 @@
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();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost })
}));
describe('planner page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
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: 30 } },
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } }
]
};
it('fetches week plan for the current week by default', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({
data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] },
error: undefined
});
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 });
mockGet.mockResolvedValueOnce({ data: { score: 8 }, 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 });
mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, 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-1');
expect(result.weekPlan.slots).toHaveLength(2);
});
it('returns variety score in page data', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, 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 } });
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 });
mockGet.mockResolvedValueOnce({ data: { score: 6 }, 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 } });
const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeNull();
});
});
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-new', 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;
});
const mockWeekPlan = {
id: 'plan-1',
weekStart: '2026-03-30',
status: 'draft',
slots: []
};
it('returns weekPlan even when variety score API fails', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
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-1');
expect(result.varietyScore).toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import type { PageServerLoad, Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => {
const weekParam = url.searchParams.get('week');
const weekStart = weekParam ?? getWeekStart(new Date());
const selectedDay = url.searchParams.get('day') ?? weekStart;
const api = apiClient(fetch);
// Load the week plan for context (week-so-far display)
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
params: { query: { weekStart } }
});
if (weekPlanError || !weekPlan?.id) {
return { weekPlan: null, suggestions: [], selectedDay, weekStart };
}
// Load variety-aware suggestions for the selected day
const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', {
params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } }
});
// Sort by simulatedScore descending (highest = best variety fit)
const suggestions = (suggestionsData?.suggestions ?? []).sort(
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
);
return { weekPlan, suggestions, selectedDay, weekStart };
};
export const actions: Actions = {
pickSuggestion: async ({ fetch, request, locals }) => {
// Role guard: only planners may assign meals
if (locals.benutzer?.rolle !== 'planer') {
return { success: false, error: 'Keine Berechtigung.' };
}
const formData = await request.formData();
const planId = formData.get('planId') as string;
const recipeId = formData.get('recipeId') as string;
const slotDate = formData.get('slotDate') as string;
const weekStart = formData.get('weekStart') as string;
// Validate slotDate format
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
return { success: false, error: 'Ungültiges Datum.' };
}
// Validate planId is non-empty
if (!planId) {
return { success: false, error: 'Fehlende Plan-ID.' };
}
// Validate recipeId is UUID-like format
if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) {
return { success: false, error: 'Ungültige Rezept-ID.' };
}
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, error: 'Gericht konnte nicht hinzugefügt werden.' };
}
// Redirect back to the planner after successful pick (spec: "returns to C1")
redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`);
}
};

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
import { formatDayLabel } from '$lib/planner/week';
let { data } = $props();
let weekPlan = $derived(data.weekPlan);
let suggestions = $derived(data.suggestions ?? []);
let selectedDay = $derived(data.selectedDay);
let weekStart = $derived(data.weekStart);
// Add rank and derive reasoning from simulatedScore for display.
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
let rankedSuggestions = $derived(
suggestions.map((s: any, i: number) => ({
...s,
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
reasoningLabel:
(s.simulatedScore ?? 0) >= 7.5
? 'Passt gut zur Woche'
: 'Wiederholung möglich'
}))
);
</script>
<svelte:head>
<title>Gerichtsvorschläge — Mealplan</title>
</svelte:head>
<!-- Mobile layout: full-width list with context banner -->
<div class="flex h-full flex-col lg:hidden">
<!-- Mobile 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)]">
Vorschläge für {formatDayLabel(selectedDay)}
</h1>
</header>
<!-- Context banner -->
<div class="px-4 pt-3">
<SuggestionContextBanner {selectedDay} {weekPlan} />
</div>
<!-- Suggestion list -->
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
{#if rankedSuggestions.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Keine Vorschläge verfügbar.
</p>
<a
href="/recipes?selectFor={selectedDay}&week={weekStart}"
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Gesamte Rezeptbibliothek durchsuchen →
</a>
</div>
{:else}
<div class="space-y-3">
{#each rankedSuggestions as suggestion, i}
<SuggestionCard
{suggestion}
rank={i + 1}
planId={weekPlan?.id ?? ''}
slotDate={selectedDay}
{weekStart}
/>
{/each}
</div>
<!-- Browse full library fallback -->
<div class="mt-6 text-center">
<a
href="/recipes?selectFor={selectedDay}&week={weekStart}"
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Gesamte Rezeptbibliothek durchsuchen →
</a>
</div>
{/if}
</div>
</div>
<!-- Desktop: 2-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">
<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-[20px] font-[300] text-[var(--color-text)]">
Vorschläge für {formatDayLabel(selectedDay)}
</h1>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left context panel (280px) -->
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Diese Woche bisher
</h2>
{#if weekPlan?.slots?.length}
<ul class="space-y-2">
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
<li class="flex items-baseline gap-2">
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
</span>
{#if slot.recipe}
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
{slot.recipe.name}
</span>
{:else}
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
Noch keine Gerichte diese Woche geplant.
</p>
{/if}
<!-- Filter reasons -->
<div class="mt-6">
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Filterkriterien
</h3>
<ul class="space-y-1">
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
· Keine Zutatenwiederholungen (3 Tage)
</li>
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
· Protein-Abwechslung beachten
</li>
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
· Aufwandsbalance
</li>
</ul>
</div>
<!-- Browse library link in desktop panel footer -->
<div class="mt-auto pt-6">
<a
href="/recipes?selectFor={selectedDay}&week={weekStart}"
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Gesamte Bibliothek →
</a>
</div>
</aside>
<!-- Right suggestions panel -->
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
{#if rankedSuggestions.length === 0}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Keine Vorschläge verfügbar.
</p>
</div>
{:else}
<div class="space-y-3">
{#each rankedSuggestions as suggestion, i}
<SuggestionCard
{suggestion}
rank={i + 1}
planId={weekPlan?.id ?? ''}
slotDate={selectedDay}
{weekStart}
/>
{/each}
</div>
<div class="mt-6 text-center">
<a
href="/recipes?selectFor={selectedDay}&week={weekStart}"
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Gesamte Rezeptbibliothek durchsuchen →
</a>
</div>
{/if}
</main>
</div>
</div>

View File

@@ -0,0 +1,216 @@
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();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost })
}));
describe('suggestions page — load', () => {
let load: any;
const mockSuggestions = {
suggestions: [
{
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
simulatedScore: 9.2
},
{
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
simulatedScore: 6.1
}
]
};
const mockWeekPlan = {
id: 'plan-1',
weekStart: '2026-03-30',
status: 'draft',
slots: [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
]
};
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('fetches suggestions for the given plan and day', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
params: expect.objectContaining({ path: { id: 'plan-1' } })
}));
});
it('returns suggestions list sorted by simulatedScore descending', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
});
it('returns the selectedDay from URL params', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.selectedDay).toBe('2026-04-01');
});
it('returns empty suggestions when API fails', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.suggestions).toEqual([]);
});
it('returns week plan slots for context display', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.weekPlan).toBeDefined();
expect(result.weekPlan.slots).toHaveLength(1);
});
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.weekPlan).toBeNull();
expect(result.suggestions).toEqual([]);
});
it('defaults day to weekStart when no day param provided', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
expect(result.selectedDay).toBe('2026-03-30');
});
});
describe('suggestions page — pickSuggestion action', () => {
let actions: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
it('adds a slot to the week plan via POST and redirects to planner', async () => {
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
try {
await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner?week=2026-03-30');
}
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
params: { path: { id: 'plan-1' } },
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
}));
});
it('returns error when planId is missing', async () => {
const formData = new FormData();
formData.set('planId', '');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('returns error for invalid recipeId format', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'not-a-uuid');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('returns error when API fails', async () => {
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(result).toEqual({ success: false, error: expect.any(String) });
});
it('returns permission error for member role', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } }
});
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('returns error for invalid slotDate format', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', 'not-a-date');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(result).toEqual({ success: false, error: expect.any(String) });
expect(mockPost).not.toHaveBeenCalled();
});
});

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,21 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/recipes', {});
if (error || !data?.data) {
return { recipes: [] };
}
return {
recipes: data.data.map((r) => ({
id: r.id!,
name: r.name!,
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl
}))
};
};

View File

@@ -1 +1,47 @@
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
<script lang="ts">
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
import type { RecipeSummary } from '$lib/recipes/types';
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
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()))
);
</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} />
</div>

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
export const load: PageServerLoad = async ({ fetch, params }) => {
const api = apiClient(fetch);
const [recipeResult, tagsResult] = await Promise.all([
api.GET('/v1/recipes/{id}', { params: { path: { id: params.id } } }),
api.GET('/v1/tags', {})
]);
if (recipeResult.error || !recipeResult.data) {
error(404, 'Recipe not found');
}
const recipe = recipeResult.data;
const allTags = tagsResult.data ?? [];
const categories = allTags
.filter((t) => t.tagType === 'category')
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
return {
recipe: {
id: recipe.id!,
name: recipe.name!,
serves: recipe.serves,
cookTimeMin: recipe.cookTimeMin,
effort: recipe.effort,
heroImageUrl: recipe.heroImageUrl,
ingredients: (recipe.ingredients ?? []).map((ing) => ({
name: ing.name ?? '',
quantity: ing.quantity ?? 0,
unit: ing.unit ?? ''
})),
steps: (recipe.steps ?? [])
.sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
.map((s) => ({ instruction: s.instruction ?? '' })),
tagIds: (recipe.tags ?? []).map((t) => t.id!)
},
categories
};
};
export const actions: Actions = {
update: async ({ request, fetch, params }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
const serves = formData.get('serves');
const cookTimeMin = formData.get('cookTimeMin');
const effort = formData.get('effort') as string;
const ingredientsJson = formData.get('ingredientsJson') as string;
const stepsJson = formData.get('stepsJson') as string;
const tagIds = formData.getAll('tagIds') as string[];
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
if (!effort || !VALID_EFFORTS.includes(effort))
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
let parsedIngredients: unknown[];
let parsedSteps: unknown[];
try {
parsedIngredients = JSON.parse(ingredientsJson || '[]');
parsedSteps = JSON.parse(stepsJson || '[]');
} catch {
return fail(400, { error: 'Ungültige Formulardaten' });
}
const api = apiClient(fetch);
const { error: apiError } = await api.PUT('/v1/recipes/{id}', {
params: { path: { id: params.id } },
body: {
name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.map((ing, i) => ({
newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0,
unit: ing.unit || '',
sortOrder: i
})),
steps: (parsedSteps as string[])
.filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
tagIds
}
});
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
redirect(303, '/recipes');
}
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.recipe?.name ?? 'Rezept bearbeiten'} — Mealplan</title>
</svelte:head>
<div class="p-[24px]">
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
Rezept bearbeiten
</h1>
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/update" />
</div>

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
const mockPut = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, PUT: mockPut })
}));
describe('edit recipe page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
mockPut.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
const mockRecipe = {
id: 'r1',
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Easy',
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
};
const mockTags = [
{ id: 't1', name: 'Pasta', tagType: 'category' },
{ id: 't2', name: 'Fleisch', tagType: 'category' }
];
it('fetches recipe and tags in parallel', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
});
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(mockGet).toHaveBeenCalledTimes(2);
});
it('returns recipe data mapped for form', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
});
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(result.recipe.name).toBe('Spaghetti Bolognese');
expect(result.recipe.effort).toBe('Easy');
});
it('returns categories from tags', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
});
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(result.categories).toHaveLength(2);
});
it('throws 404 when recipe not found', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: undefined, error: { status: 404 } });
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
});
await expect(
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
).rejects.toMatchObject({ status: 404 });
});
});
describe('edit recipe page — update action', () => {
let actions: any;
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
name: 'Test Rezept',
effort: 'Easy',
tagIds: ['t1'],
ingredientsJson: '[]',
stepsJson: '[]',
...overrides
};
const fd = new FormData();
for (const [key, val] of Object.entries(base)) {
if (Array.isArray(val)) {
for (const v of val) fd.append(key, v);
} else {
fd.append(key, val);
}
}
return fd;
};
beforeEach(async () => {
mockGet.mockReset();
mockPut.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
it('returns fail(422) when name is missing', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ name: '' }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when effort is missing', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ effort: '' }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when effort is not a valid value', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ effort: 'VeryHard' }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when no tagIds', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ tagIds: [] }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(422);
});
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(400);
});
it('returns fail(400) when stepsJson is invalid JSON', async () => {
const result = await actions.update({
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(400);
});
it('calls PUT /v1/recipes/{id} with correct body on success', async () => {
mockPut.mockResolvedValue({ error: undefined });
const fd = makeFormData({
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
stepsJson: JSON.stringify(['Kochen'])
});
await actions.update({
request: { formData: async () => fd },
fetch: vi.fn(),
params: { id: 'r1' }
} as any).catch(() => {});
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
params: { path: { id: 'r1' } },
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
}));
});
it('returns fail(500) when API returns error', async () => {
mockPut.mockResolvedValue({ error: { status: 500 } });
const result = await actions.update({
request: { formData: async () => makeFormData() },
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(500);
});
});

View File

@@ -0,0 +1,66 @@
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 })
}));
describe('recipe detail page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
const mockRecipe = {
id: 'r1',
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined,
ingredients: [
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }
],
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
tags: []
};
it('fetches recipe from GET /v1/recipes/{id}', async () => {
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(mockGet).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
params: { path: { id: 'r1' } }
}));
});
it('returns recipe data on success', async () => {
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
expect(result.recipe.name).toBe('Spaghetti Bolognese');
expect(result.recipe.serves).toBe(4);
});
it('throws 404 error when API returns error', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
await expect(
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
).rejects.toMatchObject({ status: 404 });
});
it('throws 404 error when API returns 403 (different household — intentional)', async () => {
// Security design: we return 404 for both "not found" and "forbidden"
// to avoid revealing resource existence to unauthorized users
mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
await expect(
load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
).rejects.toMatchObject({ status: 404 });
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
const mockData = {
recipe: {
id: 'r1',
name: 'Spaghetti Bolognese',
serves: 4,
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined as string | undefined,
ingredients: [
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' }
],
steps: [
{ stepNumber: 1, instruction: 'Wasser aufsetzen' },
{ stepNumber: 2, instruction: 'Sauce zubereiten' }
],
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
}
};
describe('recipe detail page', () => {
it('renders the recipe name', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
});
it('renders page title', () => {
render(Page, { props: { data: mockData } });
expect(document.title).toBe('Spaghetti Bolognese — Mealplan');
});
it('renders back link to /recipes', () => {
render(Page, { props: { data: mockData } });
const backLink = screen.getByRole('link', { name: /zurück/i });
expect(backLink).toHaveAttribute('href', '/recipes');
});
it('renders cook now link to /cook/[id]', () => {
render(Page, { props: { data: mockData } });
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
expect(cookLink).toHaveAttribute('href', '/cook/r1');
});
it('renders ingredients section heading', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
});
it('renders steps section heading', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
});
it('renders ingredient names', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
});
it('renders step instructions', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
});
it('renders edit link to /recipes/[id]/edit', () => {
render(Page, { props: { data: mockData } });
const editLink = screen.getByRole('link', { name: /bearbeiten/i });
expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
});
it('renders tag pills in hero', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText('Pasta')).toBeInTheDocument();
});
it('renders hero image when heroImageUrl is provided', () => {
render(Page, {
props: {
data: {
recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
}
}
});
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
});
});

View File

@@ -0,0 +1,70 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
export const load: PageServerLoad = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/tags', {});
const allTags = error || !data ? [] : data;
const categories = allTags
.filter((t) => t.tagType === 'category')
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
return { recipe: null, categories };
};
export const actions: Actions = {
create: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
const serves = formData.get('serves');
const cookTimeMin = formData.get('cookTimeMin');
const effort = formData.get('effort') as string;
const ingredientsJson = formData.get('ingredientsJson') as string;
const stepsJson = formData.get('stepsJson') as string;
const tagIds = formData.getAll('tagIds') as string[];
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
if (!effort || !VALID_EFFORTS.includes(effort))
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
let parsedIngredients: unknown[];
let parsedSteps: unknown[];
try {
parsedIngredients = JSON.parse(ingredientsJson || '[]');
parsedSteps = JSON.parse(stepsJson || '[]');
} catch {
return fail(400, { error: 'Ungültige Formulardaten' });
}
const api = apiClient(fetch);
const { error: apiError } = await api.POST('/v1/recipes', {
body: {
name: name.trim(),
serves: serves ? Number(serves) || undefined : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
.filter((ing) => ing.name?.trim())
.map((ing, i) => ({
newIngredientName: ing.name.trim(),
quantity: Number(ing.quantity) || 0,
unit: ing.unit || '',
sortOrder: i
})),
steps: (parsedSteps as string[])
.filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
tagIds
}
});
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
redirect(303, '/recipes');
}
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Neues Rezept — Mealplan</title>
</svelte:head>
<div class="p-[24px]">
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
Neues Rezept
</h1>
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/create" />
</div>

View File

@@ -0,0 +1,154 @@
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();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost })
}));
describe('new recipe page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
const mockTags = [
{ id: 't1', name: 'Pasta', tagType: 'category' },
{ id: 't2', name: 'Fleisch', tagType: 'category' }
];
it('fetches tags from GET /v1/tags', async () => {
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
await load({ fetch: vi.fn() } as any);
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
});
it('returns categories filtered from tags', async () => {
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
const result = await load({ fetch: vi.fn() } as any);
expect(result.categories).toHaveLength(2);
expect(result.categories[0].name).toBe('Pasta');
});
it('returns empty categories when API fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
const result = await load({ fetch: vi.fn() } as any);
expect(result.categories).toEqual([]);
});
it('returns null recipe for new form', async () => {
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
const result = await load({ fetch: vi.fn() } as any);
expect(result.recipe).toBeNull();
});
});
describe('new recipe page — create action', () => {
let actions: any;
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
name: 'Test Rezept',
effort: 'Easy',
tagIds: ['t1'],
ingredientsJson: '[]',
stepsJson: '[]',
...overrides
};
const fd = new FormData();
for (const [key, val] of Object.entries(base)) {
if (Array.isArray(val)) {
for (const v of val) fd.append(key, v);
} else {
fd.append(key, val);
}
}
return fd;
};
beforeEach(async () => {
mockGet.mockReset();
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
it('returns fail(422) when name is missing', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ name: '' }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when effort is missing', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ effort: '' }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when effort is not a valid value', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ effort: 'InvalidEffort' }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(422);
});
it('returns fail(422) when no tagIds', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ tagIds: [] }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(422);
});
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(400);
});
it('returns fail(400) when stepsJson is invalid JSON', async () => {
const result = await actions.create({
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
fetch: vi.fn()
} as any);
expect(result.status).toBe(400);
});
it('calls POST /v1/recipes with correct body on success', async () => {
mockPost.mockResolvedValue({ error: undefined });
const fd = makeFormData({
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
stepsJson: JSON.stringify(['Kochen'])
});
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
() => {}
);
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) }));
});
it('returns fail(500) when API returns error', async () => {
mockPost.mockResolvedValue({ error: { status: 500 } });
const result = await actions.create({
request: { formData: async () => makeFormData() },
fetch: vi.fn()
} as any);
expect(result.status).toBe(500);
});
});

View File

@@ -0,0 +1,45 @@
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 })
}));
describe('recipe library page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
const mockRecipes = [
{ id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' },
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
];
it('fetches recipes from GET /v1/recipes', async () => {
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
await load({ fetch: vi.fn() } as any);
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
});
it('returns recipes in data', async () => {
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
const result = await load({ fetch: vi.fn() } as any);
expect(result.recipes).toHaveLength(2);
expect(result.recipes[0].name).toBe('Spaghetti');
});
it('returns empty array when API fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
const result = await load({ fetch: vi.fn() } as any);
expect(result.recipes).toEqual([]);
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import Page from './+page.svelte';
const mockData = {
recipes: [
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
]
};
describe('recipe library page', () => {
it('renders all recipe cards initially', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
});
it('renders the page title', () => {
render(Page, { props: { data: mockData } });
expect(document.title).toBe('Rezepte — Mealplan');
});
it('renders a link to add a new recipe', () => {
render(Page, { props: { data: mockData } });
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
expect(addLink).toHaveAttribute('href', '/recipes/new');
});
it('filters recipes by search term', async () => {
const user = userEvent.setup();
render(Page, { props: { data: mockData } });
const searchInput = screen.getByPlaceholderText(/suchen/i);
await user.type(searchInput, 'Curry');
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
});
it('filters recipes by effort chip', async () => {
const user = userEvent.setup();
render(Page, { props: { data: mockData } });
await user.click(screen.getByRole('button', { name: 'Mittel' }));
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
});
it('shows empty state when no recipes match search', async () => {
const user = userEvent.setup();
render(Page, { props: { data: mockData } });
const searchInput = screen.getByPlaceholderText(/suchen/i);
await user.type(searchInput, 'xyznotexist');
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
});
it('renders filter chips', () => {
render(Page, { props: { data: mockData } });
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
});
it('renders empty state page when no recipes at all', () => {
render(Page, { props: { data: { recipes: [] } } });
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
});
it('applies search and effort filter together', async () => {
const user = userEvent.setup();
render(Page, { props: { data: mockData } });
await user.click(screen.getByRole('button', { name: 'Leicht' }));
const searchInput = screen.getByPlaceholderText(/suchen/i);
await user.type(searchInput, 'Gemüse');
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
expect(screen.queryByText('Chicken Curry')).not.toBeInTheDocument();
});
it('resets to all recipes when Alle chip is clicked', async () => {
const user = userEvent.setup();
render(Page, { props: { data: mockData } });
await user.click(screen.getByRole('button', { name: 'Mittel' }));
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Alle' }));
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,913 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — J2 Add to Plan · Screens C4C6</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-bottom:1px solid var(--color-border);border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
/* Sections */
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
/* Journey header */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}.jh-y .jn{color:var(--yellow-dark);}.jh-y p,.jh-y .fl{color:var(--yellow-text);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Preview container */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
/* Phone frame */
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;display:flex;flex-direction:column;}
/* Desktop frame */
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Desktop sidebar */
.dsb{width:196px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:16px 14px 12px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:6px;margin-bottom:2px;}.dsb-ic{width:22px;height:22px;border-radius:4px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:11px;}.dsb-nm{font-family:var(--font-display);font-size:15px;font-weight:500;letter-spacing:-.02em;}.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:28px;}
.dsb-nav{padding:10px 8px;flex:1;}
.dsb-ni{display:flex;align-items:center;gap:6px;padding:6px 6px;border-radius:5px;font-size:12px;color:var(--color-text-muted);margin-bottom:1px;}
.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}
.dsb-nc{font-size:12px;width:16px;text-align:center;}
.dsb-var{margin:0 8px 12px;padding:10px 12px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-lg);}
.dsb-ve{font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:3px;}
.dsb-vn{font-family:var(--font-display);font-size:28px;font-weight:300;line-height:1;color:var(--color-text);}
/* Desktop main */
.d-main{flex:1;display:flex;flex-direction:column;min-width:0;}
.d-tb{padding:10px 16px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.d-ttitle{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}
.d-ab{font-family:var(--font-sans);font-size:12px;font-weight:500;padding:5px 12px;border-radius:4px;background:var(--green);color:#fff;border:none;}
.d-content{flex:1;display:flex;overflow:hidden;}
.d-cal{flex:1;overflow-y:auto;padding:10px;}
.d-cg{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;}
.d-cc{display:flex;flex-direction:column;}
.d-ch{text-align:center;padding-bottom:6px;margin-bottom:6px;border-bottom:2px solid var(--color-border);}
.d-ch.th{border-bottom-color:var(--yellow);}
.d-ch.sh{border-bottom-color:var(--green);}
.d-dn{font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:3px;}
.d-db{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:4px;font-size:11px;font-weight:500;color:var(--color-text);}
.d-ch.th .d-db{background:var(--yellow);}
.d-ch.sh .d-db{background:var(--green-tint);color:var(--green-dark);}
.d-tile{flex:1;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:6px 6px 8px;cursor:pointer;box-shadow:var(--shadow-card);display:flex;flex-direction:column;gap:2px;}
.d-tile.tt{border-color:var(--yellow);border-width:2px;background:var(--yellow-tint);}
.d-te{font-size:7px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--color-text-muted);}
.d-tn{font-family:var(--font-display);font-size:10px;font-weight:400;letter-spacing:-.01em;color:var(--color-text);line-height:1.3;}
.d-tm{font-size:8px;color:var(--color-text-muted);margin-top:1px;}
.d-et{flex:1;border:1px dashed var(--color-border);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center;min-height:56px;flex-direction:column;gap:2px;cursor:pointer;}
.d-et.active{border-style:solid;border-color:var(--green);background:var(--green-tint);}
.d-ep{font-size:15px;color:var(--color-border);}
.d-el{font-size:8px;color:var(--color-border);}
.d-et.active .d-ep,.d-et.active .d-el{color:var(--green-dark);}
/* Detail panel */
.d-dp{width:216px;flex-shrink:0;background:var(--color-surface);border-left:1px solid var(--color-border);display:flex;flex-direction:column;overflow-y:auto;}
.d-dph{padding:10px 12px 8px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:flex-start;}
.d-dpd{font-family:var(--font-display);font-size:14px;font-weight:500;letter-spacing:-.01em;}
.d-dpdt{font-size:9px;color:var(--color-text-muted);margin-top:1px;}
.d-dpx{font-size:16px;color:var(--color-text-muted);cursor:pointer;line-height:1;flex-shrink:0;}
.d-psearch{padding:8px 10px;border-bottom:1px solid var(--color-subtle);}
.d-psi{width:100%;font-family:var(--font-sans);font-size:11px;padding:6px 8px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.d-prsect{font-size:7px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);padding:6px 10px 3px;background:var(--color-subtle);}
.d-prec{padding:7px 10px;border-bottom:1px solid var(--color-subtle);cursor:pointer;}
.d-prec:last-child{border-bottom:none;}
.d-prname{font-family:var(--font-display);font-size:11px;font-weight:400;color:var(--color-text);line-height:1.25;}
.d-prmeta{font-size:9px;color:var(--color-text-muted);margin-top:1px;}
.d-prbadge{display:inline-block;font-size:8px;font-weight:500;padding:1px 5px;border-radius:3px;background:var(--green-tint);color:var(--green-dark);margin-top:2px;}
.d-prbadge.warn{background:var(--yellow-tint);color:var(--yellow-text);}
/* Bottom tab nav */
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:6px 16px 20px;display:flex;justify-content:space-around;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}.mt-ic{width:18px;height:18px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:10px;}.mt-i.a .mt-ic{background:var(--green-tint);}.mt-l{font-size:8px;font-weight:500;color:var(--color-text-muted);}.mt-i.a .mt-l{color:var(--green-dark);}
/* Shared badges */
.badge{font-size:9px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;}
.badge-g{background:var(--green-tint);color:var(--green-dark);}
.badge-y{background:var(--yellow-tint);color:var(--yellow-text);}
.badge-m{background:var(--color-subtle);color:var(--color-text-muted);}
/* Eyebrow */
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
/* Annotation cards */
.ann{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:20px 24px;margin-bottom:12px;}
.ann h3{font-size:13px;font-weight:500;color:var(--color-text);margin-bottom:8px;}
.ann p{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:16px;}
.note{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px 20px;}
.note h4{font-size:11px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
.note p{font-size:13px;color:var(--color-text-muted);line-height:1.6;}
.note.hl{border-color:var(--green-light);background:var(--green-tint);}
.note.hl h4{color:var(--green-dark);}
.note.hl p{color:var(--green-deeper);}
.note.warn{border-color:var(--yellow-light);background:var(--yellow-tint);}
.note.warn h4{color:var(--yellow-text);}
.note.warn p{color:var(--yellow-text);}
/* Agent section */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── C4: Recipe picker bottom sheet ── */
.sheet-host{flex:1;display:flex;flex-direction:column;justify-content:flex-end;min-height:400px;}
.sheet-bg{position:relative;flex:1;background:rgba(28,28,24,.4);}
.sheet{background:var(--color-page);border-top:1px solid var(--color-border);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:0 -4px 20px rgba(0,0,0,.12);display:flex;flex-direction:column;max-height:320px;}
.sheet-handle{width:32px;height:4px;border-radius:2px;background:var(--color-border);margin:10px auto 0;flex-shrink:0;}
.sheet-hd{padding:8px 14px 8px;border-bottom:1px solid var(--color-subtle);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.sheet-ht{font-family:var(--font-display);font-size:14px;font-weight:500;letter-spacing:-.01em;}
.sheet-sub{font-size:10px;color:var(--color-text-muted);margin-top:1px;}
.sheet-x{font-size:17px;color:var(--color-text-muted);cursor:pointer;line-height:1;}
.sheet-search{padding:7px 12px;border-bottom:1px solid var(--color-subtle);flex-shrink:0;}
.sheet-si{width:100%;font-family:var(--font-sans);font-size:11px;padding:6px 8px 6px 26px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text);outline:none;}
.sheet-si-wrap{position:relative;}
.sheet-si-icon{position:absolute;left:8px;top:50%;transform:translateY(-50%);font-size:10px;color:var(--color-text-muted);}
.sheet-body{overflow-y:auto;flex:1;}
.sheet-sect{font-size:7px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);padding:5px 12px 3px;background:var(--color-subtle);}
.sheet-row{padding:7px 12px;border-bottom:1px solid var(--color-subtle);display:flex;align-items:center;gap:8px;}
.sheet-row:last-child{border-bottom:none;}
.sheet-rinfo{flex:1;}
.sheet-rn{font-family:var(--font-display);font-size:12px;font-weight:400;color:var(--color-text);line-height:1.25;}
.sheet-rm{font-size:9px;color:var(--color-text-muted);margin-top:1px;}
.sheet-rbadge{display:inline-block;font-size:8px;font-weight:500;padding:1px 5px;border-radius:3px;background:var(--green-tint);color:var(--green-dark);margin-top:2px;}
.sheet-rbadge.warn{background:var(--yellow-tint);color:var(--yellow-text);}
.sheet-add{font-family:var(--font-sans);font-size:10px;font-weight:500;padding:4px 8px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;white-space:nowrap;flex-shrink:0;}
/* ── C5: Recipe quick actions ── */
.rc-list{padding:8px 12px;overflow-y:auto;}
.rc-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:8px;box-shadow:var(--shadow-card);}
.rc-name{font-family:var(--font-display);font-size:14px;font-weight:400;letter-spacing:-.01em;color:var(--color-text);line-height:1.25;margin-bottom:4px;}
.rc-tags{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:8px;}
.rc-acts{display:flex;gap:5px;}
.rc-cook{flex:1;font-family:var(--font-sans);font-size:10px;font-weight:500;padding:5px 6px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;text-align:center;}
.rc-plan{flex:1;font-family:var(--font-sans);font-size:10px;font-weight:500;padding:5px 6px;border-radius:var(--radius-md);background:var(--green-tint);color:var(--green-dark);border:1px solid var(--green-light);text-align:center;}
/* Desktop recipe grid */
.d-rcgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:16px;}
.d-rccard{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;box-shadow:var(--shadow-card);}
.d-rcname{font-family:var(--font-display);font-size:16px;font-weight:400;letter-spacing:-.01em;color:var(--color-text);margin-bottom:5px;}
.d-rcdesc{font-size:12px;color:var(--color-text-muted);line-height:1.5;margin-bottom:10px;}
.d-rctags{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:12px;}
.d-rcacts{display:flex;gap:6px;}
.d-rccook{flex:1;font-family:var(--font-sans);font-size:11px;font-weight:500;padding:7px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;text-align:center;}
.d-rcplan{flex:1;font-family:var(--font-sans);font-size:11px;font-weight:500;padding:7px;border-radius:var(--radius-md);background:var(--green-tint);color:var(--green-dark);border:1px solid var(--green-light);text-align:center;}
/* ── C6: Day picker ── */
.dp-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:3px;padding:8px 12px 5px;}
.dp-chip{display:flex;flex-direction:column;align-items:center;gap:2px;padding:6px 2px;border-radius:5px;border:1px solid transparent;cursor:pointer;}
.dp-chip.empty{border-style:dashed;border-color:var(--green-light);background:var(--green-tint);}
.dp-chip.filled{border-color:var(--color-border);background:var(--color-surface);}
.dp-chip.today{border-color:var(--yellow);background:var(--yellow-tint);}
.dp-chip.sel-empty{border:2px solid var(--green-dark);background:var(--green-tint);}
.dp-chip.sel-filled{border:2px solid var(--orange-dark);background:var(--orange-tint);}
.dp-ca{font-size:7px;font-weight:500;letter-spacing:.05em;text-transform:uppercase;color:var(--color-text-muted);}
.dp-chip.empty .dp-ca{color:var(--green-dark);}
.dp-chip.today .dp-ca{color:var(--yellow-text);}
.dp-chip.sel-empty .dp-ca{color:var(--green-deeper);}
.dp-chip.sel-filled .dp-ca{color:var(--orange-dark);}
.dp-cn{font-size:11px;font-weight:500;color:var(--color-text);}
.dp-dot{width:4px;height:4px;border-radius:50%;background:var(--color-border);margin-top:1px;}
.dp-chip.empty .dp-dot{background:transparent;}
.dp-chip.filled .dp-dot,.dp-chip.sel-empty .dp-dot{background:var(--green);}
.dp-chip.today .dp-dot{background:var(--yellow-text);}
.dp-chip.sel-filled .dp-dot{background:var(--orange-dark);}
.dp-confirm{padding:8px 12px 6px;}
.dp-btn{width:100%;font-family:var(--font-sans);font-size:12px;font-weight:500;padding:9px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;text-align:center;}
.dp-btn.replace{background:var(--orange);}
.dp-warn{background:var(--orange-tint);border:1px solid #FBCDA4;border-radius:var(--radius-md);padding:6px 10px;margin:0 12px 6px;font-size:10px;color:var(--orange-dark);line-height:1.45;}
/* LLM section */
.llm{background:var(--color-text);color:#E8E8E2;padding:36px 44px;border-radius:var(--radius-lg);margin-top:80px;}
.llm h2{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#6B6A63;margin-bottom:16px;}
.llm h3{font-size:12px;font-weight:500;color:#9A9990;margin:24px 0 8px;letter-spacing:.04em;text-transform:uppercase;}
.llm p,.llm li{font-size:12px;color:#9A9990;line-height:1.7;}
.llm ul{margin-left:16px;margin-bottom:8px;}
.llm strong{color:#E8E8E2;}
.llm code{font-family:var(--font-mono);font-size:10px;background:#1E1E1A;color:#A0A090;padding:1px 5px;border-radius:3px;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>J2 Add to Plan — Screens C4C6</h1>
<p>Supplemental spec · Bottom sheet recipe picker · Recipe quick actions · Day picker</p>
</div>
<div class="doc-meta">
Journey: J2 supplement<br>
Screens: C4 · C5 · C6<br>
Status: draft<br>
Version: 1.0<br>
Updated: 2026-04
</div>
</div>
<div class="jh jh-y">
<div class="jn">J2</div>
<div>
<h2>Add to plan — supplemental</h2>
<p>Three missing flows: picking a recipe for an empty slot (C4), quick actions on recipe cards (C5), and the day picker when the recipe is already known (C6).</p>
<div class="fl">Two entry points: C1 "+" / empty slot → C4 · B1 "Zur Woche +" → C6</div>
</div>
</div>
<!-- RATIONALE -->
<div class="section">
<div class="section-title">Design rationale</div>
<div class="grid2">
<div class="ann">
<h3>Entry from planner (C4) — recipe unknown</h3>
<p>User taps "+" or an empty slot in C1. The day is known; the recipe isn't. A bottom sheet slides up over the dimmed planner, showing variety-ranked suggestions and a search-filtered full library. On desktop the empty tile highlights and the detail panel transforms to a recipe picker — the calendar grid stays fully visible. No full-page navigation; same pattern as J4 swap.</p>
</div>
<div class="ann">
<h3>Entry from recipe list (C5 → C6) — day unknown</h3>
<p>User is browsing recipes and taps "Zur Woche +". The recipe is known; the day isn't. A compact day-picker sheet shows the week's slots. Empty slots have a dashed green border to invite selection. Selecting a filled slot shows an inline replace warning — no modal dialog. "Jetzt kochen" on the same card navigates directly to J3 cook mode.</p>
</div>
</div>
<div class="grid3">
<div class="note warn">
<h4>Add ≠ Swap</h4>
<p>J4 Swap starts from a filled slot and shows system-suggested replacements. C4/C6 add starts from an empty slot or a chosen recipe. Different intent — do not reuse the swap sheet component.</p>
</div>
<div class="note hl">
<h4>Mobile: always bottom sheet</h4>
<p>No full-page navigation for either entry point. The planner or recipe list context stays visible behind the sheet at 40% opacity.</p>
</div>
<div class="note">
<h4>Desktop: panel transformation</h4>
<p>The 216px detail panel switches between states. No modal overlay. The calendar grid or recipe grid remains fully interactive beside the picker.</p>
</div>
</div>
</div>
<!-- ═══ C4: ADD MEAL FROM PLANNER ═══ -->
<div class="scr" id="c4">
<div class="scr-head"><h3>Add meal — from planner</h3><span class="scr-id">C4</span></div>
<div class="scr-desc">Entry: tap "+" in C1 nav (pre-selects next empty day) or tap any empty slot chip/tile (pre-selects that day). Shows a bottom sheet with search + variety-ranked suggestions + full recipe list. Tapping a recipe immediately fills the slot — no confirmation, undo toast instead.</div>
<div class="scr-var"><strong>V1 · Bottom sheet (mobile &amp; tablet)</strong> · <strong>V2 · Panel transformation (desktop)</strong></div>
<div class="section">
<div class="section-title">Breakpoint 1 — Mobile · &lt; 768px</div>
<div style="display:flex;gap:40px;align-items:flex-start;flex-wrap:wrap;margin-bottom:32px;">
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<!-- Planner context, dimmed -->
<div style="opacity:.3;">
<div style="padding:8px 12px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;">
<div style="font-family:var(--font-display);font-size:16px;font-weight:500;">Diese Woche</div>
<div style="display:flex;gap:3px;">
<div style="width:22px;height:22px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);"></div>
<div style="width:22px;height:22px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);"></div>
<div style="width:22px;height:22px;border-radius:4px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;">+</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:5px 10px;">
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Mo</span><span style="font-size:10px;font-weight:500;">31</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;background:var(--yellow-tint);"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--yellow-text);">Di</span><span style="font-size:10px;font-weight:500;">1</span><div style="width:3px;height:3px;border-radius:50%;background:var(--yellow-text);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Mi</span><span style="font-size:10px;font-weight:500;">2</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Do</span><span style="font-size:10px;font-weight:500;">3</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Fr</span><span style="font-size:10px;font-weight:500;">4</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;border:1px dashed var(--green-light);background:var(--green-tint);"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--green-dark);">Sa</span><span style="font-size:10px;font-weight:500;">5</span><div style="width:3px;height:3px;border-radius:50%;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 1px;border-radius:4px;"><span style="font-size:7px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">So</span><span style="font-size:10px;font-weight:500;">6</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);"></div></div>
</div>
</div>
<!-- Sheet -->
<div class="sheet-host">
<div class="sheet-bg"></div>
<div class="sheet">
<div class="sheet-handle"></div>
<div class="sheet-hd">
<div>
<div class="sheet-ht">Rezept wählen</div>
<div class="sheet-sub">Samstag, 5. April</div>
</div>
<div class="sheet-x">×</div>
</div>
<div class="sheet-search">
<div class="sheet-si-wrap">
<span class="sheet-si-icon">🔍</span>
<input class="sheet-si" type="text" placeholder="Rezept suchen…" readonly/>
</div>
</div>
<div class="sheet-body">
<div class="sheet-sect">Empfohlen · Beste Abwechslung</div>
<div class="sheet-row">
<div class="sheet-rinfo">
<div class="sheet-rn">Lachsfilet mit Gemüse</div>
<div class="sheet-rm">25 min · Einfach · Fisch</div>
<span class="sheet-rbadge">↑ +2 Punkte</span>
</div>
<button class="sheet-add">+ Wählen</button>
</div>
<div class="sheet-row">
<div class="sheet-rinfo">
<div class="sheet-rn">Mushroom Risotto</div>
<div class="sheet-rm">50 min · Mittel · Vegetarisch</div>
<span class="sheet-rbadge">↑ +2 Punkte</span>
</div>
<button class="sheet-add">+ Wählen</button>
</div>
<div class="sheet-row">
<div class="sheet-rinfo">
<div class="sheet-rn">Hähnchen-Curry</div>
<div class="sheet-rm">35 min · Einfach</div>
<span class="sheet-rbadge warn">⚠ Fr ebenfalls Hähnchen</span>
</div>
<button class="sheet-add">+ Wählen</button>
</div>
<div class="sheet-sect">Alle Rezepte</div>
<div class="sheet-row">
<div class="sheet-rinfo"><div class="sheet-rn">Beef Bourguignon</div><div class="sheet-rm">2h 30 · Aufwendig</div></div>
<button class="sheet-add">+ Wählen</button>
</div>
<div class="sheet-row">
<div class="sheet-rinfo"><div class="sheet-rn">Spaghetti Carbonara</div><div class="sheet-rm">20 min · Einfach</div></div>
<button class="sheet-add">+ Wählen</button>
</div>
<div class="sheet-row">
<div class="sheet-rinfo"><div class="sheet-rn">Tomatensuppe</div><div class="sheet-rm">30 min · Vegetarisch</div></div>
<button class="sheet-add">+ Wählen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="flex:1;min-width:240px;display:flex;flex-direction:column;gap:12px;">
<div class="note hl">
<h4>Sheet structure</h4>
<p>Drag handle → title row (day date in muted subtitle) → close × → search input → two sections: "Empfohlen" (24 variety-ranked, sorted by variety_delta DESC) + "Alle Rezepte" (full library, filtered by search). Max height ~75vh — 3 suggestions visible without scrolling.</p>
</div>
<div class="note">
<h4>Variety badges</h4>
<p>Green "↑ +N Punkte" when adding this recipe improves the variety score. Yellow "⚠ [reason]" when it creates a conflict but is still selectable. No badge in the "Alle Rezepte" section.</p>
</div>
<div class="note">
<h4>Background</h4>
<p>Weekly planner dims to 30% opacity behind the sheet. The week strip is still legible — the empty Sa chip is visible with its dashed border, providing context for which slot is being filled.</p>
</div>
<div class="note warn">
<h4>Immediate write</h4>
<p>Tapping "+ Wählen" writes to the slot immediately and dismisses the sheet. An undo toast appears for 4 seconds. No confirmation dialog — same pattern as J4.</p>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Breakpoint 3 — Desktop · &gt; 1024px</div>
<div class="ann" style="margin-bottom:16px;">
<h3>Panel in recipe-picker mode</h3>
<p>Clicking an empty slot tile (or "+" in the toolbar without a day) opens the recipe picker in the right detail panel. The calendar grid remains fully visible and interactive. The empty tile highlights with a solid green border and "Wählen…" label to indicate which slot is being edited.</p>
</div>
<div style="overflow-x:auto;margin-bottom:20px;">
<div class="desk" style="min-height:460px;">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav">
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Planer</div>
<div class="dsb-ni"><span class="dsb-nc">📖</span> Rezepte</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkauf</div>
</div>
<div class="dsb-var"><div class="dsb-ve">Variety score</div><div class="dsb-vn">8<span style="font-size:11px;color:var(--color-text-muted);">/10</span></div></div>
</div>
<div class="d-main">
<div class="d-tb">
<div style="display:flex;align-items:center;gap:12px;">
<span class="d-ttitle">Wochenplanung</span>
<div style="display:flex;gap:4px;align-items:center;">
<button style="font-family:var(--font-sans);font-size:10px;padding:3px 7px;border-radius:4px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></button>
<span style="font-size:11px;font-weight:500;color:var(--color-text);min-width:120px;text-align:center;">31 März 6 Apr</span>
<button style="font-family:var(--font-sans);font-size:10px;padding:3px 7px;border-radius:4px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></button>
</div>
</div>
<button class="d-ab">+ Mahlzeit</button>
</div>
<div class="d-content">
<div class="d-cal">
<div class="d-cg">
<div class="d-cc"><div class="d-ch"><div class="d-dn">Montag</div><div class="d-db">31</div></div><div class="d-tile"><div class="d-te">Abendessen</div><div class="d-tn">Pasta al forno</div><div class="d-tm">30 min</div></div></div>
<div class="d-cc"><div class="d-ch th"><div class="d-dn">Dienstag</div><div class="d-db">1</div></div><div class="d-tile tt"><div class="d-te">Heute</div><div class="d-tn">Gegrillter Lachs</div><div class="d-tm">25 min</div></div></div>
<div class="d-cc"><div class="d-ch"><div class="d-dn">Mittwoch</div><div class="d-db">2</div></div><div class="d-tile"><div class="d-te">Abendessen</div><div class="d-tn">Tomaten-Pasta</div><div class="d-tm">45 min</div></div></div>
<div class="d-cc"><div class="d-ch"><div class="d-dn">Donnerstag</div><div class="d-db">3</div></div><div class="d-tile"><div class="d-te">Abendessen</div><div class="d-tn">Hähnchen-Stir-fry</div><div class="d-tm">25 min</div></div></div>
<div class="d-cc"><div class="d-ch"><div class="d-dn">Freitag</div><div class="d-db">4</div></div><div class="d-tile"><div class="d-te">Abendessen</div><div class="d-tn">Hähnchen-Curry</div><div class="d-tm">40 min</div></div></div>
<div class="d-cc"><div class="d-ch sh"><div class="d-dn">Samstag</div><div class="d-db">5</div></div><div class="d-et active"><div class="d-ep">+</div><div class="d-el">Wählen…</div></div></div>
<div class="d-cc"><div class="d-ch"><div class="d-dn">Sonntag</div><div class="d-db">6</div></div><div class="d-tile"><div class="d-te">Abendessen</div><div class="d-tn">Linsensuppe</div><div class="d-tm">45 min</div></div></div>
</div>
</div>
<!-- Detail panel: recipe picker mode -->
<div class="d-dp">
<div class="d-dph">
<div><div class="d-dpd">Sa, 5. April</div><div class="d-dpdt">Rezept wählen</div></div>
<div class="d-dpx">×</div>
</div>
<div class="d-psearch">
<input class="d-psi" type="text" placeholder="🔍 Rezept suchen…" readonly/>
</div>
<div class="d-prsect">Empfohlen</div>
<div class="d-prec">
<div class="d-prname">Lachsfilet mit Gemüse</div>
<div class="d-prmeta">25 min · Einfach · Fisch</div>
<span class="d-prbadge">↑ +2 Punkte</span>
</div>
<div class="d-prec">
<div class="d-prname">Mushroom Risotto</div>
<div class="d-prmeta">50 min · Mittel · Vegetarisch</div>
<span class="d-prbadge">↑ +2 Punkte</span>
</div>
<div class="d-prec">
<div class="d-prname">Hähnchen-Curry</div>
<div class="d-prmeta">35 min · Einfach</div>
<span class="d-prbadge warn">⚠ Fr ebenfalls Hähnchen</span>
</div>
<div class="d-prsect">Alle Rezepte</div>
<div class="d-prec"><div class="d-prname">Beef Bourguignon</div><div class="d-prmeta">2h 30 · Aufwendig</div></div>
<div class="d-prec"><div class="d-prname">Spaghetti Carbonara</div><div class="d-prmeta">20 min · Einfach</div></div>
<div class="d-prec"><div class="d-prname">Tomatensuppe</div><div class="d-prmeta">30 min · Vegetarisch</div></div>
<div class="d-prec"><div class="d-prname">Rindereintopf</div><div class="d-prmeta">1h 20 · Mittel</div></div>
</div>
</div>
</div>
</div>
</div>
<div class="grid3">
<div class="note hl"><h4>Active empty tile</h4><p>Clicked empty slot: solid green border (replaces dashed), green-tint bg, green-dark "+" icon and "Wählen…" label. Provides clear visual anchor for which slot the panel is operating on.</p></div>
<div class="note"><h4>Panel header state</h4><p>"Sa, 5. April" in Fraunces 14px / "Rezept wählen" in 9px muted below. Close × returns panel to idle state. If a filled day was previously selected, × returns to that day's detail view.</p></div>
<div class="note"><h4>Clicking a recipe row</h4><p>Immediate PATCH to slot. Panel switches to the day-detail view for the newly filled slot. No undo toast needed on desktop — the panel immediately shows the result with a "Swap" option if the user wants to change it.</p></div>
</div>
</div>
<div class="agent">
<h4>C4 · Add meal from planner — implementation</h4>
<pre>/* TWO ENTRY POINTS:
* 1. "+" button in C1 nav → pre-selects next empty day in week (MonSun scan).
* 2. Empty day slot chip (mobile) / empty tile (desktop) → pre-selects that date.
*
* MOBILE: bottom sheet slides up, planner dims to 30% opacity behind.
* Sheet: drag handle + {day label} header + × + search input + two sections.
* "Empfohlen" section: GET /api/suggestions?week={id}&day={date}
* → sorted by variety_delta DESC (unlike J4 which sorts by effort ASC).
* → 24 items. Badge: green "↑ +N Punkte" if delta > 0, yellow "⚠ {reason}" if ≤ 0.
* "Alle Rezepte": GET /api/recipes?sort=name — no badge.
* Search input: client-side filter of visible list. Does not re-sort.
* "+ Wählen" tap: PATCH /api/week-plan/{weekId}/slots/{date} {recipe_id}
* → dismiss sheet → undo toast 4s with "Rückgängig" link.
*
* DESKTOP: tap empty tile → detail panel enters recipe-picker state.
* Empty tile visual: solid green border, green-tint bg, "Wählen…" label.
* Panel state machine: idle | day-detail | recipe-picker | day-picker
* Clicking panel recipe row: PATCH → panel transitions to day-detail for that date.
* No undo toast on desktop (panel shows result immediately with Swap option). */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Mobile sheet</td></tr>
<tr><td>Sheet max-height</td><td>75vh</td><td>Drag to dismiss</td></tr>
<tr><td>Dim opacity</td><td>rgba(28,28,24,.4)</td><td>Same as J4</td></tr>
<tr><td>Suggestion sort</td><td>variety_delta DESC</td><td>Different from J4 (effort ASC)</td></tr>
<tr><td>Suggestion badge</td><td>8px, green-tint or yellow-tint</td><td>Only in Empfohlen section</td></tr>
<tr><td>Write action</td><td>PATCH + undo toast 4s</td><td>No confirmation dialog</td></tr>
<tr class="grp"><td colspan="3">Desktop panel</td></tr>
<tr><td>Active empty tile</td><td>solid green border, green-tint bg</td><td>Replaces dashed border</td></tr>
<tr><td>Panel width</td><td>216px (unchanged)</td><td>Same panel, different content state</td></tr>
<tr><td>Panel recipe click</td><td>PATCH → transition to day-detail</td><td>No toast needed</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ C5: RECIPE QUICK ACTIONS ═══ -->
<div class="scr" id="c5">
<div class="scr-head"><h3>Recipe card — quick actions</h3><span class="scr-id">C5</span></div>
<div class="scr-desc">Recipe list cards (Recipes tab, B1) gain two quick action buttons below the tags row. "Jetzt kochen" navigates directly to J3 cook mode. "Zur Woche +" triggers the C6 day picker. Both buttons are always visible — no hover-only pattern, since touch-capable desktops don't have reliable hover.</div>
<div class="scr-var"><strong>V1 · Always-visible quick action row</strong> — mobile list and desktop grid</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · Recipe list</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="padding:8px 14px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;">
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;">Rezepte</div>
<div style="width:26px;height:26px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--color-text-muted);">+</div>
</div>
<div style="padding:6px 12px;border-bottom:1px solid var(--color-border);">
<input style="width:100%;font-family:var(--font-sans);font-size:11px;padding:6px 8px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);outline:none;" type="text" placeholder="🔍 Rezept suchen…" readonly/>
</div>
<div class="rc-list">
<div class="rc-card">
<div class="rc-name">Spaghetti Carbonara</div>
<div class="rc-tags"><span class="badge badge-m">20 min</span> <span class="badge badge-g">Einfach</span> <span class="badge badge-m">Nudeln</span></div>
<div class="rc-acts"><button class="rc-cook">🍳 Jetzt kochen</button><button class="rc-plan">📅 Zur Woche +</button></div>
</div>
<div class="rc-card">
<div class="rc-name">Mushroom Risotto</div>
<div class="rc-tags"><span class="badge badge-m">50 min</span> <span class="badge badge-y">Mittel</span> <span class="badge badge-g">Vegetarisch</span></div>
<div class="rc-acts"><button class="rc-cook">🍳 Jetzt kochen</button><button class="rc-plan">📅 Zur Woche +</button></div>
</div>
<div class="rc-card">
<div class="rc-name">Lachsfilet mit Gemüse</div>
<div class="rc-tags"><span class="badge badge-m">25 min</span> <span class="badge badge-g">Einfach</span> <span class="badge badge-g">Fisch</span></div>
<div class="rc-acts"><button class="rc-cook">🍳 Jetzt kochen</button><button class="rc-plan">📅 Zur Woche +</button></div>
</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><span class="mt-l">Planer</span></div>
<div class="mt-i a"><div class="mt-ic">📖</div><span class="mt-l">Rezepte</span></div>
<div class="mt-i"><div class="mt-ic">🛒</div><span class="mt-l">Einkauf</span></div>
<div class="mt-i"><div class="mt-ic">⚙️</div><span class="mt-l">Settings</span></div>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · Recipe grid</div>
<div class="desk" style="min-height:400px;">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav">
<div class="dsb-ni"><span class="dsb-nc">📅</span> Planer</div>
<div class="dsb-ni a"><span class="dsb-nc">📖</span> Rezepte</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkauf</div>
</div>
</div>
<div class="d-main">
<div style="padding:10px 18px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;">
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;">Rezepte</div>
<div style="display:flex;gap:8px;align-items:center;">
<input style="font-family:var(--font-sans);font-size:11px;padding:5px 8px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);outline:none;width:160px;" type="text" placeholder="🔍 Suchen…" readonly/>
<button style="font-family:var(--font-sans);font-size:11px;font-weight:500;padding:5px 12px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;">+ Neu</button>
</div>
</div>
<div style="flex:1;overflow-y:auto;">
<div class="d-rcgrid">
<div class="d-rccard">
<div class="d-rcname">Spaghetti Carbonara</div>
<div class="d-rcdesc">Cremige römische Pasta — kein Sahne, nur Ei und Guanciale.</div>
<div class="d-rctags"><span class="badge badge-m">20 min</span> <span class="badge badge-g">Einfach</span> <span class="badge badge-m">Nudeln</span></div>
<div class="d-rcacts"><button class="d-rccook">🍳 Jetzt kochen</button><button class="d-rcplan">📅 Zur Woche +</button></div>
</div>
<div class="d-rccard">
<div class="d-rcname">Mushroom Risotto</div>
<div class="d-rcdesc">Cremiges Risotto mit Pilzen, Parmesan und frischem Thymian.</div>
<div class="d-rctags"><span class="badge badge-m">50 min</span> <span class="badge badge-y">Mittel</span> <span class="badge badge-g">Vegetarisch</span></div>
<div class="d-rcacts"><button class="d-rccook">🍳 Jetzt kochen</button><button class="d-rcplan">📅 Zur Woche +</button></div>
</div>
<div class="d-rccard">
<div class="d-rcname">Lachsfilet mit Gemüse</div>
<div class="d-rcdesc">Knusprig gebratener Lachs auf Ofengemüse.</div>
<div class="d-rctags"><span class="badge badge-m">25 min</span> <span class="badge badge-g">Einfach</span> <span class="badge badge-g">Fisch</span></div>
<div class="d-rcacts"><button class="d-rccook">🍳 Jetzt kochen</button><button class="d-rcplan">📅 Zur Woche +</button></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid2" style="margin-top:16px;">
<div class="note hl">
<h4>Two actions, two visual weights</h4>
<p>"Jetzt kochen" is filled green (primary). "Zur Woche +" is green-tint bg / green-dark text / green-light border (secondary). Same green family — clearly related, but different priority. Both are 10px mobile / 11px desktop, weight 500, radius-md.</p>
</div>
<div class="note">
<h4>Navigation targets</h4>
<p>"Jetzt kochen" → navigate to /cook/{recipeId} (J3, no sheet or confirmation). "Zur Woche +" → open C6 day picker (bottom sheet mobile, panel transformation desktop). The Recipes tab stays active.</p>
</div>
</div>
<div class="agent">
<h4>C5 · Recipe quick actions — implementation</h4>
<pre>/* Add two buttons to every recipe card in B1 (Recipes tab).
* Position: below the tags row, above any description/ingredients.
* "Jetzt kochen": navigate to /cook/{recipeId}. No confirmation. Primary style.
* "Zur Woche +": open C6 day-picker component. Secondary style.
* Both always visible — not hover-gated. Touch-capable desktops need always-visible actions.
* Mobile: equal-width flex row, font-size 10px, padding 5px 6px.
* Desktop grid: equal-width flex row, font-size 11px, padding 7px. */</pre>
<table class="at"><thead><tr><th>Token</th><th>Cook btn</th><th>Plan btn</th></tr></thead><tbody>
<tr><td>Background</td><td>var(--green)</td><td>var(--green-tint)</td></tr>
<tr><td>Text color</td><td>#fff</td><td>var(--green-dark)</td></tr>
<tr><td>Border</td><td>none</td><td>1px var(--green-light)</td></tr>
<tr><td>Font size mobile</td><td>10px</td><td>10px</td></tr>
<tr><td>Font size desktop</td><td>11px</td><td>11px</td></tr>
<tr><td>Radius</td><td>var(--radius-md)</td><td>var(--radius-md)</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ C6: DAY PICKER ═══ -->
<div class="scr" id="c6">
<div class="scr-head"><h3>Day picker — add from recipe</h3><span class="scr-id">C6</span></div>
<div class="scr-desc">Entry: tap "Zur Woche +" on any recipe card (C5). Recipe is already known; user picks the target day. Mobile: compact bottom sheet (~55vh) with week strip. Desktop: detail panel transforms to day picker. Empty slots have dashed green border to invite selection. Selecting a filled slot shows an inline replace warning — no modal dialog.</div>
<div class="scr-var"><strong>V1 · Empty slot selected</strong> (green confirm) · <strong>V2 · Filled slot selected</strong> (orange replace confirm)</div>
<div class="section">
<div class="section-title">Breakpoint 1 — Mobile · Both variants</div>
<div class="previews">
<!-- V1: empty slot -->
<div class="prev-col">
<div class="bp-lbl">V1 · Empty slot (Sa)</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="opacity:.28;">
<div style="padding:8px 14px;border-bottom:1px solid var(--color-border);font-family:var(--font-display);font-size:16px;font-weight:500;">Rezepte</div>
<div style="padding:8px 12px;">
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:9px 11px;margin-bottom:5px;font-family:var(--font-display);font-size:12px;">Spaghetti Carbonara</div>
<div style="background:var(--green-tint);border:2px solid var(--green);border-radius:var(--radius-lg);padding:9px 11px;font-family:var(--font-display);font-size:12px;">Mushroom Risotto</div>
</div>
</div>
<div class="sheet-host">
<div class="sheet-bg"></div>
<div class="sheet" style="max-height:55%;">
<div class="sheet-handle"></div>
<div class="sheet-hd">
<div>
<div class="sheet-ht">Mushroom Risotto</div>
<div class="sheet-sub">Zu welchem Tag hinzufügen?</div>
</div>
<div class="sheet-x">×</div>
</div>
<div style="padding:6px 12px 2px;display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:10px;font-weight:500;color:var(--color-text);">31 März 6 Apr</span>
<div style="display:flex;gap:3px;">
<span style="font-size:9px;padding:2px 5px;border-radius:3px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></span>
<span style="font-size:9px;padding:2px 5px;border-radius:3px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></span>
</div>
</div>
<div class="dp-strip">
<div class="dp-chip filled"><span class="dp-ca">Mo</span><span class="dp-cn">31</span><div class="dp-dot"></div></div>
<div class="dp-chip filled today"><span class="dp-ca">Di</span><span class="dp-cn">1</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">Mi</span><span class="dp-cn">2</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">Do</span><span class="dp-cn">3</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">Fr</span><span class="dp-cn">4</span><div class="dp-dot"></div></div>
<div class="dp-chip sel-empty"><span class="dp-ca">Sa</span><span class="dp-cn">5</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">So</span><span class="dp-cn">6</span><div class="dp-dot"></div></div>
</div>
<div class="dp-confirm">
<button class="dp-btn">Zu Samstag hinzufügen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- V2: filled slot -->
<div class="prev-col">
<div class="bp-lbl">V2 · Filled slot (Mi) — ersetzen?</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="opacity:.28;">
<div style="padding:8px 14px;border-bottom:1px solid var(--color-border);font-family:var(--font-display);font-size:16px;font-weight:500;">Rezepte</div>
<div style="padding:8px 12px;">
<div style="background:var(--green-tint);border:2px solid var(--green);border-radius:var(--radius-lg);padding:9px 11px;font-family:var(--font-display);font-size:12px;">Mushroom Risotto</div>
</div>
</div>
<div class="sheet-host">
<div class="sheet-bg"></div>
<div class="sheet" style="max-height:60%;">
<div class="sheet-handle"></div>
<div class="sheet-hd">
<div>
<div class="sheet-ht">Mushroom Risotto</div>
<div class="sheet-sub">Zu welchem Tag hinzufügen?</div>
</div>
<div class="sheet-x">×</div>
</div>
<div style="padding:6px 12px 2px;display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:10px;font-weight:500;color:var(--color-text);">31 März 6 Apr</span>
<div style="display:flex;gap:3px;">
<span style="font-size:9px;padding:2px 5px;border-radius:3px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></span>
<span style="font-size:9px;padding:2px 5px;border-radius:3px;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);"></span>
</div>
</div>
<div class="dp-strip">
<div class="dp-chip filled"><span class="dp-ca">Mo</span><span class="dp-cn">31</span><div class="dp-dot"></div></div>
<div class="dp-chip filled today"><span class="dp-ca">Di</span><span class="dp-cn">1</span><div class="dp-dot"></div></div>
<div class="dp-chip sel-filled"><span class="dp-ca">Mi</span><span class="dp-cn">2</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">Do</span><span class="dp-cn">3</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">Fr</span><span class="dp-cn">4</span><div class="dp-dot"></div></div>
<div class="dp-chip empty"><span class="dp-ca">Sa</span><span class="dp-cn">5</span><div class="dp-dot"></div></div>
<div class="dp-chip filled"><span class="dp-ca">So</span><span class="dp-cn">6</span><div class="dp-dot"></div></div>
</div>
<div class="dp-warn">Ersetzt <strong>Tomaten-Pasta</strong> am Mittwoch. Undo möglich.</div>
<div class="dp-confirm">
<button class="dp-btn replace">Mittwoch ersetzen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;align-self:flex-start;max-width:220px;">
<div class="note hl">
<h4>Slot states</h4>
<p>Empty: dashed green-light border + green-tint bg (invites selection). Filled: solid color-border + surface bg + green dot. Today: yellow-tint border. Selected empty: 2px green-dark. Selected filled: 2px orange-dark + orange-tint → shows replace warning.</p>
</div>
<div class="note warn">
<h4>No replace dialog</h4>
<p>The replace warning appears inline below the week strip. The confirm button turns orange. One tap replaces. An undo toast appears for 4 seconds. No modal dialog is shown — consistent with J4 swap.</p>
</div>
<div class="note">
<h4>Week navigation</h4>
<p> buttons allow browsing to next/prev week if the current week has no empty slots. Default: shows current week.</p>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Breakpoint 3 — Desktop · Panel day-picker mode</div>
<div style="overflow-x:auto;margin-bottom:20px;">
<div class="desk" style="min-height:440px;">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav">
<div class="dsb-ni"><span class="dsb-nc">📅</span> Planer</div>
<div class="dsb-ni a"><span class="dsb-nc">📖</span> Rezepte</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkauf</div>
</div>
</div>
<div class="d-main">
<div style="padding:10px 18px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;">
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;">Rezepte</div>
<input style="font-family:var(--font-sans);font-size:11px;padding:5px 8px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);outline:none;width:160px;" type="text" placeholder="🔍 Suchen…" readonly/>
</div>
<div style="flex:1;display:flex;overflow:hidden;">
<!-- Recipe grid (dimmed, active card highlighted) -->
<div style="flex:1;overflow-y:auto;">
<div class="d-rcgrid">
<div class="d-rccard" style="opacity:.45;">
<div class="d-rcname">Spaghetti Carbonara</div>
<div class="d-rctags"><span class="badge badge-m">20 min</span> <span class="badge badge-g">Einfach</span></div>
<div class="d-rcacts"><button class="d-rccook">🍳 Kochen</button><button class="d-rcplan">📅 Zur Woche +</button></div>
</div>
<div class="d-rccard" style="border:2px solid var(--green);background:var(--green-tint);">
<div class="d-rcname">Mushroom Risotto</div>
<div class="d-rcdesc">Cremiges Risotto mit Pilzen und Parmesan.</div>
<div class="d-rctags"><span class="badge badge-m">50 min</span> <span class="badge badge-y">Mittel</span> <span class="badge badge-g">Vegetarisch</span></div>
<div class="d-rcacts">
<button class="d-rccook">🍳 Kochen</button>
<button style="flex:1;font-family:var(--font-sans);font-size:11px;font-weight:500;padding:7px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;text-align:center;">📅 Tag wählen…</button>
</div>
</div>
<div class="d-rccard" style="opacity:.45;">
<div class="d-rcname">Lachsfilet</div>
<div class="d-rctags"><span class="badge badge-m">25 min</span> <span class="badge badge-g">Einfach</span></div>
<div class="d-rcacts"><button class="d-rccook">🍳 Kochen</button><button class="d-rcplan">📅 Zur Woche +</button></div>
</div>
</div>
</div>
<!-- Detail panel: day picker mode -->
<div class="d-dp">
<div class="d-dph">
<div><div class="d-dpd">Mushroom Risotto</div><div class="d-dpdt">Tag für diese Woche wählen</div></div>
<div class="d-dpx">×</div>
</div>
<!-- Mini week strip in panel -->
<div style="padding:8px 10px 4px;">
<div style="font-size:9px;font-weight:500;color:var(--color-text-muted);margin-bottom:5px;">31 März 6 Apr</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:3px;">
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Mo</span><span style="font-size:9px;font-weight:500;">31</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--yellow);background:var(--yellow-tint);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--yellow-text);">Di</span><span style="font-size:9px;font-weight:500;">1</span><div style="width:3px;height:3px;border-radius:50%;background:var(--yellow-text);margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Mi</span><span style="font-size:9px;font-weight:500;">2</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Do</span><span style="font-size:9px;font-weight:500;">3</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">Fr</span><span style="font-size:9px;font-weight:500;">4</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:2px solid var(--green-dark);background:var(--green-tint);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--green-deeper);">Sa</span><span style="font-size:9px;font-weight:500;">5</span><div style="width:3px;height:3px;border-radius:50%;background:transparent;margin-top:1px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:1px;padding:4px 2px;border-radius:4px;border:1px solid var(--color-border);background:var(--color-surface);cursor:pointer;"><span style="font-size:6px;font-weight:500;text-transform:uppercase;color:var(--color-text-muted);">So</span><span style="font-size:9px;font-weight:500;">6</span><div style="width:3px;height:3px;border-radius:50%;background:var(--green);margin-top:1px;"></div></div>
</div>
</div>
<!-- Selected day + confirm -->
<div style="padding:6px 10px;border-top:1px solid var(--color-subtle);">
<div style="font-size:9px;color:var(--color-text-muted);margin-bottom:5px;">Samstag, 5. April · leer</div>
<button style="width:100%;font-family:var(--font-sans);font-size:11px;font-weight:500;padding:7px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;text-align:center;">Zu Samstag hinzufügen</button>
</div>
<!-- Variety preview (desktop only) -->
<div style="padding:8px 10px;border-top:1px solid var(--color-subtle);">
<div style="font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;">Variety-Vorschau</div>
<div style="background:var(--green-tint);border:1px solid var(--green-light);border-radius:var(--radius-md);padding:6px 8px;font-size:10px;color:var(--green-dark);">↑ Score: 8 → 9 · Neues Protein</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid3">
<div class="note hl"><h4>Active card highlight</h4><p>The card whose day picker is open: 2px green border, green-tint bg, "Zur Woche +" button becomes green filled "Tag wählen…". Other cards at 45% opacity to keep focus on the active recipe.</p></div>
<div class="note"><h4>Variety preview (desktop only)</h4><p>Below the confirm button the panel shows the projected score change and reason: "↑ Score: 8 → 9 · Neues Protein". Omitted on mobile to keep the sheet compact. Calls GET /api/variety/preview?add={recipeId}&amp;date={date}.</p></div>
<div class="note warn"><h4>Replace on desktop</h4><p>Same V2 pattern: clicking a filled day chip turns the confirm button orange and shows inline warning "Ersetzt [recipe] am [day]." Panel width is enough to show both elements without overlap.</p></div>
</div>
</div>
<div class="agent">
<h4>C6 · Day picker — implementation</h4>
<pre>/* Entry: tap "Zur Woche +" on recipe card. Recipe ID is known; date is unknown.
* Mobile: compact bottom sheet (~55vh). Background: recipe list at 28% opacity.
* Sheet: drag handle + recipe name header + week label + week nav + 7-day strip + confirm.
*
* DAY CHIP STATES (7 chips, current week):
* .empty → dashed green-light border, green-tint bg (invite selection)
* .filled → solid color-border, surface bg, green dot below date number
* .today → solid yellow border, yellow-tint bg, yellow-text label
* .sel-empty → 2px green-dark border, green-tint bg → confirm btn = green
* .sel-filled → 2px orange-dark border, orange-tint bg → show dp-warn → confirm btn = orange
*
* Confirm empty: PATCH /api/week-plan/{weekId}/slots/{date} {recipe_id}
* → dismiss sheet → undo toast 4s "Rückgängig"
* Confirm filled: same PATCH (server replaces existing recipe_id)
* → dismiss sheet → undo toast 4s "Rückgängig"
*
* Desktop panel state: 'day-picker'
* Active recipe card: 2px green border, green-tint bg, button text "Tag wählen…" (green filled)
* Other cards: opacity 0.45
* Panel adds variety-preview section below confirm: GET /api/variety/preview?add={id}&date={date}
* Confirm → panel transitions to day-detail for newly filled date.
*
* Week navigation: loads prev/next week's slots from GET /api/week-plan/{weekId±1}
* Default: current week. If current week fully filled, auto-advance to next week. */</pre>
<table class="at"><thead><tr><th>State</th><th>Border</th><th>Background</th><th>Confirm btn</th></tr></thead><tbody>
<tr><td>empty</td><td>dashed green-light</td><td>green-tint</td><td></td></tr>
<tr><td>filled</td><td>solid color-border</td><td>color-surface</td><td></td></tr>
<tr><td>today</td><td>solid yellow</td><td>yellow-tint</td><td></td></tr>
<tr><td>sel-empty</td><td>2px green-dark</td><td>green-tint</td><td>green</td></tr>
<tr><td>sel-filled</td><td>2px orange-dark</td><td>orange-tint</td><td>orange + dp-warn</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ LLM INSTRUCTIONS ═══ -->
<div class="llm">
<h2>LLM Implementation Notes — J2 Add to Plan (C4C6)</h2>
<h3>1. Screens and entry points</h3>
<p><strong>C4</strong>: User knows the day, not the recipe. Entry: "+" or empty slot tap in C1. <strong>C5</strong>: Recipe card in B1 gains two quick action buttons. <strong>C6</strong>: User knows the recipe, not the day. Entry: "Zur Woche +" on C5 card. All three flows share the same backend write: <code>PATCH /api/week-plan/{weekId}/slots/{date} { recipe_id }</code>.</p>
<h3>2. C4 vs J4 — different sort, same sheet pattern</h3>
<p>Both use a bottom sheet over dimmed content. The difference: J4 swap suggestions are sorted <strong>effort ASC</strong> (easiest first, because mid-week changes happen under stress). C4 suggestions are sorted <strong>variety_delta DESC</strong> (best variety impact first, because this is deliberate weekly planning).</p>
<h3>3. Panel state machine (desktop)</h3>
<p>The 216px right panel can be in four states: <strong>idle</strong> (no selection) → <strong>day-detail</strong> (filled day selected in C1) → <strong>recipe-picker</strong> (C4 mode) → <strong>day-picker</strong> (C6 mode). The "×" close button always returns to the previous state. Successful pick transitions to day-detail for the affected date.</p>
<h3>4. Tap counts</h3>
<ul>
<li><strong>C4 via empty slot:</strong> 2 taps (slot → pick recipe)</li>
<li><strong>C4 via "+" button:</strong> 2 taps ("+" → pick recipe, day pre-selected as next empty)</li>
<li><strong>C6 empty slot:</strong> 2 taps ("Zur Woche +" → pick day)</li>
<li><strong>C6 filled slot:</strong> 3 taps ("Zur Woche +" → pick day → confirm replace)</li>
</ul>
<h3>5. Undo</h3>
<p>All flows use <code>DELETE /api/week-plan/{weekId}/slots/{date}</code> when "Rückgängig" is tapped in the 4-second toast. Toast is dismissed on navigation. On desktop, no toast for C4 (panel shows result immediately with Swap option in the day-detail view).</p>
<h3>6. C5 "Jetzt kochen" — no back-stack issue</h3>
<p>Navigating to <code>/cook/{recipeId}</code> from the recipe list is a standard route push. The recipe list is available via the back button or bottom nav. Do not open cook mode in a sheet or modal — it is a full-screen experience per J3 spec.</p>
</div>
</div>
</body>
</html>