Compare commits

166 Commits

Author SHA1 Message Date
32bfcd5c16 chore: merge master into feat/issue-21-join-household
Resolved conflicts in HouseholdService, HouseholdController,
V026 migration, and both household test classes in favour of
the more complete invite implementation on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:00:11 +02:00
9a644b5640 fix(test): update AuthControllerTest to verify authenticateInSession delegation
After extracting authenticateInSession to AuthService, the mock doesn't
populate the session. Replace session-attribute assertions with verify()
calls that confirm the controller correctly delegates to authService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:50 +02:00
df0d453b69 fix(join): replace rounded-2xl with --radius-xl design token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:58 +02:00
26256ef492 fix(join): add accessible name to permissions list in HouseholdIdentityPanel
The <ul> had no programmatic association to its visible label.
Screen readers could not announce what the list represents.
Add aria-label="Als Mitglied kannst du" directly on the <ul>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:43 +02:00
ccfc72ab38 fix(join): update password toggle aria-label with state
Static aria-label "Passwort anzeigen" stayed unchanged after the password
became visible, giving screen readers wrong information. Bind label to
showPassword state: "Passwort anzeigen" / "Passwort verbergen".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:16 +02:00
230ee5a067 fix(join): use secure: !dev for JSESSIONID cookie to work in local dev
Hardcoded secure: true silently drops the cookie on HTTP (localhost),
causing the post-join redirect to bounce back to /login. Use $app/environment
dev flag so the cookie works in development while remaining Secure in production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:25:48 +02:00
0b182a33fd refactor(auth): extract authenticateInSession to AuthService
Remove duplicated private authenticateInSession from AuthController and
HouseholdController. Add a single public implementation on AuthService
with session fixation protection built in. HouseholdController now
injects AuthService and passes role "user" for invite-accepted accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:24:58 +02:00
73af11e84b fix(invite): reject invalidated invites in acceptInvite
Same invalidatedAt gap as getInviteInfo: a superseded invite (status
still 'pending', invalidatedAt set) could still be used to create an
account and join the household.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:22:54 +02:00
0ab1ba0b1b fix(invite): reject invalidated invites in getInviteInfo
Superseded invites had invalidatedAt set but status stayed 'pending',
so they passed the validity check and could still be viewed and accepted.
Add invalidatedAt != null guard to getInviteInfo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:22:07 +02:00
44fd398701 fix(invite): saveAndFlush invalidation before INSERT + set invalidated_at on accept
- createInvite: use saveAndFlush when invalidating existing invite so the
  UPDATE is guaranteed to hit the DB before the new INSERT, preventing
  duplicate key violation on uq_household_invite_active
- acceptInvite: also set invalidated_at when marking invite as used, so
  accepted invites are fully removed from the partial unique index and
  cannot block future invite creation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:04:24 +02:00
6aed303627 fix(join): permit /v1/invites/** (not just /*) + match panel color to login
- SecurityConfig: /** covers /v1/invites/{code}/accept (two path segments);
  /* only matched one segment so the accept endpoint was returning 401
- HouseholdIdentityPanel + page: use --green-dark bg (matching BrandPanel
  on login) instead of --green-tint; text updated to white/--green-light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:00:47 +02:00
c5ec3396b2 fix(migration): deduplicate active invites before creating unique index in V026
Dev databases that accumulated multiple pending invites before V026 was
written would fail to create uq_household_invite_active. Added a cleanup
UPDATE that marks all-but-the-latest invite per household as invalidated
before the index is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:51:19 +02:00
6950b3d8db feat(join): implement A4 join household page (/join/[token])
- schema.d.ts: add GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest; update acceptInvite operation
- hooks.server.ts: add /join to PUBLIC_ROUTES; redirect authenticated users on /join/* to /
- +page.server.ts: load validates token (invalid:true on 404); action creates account + joins + sets session cookie
- HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list
- JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, pre-fill
- +page.svelte: no-chrome layout, mobile (banner+form stacked) / desktop (400px panel + flex:1) split, invalid-token inline state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:31:03 +02:00
92f25e56fc feat(invite): add GET /v1/invites/{code} + rework POST accept as signup+join
- V027 migration: add invited_by FK column on household_invite
- HouseholdInvite entity: add invitedBy field, set on createInvite
- New DTOs: InviteInfoResponse, AcceptInviteRequest
- HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction
- HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join
- SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:24:26 +02:00
b577b7a0f8 fix(members): add error toasts for invite failures + Content-Type header
- handleInviteClick: show toast and bail early when POST /invites fails
- handleRegenerate: show toast when regeneration POST fails
- handleRoleChange: add Content-Type: application/json header on PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
69d695b2c4 fix(members): guard against removing the last planner from household
removeMember now checks the planner count before deleting a planner
member. Throws ConflictException("Cannot remove the last planner")
when only one planner remains, matching the spec requirement in S4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
43227b2265 feat(backend): make invite share URL base configurable via app.base-url
Replaces hardcoded \"https://yourapp.com\" with a Spring property.

- application.yml: app.base-url defaults to http://localhost:5173
- application-docker.yml: reads APP_BASE_URL env var, same default
- HouseholdService: injects @Value("${app.base-url}") and uses it in
  toInviteResponse() to build shareUrl
- HouseholdServiceTest: sets field via ReflectionTestUtils in @BeforeEach;
  adds test asserting shareUrl starts with configured base URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
a6683d06bb fix(members/invites): unwrap ApiResponse before returning to client
POST /members/invites was returning the full ApiResponseInviteResponse
wrapper. The client set activeInvite directly from the response body,
so shareUrl/inviteCode/expiresAt were missing (nested under .data).
Fixed to return data?.data — the inner InviteResponse — matching the
shape that InvitePanel and page.server.ts already expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
ed0f3c21fe fix(members): create invite on first click when no active invite exists
When activeInvite is null and the user clicks the invite card, POST to
/members/invites first to generate a code, then toggle the panel open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
dbf2951f09 feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
d6bfd2cb46 fix(members): match settings page padding and h1 typography
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
9ccd367d74 feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)
Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
6aef12fa3c feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
27b7058d31 feat(members): implement DELETE/PATCH member + GET invites backend endpoints
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
60d84c0c94 fix(members): add error toasts for invite failures + Content-Type header
- handleInviteClick: show toast and bail early when POST /invites fails
- handleRegenerate: show toast when regeneration POST fails
- handleRoleChange: add Content-Type: application/json header on PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:31:19 +02:00
9d3be84a0c fix(members): guard against removing the last planner from household
removeMember now checks the planner count before deleting a planner
member. Throws ConflictException("Cannot remove the last planner")
when only one planner remains, matching the spec requirement in S4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:30:55 +02:00
2ad75cc1b7 spec(staples): tile redesign, seed catalog & add-ingredient flow
Adds two spec files for issue #59:
- staples-settings-redesign.html — 5 initial concept variations
- staples-settings-tile.html    — chosen direction: tile layout (matching
  SettingsCard shell), German seed catalog (~100 ingredients / 8 categories),
  and inline per-tile add-ingredient flow with duplicate detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:28:57 +02:00
eb5699577b feat(backend): make invite share URL base configurable via app.base-url
Replaces hardcoded \"https://yourapp.com\" with a Spring property.

- application.yml: app.base-url defaults to http://localhost:5173
- application-docker.yml: reads APP_BASE_URL env var, same default
- HouseholdService: injects @Value("${app.base-url}") and uses it in
  toInviteResponse() to build shareUrl
- HouseholdServiceTest: sets field via ReflectionTestUtils in @BeforeEach;
  adds test asserting shareUrl starts with configured base URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:07:53 +02:00
05476ecaab fix(members/invites): unwrap ApiResponse before returning to client
POST /members/invites was returning the full ApiResponseInviteResponse
wrapper. The client set activeInvite directly from the response body,
so shareUrl/inviteCode/expiresAt were missing (nested under .data).
Fixed to return data?.data — the inner InviteResponse — matching the
shape that InvitePanel and page.server.ts already expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:57:13 +02:00
c40b0fe095 fix(members): create invite on first click when no active invite exists
When activeInvite is null and the user clicks the invite card, POST to
/members/invites first to generate a code, then toggle the panel open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:55:00 +02:00
4e67ff4258 feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:54:26 +02:00
df3b774f0c fix(members): match settings page padding and h1 typography
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:45:32 +02:00
1b5704c8b5 feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)
Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:01:08 +02:00
b04f2c51d2 feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:42:52 +02:00
d1e4b6c49e feat(members): implement DELETE/PATCH member + GET invites backend endpoints
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:41:38 +02:00
27163e3d72 feat(nav): remove Mitglieder link from desktop sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:32:37 +02:00
5904102b1a refactor(settings): document benutzer non-null assertion in page.server.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:23:31 +02:00
d66120b191 refactor(settings): replace hardcoded #C0BFB8 with --color-border-hover token
Add --color-border-hover to the design system neutrals and replace the
hardcoded hex in all three card definitions (settings hub ×2, SettingsCard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:23:22 +02:00
98c8aa9610 refactor(settings): remove dead accent prop from SettingsCard
The green left border was removed from the design — the accent prop,
data-accent attribute, and inline style were never used on the hub page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:22:54 +02:00
af275642b0 refactor(staples): remove redundant {#if !isOnboarding} guards in else block
The outer {:else} already guarantees isOnboarding is false — inner guards
were always-true dead conditions unreachable by tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:22:20 +02:00
dde78baa84 fix(settings): remove green left border from Vorräte card — no active state on hub page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:07:30 +02:00
6e559d9f9d refactor(staples): remove ctx=settings — default view is settings, only ctx=onboarding differs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:57:48 +02:00
ef39a97f57 fix(settings): align hub page with V2 spec — padding, H1, card radius/padding/sizes
- Content area: p-[16px_20px] md:p-[40px_56px], max-w-[820px] grid
- H1: display font, 28px, weight 500, tracking -0.02em
- Cards: --radius-xl, p-[28px], shadow-card
- Card title: 16px (was 14px)
- Stat number: 28px font-light tracking-[-0.02em] (was 36px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:49:56 +02:00
824bb9445f refactor(staples): move household/staples route into (app) group — adds sidebar nav
Onboarding context uses fixed inset overlay to cover AppShell.
Settings context inherits AppShell layout by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:47:25 +02:00
b0fc9f55c1 feat(nav): pass extraPaths to isActiveRoute in DesktopSidebar and MobileTabBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:45:32 +02:00
2ed5186ac8 feat(nav): add extraPaths to NavItem — Einstellungen active on /household/staples
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:44:11 +02:00
48802a04f7 feat(settings): add autosave hint text below StaplesManager on D3 when ctx=settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:30:19 +02:00
0b3d062ed1 feat(settings): add ← Einstellungen back-link on D3 staples page when ctx=settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:28:39 +02:00
109b41b434 feat(settings): implement settings hub page with three cards (Vorräte, Haushalt, Profil)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:27:18 +02:00
3f9fb900c4 feat(settings): add SettingsCard component with title, href, cta, meta, accent props
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:24:38 +02:00
33cccd3d63 feat(settings): add +page.server.ts loading stapleCount, memberCount, userName
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:23:15 +02:00
cfbde18435 test(planner): clear shared mocks before each RecipePickerDrawer test
Adds beforeEach(vi.clearAllMocks) to prevent shared vi.fn() state in
baseProps from leaking across tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:18:50 +02:00
4835231f6d test(planner): cover Entfernen hidden when slot.id is null
Adds regression test for the {#if slot.id} guard on the remove button —
QA flagged the missing negative test case for optimistic slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:18:16 +02:00
3f9bd2b226 refactor(planner): replace hardcoded values with design tokens
- border-radius: 10px → var(--radius-lg) in both tile components
- opacity: 0.42 → var(--opacity-dimmed) in DesktopDayTile
- var(--yellow) → var(--color-ring-today) for today ring and date circle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:17:50 +02:00
9423cd673c refactor(planner): type tag mapping callback as TagItem in server load
Replaces (t: any) with (t: TagItem) so the API response shape is
validated against the shared TagItem interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:16:50 +02:00
4c87d9c134 feat(planner): sanitize heroImageUrl before embedding in CSS url()
Extracts sanitizeForCssUrl helper that strips '"()\ before the URL
is embedded in url("..."). Prevents CSS injection via the hero image
field in inline style bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:16:10 +02:00
e5c361fe42 refactor(planner): import shared types in reasoningTags instead of re-declaring
Removes local TagItem, Recipe, SlotRecipe, Slot, SlotMap definitions
and imports Recipe, Slot, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:13:55 +02:00
a8a781f1e9 refactor(planner): import shared types in EmptyDayTile instead of re-declaring
Removes local TagItem, SuggestionRecipe, TopSuggestion, Slot interfaces
and imports Suggestion, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:13:25 +02:00
b0800ca4f3 refactor(planner): import shared types in DesktopDayTile instead of re-declaring
Removes local TagItem, SlotRecipe, Slot, Suggestion interfaces and
imports Recipe, Slot, Suggestion from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:12:51 +02:00
66447a7ea0 refactor(planner): export Slot and SlotMap from types.ts
Adds shared Slot and SlotMap interfaces so DesktopDayTile,
EmptyDayTile, and reasoningTags can import rather than re-declare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:12:03 +02:00
7f4413852d fix(planner): bump front face font sizes again
name: 17→19px, meta: 12→14px, tags: 10→12px,
day-abbr: 11→13px, day-num: 12→14px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:53:29 +02:00
eb3f6fad25 fix(planner): bump front face font sizes
name: 15→17px, meta: 10→12px, tags: 8→10px,
day-abbr: 9→11px, day-num: 10→12px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:52:05 +02:00
fc682bfc54 fix(planner): increase tile front face recipe name to 15px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:50:28 +02:00
38528a50e5 fix(planner): eliminate front-face bleed by removing preserve-3d
transform-style:preserve-3d on a parent with box-shadow/transition
causes Chrome to fail backface-visibility:hidden. Replace with
independent per-face rotateY transforms:
  front: 0deg → -180deg (flipped)
  back:  180deg → 0deg (flipped)
No preserve-3d needed — each face is its own compositing layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:49:28 +02:00
a43a8ec33f fix(planner): prevent front face bleeding through flipped card
overflow:hidden on direct children of preserve-3d flattens the 3D
context in Chrome, causing backface-visibility:hidden to fail.

Move border-radius + overflow to inner wrapper divs (.card-front-inner,
.card-back-inner) and keep the face elements themselves free of those
properties. Also add -webkit-backface-visibility:hidden and
will-change:transform for consistent GPU compositing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:47:10 +02:00
8679ebc6e3 fix(planner): fix flip tile pointer events and selected ring hover
- backface-visibility hides elements visually but not to pointer events;
  disable pointer events on the hidden face explicitly so the X button
  on the back face is clickable and the front face doesn't intercept clicks
- Add .scene-selected:hover rule so green ring is not overwritten by the
  higher-specificity .scene:hover box-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:42:06 +02:00
0ae1767649 feat(planner): align tile design with spec
Front face:
- Full dual gradient overlay (dark top 32% → transparent → dark bottom 55%)
- Day abbreviation + date number pill at top of each tile
- Recipe name 13px/weight-300 with text-shadow
- Meta line (cookTimeMin · effort) below name
- Glassmorphism tag pills (protein + cuisine only)
- State rings via box-shadow (yellow for today, green for selected)
- Dimming (opacity 0.42) on non-selected filled tiles

Back face:
- Koch-Modus as green primary button
- Entfernen as red outline (transparent bg)
- All buttons 11px / weight 500

EmptyDayTile: add day header + spec-aligned suggestion list layout
Page: remove external column header (now rendered inside each tile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:29:58 +02:00
d54ac6a37a feat(planner): use cuisine gradient as fallback when no protein tag
Fallback chain: heroImageUrl → protein gradient → cuisine gradient → surface.
Also rename --gradient-cuisine-italienisch → --gradient-cuisine-deutsch
(actual seed tag) with an earthy warm-grey colour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:12:03 +02:00
d901310897 feat(backend): add heroImageUrl and tags to RecipeSummaryResponse
GET /v1/recipes was returning RecipeSummaryResponse with no tags and
only heroImagePreview. The planner frontend needs protein tags to pick
gradient backgrounds for tiles without a hero image.

- Replace JPQL constructor projection with entity query + LEFT JOIN FETCH tags
- Map Recipe entity to RecipeSummaryResponse in service (includes tags + heroImageUrl)
- Drop heroImagePreview in favour of heroImageUrl on the summary DTO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:03:03 +02:00
ed4cdbf230 fix(planner): merge recipe tags into slotMap from data.recipes
SlotRecipe from the week-plan API carries no tags, so the protein
gradient lookup in DesktopDayTile always fell through to --color-surface.
Build a recipeById lookup from data.recipes and spread tags onto each
slot's recipe when constructing slotMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:55:55 +02:00
75228058a6 fix(planner): align protein gradient CSS vars with actual seed tag names
- Rename --gradient-protein-ei → --gradient-protein-eier (tag is 'Eier')
- Add --gradient-protein-kaese for tag 'Käse' (was missing entirely)

The only protein tags in seed data are Käse, Hülsenfrüchte, Eier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:37:54 +02:00
b919a716f5 fix(planner): rename gradient-protein-veg → gradient-protein-vegetarisch
The CSS variable key must match the actual tag name after umlaut
transliteration. 'veg' would never match a real tag named 'vegetarisch'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:32:05 +02:00
389500c1dd fix(planner): transliterate German umlauts in protein gradient CSS key
'Hähnchen'.toLowerCase() → 'hähnchen' which never matched the CSS var
--gradient-protein-haehnchen. Add toCssKey() to replace ä→ae, ö→oe,
ü→ue, ß→ss so gradient fallbacks actually resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:31:06 +02:00
8709e85d80 fix(planner): increase card front recipe name font size to 15px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:24:20 +02:00
358edb9a12 fix(planner): improve DesktopDayTile visual polish
- Add dark gradient scrim on card front so recipe name is always readable
  over images and protein/cuisine gradients
- Style card-back actions as proper buttons (border, padding, border-radius)
  instead of unstyled browser defaults
- Add meta chips for cookTimeMin and effort
- Scope Entfernen inside isPlanner guard alongside Gericht tauschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:23:05 +02:00
f97cf49bd0 feat(planner): overhaul desktop layout — flip tiles, no right panel
Replaces 3-panel layout with 2-panel (sidebar + full-width grid):
- Remove persistent right panel and toolbar + Gericht hinzufügen button
- grid-cols-7 tiles use DesktopDayTile (CSS 3D card flip)
- RecipePickerDrawer slides in on tile CTA / Gericht tauschen
- Page-owned activeSlotId + drawerOpen/drawerSlotId state
- Single Escape handler: drawer > flip priority
- Extend server load to forward recipe tags from /v1/recipes API

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:56:57 +02:00
520dae5adf feat(recipes): add image upload, fix save 500, seed HelloFresh data
- Store hero image as base64 data URI in text column (V023 migration)
- Add file upload UI to RecipeForm with FileReader preview
- Remove isChildFriendly from RecipeCreateRequest (no form field)
- Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed
  from primitive short to nullable Integer to survive omitted fields
- Fix empty categories panel: removed stale tagType=category filter
- Group category tags by type with German headings in recipe form
- Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe
- Seed 11 HelloFresh recipes with ingredients, steps and tags (V101)
- Add frontend e2e scaffold, specs and dev yml

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:33:52 +02:00
163 changed files with 26264 additions and 1132 deletions

3
backend/.gitignore vendored
View File

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

View File

@@ -7,6 +7,7 @@ COPY src src
RUN ./mvnw package -DskipTests -B RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache libwebp
WORKDIR /app WORKDIR /app
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080

View File

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

View File

@@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.List;
@RestController @RestController
@RequestMapping("/v1/auth") @RequestMapping("/v1/auth")
@@ -32,7 +27,7 @@ public class AuthController {
@Valid @RequestBody SignupRequest request, @Valid @RequestBody SignupRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
UserResponse user = authService.signup(request); UserResponse user = authService.signup(request);
authenticateInSession(user.email(), "user", httpRequest); authService.authenticateInSession(user.email(), "user", httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
} }
@@ -41,30 +36,10 @@ public class AuthController {
@Valid @RequestBody LoginRequest request, @Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
UserResponse user = authService.login(request); UserResponse user = authService.login(request);
// Session fixation protection: invalidate old session before creating new one authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
var oldSession = httpRequest.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
return ResponseEntity.ok(ApiResponse.success(user)); return ResponseEntity.ok(ApiResponse.success(user));
} }
/**
* Creates an authenticated Spring Security context and stores it in the HTTP session
* so that subsequent requests from the same session are recognised as authenticated.
* We do this manually because we are not using Spring Security's built-in form login.
*/
private void authenticateInSession(String email, String role, HttpServletRequest request) {
var auth = UsernamePasswordAuthenticationToken.authenticated(
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) { public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false); HttpSession session = httpRequest.getSession(false);

View File

@@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException; import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdMemberRepository; import com.recipeapp.household.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember; import com.recipeapp.household.entity.HouseholdMember;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service @Service
public class AuthService { public class AuthService {
@@ -82,6 +90,24 @@ public class AuthService {
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName()); return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
} }
/**
* Establishes an authenticated Spring Security session for the given user.
* Invalidates any existing session first (session fixation protection).
*/
public void authenticateInSession(String email, String role, HttpServletRequest request) {
var oldSession = request.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
var auth = UsernamePasswordAuthenticationToken.authenticated(
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
private UserResponse toUserResponse(UserAccount user) { private UserResponse toUserResponse(UserAccount user) {
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail()) return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
.map(member -> UserResponse.withHousehold( .map(member -> UserResponse.withHousehold(

View File

@@ -24,11 +24,13 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/v1/invites/**").permitAll()
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(ex -> ex .exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(session -> session .sessionManagement(session -> session
.sessionFixation().changeSessionId()
.maximumSessions(1)); .maximumSessions(1));
return http.build(); return http.build();

View File

@@ -1,7 +1,9 @@
package com.recipeapp.household; package com.recipeapp.household;
import com.recipeapp.auth.AuthService;
import com.recipeapp.common.ApiResponse; import com.recipeapp.common.ApiResponse;
import com.recipeapp.household.dto.*; import com.recipeapp.household.dto.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -9,15 +11,19 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/v1") @RequestMapping("/v1")
public class HouseholdController { public class HouseholdController {
private final HouseholdService householdService; private final HouseholdService householdService;
private final AuthService authService;
public HouseholdController(HouseholdService householdService) { public HouseholdController(HouseholdService householdService, AuthService authService) {
this.householdService = householdService; this.householdService = householdService;
this.authService = authService;
} }
@PostMapping("/households") @PostMapping("/households")
@@ -40,17 +46,49 @@ public class HouseholdController {
return ResponseEntity.ok(members); return ResponseEntity.ok(members);
} }
@GetMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
return invite
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping("/households/mine/invites") @PostMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) { public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
InviteResponse response = householdService.createInvite(principal.getName()); InviteResponse response = householdService.createInvite(principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
} }
@DeleteMapping("/households/mine/members/{userId}")
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
householdService.removeMember(principal.getName(), userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/households/mine/members/{userId}")
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
Principal principal,
@PathVariable UUID userId,
@Valid @RequestBody ChangeRoleRequest request) {
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/invites/{code}")
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
InviteInfoResponse response = householdService.getInviteInfo(code);
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/invites/{code}/accept") @PostMapping("/invites/{code}/accept")
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite( public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
Principal principal, @PathVariable String code,
@PathVariable String code) { @Valid @RequestBody AcceptInviteRequest request,
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code); HttpServletRequest httpRequest) {
AcceptInviteResponse response = householdService.acceptInvite(
code, request.name(), request.email(), request.password());
authService.authenticateInSession(request.email(), "user", httpRequest);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
} }

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> { public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
Optional<HouseholdInvite> findByInviteCode(String inviteCode); Optional<HouseholdInvite> findByInviteCode(String inviteCode);
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
} }

View File

@@ -10,4 +10,6 @@ import java.util.UUID;
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> { public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email); Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
List<HouseholdMember> findByHouseholdId(UUID householdId); List<HouseholdMember> findByHouseholdId(UUID householdId);
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
long countByHouseholdIdAndRole(UUID householdId, String role);
} }

View File

@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException; import com.recipeapp.common.ValidationException;
import com.recipeapp.household.dto.*; import com.recipeapp.household.dto.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember; import com.recipeapp.household.entity.HouseholdMember;
@@ -17,12 +18,15 @@ import com.recipeapp.recipe.TagRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag; import com.recipeapp.recipe.entity.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service @Service
public class HouseholdService { public class HouseholdService {
@@ -35,6 +39,10 @@ public class HouseholdService {
private final IngredientCategoryRepository ingredientCategoryRepository; private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.base-url}")
private String baseUrl;
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -46,7 +54,8 @@ public class HouseholdService {
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository, IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository, TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) { VarietyScoreConfigRepository varietyScoreConfigRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository; this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository; this.householdMemberRepository = householdMemberRepository;
@@ -55,6 +64,7 @@ public class HouseholdService {
this.ingredientCategoryRepository = ingredientCategoryRepository; this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository; this.varietyScoreConfigRepository = varietyScoreConfigRepository;
this.passwordEncoder = passwordEncoder;
} }
@Transactional @Transactional
@@ -91,42 +101,121 @@ public class HouseholdService {
.toList(); .toList();
} }
@Transactional
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getRole().equals(newRole)) {
return toMemberResponse(target);
}
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot degrade the last planner");
}
}
target.setRole(newRole);
return toMemberResponse(householdMemberRepository.save(target));
}
@Transactional
public void removeMember(String requesterEmail, UUID targetUserId) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
throw new ConflictException("Planner cannot remove yourself");
}
if ("planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot remove the last planner");
}
}
householdMemberRepository.delete(target);
}
@Transactional(readOnly = true)
public Optional<InviteResponse> getActiveInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdInviteRepository
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
.map(this::toInviteResponse);
}
@Transactional @Transactional
public InviteResponse createInvite(String userEmail) { public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail); HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold(); Household household = member.getHousehold();
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
.ifPresent(existing -> {
existing.setInvalidatedAt(Instant.now());
householdInviteRepository.saveAndFlush(existing);
});
String code = generateInviteCode(); String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600); Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save( HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
new HouseholdInvite(household, code, expiresAt)); invite.setInvitedBy(member.getUser());
householdInviteRepository.save(invite);
return new InviteResponse( return toInviteResponse(invite);
invite.getInviteCode(), }
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt()); @Transactional(readOnly = true)
public InviteInfoResponse getInviteInfo(String code) {
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if ("used".equals(invite.getStatus())
|| invite.getInvalidatedAt() != null
|| invite.getExpiresAt().isBefore(Instant.now())) {
throw new ResourceNotFoundException("Invite not found or invalid");
}
String inviterName = invite.getInvitedBy() != null
? invite.getInvitedBy().getDisplayName()
: invite.getHousehold().getCreatedBy().getDisplayName();
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
} }
@Transactional @Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) { public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
UserAccount user = findUser(userEmail); if (userAccountRepository.existsByEmailIgnoreCase(email)) {
throw new ConflictException("Email already registered");
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
} }
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found")); .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if ("used".equals(invite.getStatus())) { if ("used".equals(invite.getStatus())
throw new ConflictException("Invite code already used"); || invite.getInvalidatedAt() != null
} || invite.getExpiresAt().isBefore(Instant.now())) {
if (invite.getExpiresAt().isBefore(Instant.now())) { throw new ResourceNotFoundException("Invite not found or invalid");
throw new ValidationException("Invite code has expired");
} }
UserAccount user = userAccountRepository.save(
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
invite.setStatus("used"); invite.setStatus("used");
invite.setInvalidatedAt(Instant.now());
householdInviteRepository.save(invite); householdInviteRepository.save(invite);
Household household = invite.getHousehold(); Household household = invite.getHousehold();
@@ -204,4 +293,11 @@ public class HouseholdService {
member.getRole(), member.getRole(),
member.getJoinedAt()); member.getJoinedAt());
} }
private InviteResponse toInviteResponse(HouseholdInvite invite) {
return new InviteResponse(
invite.getInviteCode(),
baseUrl + "/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
} }

View File

@@ -0,0 +1,11 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AcceptInviteRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password
) {}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record ChangeRoleRequest(
@NotBlank
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
String role
) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.household.dto;
public record InviteInfoResponse(
String householdName,
String inviterName
) {}

View File

@@ -1,5 +1,6 @@
package com.recipeapp.household.entity; package com.recipeapp.household.entity;
import com.recipeapp.auth.entity.UserAccount;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
@@ -16,6 +17,10 @@ public class HouseholdInvite {
@JoinColumn(name = "household_id", nullable = false) @JoinColumn(name = "household_id", nullable = false)
private Household household; private Household household;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "invited_by")
private UserAccount invitedBy;
@Column(name = "invite_code", nullable = false, unique = true, length = 20) @Column(name = "invite_code", nullable = false, unique = true, length = 20)
private String inviteCode; private String inviteCode;
@@ -25,6 +30,9 @@ public class HouseholdInvite {
@Column(name = "expires_at", nullable = false) @Column(name = "expires_at", nullable = false)
private Instant expiresAt; private Instant expiresAt;
@Column(name = "invalidated_at")
private Instant invalidatedAt;
protected HouseholdInvite() {} protected HouseholdInvite() {}
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
@@ -35,8 +43,12 @@ public class HouseholdInvite {
public UUID getId() { return id; } public UUID getId() { return id; }
public Household getHousehold() { return household; } public Household getHousehold() { return household; }
public UserAccount getInvitedBy() { return invitedBy; }
public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; }
public String getInviteCode() { return inviteCode; } public String getInviteCode() { return inviteCode; }
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public Instant getExpiresAt() { return expiresAt; } public Instant getExpiresAt() { return expiresAt; }
public Instant getInvalidatedAt() { return invalidatedAt; }
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
} }

View File

@@ -26,6 +26,8 @@ import java.util.stream.Collectors;
@Service @Service
public class PlanningService { public class PlanningService {
private static final double MAX_VARIETY_SCORE = 10.0;
private final WeekPlanRepository weekPlanRepository; private final WeekPlanRepository weekPlanRepository;
private final WeekPlanSlotRepository weekPlanSlotRepository; private final WeekPlanSlotRepository weekPlanSlotRepository;
private final CookingLogRepository cookingLogRepository; private final CookingLogRepository cookingLogRepository;
@@ -135,6 +137,8 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId()) .map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream() Set<String> lowerTagFilters = tagFilters.stream()
@@ -145,11 +149,13 @@ public class PlanningService {
.filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> !usedRecipeIds.contains(r.getId()))
.filter(r -> matchesAllTags(r, lowerTagFilters)) .filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> { .map(candidate -> {
double score = simulateVarietyScore( double simulatedScore = simulateVarietyScore(
plan, candidate, slotDate, config, recentlyCookedIds); plan, candidate, slotDate, config, recentlyCookedIds);
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); double scoreDelta = simulatedScore - currentScore;
boolean hasConflict = scoreDelta < 0;
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
}) })
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
.limit(limit) .limit(limit)
.toList(); .toList();
@@ -168,12 +174,22 @@ public class PlanningService {
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) { VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
List<SimulatedSlot> simulatedSlots = new ArrayList<>(); List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) { for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); if (!slot.getSlotDate().equals(slotDate)) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
} }
simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds); return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
} }
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
.toList();
return currentSlots.isEmpty() ? MAX_VARIETY_SCORE
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {} private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -191,11 +207,7 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId()) .map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
List<SimulatedSlot> currentSlots = plan.getSlots().stream() double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
.toList();
double currentScore = currentSlots.isEmpty() ? 10.0
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore); return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
@@ -204,10 +216,6 @@ public class PlanningService {
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config, private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
Set<UUID> recentlyCookedIds) { Set<UUID> recentlyCookedIds) {
List<String> checkedTagTypes = config.getRepeatTagTypes(); List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days // 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>(); Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
@@ -247,12 +255,17 @@ public class PlanningService {
.mapToLong(c -> c - 1) .mapToLong(c -> c - 1)
.sum(); .sum();
double score = 10.0; return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
score -= tagRepeatCount * wTagRepeat; }
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat; private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats,
score -= duplicatePenaltyCount * wPlanDuplicate; long duplicates, VarietyScoreConfig config) {
return Math.max(0, Math.min(10, score)); double score = MAX_VARIETY_SCORE;
score -= tagRepeats * config.getWTagRepeat().doubleValue();
score -= ingredientOverlaps * config.getWIngredientOverlap().doubleValue();
score -= recentRepeats * config.getWRecentRepeat().doubleValue();
score -= duplicates * config.getWPlanDuplicate().doubleValue();
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -269,10 +282,6 @@ public class PlanningService {
.orElse(VarietyScoreConfig.defaults(plan.getHousehold())); .orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
List<String> checkedTagTypes = config.getRepeatTagTypes(); List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
int historyDays = config.getHistoryDays(); int historyDays = config.getHistoryDays();
// 1. Tag-type repeats on consecutive days // 1. Tag-type repeats on consecutive days
@@ -340,13 +349,7 @@ public class PlanningService {
} }
} }
// Calculate score double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
double score = 10.0;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package com.recipeapp.recipe; package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.Recipe;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -17,22 +16,19 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId); List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
@Query(""" @Query("""
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse( SELECT r FROM Recipe r
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl) LEFT JOIN FETCH r.tags
FROM Recipe r
WHERE r.household.id = :householdId WHERE r.household.id = :householdId
AND r.deletedAt IS NULL AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%'))) 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 (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
ORDER BY r.createdAt DESC ORDER BY r.createdAt DESC
""") """)
List<RecipeSummaryResponse> findFiltered( List<Recipe> findFiltered(
@Param("householdId") UUID householdId, @Param("householdId") UUID householdId,
@Param("search") String search, @Param("search") String search,
@Param("effort") String effort, @Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin, @Param("cookTimeMaxMin") Integer cookTimeMaxMin,
@Param("sort") String sort, @Param("sort") String sort,
@Param("limit") int limit, @Param("limit") int limit,
@@ -45,13 +41,11 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
AND r.deletedAt IS NULL AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%'))) 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 (:effort IS NULL OR r.effort = CAST(:effort AS string))
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
""") """)
long countFiltered( long countFiltered(
@Param("householdId") UUID householdId, @Param("householdId") UUID householdId,
@Param("search") String search, @Param("search") String search,
@Param("effort") String effort, @Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin); @Param("cookTimeMaxMin") Integer cookTimeMaxMin);
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.recipeapp.recipe.dto; package com.recipeapp.recipe.dto;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public record RecipeSummaryResponse( public record RecipeSummaryResponse(
@@ -8,6 +9,6 @@ public record RecipeSummaryResponse(
short serves, short serves,
short cookTimeMin, short cookTimeMin,
String effort, String effort,
boolean isChildFriendly, String heroImageUrl,
String heroImageUrl List<TagResponse> tags
) {} ) {}

View File

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

View File

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

View File

@@ -2,3 +2,6 @@ spring:
flyway: flyway:
locations: classpath:db/migration,classpath:db/seed locations: classpath:db/migration,classpath:db/seed
out-of-order: true out-of-order: true
app:
base-url: ${APP_BASE_URL:http://localhost:5173}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
ALTER TABLE household_invite
ADD COLUMN invalidated_at timestamptz;
-- Mark all but the most-recent invite per household as invalidated,
-- so the unique partial index below can be created on dev databases
-- that accumulated multiple pending invites before this migration was added.
UPDATE household_invite
SET invalidated_at = NOW()
WHERE id NOT IN (
SELECT DISTINCT ON (household_id) id
FROM household_invite
ORDER BY household_id, expires_at DESC
);
CREATE UNIQUE INDEX uq_household_invite_active
ON household_invite (household_id)
WHERE invalidated_at IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE household_invite
ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL;

View File

@@ -0,0 +1,434 @@
-- Dev seed: 11 HelloFresh vegetarian recipes (4 persons)
-- Fixed UUIDs so the migration is idempotent and references are stable.
-- Ingredients use dd000002-prefix, tags ee000001-prefix, recipes ff000002-prefix.
-- ─── Tags ────────────────────────────────────────────────────────────────────
INSERT INTO tag (id, household_id, name, tag_type) VALUES
('ee000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Vegetarisch', 'dietary'),
('ee000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Glutenfrei', 'dietary'),
('ee000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Deutsch', 'cuisine'),
('ee000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mediterran', 'cuisine'),
('ee000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Asiatisch', 'cuisine'),
('ee000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mexikanisch', 'cuisine'),
('ee000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Käse', 'protein'),
('ee000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 'protein'),
('ee000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', 'protein'),
('ee000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Auflauf', 'other'),
('ee000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nudeln', 'other'),
('ee000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Reis', 'other'),
('ee000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnell', 'other'),
('ee000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Ofengericht', 'other'),
('ee000001-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchen', 'other')
ON CONFLICT ON CONSTRAINT uq_tag_name DO NOTHING;
-- ─── Additional Ingredients ──────────────────────────────────────────────────
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
-- Gemüse
('dd000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rucola', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kirschtomaten', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilischote', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gurke', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Radieschen', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Zwiebeln', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Spitzpaprika', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gelbe Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
('dd000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Feldsalat', false, 'cc000001-0000-0000-0000-000000000001'),
-- Obst
('dd000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Avocado', false, 'cc000001-0000-0000-0000-000000000002'),
('dd000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Äpfel', false, 'cc000001-0000-0000-0000-000000000002'),
-- Milchprodukte & Eier
('dd000002-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hartkäse ital. Art', false, 'cc000001-0000-0000-0000-000000000004'),
('dd000002-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Cheddar (gerieben)', false, 'cc000001-0000-0000-0000-000000000004'),
('dd000002-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Frischkäse', false, 'cc000001-0000-0000-0000-000000000004'),
('dd000002-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Joghurt', false, 'cc000001-0000-0000-0000-000000000004'),
('dd000002-0000-0000-0000-000000000016', 'bbbbbbbb-0000-0000-0000-000000000001', 'Halloumi', false, 'cc000001-0000-0000-0000-000000000004'),
('dd000002-0000-0000-0000-000000000017', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tex-Mex-Käsemischung', false, 'cc000001-0000-0000-0000-000000000004'),
-- Getreide & Nudeln
('dd000002-0000-0000-0000-000000000018', 'bbbbbbbb-0000-0000-0000-000000000001', 'Orzonudeln', false, 'cc000001-0000-0000-0000-000000000005'),
('dd000002-0000-0000-0000-000000000019', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tortellini', false, 'cc000001-0000-0000-0000-000000000005'),
('dd000002-0000-0000-0000-000000000020', 'bbbbbbbb-0000-0000-0000-000000000001', 'Jasminreis', true, 'cc000001-0000-0000-0000-000000000005'),
('dd000002-0000-0000-0000-000000000021', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gnocchi (frisch)', false, 'cc000001-0000-0000-0000-000000000005'),
('dd000002-0000-0000-0000-000000000022', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fladenbrot', false, 'cc000001-0000-0000-0000-000000000005'),
-- Hülsenfrüchte
('dd000002-0000-0000-0000-000000000023', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarze Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
-- Konserven
('dd000002-0000-0000-0000-000000000024', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten-Polpa', true, 'cc000001-0000-0000-0000-000000000007'),
('dd000002-0000-0000-0000-000000000025', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilipolpa', true, 'cc000001-0000-0000-0000-000000000007'),
('dd000002-0000-0000-0000-000000000026', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getrocknete Tomaten', true, 'cc000001-0000-0000-0000-000000000007'),
('dd000002-0000-0000-0000-000000000027', 'bbbbbbbb-0000-0000-0000-000000000001', 'Grüne Oliven', true, 'cc000001-0000-0000-0000-000000000007'),
-- Gewürze & Kräuter
('dd000002-0000-0000-0000-000000000028', 'bbbbbbbb-0000-0000-0000-000000000001', 'Petersilie (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000029', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000030', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnittlauch', false, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000031', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000032', 'bbbbbbbb-0000-0000-0000-000000000001', 'Scharfes Currypulver', true, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000033', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kumin (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000034', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander & Kumin', true, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000035', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMexico', true, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000036', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung Kartoffelknaller', true, 'cc000001-0000-0000-0000-000000000008'),
('dd000002-0000-0000-0000-000000000037', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMediterraneo',true, 'cc000001-0000-0000-0000-000000000008'),
-- Tiefkühl
('dd000002-0000-0000-0000-000000000038', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchenteig', false, 'cc000001-0000-0000-0000-000000000013'),
-- Saucen & Pasten
('dd000002-0000-0000-0000-000000000039', 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamicocreme', true, 'cc000001-0000-0000-0000-000000000010'),
('dd000002-0000-0000-0000-000000000040', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikumpaste', true, 'cc000001-0000-0000-0000-000000000010'),
('dd000002-0000-0000-0000-000000000041', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mayonnaise', true, 'cc000001-0000-0000-0000-000000000010'),
-- Nüsse & Samen
('dd000002-0000-0000-0000-000000000042', 'bbbbbbbb-0000-0000-0000-000000000001', 'Haselnusskerne', true, 'cc000001-0000-0000-0000-000000000011')
ON CONFLICT (id) DO NOTHING;
-- ─── Recipes ─────────────────────────────────────────────────────────────────
INSERT INTO recipe (id, household_id, name, serves, cook_time_min, effort, is_child_friendly) VALUES
('ff000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001',
'Scharfer Auflauf mit Orzonudeln', 4, 30, 'easy', false),
('ff000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001',
'Tortellini mit Ricotta-Füllung', 4, 25, 'easy', true),
('ff000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001',
'Knuspriger Flammkuchen mit Mozzarella', 4, 35, 'easy', false),
('ff000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001',
'Fruchtiges Tomatenrisotto mit Zitrone', 4, 30, 'medium', false),
('ff000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001',
'Karotten-Hafer-Puffer', 4, 40, 'medium', false),
('ff000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001',
'Überbackene Penne mit getrockneten Tomaten', 4, 50, 'easy', false),
('ff000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001',
'Chili sin Carne', 4, 40, 'easy', false),
('ff000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001',
'Gebratene Gnocchi mit Ofenzucchini', 4, 35, 'easy', false),
('ff000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001',
'Pasta nach Art Caponata', 4, 45, 'easy', false),
('ff000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001',
'Auflauf mit Halloumi und Aubergine', 4, 40, 'medium', false),
('ff000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001',
'Buntes Ofengemüse mit Halloumi', 4, 30, 'easy', false)
ON CONFLICT (id) DO NOTHING;
-- ─── Recipe Ingredients ──────────────────────────────────────────────────────
-- V100 ingredients referenced by name via subquery (gen_random_uuid IDs).
-- New dd000002 ingredients referenced by fixed UUID.
-- 01 Scharfer Auflauf mit Orzonudeln
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000024', 2, 'Dose', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Oliven (schwarz)'), 100, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000018', 300, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000013', 100, 'g', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 11),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 800, 'ml', 12);
-- 02 Tortellini mit Ricotta-Füllung
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000029', 5, 'g', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000028', 3, 'g', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000030', 2, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000019', 800, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Sonnenblumenkerne'), 10, 'g', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 4, 'EL', 10);
-- 03 Knuspriger Flammkuchen mit Mozzarella
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000028', 5, 'g', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000030', 5, 'g', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000014', 200, 'g', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000038', 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 250, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000005', 200, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000015', 200, 'g', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000001', 200, 'g', 11),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 3, 'EL', 12),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 2, 'EL', 13);
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 2, 'Stück', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Risottoreis (Arborio)'), 600, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 2, 'Stück', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000040', 24, 'ml', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 1, 'EL', 12);
-- 05 Karotten-Hafer-Puffer
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Kartoffeln'), 1200,'g', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000036', 4, 'g', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000011', 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000015', 150, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000041', 4, 'EL', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Haferflocken'), 50, 'g', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000017', 200, 'g', 11),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000032', 2, 'g', 12),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000009', 150, 'g', 13),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Eier'), 2, 'Stück', 14),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Balsamico-Essig'), 2, 'EL', 15);
-- 06 Überbackene Penne mit getrockneten Tomaten
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000030', 10, 'g', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000002', 300, 'g', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000013', 200, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Butter'), 5, 'g', 9);
-- 07 Chili sin Carne
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000020', 300, 'g', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 3, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000007', 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000008', 2, 'Stück', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000023', 2, 'Dose', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000035', 8, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 11),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 12),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Schmand'), 150, 'g', 13),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000028', 10, 'g', 14),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 15);
-- 08 Gebratene Gnocchi mit Ofenzucchini
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000037', 6, 'g', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000042', 40, 'g', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000021', 800, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 9);
-- 09 Pasta nach Art Caponata
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000027', 120, 'g', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
-- 10 Auflauf mit Halloumi und Aubergine
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 4, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 4, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 6, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 2, 'Stück', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000034', 4, 'g', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomatenmark'), 70, 'g', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000016', 400, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000029', 20, 'g', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000022', 1, 'Stück', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 5, 'EL', 11);
-- 11 Buntes Ofengemüse mit Halloumi
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Süßkartoffeln'), 2, 'Stück', 1),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 2),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 2, 'Stück', 3),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000028', 20, 'g', 4),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 5),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 6),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 7),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000033', 2, 'g', 8),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000003', 1, 'Stück', 9),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000016', 500, 'g', 10),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
-- ─── Recipe Steps ─────────────────────────────────────────────────────────────
-- 01 Scharfer Auflauf mit Orzonudeln
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 1, 'Backofen auf 200 °C (Grillfunktion) vorheizen. Zwiebeln und Knoblauch abziehen und fein hacken. Aubergine in ca. 2 cm große Würfel schneiden. Heiße Gemüsebrühe vorbereiten. Chilischote halbieren, Kerne entfernen und in feine Streifen schneiden (Achtung: scharf!).'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und Knoblauch darin 23 Min. glasig andünsten. Orzonudeln und Aubergine zugeben und anbraten, bis das Öl vollständig aufgenommen ist. Brühe, Tomaten-Polpa und Chili zugeben, verrühren und ca. 10 Min. bei mittlerer Hitze köcheln lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 3, 'Zucchini längs halbieren und in 0,5 cm Scheiben schneiden. Oliven in Ringe schneiden. Zucchini und die Hälfte der Oliven zum Orzo geben und ca. 3 Min. mitkochen. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 4, 'Hartkäse fein reiben. Cheddar unter den Orzo heben und alles in eine Auflaufform füllen. Mit Hartkäse bestreuen und im Backofen 510 Min. gratinieren, bis der Käse goldbraun ist.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 5, 'Öl, Salz und Pfeffer in einer großen Schüssel vermengen. Rucola und restliche Oliven unterheben.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 6, 'Orzoauflauf auf Teller verteilen und mit dem Rucola-Oliven-Salat servieren.');
-- 02 Tortellini mit Ricotta-Füllung
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Knoblauch abziehen. Zucchini in 0,5 cm dünne Scheiben schneiden. Kirschtomaten halbieren. Gemüse in eine große Schüssel geben, Knoblauch hinzupressen, mit Olivenöl, Salz und Pfeffer vermengen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 2, 'Gemüse auf einem mit Backpapier belegten Blech verteilen und 1820 Min. backen, bis die Zucchini leicht bräunt und die Tomaten fast geschmolzen sind. Währenddessen Kräuter abzupfen, Basilikum und Petersilie fein hacken, Schnittlauch in Röllchen schneiden.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 3, 'Großen Topf mit gesalzenem Wasser zum Kochen bringen. Frischkäse mit den gehackten Kräutern verrühren, mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 4, 'Sonnenblumenkerne in einer kleinen Pfanne ohne Fett bei mittlerer Hitze goldbraun rösten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 5, 'Tortellini in den letzten 34 Min. der Gemüse-Backzeit in das kochende Wasser geben und al dente garen. Abgießen, zurück in den Topf geben. Gebackenes Gemüse und 4 EL Kräuterfrischkäse unterheben.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 6, 'Tortellini auf Teller verteilen. Restlichen Kräuterfrischkäse als Kleckse darauf verteilen, mit Sonnenblumenkernen bestreuen und mit Basilikum dekorieren.');
-- 03 Knuspriger Flammkuchen mit Mozzarella
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Schnittlauch und Petersilie fein hacken und unter den Frischkäse heben. Flammkuchenteig auf einem mit Backpapier belegten Blech ausrollen und gleichmäßig mit dem Kräuterfrischkäse bestreichen (ca. 1 cm Rand frei lassen). Mit Salz und Pfeffer bestreuen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 2, 'Rote Zwiebeln abziehen, halbieren und in feine Streifen schneiden. Mozzarella in kleine Stücke zupfen. Flammkuchen mit Zwiebelstreifen belegen und Mozzarellastücke darauf verteilen. Auf der mittleren Schiene 1315 Min. knusprig backen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 3, 'Gurke in lange, dünne Scheiben hobeln oder schneiden. Radieschen vierteln.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 4, 'Joghurt, Senf, Olivenöl, Weißweinessig, Salz und Pfeffer in einer großen Schüssel zu einem Dressing verrühren.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 5, 'Rucola, Gurkenstreifen und Radieschen in die Schüssel geben und unterheben. Bis zum Anrichten ziehen lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 6, 'Flammkuchen in Stücke schneiden und auf Teller verteilen. Mit dem Salat servieren.');
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 1, '1200 ml Wasser erhitzen. Karotten schälen und grob reiben. Zwiebeln fein würfeln. Knoblauch in dünne Scheiben schneiden. Hartkäse fein reiben. Zitronenschale abreiben, Zitronen halbieren und entsaften. Gemüsebrühe im heißen Wasser auflösen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 2, 'Öl in einem großen Topf erhitzen. Zwiebeln und Knoblauch darin 23 Min. glasig andünsten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 3, 'Risottoreis zugeben und unter Rühren erhitzen, bis das Öl vollständig aufgenommen ist. Karotten und ein Drittel der Brühe zugeben und gut verrühren. Restliche Brühe nach und nach einrühren. Insgesamt ca. 20 Min. köcheln lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 4, 'Mozzarella in mundgerechte Stücke schneiden. Mit Basilikumpaste, Olivenöl, Weißweinessig, Salz, Pfeffer und 1 Prise Zucker marinieren und ziehen lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 5, 'Kirschtomaten und Hartkäse in den Risotto einrühren. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 6, 'Risotto auf Teller verteilen und mit dem marinierten Basilikummozzarella toppen.');
-- 05 Karotten-Hafer-Puffer
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Kartoffeln ungeschält in Spalten (Wedges) schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Öl beträufeln, mit Gewürzmischung Kartoffelknaller, Salz und Pfeffer würzen. 2025 Min. backen, bis die Wedges innen weich und außen knusprig sind.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 2, 'Gurke längs halbieren und in Halbmondscheiben schneiden. Äpfel entkernen und in dünne Halbmonde schneiden. Balsamicoessig, Öl und Senf zu einem Dressing verrühren, mit Salz und Pfeffer abschmecken. Gurke und Apfel unterheben und marinieren lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 3, 'Koriander fein hacken und mit Joghurt und Mayonnaise verrühren. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 4, 'Karotten schälen und grob raspeln. In einer großen Schüssel Karotten, Haferflocken, Eier, Tex-Mex-Käsemischung und Currypulver vermischen. Mit Salz und Pfeffer würzen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 5, 'Öl in einer großen Pfanne erhitzen. Karottenmischung mithilfe eines Esslöffels zu Puffern formen und leicht flach drücken. Von beiden Seiten je ca. 3 Min. goldbraun braten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 6, 'Feldsalat unter den Apfel-Gurken-Salat heben. Auf Tellern anrichten, Karottenpuffer und Kartoffelwedges dazu platzieren. Mit Korianderdip beträufeln und genießen.');
-- 06 Überbackene Penne mit getrockneten Tomaten
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Reichlich gesalzenes Wasser zum Kochen bringen. Penne 79 Min. bissfest garen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 2, 'Knoblauch abziehen und fein würfeln. Schnittlauch in Röllchen schneiden. Kirschtomaten halbieren. Getrocknete Tomaten grob zerkleinern.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 3, 'Frischkäse mit Knoblauch, Senf und dem Großteil des Schnittlauchs verrühren. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 4, 'Penne abgießen, dabei 100 ml Kochwasser auffangen. Penne zurück in den Topf geben. Frischkäsemischung und getrocknete Tomaten einrühren, bei Bedarf Kochwasser zugeben, bis eine cremige Konsistenz entsteht. Kirschtomaten unterheben.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 5, 'Penne-Mischung in eine mit Butter eingefettete Auflaufform füllen. Mit Cheddar bestreuen und im Backofen 67 Min. gratinieren.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 6, 'Auflauf auf Teller verteilen und mit restlichem Schnittlauch bestreuen.');
-- 07 Chili sin Carne
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 1, 'Jasminreis mit 600 ml heißem Wasser in einem kleinen Topf aufkochen. Bei niedriger Hitze ca. 10 Min. köcheln lassen, vom Herd nehmen und abgedeckt 10 Min. quellen lassen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 2, 'Knoblauch abziehen und in feine Streifen schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Paprika halbieren, entkernen und in Streifen schneiden. Schwarze Bohnen abgießen und kalt abspülen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 3, 'Öl in einem großen Topf erhitzen. Zwiebeln und Paprika 23 Min. anbraten. Knoblauch und Gewürzmischung HelloMexico zugeben und 1 Min. mitbraten. Mit Salz und Pfeffer würzen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 4, 'Schwarze Bohnen, Chilipolpa, Gemüsebrühe und Balsamicocreme zugeben. Chili 2530 Min. bei niedriger Hitze köcheln lassen, bis die Paprika weich und das Chili cremig ist. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 5, 'Koriander und Petersilie fein hacken. Chilischote entkernen und in Streifen schneiden (Achtung: scharf!). Avocado halbieren, Stein entfernen und in Streifen schneiden. Schmand mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 6, 'Reis mit einer Gabel auflockern, Koriander unterheben und auf Teller verteilen. Chili daneben anrichten, mit Chili und Petersilie bestreuen. Mit Avocado und einem Klecks Schmand servieren.');
-- 08 Gebratene Gnocchi mit Ofenzucchini
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Zucchini in 0,5 cm dünne Scheiben schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Gewürzmischung HelloMediterraneo, Öl, Salz und Pfeffer würzen. Ca. 15 Min. backen, bis die Zucchini weich ist.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 2, 'Haselnusskerne in einer großen Pfanne ohne Fett bei mittlerer Hitze rösten, bis sie duften. Herausnehmen, abkühlen lassen und grob hacken. Pfanne beiseite stellen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 3, 'Knoblauch abziehen. Getrocknete Tomaten grob hacken und mit Frischkäse, Knoblauch und 200 ml Wasser in ein hohes Gefäß geben. Mit einem Pürierstab zu einer glatten Soße mixen. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 4, 'Öl in der Pfanne bei mittlerer Hitze erhitzen. Gnocchi darin 89 Min. anbraten, bis sie knusprig und leicht gebräunt sind.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 5, 'Soße zu den Gnocchi geben, alles vermengen und ca. 2 Min. einkochen lassen. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 6, 'Gnocchi auf Teller verteilen, mit gebackener Zucchini, geriebenem Hartkäse und Haselnusskernen toppen.');
-- 09 Pasta nach Art Caponata
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 1, 'Reichlich gesalzenes Wasser für die Pasta aufkochen. Getrocknete Tomaten und grüne Oliven grob hacken (Öl der Oliven auffangen). Knoblauch abziehen und fein hacken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 2, 'Aubergine in 12 cm Würfel schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Olivenöl in einer großen Pfanne stark erhitzen. Aubergine 34 Min. scharf anbraten. Zwiebeln, getrocknete Tomaten, Oliven und Knoblauch zugeben und 2 Min. mitbraten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 3, 'Hitze reduzieren, Chilipolpa zugeben und alles 1012 Min. köcheln lassen, bis die Soße eingedickt und das Gemüse weich ist. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 4, 'Penne ca. 10 Min. bissfest garen und abgießen. Zitrone heiß abwaschen, Schale abreiben und in Spalten schneiden.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 5, 'Penne zur Soße in die Pfanne geben und gut vermengen. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 6, 'Pasta in tiefen Tellern anrichten, mit geriebenem Hartkäse und Rucola bestreuen.');
-- 10 Auflauf mit Halloumi und Aubergine
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. 2 Knoblauchzehen mit dem Messerrücken andrücken und 15 Min. im Ofen rösten. Restlichen Knoblauch abziehen und fein hacken. Zwiebeln in Streifen schneiden. Tomaten und Auberginen in ca. 2 cm Würfel schneiden.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und gehackten Knoblauch 3 Min. andünsten. Aubergine, Tomatenwürfel sowie Koriander & Kumin zugeben und kurz anbraten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 3, 'Gemüse mit 200 ml Wasser ablöschen. Tomatenmark und Balsamicocreme einrühren und ca. 10 Min. köcheln lassen. Mit Salz und Pfeffer abschmecken. Währenddessen Halloumi in 0,5 cm Scheiben schneiden und Basilikumblätter abzupfen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 4, 'Soße in eine große Auflaufform geben und Halloumischeiben darüber verteilen. Ca. 20 Min. im Ofen backen. Fladenbrot in 2 cm Scheiben schneiden.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 5, 'Geröstete Knoblauchzehen abziehen und fein hacken. Mit Olivenöl, Salz und Pfeffer verrühren. Knoblauchöl auf die Brotscheiben träufeln, auf ein Backblech legen und 510 Min. knusprig aufbacken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 6, 'Auflauf 2 Min. unter dem Grill bräunen, bis der Halloumi goldbraun ist. Mit Basilikumblättern bestreuen und mit dem Knoblauchbrot servieren.');
-- 11 Buntes Ofengemüse mit Halloumi
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Süßkartoffeln schälen und in 2 cm Würfel schneiden. Rote Zwiebeln halbieren und in ca. 1 cm Spalten schneiden.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 2, 'Süßkartoffelwürfel und Zwiebelspalten auf einem mit Backpapier belegten Blech verteilen, mit Salz und Pfeffer würzen. Ca. 25 Min. backen, bis die Süßkartoffeln weich sind.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 3, 'Tomaten halbieren und in Spalten schneiden. Petersilie fein hacken. Avocado würfeln. Die Hälfte der Petersilie und die Avocadowürfel zu den Tomaten geben und beiseitestellen.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 4, 'Knoblauch und Chilischote fein hacken. Restliche Petersilie mit Kumin, Knoblauch, Chili (Achtung: scharf!), Zitronensaft und Olivenöl zu einem Chimichurri verrühren. Mit Salz und Pfeffer abschmecken.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 5, 'Halloumi in ca. 3 cm Würfel schneiden. In einer Pfanne mit etwas Öl bei mittlerer Hitze rundherum 34 Min. goldbraun braten.'),
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 6, 'Geröstetes Gemüse und Zwiebeln in die Schüssel mit Tomaten und Avocado geben, vorsichtig vermengen. Auf Teller verteilen, mit Halloumiwürfeln toppen und Petersilien-Chimichurri darüberträufeln.');
-- ─── Recipe Tags ──────────────────────────────────────────────────────────────
INSERT INTO recipe_tag (recipe_id, tag_id) VALUES
-- 01 Scharfer Auflauf
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
-- 02 Tortellini
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
-- 03 Flammkuchen
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000015'), -- Flammkuchen
-- 04 Tomatenrisotto
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000012'), -- Reis
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
-- 05 Karotten-Hafer-Puffer
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000003'), -- Deutsch
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000009'), -- Eier
-- 06 Überbackene Penne
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
-- 07 Chili sin Carne
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000006'), -- Mexikanisch
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000008'), -- Hülsenfrüchte
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000012'), -- Reis
-- 08 Gnocchi
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
-- 09 Pasta Caponata
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
-- 10 Auflauf Halloumi
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
-- 11 Buntes Ofengemüse
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000002'), -- Glutenfrei
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000007'), -- Käse
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000014') -- Ofengericht
ON CONFLICT DO NOTHING;

View File

@@ -10,19 +10,17 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.UUID; import java.util.UUID;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -100,7 +98,7 @@ class AuthControllerTest {
} }
@Test @Test
void signupShouldStoreSecurityContextInSession() throws Exception { void signupShouldDelegateSessionCreationToAuthService() throws Exception {
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah"); var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah"); var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
@@ -109,14 +107,13 @@ class AuthControllerTest {
mockMvc.perform(post("/v1/auth/signup") mockMvc.perform(post("/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated()) .andExpect(status().isCreated());
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
notNullValue()));
} }
@Test @Test
void loginShouldStoreSecurityContextInSession() throws Exception { void loginShouldDelegateSessionCreationToAuthService() throws Exception {
var request = new LoginRequest("sarah@example.com", "s3cure!Pass"); var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
var response = UserResponse.withHousehold( var response = UserResponse.withHousehold(
UUID.randomUUID(), "sarah@example.com", "Sarah", UUID.randomUUID(), "sarah@example.com", "Sarah",
@@ -127,10 +124,9 @@ class AuthControllerTest {
mockMvc.perform(post("/v1/auth/login") mockMvc.perform(post("/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk()) .andExpect(status().isOk());
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
notNullValue()));
} }
@Test @Test

View File

@@ -0,0 +1,51 @@
package com.recipeapp.auth;
import com.recipeapp.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class SecurityConfigTest extends AbstractIntegrationTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@Test
void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception {
// 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401
mockMvc.perform(get("/v1/invites/ANYCODE"))
.andExpect(status().isNotFound());
}
@Test
void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception {
// 400 = validation error (empty body), but NOT 401 — proves the path is permitted
mockMvc.perform(post("/v1/invites/ANYCODE/accept")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
void protectedEndpointRequiresAuthentication() throws Exception {
mockMvc.perform(get("/v1/households/mine"))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,7 +1,10 @@
package com.recipeapp.household; package com.recipeapp.household;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipeapp.auth.AuthService;
import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ConflictException;
import com.recipeapp.household.dto.*; import com.recipeapp.household.dto.*;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -15,10 +18,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -32,6 +37,9 @@ class HouseholdControllerTest {
@Mock @Mock
private HouseholdService householdService; private HouseholdService householdService;
@Mock
private AuthService authService;
@InjectMocks @InjectMocks
private HouseholdController householdController; private HouseholdController householdController;
@@ -104,16 +112,119 @@ class HouseholdControllerTest {
} }
@Test @Test
void acceptInviteShouldReturn200() throws Exception { void getActiveInviteShouldReturn200WithInvite() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
Instant.now().plusSeconds(172800));
when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response); when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
}
@Test
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
}
@Test
void deleteMemberShouldReturn204() throws Exception {
var memberId = UUID.randomUUID();
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
verify(householdService).removeMember("sarah@example.com", memberId);
}
@Test
void patchMemberRoleShouldReturn200() throws Exception {
var memberId = UUID.randomUUID();
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
var request = new ChangeRoleRequest("planner");
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
.thenReturn(memberResponse);
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.role").value("planner"));
}
@Test
void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception {
var response = new InviteInfoResponse("Smith family", "Sarah");
when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response);
mockMvc.perform(get("/v1/invites/ABC12XYZ"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
.andExpect(jsonPath("$.data.inviterName").value("Sarah"));
}
@Test
void getInviteInfoShouldReturn404WhenInvalid() throws Exception {
when(householdService.getInviteInfo("BADTOKEN"))
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
mockMvc.perform(get("/v1/invites/BADTOKEN"))
.andExpect(status().isNotFound());
}
@Test
void acceptInviteShouldReturn200AndCreateSession() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.thenReturn(response);
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
.principal(() -> "tom@example.com")) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.householdName").value("Smith family")) .andExpect(jsonPath("$.data.householdName").value("Smith family"))
.andExpect(jsonPath("$.data.role").value("member")); .andExpect(jsonPath("$.data.role").value("member"));
} }
@Test
void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception {
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.thenThrow(new ConflictException("Email already registered"));
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception {
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123"))
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
mockMvc.perform(post("/v1/invites/BADTOKEN/accept")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}
} }

View File

@@ -17,11 +17,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -38,10 +41,16 @@ class HouseholdServiceTest {
@Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private TagRepository tagRepository; @Mock private TagRepository tagRepository;
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
@Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
@InjectMocks @InjectMocks
private HouseholdService householdService; private HouseholdService householdService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
}
private UserAccount testUser() { private UserAccount testUser() {
return new UserAccount("sarah@example.com", "Sarah", "hashed"); return new UserAccount("sarah@example.com", "Sarah", "hashed");
} }
@@ -132,85 +141,164 @@ class HouseholdServiceTest {
} }
@Test @Test
void acceptInviteShouldAddUserAsMember() { void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
var user = new UserAccount("tom@example.com", "Tom", "hashed"); var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
InviteResponse result = householdService.createInvite("sarah@example.com");
assertThat(result.shareUrl()).startsWith("http://localhost:5173/join/");
assertThat(result.shareUrl()).endsWith(result.inviteCode());
}
// ── getInviteInfo ─────────────────────────────────────────────────────────
@Test
void getInviteInfoShouldReturnHouseholdNameAndInviterName() {
var owner = testUser(); var owner = testUser();
var household = new Household("Smith family", owner); var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ"); InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ");
assertThat(result.householdName()).isEqualTo("Smith family"); assertThat(result.householdName()).isEqualTo("Smith family");
assertThat(result.role()).isEqualTo("member"); assertThat(result.inviterName()).isEqualTo("Sarah");
} }
@Test @Test
void acceptInviteShouldThrowWhenAlreadyInHousehold() { void getInviteInfoShouldThrow404WhenCodeNotFound() {
var user = new UserAccount("tom@example.com", "Tom", "hashed"); when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
var household = new Household("Other", user);
var member = new HouseholdMember(household, user, "member"); assertThatThrownBy(() -> householdService.getInviteInfo("INVALID"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenCodeExpired() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
invite.setInvitedBy(owner);
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
invite.setStatus("used");
invite.setInvitedBy(owner);
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("USED123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenInviteIsInvalidated() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED"))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── acceptInvite (new: creates account + joins) ───────────────────────────
@Test
void acceptInviteShouldCreateAccountAndAddAsMember() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member)); when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
when(passwordEncoder.encode("secret123")).thenReturn("hashed");
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ")) AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123");
assertThat(result.householdName()).isEqualTo("Smith family");
assertThat(result.role()).isEqualTo("member");
verify(userAccountRepository).save(any(UserAccount.class));
verify(householdMemberRepository).save(any(HouseholdMember.class));
}
@Test
void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() {
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true);
assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ConflictException.class); .isInstanceOf(ConflictException.class);
} }
@Test @Test
void acceptInviteShouldThrowWhenCodeExpired() { void acceptInviteShouldThrow404WhenCodeExpired() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var owner = testUser(); var owner = testUser();
var household = new Household("Smith family", owner); var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED")) assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ValidationException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test @Test
void acceptInviteShouldThrowWhenCodeAlreadyUsed() { void acceptInviteShouldThrow404WhenCodeAlreadyUsed() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var owner = testUser(); var owner = testUser();
var household = new Household("Smith family", owner); var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
invite.setStatus("used"); invite.setStatus("used");
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123")) assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ConflictException.class);
}
@Test
void acceptInviteShouldThrowWhenInviteNotFound() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID"))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test @Test
void acceptInviteShouldThrowWhenUserNotFound() { void acceptInviteShouldThrow404WhenInviteNotFound() {
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ")) assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void acceptInviteShouldThrow404WhenInviteIsInvalidated() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@@ -223,6 +311,187 @@ class HouseholdServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
// ── changeMemberRole ──────────────────────────────────────────────────────
@Test
void changeMemberRoleShouldUpdateRole() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
assertThat(result.role()).isEqualTo("planner");
}
@Test
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
assertThat(result.role()).isEqualTo("member");
verify(householdMemberRepository, never()).save(any());
}
@Test
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── removeMember ──────────────────────────────────────────────────────────
@Test
void removeMemberShouldDeleteMember() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
householdService.removeMember("sarah@example.com", targetId);
verify(householdMemberRepository).delete(targetMembership);
}
@Test
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var plannerId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("cannot remove yourself");
}
@Test
void removeMemberShouldThrow409WhenRemovingLastPlanner() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", targetId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── getActiveInvite ───────────────────────────────────────────────────────
@Test
void getActiveInviteShouldReturnActiveInviteResponse() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isPresent();
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
}
@Test
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test
void getActiveInviteShouldReturnEmptyWhenExpired() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test @Test
void getMembersShouldReturnAllMembers() { void getMembersShouldReturnAllMembers() {
var user1 = testUser(); var user1 = testUser();
@@ -256,4 +525,23 @@ class HouseholdServiceTest {
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test
void createInviteShouldInvalidatePreviousActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
householdService.createInvite("sarah@example.com");
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
verify(householdInviteRepository).saveAndFlush(existingInvite);
verify(householdInviteRepository).save(any(HouseholdInvite.class));
}
} }

View File

@@ -55,7 +55,7 @@ class PlanningServiceTest {
} }
private Recipe testRecipe(Household household, String name) { private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

View File

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

View File

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

View File

@@ -162,7 +162,7 @@ class WeekPlanControllerTest {
@Test @Test
void getSuggestionsShouldReturn200() throws Exception { void getSuggestionsShouldReturn200() throws Exception {
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
var response = new SuggestionResponse(List.of(item)); var response = new SuggestionResponse(List.of(item));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
@@ -175,7 +175,8 @@ class WeekPlanControllerTest {
.param("slotDate", "2026-04-08")) .param("slotDate", "2026-04-08"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry"))
.andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); .andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5))
.andExpect(jsonPath("$.suggestions[0].hasConflict").value(false));
} }
@Test @Test

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ class ShoppingServiceTest {
} }
private Recipe testRecipe(Household household, String name) { private Recipe testRecipe(Household household, String name) {
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
setId(r, Recipe.class, UUID.randomUUID()); setId(r, Recipe.class, UUID.randomUUID());
return r; return r;
} }

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

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

View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('Startseite lädt korrekt', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible();
});

View File

@@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

View File

@@ -11,6 +11,7 @@
--color-surface: #f5f4ee; --color-surface: #f5f4ee;
--color-subtle: #edecea; --color-subtle: #edecea;
--color-border: #d8d7d0; --color-border: #d8d7d0;
--color-border-hover: #c0bfb8;
--color-text: #1c1c18; --color-text: #1c1c18;
--color-text-muted: #6b6a63; --color-text-muted: #6b6a63;
@@ -86,4 +87,28 @@
--btn-font-size: 13px; --btn-font-size: 13px;
--btn-font-weight: 500; --btn-font-weight: 500;
--btn-letter-spacing: 0.04em; --btn-letter-spacing: 0.04em;
/* ── Planner flip-tile semantic tokens ──────────────────────────── */
--color-ring-today: var(--yellow-text);
--color-ring-selected: var(--green-dark);
--opacity-dimmed: 0.38;
/* ── Protein gradient tokens ────────────────────────────────────── */
--gradient-protein-haehnchen: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
--gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
--gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
--gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
--gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
--gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%);
--gradient-protein-eier: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
--gradient-protein-kaese: linear-gradient(135deg, #fcd34d 0%, #d97706 100%);
--gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%);
/* ── Cuisine gradient tokens ────────────────────────────────────── */
--gradient-cuisine-deutsch: linear-gradient(135deg, #78716c 0%, #44403c 100%);
--gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%);
--gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%);
--gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
--gradient-cuisine-mediterran: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
} }

View File

@@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event); expect(resolve).toHaveBeenCalledWith(event);
}); });
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])( it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])(
'allows public route %s without auth', 'allows public route %s without auth',
async (path) => { async (path) => {
const { event, resolve } = createEvent(path); const { event, resolve } = createEvent(path);
@@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => {
} }
); );
it('redirects authenticated user on /join/[token] to /', async () => {
const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session');
try {
await handle({ event, resolve });
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/');
}
});
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
'allows static asset %s without auth', 'allows static asset %s without auth',
async (path) => { async (path) => {

View File

@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api'; import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite']; const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join'];
const STATIC_PREFIXES = ['/_app/', '/favicon']; const STATIC_PREFIXES = ['/_app/', '/favicon'];
@@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never {
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) { if (isPublicRoute(event.url.pathname)) {
const isJoinRoute = event.url.pathname.startsWith('/join/');
if (isJoinRoute && event.cookies.get('JSESSIONID')) {
throw redirect(302, '/');
}
return resolve(event); return resolve(event);
} }

File diff suppressed because one or more lines are too long

View File

@@ -148,6 +148,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/invites/{code}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getInviteInfo"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/invites/{code}/accept": { "/v1/invites/{code}/accept": {
parameters: { parameters: {
query?: never; query?: never;
@@ -203,7 +219,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; get: operations["getActiveInvite"];
put?: never; put?: never;
post: operations["createInvite"]; post: operations["createInvite"];
delete?: never; delete?: never;
@@ -212,6 +228,24 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/households/mine/members/{userId}": {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["removeMember"];
options?: never;
head?: never;
patch: operations["changeMemberRole"];
trace?: never;
};
"/v1/cooking-logs": { "/v1/cooking-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -552,7 +586,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort: string; effort: string;
isChildFriendly?: boolean;
heroImageUrl?: string; heroImageUrl?: string;
ingredients: components["schemas"]["IngredientEntry"][]; ingredients: components["schemas"]["IngredientEntry"][];
steps?: components["schemas"]["StepEntry"][]; steps?: components["schemas"]["StepEntry"][];
@@ -587,7 +620,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
isChildFriendly?: boolean;
heroImageUrl?: string; heroImageUrl?: string;
ingredients?: components["schemas"]["IngredientItem"][]; ingredients?: components["schemas"]["IngredientItem"][];
steps?: components["schemas"]["StepItem"][]; steps?: components["schemas"]["StepItem"][];
@@ -723,6 +755,20 @@ export interface components {
data?: components["schemas"]["AcceptInviteResponse"]; data?: components["schemas"]["AcceptInviteResponse"];
meta?: components["schemas"]["Meta"]; meta?: components["schemas"]["Meta"];
}; };
InviteInfoResponse: {
householdName?: string;
inviterName?: string;
};
ApiResponseInviteInfoResponse: {
status?: string;
data?: components["schemas"]["InviteInfoResponse"];
meta?: components["schemas"]["Meta"];
};
AcceptInviteRequest: {
name: string;
email: string;
password: string;
};
Meta: { Meta: {
pagination?: components["schemas"]["Pagination"]; pagination?: components["schemas"]["Pagination"];
}; };
@@ -765,6 +811,14 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
joinedAt?: string; joinedAt?: string;
}; };
ChangeRoleRequest: {
role: string;
};
ApiResponseMemberResponse: {
status?: string;
data?: components["schemas"]["MemberResponse"];
meta?: components["schemas"]["Meta"];
};
ApiResponseInviteResponse: { ApiResponseInviteResponse: {
status?: string; status?: string;
data?: components["schemas"]["InviteResponse"]; data?: components["schemas"]["InviteResponse"];
@@ -914,7 +968,8 @@ export interface components {
SuggestionItem: { SuggestionItem: {
recipe?: components["schemas"]["SlotRecipe"]; recipe?: components["schemas"]["SlotRecipe"];
/** Format: double */ /** Format: double */
simulatedScore?: number; scoreDelta?: number;
hasConflict?: boolean;
}; };
SuggestionResponse: { SuggestionResponse: {
suggestions?: components["schemas"]["SuggestionItem"][]; suggestions?: components["schemas"]["SuggestionItem"][];
@@ -933,8 +988,7 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
isChildFriendly?: boolean; heroImagePreview?: string;
heroImageUrl?: string;
}; };
ApiResponseListAdminUserResponse: { ApiResponseListAdminUserResponse: {
status?: string; status?: string;
@@ -1321,7 +1375,7 @@ export interface operations {
}; };
}; };
}; };
acceptInvite: { getInviteInfo: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -1331,6 +1385,37 @@ export interface operations {
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseInviteInfoResponse"];
};
};
/** @description Not found */
404: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
};
};
acceptInvite: {
parameters: {
query?: never;
header?: never;
path: {
code: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AcceptInviteRequest"];
};
};
responses: { responses: {
/** @description OK */ /** @description OK */
200: { 200: {
@@ -1341,6 +1426,16 @@ export interface operations {
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"]; "*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
}; };
}; };
/** @description Email already registered */
409: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
/** @description Invite not found or invalid */
404: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
}; };
}; };
listCategories: { listCategories: {
@@ -2012,6 +2107,97 @@ export interface operations {
}; };
}; };
}; };
getActiveInvite: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseInviteResponse"];
};
};
/** @description No active invite */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeMember: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
changeMemberRole: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangeRoleRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMemberResponse"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
listAuditLog: { listAuditLog: {
parameters: { parameters: {
query?: { query?: {

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,50 @@
<script lang="ts">
let {
options,
value,
onchange
}: {
options: { value: string; label: string }[];
value: string;
onchange: (value: string) => void;
} = $props();
</script>
<div role="group" class="segmented-control">
{#each options as option (option.value)}
<button
type="button"
aria-pressed={option.value === value ? 'true' : 'false'}
class="segment"
class:active={option.value === value}
onclick={() => onchange(option.value)}
>
{option.label}
</button>
{/each}
</div>
<style>
.segmented-control {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-md);
padding: 2px;
}
.segment {
border: none;
cursor: pointer;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
border-radius: var(--radius-sm);
color: inherit;
}
.segment.active {
background: var(--color-page);
}
</style>

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SegmentedControl from './SegmentedControl.svelte';
const options = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
describe('SegmentedControl', () => {
it('renders all option labels', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
});
it('marks the active option with aria-pressed', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onchange with the new value when an option is clicked', async () => {
const onchange = vi.fn();
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
expect(onchange).toHaveBeenCalledWith('member');
});
});

View File

@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
title: string;
href: string;
cta: string;
meta?: string;
}
let { title, href, cta, meta }: Props = $props();
</script>
<a
{href}
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium text-[var(--color-text)]">
{title}
</span>
{#if meta}
<p data-testid="card-meta" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
{meta}
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{cta}
</span>
</a>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SettingsCard from './SettingsCard.svelte';
describe('SettingsCard', () => {
it('renders the title', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte')).toBeInTheDocument();
});
it('renders as an anchor tag with the given href', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/household/staples');
});
it('renders the cta text', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
it('renders meta text when provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } });
expect(screen.getByText('3 Mitglieder')).toBeInTheDocument();
});
it('does not render meta element when meta is not provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } });
expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
<script lang="ts">
const { message, visible, ondismiss }: {
message: string;
visible: boolean;
ondismiss?: () => void;
} = $props();
</script>
{#if visible}
<div
role="status"
style="
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-overlay);
border-radius: var(--radius-lg);
color: var(--color-text);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
"
>
<span>{message}</span>
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Toast from './Toast.svelte';
describe('Toast', () => {
it('is not mounted when visible is false', () => {
render(Toast, { props: { message: 'Hallo', visible: false } });
expect(screen.queryByRole('status')).toBeNull();
});
it('shows the message when visible is true', () => {
render(Toast, { props: { message: 'Gespeichert', visible: true } });
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
});
it('calls ondismiss when close button is clicked', async () => {
const ondismiss = vi.fn();
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
expect(ondismiss).toHaveBeenCalledOnce();
});
});

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ describe('AppShell', () => {
it('renders all navigation links from all nav variants', () => { it('renders all navigation links from all nav variants', () => {
render(AppShell, { props: defaultProps }); render(AppShell, { props: defaultProps });
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total // Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
expect(links).toHaveLength(13); expect(links).toHaveLength(12);
}); });
}); });

View File

@@ -24,7 +24,7 @@
{section.title} {section.title}
</p> </p>
{#each section.items as item (item.href)} {#each section.items as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)} {@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a <a
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}

View File

@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
expect(screen.getByText('Einkauf')).toBeInTheDocument(); expect(screen.getByText('Einkauf')).toBeInTheDocument();
}); });
it('renders Household section with 2 items', () => { it('renders Household section with Einstellungen', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Haushalt')).toBeInTheDocument(); expect(screen.getByText('Haushalt')).toBeInTheDocument();
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument(); expect(screen.getByText('Einstellungen')).toBeInTheDocument();
expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument();
}); });
it('has 5 navigation links total', () => { it('has 4 navigation links total', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
expect(links).toHaveLength(5); expect(links).toHaveLength(4);
}); });
it('marks active item with aria-current="page"', () => { it('marks active item with aria-current="page"', () => {
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
expect(widget).toBeInTheDocument(); expect(widget).toBeInTheDocument();
}); });
}); });
describe('DesktopSidebar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: Sidebar } = await import('./DesktopSidebar.svelte');
r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } });
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -8,7 +8,7 @@
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden" class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
> >
{#each mobileNavItems as item (item.href)} {#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)} {@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a <a
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}

View File

@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
expect(recipesLink).not.toHaveAttribute('aria-current'); expect(recipesLink).not.toHaveAttribute('aria-current');
}); });
}); });
describe('MobileTabBar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: TabBar } = await import('./MobileTabBar.svelte');
r(TabBar);
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -34,9 +34,9 @@ describe('nav config', () => {
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']); expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
}); });
it('Household section has Members, Settings', () => { it('Household section has Settings', () => {
const labels = desktopNavSections[1].items.map((item) => item.label); const labels = desktopNavSections[1].items.map((item) => item.label);
expect(labels).toEqual(['Mitglieder', 'Einstellungen']); expect(labels).toEqual(['Einstellungen']);
}); });
}); });
@@ -56,5 +56,35 @@ describe('nav config', () => {
it('does not match unrelated route', () => { it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false); expect(isActiveRoute('/planner', '/recipes')).toBe(false);
}); });
it('matches when pathname is in extraPaths', () => {
expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true);
});
it('matches sub-route of extraPath', () => {
expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true);
});
it('does not match extraPath with similar prefix', () => {
expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false);
});
it('returns false when extraPaths provided but no match', () => {
expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false);
});
});
describe('NavItem extraPaths', () => {
it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => {
const einstellungen = desktopNavSections
.flatMap((s) => s.items)
.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => {
const einstellungen = mobileNavItems.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
}); });
}); });

View File

@@ -2,6 +2,7 @@ export interface NavItem {
href: string; href: string;
label: string; label: string;
icon: string; icon: string;
extraPaths?: string[];
} }
export interface NavSection { export interface NavSection {
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: '📅' }, { href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' }, { href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' }, { href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' } { href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
]; ];
export function isActiveRoute(href: string, pathname: string): boolean { export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
return pathname === href || pathname.startsWith(href + '/'); if (pathname === href || pathname.startsWith(href + '/')) return true;
if (extraPaths) {
return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
return false;
} }
export const desktopNavSections: NavSection[] = [ export const desktopNavSections: NavSection[] = [
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
{ {
title: 'Haushalt', title: 'Haushalt',
items: [ items: [
{ href: '/members', label: 'Mitglieder', icon: '👥' }, { href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
] ]
} }
]; ];

View File

@@ -0,0 +1,412 @@
<script lang="ts">
import EmptyDayTile from './EmptyDayTile.svelte';
import { formatDayAbbr } from '$lib/planner/week';
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
let {
slot,
isToday,
activeSlotId,
isPlanner,
slotMap,
suggestions,
topSuggestion,
onflip,
onclose,
onswap,
onremove,
onaddrecipe
}: {
slot: Slot;
isToday: boolean;
activeSlotId: string | null;
isPlanner: boolean;
slotMap: Record<string, any>;
suggestions: Suggestion[];
topSuggestion?: Suggestion;
onflip?: (slotId: string) => void;
onclose?: () => void;
onswap?: () => void;
onremove?: () => void;
onaddrecipe?: () => void;
} = $props();
const slotId = $derived(slot.id ?? '');
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
const dayAbbr = $derived(slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : '');
const dateNum = $derived(slot.slotDate ? slot.slotDate.slice(-2).replace(/^0/, '') : '');
const visibleTags = $derived(
(slot.recipe?.tags ?? []).filter((t) => t.tagType === 'protein' || t.tagType === 'cuisine')
);
const metaLine = $derived((() => {
const parts: string[] = [];
if (slot.recipe?.cookTimeMin) parts.push(`${slot.recipe.cookTimeMin} Min`);
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
return parts.join(' · ');
})());
function handleFlip() {
onflip?.(slotId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onflip?.(slotId);
}
}
const umlautMap: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
function toCssKey(name: string): string {
return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c);
}
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
}
const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine');
if (cuisineTag?.name) {
return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`;
}
return 'var(--color-surface)';
})());
</script>
{#if slot.recipe}
<div
data-testid="day-meal-card-{slot.slotDate ?? ''}"
role="button"
tabindex="0"
aria-label={slot.recipe?.name ?? 'Gericht'}
aria-expanded={isFlipped}
data-today={isToday}
data-flipped={isFlipped}
data-dimmed={isDimmed}
class="scene"
class:scene-today={isToday && !isFlipped}
class:scene-selected={isFlipped}
class:scene-dimmed={isDimmed}
onclick={handleFlip}
onkeydown={handleKeydown}
>
<!-- FRONT -->
<div class="card-front" class:flipped={isFlipped}>
<div
class="card-front-inner"
style="background: {gradientBackground}; background-size: cover; background-position: center;"
>
<!-- Full-tile dual gradient overlay -->
<div class="tile-overlay"></div>
<!-- Day header -->
<div class="tile-head">
<span class="tile-day-abbr">{dayAbbr}</span>
<span class="tile-day-num" class:dn-today={isToday} class:dn-selected={isFlipped}>
{dateNum}
</span>
</div>
<!-- Recipe info at bottom -->
<div class="tile-info">
<p class="tile-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="tile-meta">{metaLine}</p>
{/if}
{#if visibleTags.length > 0}
<div class="tile-tags">
{#each visibleTags as tag (tag.id)}
<span class="tile-tag" class:tag-today={isToday} class:tag-selected={isFlipped}>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</div> <!-- /.card-front-inner -->
</div>
<!-- BACK -->
<div class="card-back" class:flipped={isFlipped} aria-hidden={!isFlipped}>
<div class="card-back-inner">
<button
type="button"
aria-label="Schließen"
class="btn-close"
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
>
×
</button>
<p class="back-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="back-meta">{metaLine}</p>
{/if}
<div class="back-actions">
<a class="btn-action btn-primary" href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a class="btn-action" href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
{#if isPlanner}
<button
type="button"
class="btn-action"
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
>
Gericht tauschen
</button>
{#if slot.id}
<button
type="button"
class="btn-action btn-danger"
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
>
Entfernen
</button>
{/if}
{/if}
</div>
</div> <!-- /.card-back-inner -->
</div>
</div>
{:else}
<EmptyDayTile
slotDate={slot.slotDate ?? ''}
slotId={slot.id ?? ''}
{isPlanner}
{slotMap}
{topSuggestion}
{onaddrecipe}
/>
{/if}
<style>
/* ── Scene (outermost positioned element) ── */
.scene {
perspective: 900px;
height: 100%;
width: 100%;
cursor: pointer;
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
transition: box-shadow .15s, opacity .15s;
}
.scene:hover {
box-shadow: 0 6px 18px rgba(28,28,24,.14), 0 2px 6px rgba(28,28,24,.08);
}
.scene-today {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 1px 3px rgba(28,28,24,.06);
}
.scene-today:hover {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 6px 18px rgba(28,28,24,.14);
}
.scene-selected {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
/* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */
.scene-selected:hover {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
.scene-dimmed {
opacity: var(--opacity-dimmed);
pointer-events: none;
}
/* ── Card flip — independent face transforms, no preserve-3d ──
preserve-3d + box-shadow/transition on parent causes Chrome to
fail backface-visibility:hidden. Rotating each face independently
avoids the 3D context entirely. */
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.card-front { transform: rotateY(0deg); }
.card-front.flipped { transform: rotateY(-180deg); }
.card-back { transform: rotateY(180deg); pointer-events: none; }
.card-back.flipped { transform: rotateY(0deg); pointer-events: auto; }
.card-front.flipped { pointer-events: none; }
.card-front-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ── Front face ── */
.tile-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,.32) 0%,
rgba(0,0,0,0) 30%,
rgba(0,0,0,0) 45%,
rgba(0,0,0,.55) 100%
);
border-radius: inherit;
}
.tile-head {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px;
z-index: 2;
}
.tile-day-abbr {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .06em;
color: rgba(255,255,255,.85);
font-weight: 500;
}
.tile-day-num {
width: 20px; height: 20px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: rgba(255,255,255,.9);
background: rgba(255,255,255,.22);
}
.dn-today {
background: var(--color-ring-today) !important;
color: #fff !important;
}
.dn-selected {
background: var(--green-dark) !important;
color: #fff !important;
}
.tile-info {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 8px 9px 9px;
z-index: 2;
}
.tile-name {
font-size: 19px;
font-weight: 300;
color: #fff;
line-height: 1.3;
margin: 0;
text-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.tile-meta {
font-size: 14px;
color: rgba(255,255,255,.75);
margin: 2px 0 0;
}
.tile-tags {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 5px;
}
.tile-tag {
font-size: 12px;
font-weight: 500;
padding: 2px 5px;
border-radius: 2px;
background: rgba(255,255,255,.2);
color: rgba(255,255,255,.92);
backdrop-filter: blur(2px);
}
.tag-today { background: rgba(242,193,46,.35); }
.tag-selected { background: rgba(46,110,57,.45); }
/* ── Back face ── */
.card-back-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow-y: auto;
background: var(--color-page);
border: 1px solid var(--color-border);
padding: 10px;
display: flex;
flex-direction: column;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: var(--color-text-muted);
padding: 2px 6px;
align-self: flex-end;
margin: -4px -4px 2px 0;
}
.btn-close:hover { color: var(--color-text); }
.back-name {
font-family: var(--font-display);
font-size: 13px;
font-weight: 300;
margin: 0 0 2px;
line-height: 1.3;
color: var(--color-text);
}
.back-meta {
font-size: 10px;
color: var(--color-text-muted);
margin: 0 0 10px;
}
.back-actions {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: auto;
}
.btn-action {
display: block;
width: 100%;
padding: 7px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
font-size: 11px;
font-weight: 500;
letter-spacing: .04em;
text-align: center;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
}
.btn-action:hover { background: var(--color-surface); }
.btn-primary {
background: var(--green-dark);
color: #fff;
border: none;
}
.btn-primary:hover { background: var(--green-dark); filter: brightness(1.1); }
.btn-danger {
color: #dc4c3e;
border-color: #dc4c3e;
background: transparent;
}
.btn-danger:hover { background: rgba(220,76,62,.08); }
@media (prefers-reduced-motion: reduce) {
.card-front, .card-back { transition: none; }
.scene { transition: none; }
}
</style>

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DesktopDayTile from './DesktopDayTile.svelte';
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
const filledSlot = {
id: 's1',
slotDate: '2026-04-14',
recipe: {
id: 'r1',
name: 'Pasta Bolognese',
cookTimeMin: 45,
effort: 'mittel',
heroImageUrl: null,
tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }]
}
};
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
describe('sanitizeForCssUrl', () => {
it('strips parentheses that could break out of url() context', () => {
expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')');
});
it('strips single quotes', () => {
expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'");
});
it('strips double quotes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"');
});
it('strips backslashes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\');
});
it('preserves a safe data URI unchanged', () => {
const safe = 'data:image/png;base64,abc123+/==';
expect(sanitizeForCssUrl(safe)).toBe(safe);
});
});
describe('DesktopDayTile — filled slot', () => {
describe('front face', () => {
it('renders recipe name on front face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getAllByText('Pasta Bolognese').length).toBeGreaterThanOrEqual(1);
});
it('has data-testid="day-meal-card" on the scene element', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
});
it('applies today ring when isToday', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-today')).toBe('true');
});
it('applies selected ring when activeSlotId matches slot id', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-flipped')).toBe('true');
});
it('dims tile when another slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('true');
});
it('is not dimmed when no slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('false');
});
});
describe('flip interaction', () => {
it('calls onflip with slot id when scene is clicked', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
expect(onflip).toHaveBeenCalledWith('s1');
});
it('calls onflip when Enter key is pressed on scene', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard('{Enter}');
expect(onflip).toHaveBeenCalledWith('s1');
});
it('calls onflip when Space key is pressed on scene', async () => {
const onflip = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard(' ');
expect(onflip).toHaveBeenCalledWith('s1');
});
});
describe('back face (flipped state)', () => {
it('shows recipe name on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
// Back face should also show recipe name
const names = screen.getAllByText('Pasta Bolognese');
expect(names.length).toBeGreaterThanOrEqual(1);
});
it('shows Koch-Modus link on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy();
});
it('shows Rezept ansehen link on back face when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
});
it('shows close button on back face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy();
});
it('calls onclose when close button clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } });
await user.click(screen.getByRole('button', { name: /Schließen/i }));
expect(onclose).toHaveBeenCalledOnce();
});
it('shows Gericht tauschen button for planner on back face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
});
it('hides Gericht tauschen for non-planner', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } });
expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull();
});
it('calls onswap when Gericht tauschen clicked', async () => {
const onswap = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } });
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
expect(onswap).toHaveBeenCalledOnce();
});
it('shows Entfernen button for planner when slot has id', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
});
it('hides Entfernen button when slot.id is null (optimistic slot)', () => {
const slotWithoutId = { ...filledSlot, id: null };
render(DesktopDayTile, { props: { slot: slotWithoutId, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
// flip the tile first so back face is visible
// activeSlotId must match slot.id to flip — but slot.id is null, so isFlipped = false
// The back face is still rendered in the DOM; check button is absent
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
});
it('calls onremove when Entfernen clicked', async () => {
const onremove = vi.fn();
const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } });
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
expect(onremove).toHaveBeenCalledOnce();
});
it('aria-expanded is true when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('true');
});
it('aria-expanded is false when not flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('false');
});
});
});
describe('DesktopDayTile — empty slot', () => {
it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => {
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
});
it('does not render Koch-Modus for empty slot', () => {
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull();
});
});

View File

@@ -0,0 +1,8 @@
/**
* Strips characters that could break out of a CSS url() context or inject
* CSS into an inline style attribute. Safe data URIs (base64) are unaffected
* as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix.
*/
export function sanitizeForCssUrl(url: string): string {
return url.replace(/['"()\\]/g, '');
}

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { computeReasoningTags } from './reasoningTags';
import { formatDayAbbr } from '$lib/planner/week';
import type { Suggestion, SlotMap } from '$lib/planner/types';
let {
slotDate,
slotId,
isPlanner,
slotMap,
topSuggestion,
onaddrecipe
}: {
slotDate: string;
slotId: string;
isPlanner: boolean;
slotMap: SlotMap;
topSuggestion?: Suggestion;
onaddrecipe?: () => void;
} = $props();
let reasoningTags = $derived(
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
);
const dayAbbr = $derived(slotDate ? formatDayAbbr(slotDate, 'short') : '');
const dateNum = $derived(slotDate ? slotDate.slice(-2).replace(/^0/, '') : '');
</script>
<div
data-testid="empty-day-tile"
role="group"
class="h-full flex flex-col overflow-hidden"
style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: 0 1px 3px rgba(28,28,24,.06);"
>
<!-- Day header -->
<div style="display: flex; align-items: center; justify-content: space-between; padding: 7px 8px 0; flex-shrink: 0;">
<span style="font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); font-weight: 500;">{dayAbbr}</span>
<span style="font-size: 10px; font-weight: 500; color: var(--color-text-muted);">{dateNum}</span>
</div>
<!-- CTA -->
{#if isPlanner}
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 6px 6px; gap: 2px; flex-shrink: 0; border-bottom: 1px solid var(--color-border);">
<button
type="button"
aria-label="Gericht wählen"
onclick={() => onaddrecipe?.()}
style="background: none; border: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
<span style="font-size: 18px; color: var(--color-border); line-height: 1;">+</span>
<span style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans);">Gericht wählen</span>
</button>
</div>
{/if}
{#if topSuggestion}
<div style="display: flex; flex-direction: column; padding: 5px 7px 6px; flex: 1; overflow: hidden;">
<div style="font-size: 8px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--color-text-muted); padding: 3px 0 5px; border-bottom: 1px solid var(--color-subtle, var(--color-border)); margin-bottom: 2px;">Vorschlag</div>
<div style="display: flex; align-items: center; gap: 4px; padding: 5px 0;">
<span style="font-family: var(--font-display); font-size: 11px; font-weight: 300; color: var(--color-text); flex: 1; line-height: 1.2;">{topSuggestion.recipe.name}</span>
{#each reasoningTags as tag (tag.id)}
<span
data-testid="reasoning-tag"
style="font-size: 8px; font-weight: 500; padding: 1px 4px; border-radius: 2px; white-space: nowrap; flex-shrink: 0; {tag.color === 'green' ? 'background: var(--green-tint); color: var(--green-dark);' : 'background: var(--yellow-tint); color: var(--yellow-text);'}"
>
{tag.label}
</span>
{/each}
</div>
</div>
{/if}
</div>

View File

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

View File

@@ -17,9 +17,10 @@
slot: Slot; slot: Slot;
onswap: () => void; onswap: () => void;
oncancel: () => void; oncancel: () => void;
onremove?: () => void;
} }
let { open, slot, onswap, oncancel }: Props = $props(); let { open, slot, onswap, oncancel, onremove }: Props = $props();
const meta = $derived.by(() => { const meta = $derived.by(() => {
const parts: string[] = []; const parts: string[] = [];
@@ -82,6 +83,16 @@
↻ Gericht tauschen ↻ Gericht tauschen
</button> </button>
{#if onremove}
<button
type="button"
style="width:100%;background:var(--color-error, #d9534f);border:1px solid var(--color-error, #d9534f);color:#fff;font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
onclick={onremove}
>
✕ Gericht entfernen
</button>
{/if}
{#if slot.recipe} {#if slot.recipe}
<a <a
href="/recipes/{slot.recipe.id}/cook" href="/recipes/{slot.recipe.id}/cook"

View File

@@ -13,7 +13,8 @@ const baseProps = {
open: true, open: true,
slot, slot,
onswap: vi.fn(), onswap: vi.fn(),
oncancel: vi.fn() oncancel: vi.fn(),
onremove: vi.fn()
}; };
describe('MealActionSheet', () => { describe('MealActionSheet', () => {
@@ -28,14 +29,29 @@ describe('MealActionSheet', () => {
expect(screen.getByText(/easy/i)).toBeTruthy(); expect(screen.getByText(/easy/i)).toBeTruthy();
}); });
it('renders all 4 action buttons', () => { it('renders all 5 action buttons', () => {
render(MealActionSheet, { props: baseProps }); render(MealActionSheet, { props: baseProps });
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy(); expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy(); expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
}); });
it('clicking Entfernen calls onremove', async () => {
const onremove = vi.fn();
const user = userEvent.setup();
render(MealActionSheet, { props: { ...baseProps, onremove } });
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
expect(onremove).toHaveBeenCalledOnce();
});
it('does not render Entfernen button when onremove is not provided', () => {
const { onremove: _, ...propsWithoutRemove } = baseProps;
render(MealActionSheet, { props: propsWithoutRemove });
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
});
it('Jetzt kochen links to the cook route', () => { it('Jetzt kochen links to the cook route', () => {
render(MealActionSheet, { props: baseProps }); render(MealActionSheet, { props: baseProps });
const link = screen.getByRole('link', { name: /Jetzt kochen/i }); const link = screen.getByRole('link', { name: /Jetzt kochen/i });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,83 +0,0 @@
<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

@@ -1,60 +0,0 @@
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

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

View File

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

View File

@@ -20,7 +20,7 @@
<div class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] p-4"> <div class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] p-4">
<div class="flex items-baseline gap-1"> <div class="flex items-baseline gap-1">
<span class="font-[var(--font-display)] text-[28px] font-[300] text-[var(--color-text)] md:text-[40px]"> <span class="font-[var(--font-display)] text-[28px] font-[300] text-[var(--color-text)] md:text-[40px]">
{score} {score.toFixed(1)}
</span> </span>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/10</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> <span class="ml-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">Abwechslungs-Score</span>

View File

@@ -51,7 +51,13 @@ describe('VarietyScoreCard', () => {
it('renders with score 0', () => { it('renders with score 0', () => {
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } }); render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
expect(screen.getByText('0')).toBeTruthy(); expect(screen.getByText('0.0')).toBeTruthy();
});
it('rounds floating-point scores to one decimal place', () => {
render(VarietyScoreCard, { props: { ...baseProps, score: 6.199999999999999 } });
expect(screen.getByText('6.2')).toBeTruthy();
expect(screen.queryByText('6.199999999999999')).toBeNull();
}); });
it('renders multiple ingredient overlap warnings', () => { it('renders multiple ingredient overlap warnings', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
export interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
export interface Recipe {
id: string;
name: string;
effort?: string;
cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
}
export interface Slot {
id?: string | null;
slotDate?: string;
recipe?: Recipe | null;
}
export type SlotMap = Record<string, Slot>;
export interface Suggestion {
recipe: Recipe;
scoreDelta: number;
hasConflict: boolean;
}

View File

@@ -23,8 +23,8 @@
data-testid="image-area" data-testid="image-area"
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}" class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
> >
{#if recipe.heroImageUrl} {#if recipe.heroImagePreview}
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" /> <img src={recipe.heroImagePreview} alt={recipe.name} class="w-full h-full object-cover" />
{:else} {:else}
<div <div
data-testid="image-placeholder" data-testid="image-placeholder"

View File

@@ -8,7 +8,7 @@ const mockRecipe = {
name: 'Spaghetti Bolognese', name: 'Spaghetti Bolognese',
cookTimeMin: 30, cookTimeMin: 30,
effort: 'Easy', effort: 'Easy',
heroImageUrl: undefined heroImagePreview: undefined
}; };
describe('RecipeCard', () => { describe('RecipeCard', () => {
@@ -27,18 +27,18 @@ describe('RecipeCard', () => {
expect(screen.getByText(/easy/i)).toBeInTheDocument(); expect(screen.getByText(/easy/i)).toBeInTheDocument();
}); });
it('shows placeholder when no heroImageUrl', () => { it('shows placeholder when no heroImagePreview', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } }); render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument(); expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
}); });
it('shows image when heroImageUrl is provided', () => { it('shows image when heroImagePreview is provided', () => {
render(RecipeCard, { render(RecipeCard, {
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } } props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
}); });
const img = screen.getByRole('img'); const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/uploads/test.jpg'); expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
}); });

View File

@@ -23,13 +23,30 @@
} = $props(); } = $props();
const effortOptions = [ const effortOptions = [
{ label: 'Leicht', value: 'Easy' }, { label: 'Leicht', value: 'easy' },
{ label: 'Mittel', value: 'Medium' }, { label: 'Mittel', value: 'medium' },
{ label: 'Schwer', value: 'Hard' } { label: 'Schwer', value: 'hard' }
]; ];
const initial = (() => $state.snapshot(recipe))(); const initial = (() => $state.snapshot(recipe))();
const TAG_TYPE_LABELS: Record<string, string> = {
dietary: 'Ernährung',
cuisine: 'Küche',
protein: 'Protein',
other: 'Sonstiges'
};
const groupedCategories = $derived(
Object.entries(
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
const type = cat.tagType ?? 'other';
(acc[type] ??= []).push(cat);
return acc;
}, {})
)
);
let name = $state(initial?.name ?? ''); let name = $state(initial?.name ?? '');
let serves = $state<number | ''>(initial?.serves ?? ''); let serves = $state<number | ''>(initial?.serves ?? '');
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? ''); let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
@@ -43,6 +60,32 @@
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }] })) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
); );
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']); let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
let imageError = $state<string | null>(null);
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
function handleImageChange(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
if (file.size > MAX_IMAGE_BYTES) {
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
(e.currentTarget as HTMLInputElement).value = '';
return;
}
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
imageError = 'Dateityp nicht unterstützt. Erlaubt: JPEG, PNG, GIF, WebP.';
(e.currentTarget as HTMLInputElement).value = '';
return;
}
imageError = null;
const reader = new FileReader();
reader.onload = () => {
heroImageUrl = reader.result as string;
};
reader.readAsDataURL(file);
}
</script> </script>
<form method="POST" {action} use:enhance> <form method="POST" {action} use:enhance>
@@ -100,7 +143,7 @@
for="cookTimeMin" for="cookTimeMin"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]" class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
> >
Kochzeit Kochzeit (min)
</label> </label>
<input <input
id="cookTimeMin" id="cookTimeMin"
@@ -140,6 +183,42 @@
</div> </div>
</div> </div>
<!-- Image -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
{#if heroImageUrl}
<img
src={heroImageUrl}
alt=""
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
/>
<button
type="button"
onclick={() => (heroImageUrl = null)}
class="mb-[8px] text-[12px] text-[var(--color-error)] opacity-60 hover:opacity-100 cursor-pointer"
>
Bild entfernen
</button>
{/if}
<label
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
>
<input
type="file"
accept="image/*"
onchange={handleImageChange}
class="sr-only"
/>
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
</label>
{#if imageError}
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
{:else}
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
{/if}
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
</div>
<!-- Ingredients --> <!-- Ingredients -->
<div class="mb-[24px]"> <div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p> <p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
@@ -227,35 +306,42 @@
<div <div
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]" 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> <p class="mb-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
<div class="flex flex-wrap gap-[8px]"> {#each groupedCategories as [type, tags] (type)}
{#each categories as cat (cat.id)} <div class="mb-[16px] last:mb-0">
<label <p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
class={[ {TAG_TYPE_LABELS[type] ?? type}
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium', </p>
selectedTagIds.includes(cat.id) <div class="flex flex-wrap gap-[8px]">
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]' {#each tags as cat (cat.id)}
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]' <label
].join(' ')} class={[
> 'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
<input selectedTagIds.includes(cat.id)
type="checkbox" ? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
name="tagIds" : 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
value={cat.id} ].join(' ')}
checked={selectedTagIds.includes(cat.id)} >
onchange={(e) => { <input
if (e.currentTarget.checked) { type="checkbox"
selectedTagIds = [...selectedTagIds, cat.id]; name="tagIds"
} else { value={cat.id}
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id); checked={selectedTagIds.includes(cat.id)}
} onchange={(e) => {
}} if (e.currentTarget.checked) {
class="sr-only" selectedTagIds = [...selectedTagIds, cat.id];
/> } else {
{cat.name} selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
</label> }
{/each} }}
</div> class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
</div>
{/each}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@ const editProps = {
name: 'Spaghetti Bolognese', name: 'Spaghetti Bolognese',
serves: 4, serves: 4,
cookTimeMin: 30, cookTimeMin: 30,
effort: 'Medium', effort: 'medium',
heroImageUrl: undefined as string | undefined, heroImageUrl: undefined as string | undefined,
ingredients: [ ingredients: [
{ name: 'Spaghetti', quantity: 200, unit: 'g' } { name: 'Spaghetti', quantity: 200, unit: 'g' }
@@ -162,4 +162,53 @@ describe('RecipeForm', () => {
render(RecipeForm, { props: emptyProps }); render(RecipeForm, { props: emptyProps });
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument();
}); });
it('shows Max. 5 MB hint below upload button', () => {
render(RecipeForm, { props: emptyProps });
expect(screen.getByText('Max. 5 MB')).toBeInTheDocument();
});
it('shows error when selected file exceeds 5 MB', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, oversizedFile);
expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument();
});
it('does not show file size error for file within 5 MB', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, okFile);
expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument();
});
it('shows error when selected file has unsupported type', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const bmpFile = new File(['content'], 'image.bmp', { type: 'image/bmp' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, bmpFile);
expect(screen.getByText(/dateityp/i)).toBeInTheDocument();
});
it('does not show type error for supported image types', async () => {
const user = userEvent.setup();
render(RecipeForm, { props: emptyProps });
const jpgFile = new File(['content'], 'photo.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, jpgFile);
expect(screen.queryByText(/dateityp/i)).not.toBeInTheDocument();
});
}); });

View File

@@ -3,7 +3,7 @@ export type RecipeSummary = {
name: string; name: string;
cookTimeMin?: number; cookTimeMin?: number;
effort?: string; effort?: string;
heroImageUrl?: string; heroImagePreview?: string;
}; };
export type Tag = { export type Tag = {

View File

@@ -14,7 +14,7 @@
</svelte:head> </svelte:head>
{#if isOnboarding} {#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]"> <div class="fixed inset-0 z-50 flex bg-[var(--color-page)]">
<!-- Desktop sidebar --> <!-- Desktop sidebar -->
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]"> <aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
<ProgressSidebar currentStep={2} /> <ProgressSidebar currentStep={2} />
@@ -44,8 +44,10 @@
</main> </main>
</div> </div>
{:else} {:else}
<div class="flex min-h-screen flex-col bg-[var(--color-page)]"> <div class="p-[16px_20px] md:p-[40px_56px]">
<a href="/settings" class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] mb-4 inline-block">← Einstellungen</a>
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1> <h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
<StaplesManager categories={data.categories} context="settings" /> <StaplesManager categories={data.categories} context="settings" />
<p class="mt-4 font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</p>
</div> </div>
{/if} {/if}

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