Compare commits

118 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
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
6dd0b7ac93 docs(specs): add final frontend specs for members and settings Kachel views
Finalised implementation specs for /members (E2) and /settings (E1)
pages using the chosen Kachel (card grid) variation. Members spec
covers 6 states including role-change inline control and remove
confirmation dialog; notes backend gaps (DELETE/PATCH member
endpoints). Settings spec covers hub layout, D3 staples sub-page,
hover and empty states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:06:11 +02:00
103 changed files with 16483 additions and 376 deletions

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

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

@@ -153,7 +153,7 @@ public class PlanningService {
plan, candidate, slotDate, config, recentlyCookedIds); plan, candidate, slotDate, config, recentlyCookedIds);
double scoreDelta = simulatedScore - currentScore; double scoreDelta = simulatedScore - currentScore;
boolean hasConflict = scoreDelta < 0; boolean hasConflict = scoreDelta < 0;
return new SuggestionResponse.SuggestionItem(toSuggestionRecipe(candidate), scoreDelta, hasConflict); return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
}) })
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
.limit(limit) .limit(limit)
@@ -422,11 +422,6 @@ public class PlanningService {
recipe.getCookTimeMin(), recipe.getHeroImageUrl()); recipe.getCookTimeMin(), recipe.getHeroImageUrl());
} }
private SuggestionResponse.SuggestionRecipe toSuggestionRecipe(Recipe recipe) {
return new SuggestionResponse.SuggestionRecipe(recipe.getId(), recipe.getName(),
recipe.getEffort(), recipe.getCookTimeMin());
}
private boolean hasConsecutiveDays(List<LocalDate> days) { private boolean hasConsecutiveDays(List<LocalDate> days) {
if (days.size() < 2) return false; if (days.size() < 2) return false;
List<LocalDate> sorted = days.stream().sorted().toList(); List<LocalDate> sorted = days.stream().sorted().toList();

View File

@@ -1,14 +1,11 @@
package com.recipeapp.planning.dto; package com.recipeapp.planning.dto;
import java.util.List; import java.util.List;
import java.util.UUID;
public record SuggestionResponse(List<SuggestionItem> suggestions) { public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionRecipe(UUID id, String name, String effort, short cookTimeMin) {}
public record SuggestionItem( public record SuggestionItem(
SuggestionRecipe recipe, SlotResponse.SlotRecipe recipe,
double scoreDelta, double scoreDelta,
boolean hasConflict boolean hasConflict
) {} ) {}

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,9 +16,8 @@ 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.heroImagePreview) 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), '%')))
@@ -27,7 +25,7 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
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,

View File

@@ -42,7 +42,15 @@ public class RecipeService {
@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,
Integer cookTimeMaxMin, String sort, int limit, int offset) { Integer cookTimeMaxMin, String sort, int limit, int offset) {
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset); return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset)
.stream()
.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)

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,5 +9,6 @@ public record RecipeSummaryResponse(
short serves, short serves,
short cookTimeMin, short cookTimeMin,
String effort, String effort,
String heroImagePreview String heroImageUrl,
List<TagResponse> tags
) {} ) {}

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

@@ -30,3 +30,6 @@ spring:
server: server:
port: 8080 port: 8080
app:
base-url: http://localhost:5173

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

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

@@ -161,7 +161,7 @@ class WeekPlanControllerTest {
@Test @Test
void getSuggestionsShouldReturn200() throws Exception { void getSuggestionsShouldReturn200() throws Exception {
var recipe = new SuggestionResponse.SuggestionRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15); var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false); var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
var response = new SuggestionResponse(List.of(item)); var response = new SuggestionResponse(List.of(item));

View File

@@ -46,8 +46,9 @@ 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", 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(), when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
@@ -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));
} }

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

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;
@@ -721,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"];
}; };
@@ -763,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"];
@@ -1319,7 +1375,7 @@ export interface operations {
}; };
}; };
}; };
acceptInvite: { getInviteInfo: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -1329,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: {
@@ -1339,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: {
@@ -2010,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,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

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

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

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

@@ -1,10 +1,26 @@
export interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
export interface Recipe { export interface Recipe {
id: string; id: string;
name: string; name: string;
effort?: string; effort?: string;
cookTimeMin?: number; 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 { export interface Suggestion {
recipe: Recipe; recipe: Recipe;
scoreDelta: number; scoreDelta: number;

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}

View File

@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
}); });
it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toBeInTheDocument();
});
it('back-link points to /settings', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toHaveAttribute('href', '/settings');
});
it('renders hint text about autosave', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
});
it('renders hint text about next shopping list', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
});
it('does not render back-link in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
});
it('does not render hint text in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
});
}); });

View File

@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [membersRes, inviteRes] = await Promise.all([
api.GET('/v1/households/mine/members'),
api.GET('/v1/households/mine/invites')
]);
return {
members: membersRes.data ?? [],
currentUserId: locals.benutzer!.id,
activeInvite: inviteRes.data?.data ?? null,
householdName: locals.haushalt?.name ?? ''
};
};

View File

@@ -1 +1,114 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1> <script lang="ts">
import { untrack } from 'svelte';
import MemberGrid from './MemberGrid.svelte';
import InvitePanel from './InvitePanel.svelte';
import RemoveDialog from './RemoveDialog.svelte';
import Toast from '$lib/components/Toast.svelte';
let { data } = $props();
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
let showInvitePanel = $state(false);
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
let toastMessage = $state('');
let toastVisible = $state(false);
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
const isPlanner = $derived(currentUserRole === 'planner');
function showToast(message: string) {
toastMessage = message;
toastVisible = true;
}
async function handleRoleChange(
member: { userId: string; displayName: string; role: string; joinedAt: string },
newRole: string
) {
const original = members.slice();
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
const res = await fetch('/members/' + member.userId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
});
if (!res.ok) {
members = original;
showToast('Fehler beim Ändern der Rolle');
}
}
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
removeTarget = member;
}
async function handleConfirmRemove() {
if (!removeTarget) return;
const target = removeTarget;
const original = members.slice();
removeTarget = null;
members = members.filter((m) => m.userId !== target.userId);
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
if (!res.ok) {
members = original;
showToast('Fehler beim Entfernen des Mitglieds');
}
}
async function handleInviteClick() {
if (!activeInvite) {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Einladung konnte nicht erstellt werden');
return;
}
}
showInvitePanel = !showInvitePanel;
}
async function handleRegenerate() {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Link konnte nicht erneuert werden');
}
}
</script>
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
<MemberGrid
{members}
currentUserId={data.currentUserId}
{isPlanner}
showInviteCard={isPlanner}
onremove={handleRemove}
onrolechange={handleRoleChange}
oninviteclick={handleInviteClick}
/>
{#if showInvitePanel && isPlanner && activeInvite}
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
{/if}
<RemoveDialog
show={removeTarget !== null}
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
onconfirm={handleConfirmRemove}
oncancel={() => (removeTarget = null)}
/>
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let {
onclick
}: {
onclick: () => void;
} = $props();
</script>
<button
type="button"
data-testid="invite-card"
{onclick}
class="invite-card"
>
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</button>
<style>
.invite-card {
background: white;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
min-height: 180px;
gap: 10px;
width: 100%;
}
.invite-card:hover {
border-color: var(--green-light);
background: var(--green-tint);
}
.invite-plus {
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-subtle);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--color-text-muted);
}
.invite-card:hover .invite-plus {
background: var(--green-light);
color: var(--green-dark);
}
.invite-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.invite-card:hover .invite-label {
color: var(--green-dark);
}
@media (max-width: 768px) {
.invite-card {
padding: 16px;
min-height: 120px;
}
.invite-plus {
width: 36px;
height: 36px;
font-size: 18px;
}
.invite-label {
font-size: 11px;
}
}
</style>

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 InviteCard from './InviteCard.svelte';
describe('InviteCard', () => {
it('renders the invite tile', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('shows a descriptive label', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
});
it('calls onclick when tile is clicked', async () => {
const onclick = vi.fn();
render(InviteCard, { props: { onclick } });
await userEvent.click(screen.getByTestId('invite-card'));
expect(onclick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,139 @@
<script lang="ts">
let {
invite,
onregenerate
}: {
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
onregenerate: () => void;
} = $props();
let copied = $state(false);
function copy() {
if (navigator.clipboard) {
navigator.clipboard.writeText(invite.shareUrl);
}
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function formatExpiry(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const isExpiringSoon = $derived(
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
);
</script>
<div class="invite-panel">
<div class="invite-panel-title">Einladelink teilen</div>
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
<div class="invite-link-row">
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
{copied ? 'Kopiert ✓' : 'Kopieren'}
</button>
</div>
<div class="invite-expiry">
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
</div>
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
Neuen Link generieren
</button>
</div>
<style>
.invite-panel {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px;
margin-top: 8px;
}
.invite-panel-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.invite-panel-desc {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.invite-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.invite-link-box {
flex: 1;
background: var(--color-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--color-subtle);
}
.invite-expiry {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.invite-expiry span {
font-weight: 500;
}
.invite-expiry span.yellow {
background: var(--yellow-tint);
color: var(--yellow-text);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.btn-regen {
margin-top: 12px;
font-size: 12px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
display: block;
}
.btn-regen:hover {
color: var(--color-text);
}
</style>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InvitePanel from './InvitePanel.svelte';
const invite = {
inviteCode: 'ABC123XY',
shareUrl: 'https://example.com/join/ABC123XY',
expiresAt: '2026-12-01T00:00:00Z'
};
describe('InvitePanel', () => {
it('shows the invite URL', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
});
it('has a copy button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('has a regenerate button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
});
it('calls onregenerate when regenerate button is clicked', async () => {
const onregenerate = vi.fn();
render(InvitePanel, { props: { invite, onregenerate } });
await userEvent.click(screen.getByTestId('regenerate-btn'));
expect(onregenerate).toHaveBeenCalledOnce();
});
it('shows the panel title', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,386 @@
<script lang="ts">
import { browser } from '$app/environment';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
member,
isCurrentUser,
isPlanner,
onremove,
onrolechange
}: {
member: Member;
isCurrentUser: boolean;
isPlanner: boolean;
onremove: (member: Member) => void;
onrolechange: (member: Member, newRole: string) => void;
} = $props();
let menuOpen = $state(false);
let editingRole = $state(false);
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
const joinDateFormatted = $derived(
new Date(member.joinedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
);
let cardEl: HTMLElement | undefined = $state(undefined);
$effect(() => {
if (!browser || !menuOpen) return;
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
menuOpen = false;
}
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
$effect(() => {
if (!browser || !menuOpen) return;
function onClickAway(e: MouseEvent) {
if (cardEl && !cardEl.contains(e.target as Node)) {
menuOpen = false;
}
}
document.addEventListener('click', onClickAway);
return () => document.removeEventListener('click', onClickAway);
});
</script>
<article
bind:this={cardEl}
data-testid="member-card"
class="member-card"
class:own={isCurrentUser}
class:editing={editingRole}
>
<!-- Avatar -->
<div class="avatar" style="background: {avatarBg};">
{initials}
</div>
<!-- Name -->
<div class="member-name">{member.displayName}</div>
<!-- Role badge or inline role control -->
{#if editingRole}
<div role="group" class="role-control">
<button
type="button"
class="role-control-btn"
class:active={member.role === 'planner'}
onclick={() => {
if (member.role !== 'planner') {
onrolechange(member, 'planner');
editingRole = false;
}
}}
>Planer</button>
<button
type="button"
class="role-control-btn"
class:active={member.role === 'member'}
onclick={() => {
if (member.role !== 'member') {
onrolechange(member, 'member');
editingRole = false;
}
}}
>Mitglied</button>
</div>
{:else}
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
</span>
{/if}
<!-- Join date -->
<div class="join-date">seit {joinDateFormatted}</div>
<!-- Du badge (own card) or Abbrechen (when editing role) -->
{#if isCurrentUser}
<div class="self-badge-wrap">
<span class="self-badge">Du</span>
</div>
{:else if editingRole}
<button
type="button"
class="cancel-btn"
onclick={() => { editingRole = false; }}
>Abbrechen</button>
{/if}
<!-- Kebab button -->
{#if isPlanner && !isCurrentUser && !editingRole}
<button
data-testid="kebab-btn"
type="button"
class="kebab-btn"
onclick={() => { menuOpen = true; }}
aria-label="Optionen"
></button>
<!-- Dropdown -->
{#if menuOpen}
<div class="dropdown">
<button
type="button"
class="dropdown-item"
onclick={() => { menuOpen = false; editingRole = true; }}
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
<div class="dropdown-divider"></div>
<button
type="button"
class="dropdown-item danger"
onclick={() => { menuOpen = false; onremove(member); }}
><span class="dropdown-icon"></span>Entfernen</button>
</div>
{/if}
{/if}
</article>
<style>
.member-card {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.member-card.own {
border-color: var(--green-light);
}
.member-card.editing {
border-color: #B5D4F4;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
margin-bottom: 12px;
flex-shrink: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.role-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
white-space: nowrap;
}
.role-badge.planer {
background: var(--green-tint);
color: var(--green-dark);
}
.role-badge.mitglied {
background: var(--blue-tint);
color: var(--blue-dark);
}
.join-date {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.self-badge-wrap {
margin-top: 8px;
}
.self-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--green-tint);
color: var(--green-dark);
}
.cancel-btn {
margin-top: 8px;
font-size: 11px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.kebab-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-text-muted);
opacity: 0;
}
.kebab-btn:hover {
background: var(--color-subtle);
color: var(--color-text);
}
@media (hover: none) {
.kebab-btn {
opacity: 1;
}
}
.member-card:hover .kebab-btn,
.member-card:focus-within .kebab-btn {
opacity: 1;
}
.dropdown {
position: absolute;
top: 44px;
right: 12px;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
min-width: 160px;
z-index: 10;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
width: 100%;
background: none;
border: none;
text-align: left;
}
.dropdown-item:hover {
background: var(--color-subtle);
}
.dropdown-item.danger {
color: var(--color-error);
}
.dropdown-item.danger:hover {
background: var(--error-tint, #FDECEA);
}
.dropdown-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 2px 0;
}
.role-control {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-top: 8px;
width: 100%;
}
.role-control-btn {
flex: 1;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
background: white;
border: none;
cursor: pointer;
color: var(--color-text-muted);
}
.role-control-btn:first-child {
border-right: 1px solid var(--color-border);
}
.role-control-btn.active {
background: var(--green-dark);
color: white;
}
@media (max-width: 768px) {
.member-card {
padding: 16px;
min-height: auto;
}
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
margin-bottom: 8px;
}
.member-name {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import MemberCard from './MemberCard.svelte';
const plannerMember = {
userId: 'u1',
displayName: 'Sarah',
role: 'planner',
joinedAt: '2024-01-01T00:00:00Z'
};
const regularMember = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('MemberCard', () => {
it('shows the member display name', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
});
it('shows "Du"-badge when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
it('does not show kebab button when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('does not show kebab button when viewer is not a planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('shows kebab button for other members when viewer is planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
});
it('opens dropdown when kebab is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
expect(screen.getByText('Entfernen')).toBeInTheDocument();
});
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
const onremove = vi.fn();
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove,
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Entfernen'));
expect(onremove).toHaveBeenCalledWith(regularMember);
});
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('closes dropdown on Escape key', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Entfernen')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByText('Entfernen')).toBeNull();
});
it('shows formatted join date', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
});
it('shows Abbrechen button when editing role', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import MemberCard from './MemberCard.svelte';
import InviteCard from './InviteCard.svelte';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
members,
currentUserId,
isPlanner,
showInviteCard,
onremove,
onrolechange,
oninviteclick
}: {
members: Member[];
currentUserId: string;
isPlanner: boolean;
showInviteCard: boolean;
onremove: (member: any) => void;
onrolechange: (member: any, newRole: string) => void;
oninviteclick: () => void;
} = $props();
const sortedMembers = $derived(
[...members].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
})
);
</script>
<div class="member-grid">
{#each sortedMembers as m (m.userId)}
<MemberCard
member={m}
isCurrentUser={m.userId === currentUserId}
{isPlanner}
{onremove}
onrolechange={(m, role) => onrolechange(m, role)}
/>
{/each}
{#if isPlanner && showInviteCard}
<InviteCard onclick={oninviteclick} />
{/if}
</div>
<style>
.member-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.member-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MemberGrid from './MemberGrid.svelte';
const members = [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
];
describe('MemberGrid', () => {
it('renders all member cards', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
expect(screen.getByText('Tom')).toBeInTheDocument();
expect(screen.getByText('Anna')).toBeInTheDocument();
});
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('hides invite card when isPlanner is false', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u2',
isPlanner: false,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.queryByTestId('invite-card')).toBeNull();
});
it('shows "Du"-badge on the current user card', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: false,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import BottomSheet from '$lib/components/BottomSheet.svelte';
let {
show,
member,
onconfirm,
oncancel
}: {
show: boolean;
member: { userId: string; displayName: string; role: string; joinedAt: string };
onconfirm: () => void;
oncancel: () => void;
} = $props();
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
</script>
{#if show}
{#if isMobile()}
<BottomSheet open={show} onclose={oncancel}>
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</BottomSheet>
{:else}
<div
data-testid="dialog-backdrop"
role="presentation"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
>
<div
data-testid="remove-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="remove-dialog-title"
tabindex="-1"
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RemoveDialog from './RemoveDialog.svelte';
const member = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('RemoveDialog', () => {
it('is not rendered when show is false', () => {
render(RemoveDialog, {
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.queryByTestId('remove-dialog')).toBeNull();
});
it('shows the member displayName in dialog', () => {
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
expect(screen.getByText(/Tom/)).toBeInTheDocument();
});
it('calls onconfirm when confirm button is clicked', async () => {
const onconfirm = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm, oncancel: vi.fn() }
});
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
expect(onconfirm).toHaveBeenCalledOnce();
});
it('calls oncancel when cancel button is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
it('does NOT call oncancel when backdrop is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
const backdrop = screen.getByTestId('dialog-backdrop');
await userEvent.click(backdrop);
expect(oncancel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const DELETE: RequestHandler = async ({ fetch, params }) => {
const api = apiClient(fetch);
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } }
});
return new Response(null, { status: response?.status ?? 204 });
};
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
const body = await request.json();
const api = apiClient(fetch);
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } },
body
});
return json(data, { status: response?.status ?? 200 });
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockDelete = vi.fn();
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
}));
const USER_UUID = '22222222-2222-2222-2222-222222222222';
describe('members server routes', () => {
let DELETE: any;
let PATCH: any;
beforeEach(async () => {
mockDelete.mockReset();
mockPatch.mockReset();
vi.resetModules();
const mod = await import('./+server');
DELETE = mod.DELETE;
PATCH = mod.PATCH;
});
it('DELETE proxies to backend and returns 204', async () => {
mockDelete.mockResolvedValue({ response: { status: 204 } });
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: vi.fn() }
} as any;
const res = await DELETE(event);
expect(res.status).toBe(204);
});
it('PATCH proxies to backend and returns member response', async () => {
mockPatch.mockResolvedValue({
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
response: { status: 200 }
});
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: async () => ({ role: 'planner' }) }
} as any;
const res = await PATCH(event);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const POST: RequestHandler = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, response } = await api.POST('/v1/households/mine/invites');
return json(data?.data, { status: response?.status ?? 201 });
};

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ POST: mockPost })
}));
describe('invites server route', () => {
let POST: any;
beforeEach(async () => {
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+server');
POST = mod.POST;
});
it('POST returns unwrapped InviteResponse', async () => {
const invite = { inviteCode: 'ABC123', shareUrl: 'https://x.com/join/ABC123', expiresAt: '2026-12-01T00:00:00Z' };
mockPost.mockResolvedValue({
data: { status: 'success', data: invite },
response: { status: 200 }
});
const event = { fetch: vi.fn() } as any;
const res = await POST(event);
const body = await res.json();
expect(body.inviteCode).toBe('ABC123');
expect(body.shareUrl).toBe('https://x.com/join/ABC123');
expect(body.expiresAt).toBe('2026-12-01T00:00:00Z');
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/api', () => ({
apiClient: vi.fn(() => ({
GET: vi.fn()
}))
}));
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
describe('members page.server load', () => {
let load: any;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('returns members and currentUserId', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') {
return {
data: [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
]
};
}
if (path === '/v1/households/mine/invites') {
return {
data: {
data: {
inviteCode: 'ABC123',
shareUrl: 'https://x.com/join/ABC123',
expiresAt: '2024-12-01T00:00:00Z'
}
}
};
}
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.members).toHaveLength(2);
expect(result.currentUserId).toBe('u1');
expect(result.activeInvite).toBeDefined();
});
it('returns null activeInvite when no active invite exists', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') return { data: [] };
if (path === '/v1/households/mine/invites') return { data: null };
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.activeInvite).toBeNull();
});
});

View File

@@ -2,6 +2,7 @@ import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api'; import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week'; import { getWeekStart } from '$lib/planner/week';
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions'; import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
import type { TagItem } from '$lib/planner/types';
export const load: PageServerLoad = async ({ fetch, url }) => { export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week'); const weekParam = url.searchParams.get('week');
@@ -21,7 +22,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
name: r.name!, name: r.name!,
cookTimeMin: r.cookTimeMin, cookTimeMin: r.cookTimeMin,
effort: r.effort, effort: r.effort,
heroImageUrl: r.heroImageUrl heroImageUrl: r.heroImageUrl,
tags: (r.tags ?? []).map((t: TagItem) => ({ id: t.id, name: t.name, tagType: t.tagType }))
})); }));
if (weekPlanResult.error || !weekPlanResult.data?.id) { if (weekPlanResult.error || !weekPlanResult.data?.id) {

View File

@@ -5,7 +5,9 @@
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte'; import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
import WeekStrip from '$lib/planner/WeekStrip.svelte'; import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte'; import DayMealCard from '$lib/planner/DayMealCard.svelte';
import DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte'; import RecipePicker from '$lib/planner/RecipePicker.svelte';
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.svelte'; import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte'; import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte'; import UndoBar from '$lib/planner/UndoBar.svelte';
@@ -23,7 +25,20 @@
let days = $derived(weekDays(weekStart)); let days = $derived(weekDays(weekStart));
let slots = $derived(weekPlan?.slots ?? []); let slots = $derived(weekPlan?.slots ?? []);
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s]))); // SlotRecipe from the API has no tags — merge from data.recipes by id
const recipeById = $derived(
Object.fromEntries((data.recipes ?? []).map((r: any) => [r.id, r]))
);
let slotMap = $derived(
Object.fromEntries(
slots.map((s: any) => [
s.slotDate,
s.recipe
? { ...s, recipe: { ...s.recipe, tags: recipeById[s.recipe.id]?.tags ?? [] } }
: s
])
)
);
// Default selected day: today if in this week, else first day // Default selected day: today if in this week, else first day
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value. // We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
@@ -49,35 +64,27 @@
let weekRange = $derived(formatWeekRange(weekStart)); let weekRange = $derived(formatWeekRange(weekStart));
// Desktop right panel state machine
type PanelState =
| { kind: 'idle' }
| { kind: 'day-detail'; date: string }
| { kind: 'recipe-picker'; date: string };
let panelState = $state<PanelState>({ kind: 'idle' });
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow // Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false); let pickerOpen = $state(false);
let actionSheetOpen = $state(false); let actionSheetOpen = $state(false);
let swapSheetOpen = $state(false); let swapSheetOpen = $state(false);
let swapLoading = $state(false); let swapLoading = $state(false);
// Desktop flip tile + drawer state (page-owned per Kai's architecture decision)
let activeSlotId = $state<string | null>(null);
let drawerOpen = $state(false);
let drawerSlotId = $state<string | null>(null);
const activePickerDate = $derived( const activePickerDate = $derived(
pickerOpen ? selectedDay pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay : swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date : drawerOpen && drawerSlotId ? drawerSlotId
: null : null
); );
let suggestions: Suggestion[] = $state([]); let suggestions: Suggestion[] = $state([]);
let isLoadingSuggestions = $state(false); let isLoadingSuggestions = $state(false);
// Recipes already in any slot this week — used for ⚠ overlap warnings
let currentWeekRecipeIds = $derived(
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
);
// Hidden form field bindings // Hidden form field bindings
let addPlanId = $state(''); let addPlanId = $state('');
let addSlotDate = $state(''); let addSlotDate = $state('');
@@ -115,9 +122,23 @@
return () => controller.abort(); return () => controller.abort();
}); });
// Single Escape key handler — priority: drawer > flip (Kai architecture decision)
$effect(() => {
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (drawerOpen) {
drawerOpen = false;
drawerSlotId = null;
} else if (activeSlotId) {
activeSlotId = null;
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function handleSelectDay(day: string) { function handleSelectDay(day: string) {
selectedDay = day; selectedDay = day;
panelState = { kind: 'day-detail', date: day };
} }
async function navigateWeek(direction: 'prev' | 'next' | 'today') { async function navigateWeek(direction: 'prev' | 'next' | 'today') {
@@ -130,14 +151,13 @@
} }
async function handleRecipePick(recipeId: string, recipeName: string) { async function handleRecipePick(recipeId: string, recipeName: string) {
// Capture date before modifying panel state // Drawer date takes priority (desktop), then mobile picker date
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay; const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
// Close pickers // Close all pickers
pickerOpen = false; pickerOpen = false;
if (panelState.kind === 'recipe-picker') { drawerOpen = false;
panelState = { kind: 'idle' }; drawerSlotId = null;
}
const existingSlot = slotMap[date]; const existingSlot = slotMap[date];
@@ -196,17 +216,39 @@
swapLoading = false; swapLoading = false;
} }
function closePanelToIdle() { // Desktop tile handlers
panelState = { kind: 'idle' }; function handleTileFlip(slotId: string) {
activeSlotId = slotId;
} }
function closePanelToDayDetail() { function handleTileClose() {
if (panelState.kind === 'recipe-picker') { activeSlotId = null;
panelState = { kind: 'day-detail', date: panelState.date };
} else {
panelState = { kind: 'idle' };
} }
function handleTileSwap(slotDate: string) {
activeSlotId = null;
drawerSlotId = slotDate;
drawerOpen = true;
} }
async function handleTileRemove(slot: any) {
activeSlotId = null;
await handleRemoveMeal(slot);
}
function handleEmptyTileAdd(slotDate: string) {
drawerSlotId = slotDate;
drawerOpen = true;
}
const drawerSlot = $derived(drawerSlotId ? (slotMap[drawerSlotId] ?? null) : null);
const drawerReplacingMeta = $derived(
drawerSlot?.recipe
? [drawerSlot.recipe.cookTimeMin ? `${drawerSlot.recipe.cookTimeMin} Min` : null, drawerSlot.recipe.effort ?? null]
.filter(Boolean)
.join(' · ')
: null
);
</script> </script>
<!-- Mobile & Tablet: vertical stack --> <!-- Mobile & Tablet: vertical stack -->
@@ -369,7 +411,7 @@
</BottomSheet> </BottomSheet>
</div> </div>
<!-- Desktop: 3-panel layout --> <!-- Desktop: 2-panel layout (sidebar + full-width flip-tile grid) -->
<div class="hidden h-screen lg:flex lg:flex-col"> <div class="hidden h-screen lg:flex lg:flex-col">
<!-- Topbar --> <!-- Topbar -->
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4"> <header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
@@ -400,21 +442,11 @@
Heute Heute
</button> </button>
</div> </div>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
+ Gericht hinzufügen
</button>
{/if}
</header> </header>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Left sidebar --> <!-- Left sidebar (unchanged) -->
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4"> <aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<!-- Variety widget at bottom -->
{#if varietyScore} {#if varietyScore}
<div class="mt-auto"> <div class="mt-auto">
<VarietyScoreCard <VarietyScoreCard
@@ -426,8 +458,8 @@
{/if} {/if}
</aside> </aside>
<!-- Main calendar (only scrollable panel) --> <!-- Main grid — full width, full height -->
<main class="flex-1 overflow-y-auto p-5"> <main class="flex-1 overflow-hidden p-5">
{#if !weekPlan} {#if !weekPlan}
<div class="flex h-full flex-col items-center justify-center"> <div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p> <p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
@@ -441,198 +473,47 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="grid grid-cols-7 gap-[8px]"> <div class="grid h-full grid-cols-7 gap-2">
{#each days as day (day)} {#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }} {@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today} {@const isTodayDay = day === today}
{@const isSelectedDay = day === selectedDay} {@const isThisTileActive = drawerSlotId === day}
{@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
<div class="flex flex-col"> <div class="h-full">
<!-- Column header: day name + date badge --> <DesktopDayTile
<div class="mb-2 flex flex-col items-center gap-1"> {slot}
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]"> isToday={isTodayDay}
{dayAbbr} {activeSlotId}
</p> {isPlanner}
<div {slotMap}
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium {suggestions}
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''} topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''} onflip={handleTileFlip}
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}" onclose={handleTileClose}
> onswap={() => handleTileSwap(day)}
{dateNum} onremove={() => handleTileRemove(slot)}
</div> onaddrecipe={() => handleEmptyTileAdd(day)}
</div> />
<!-- Meal tile -->
<button
type="button"
onclick={() => {
handleSelectDay(day);
if (!slot.recipe && isPlanner) {
panelState = { kind: 'recipe-picker', date: day };
}
}}
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
>
{#if slot.recipe}
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
{slot.recipe.name}
</p>
{:else}
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
<span class="text-[18px]" aria-hidden="true">+</span>
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
</div>
{/if}
</button>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</main> </main>
<!-- Right detail panel -->
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
{#if panelState.kind === 'idle'}
<div class="flex flex-1 flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
</div> </div>
{:else if panelState.kind === 'day-detail'} <!-- Recipe picker drawer (slide-in from right) -->
{@const detailDate = panelState.date} <RecipePickerDrawer
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }} open={drawerOpen}
slotDate={drawerSlotId ?? ''}
<!-- Panel header with close button -->
<div class="mb-3 flex items-start justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(detailDate)} · Abendessen
</p>
<button
type="button"
onclick={closePanelToIdle}
aria-label="Panel schließen"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
{#if detailSlot.recipe}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
{detailSlot.recipe.name}
</h2>
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
</p>
{/if}
<div class="mt-4 space-y-2">
<a
href="/recipes/{detailSlot.recipe.id}"
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Rezept ansehen
</a>
<a
href="/recipes/{detailSlot.recipe.id}/cook"
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
Koch-Modus
</a>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Gericht tauschen
</button>
{#if detailSlot.id}
<button
type="button"
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
>
Entfernen
</button>
{/if}
{/if}
</div>
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
>
+ Gericht wählen
</button>
{/if}
{/if}
{:else if panelState.kind === 'recipe-picker'}
{@const pickerDate = panelState.date}
{@const pickerSlot = slotMap[pickerDate] ?? null}
{@const isSwapContext = !!pickerSlot?.recipe}
<!-- Panel header with back/close button -->
<div class="mb-3 flex items-center justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
</p>
<button
type="button"
onclick={closePanelToDayDetail}
aria-label="Zurück"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
{#if isSwapContext}
{@const replacingMeta = [
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
pickerSlot.recipe.effort ?? null
].filter(Boolean).join(' · ')}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''} planId={weekPlan?.id ?? ''}
date={pickerDate} {suggestions}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
excludeRecipeId={pickerSlot.recipe.id}
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
onpick={handleRecipePick}
/>
</div>
{:else}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes} allRecipes={data.recipes}
isLoading={isLoadingSuggestions} isLoading={isLoadingSuggestions}
onpick={handleRecipePick} onpick={handleRecipePick}
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
excludeRecipeId={drawerSlot?.recipe?.id}
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
/> />
</div>
{/if}
{/if}
</aside>
</div>
</div> </div>
<!-- Hidden forms for slot mutations --> <!-- Hidden forms for slot mutations -->

View File

@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [ingredientsRes, householdRes] = await Promise.all([
api.GET('/v1/ingredients'),
api.GET('/v1/households/mine')
]);
const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0;
const memberCount = householdRes.data?.data?.members?.length ?? 0;
return {
stapleCount,
memberCount,
// hooks.server.ts guarantees benutzer is set for all (app) routes
userName: locals.benutzer!.name
};
};

View File

@@ -1 +1,72 @@
<h1 class="text-2xl font-medium p-6">Einstellungen</h1> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte';
interface Props {
data: {
stapleCount: number;
memberCount: number;
userName: string;
};
}
let { data }: Props = $props();
</script>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Einstellungen</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]">
<!-- Card 1: Vorräte (inline, conditional content) -->
<a
href="/household/staples"
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">Vorräte</span>
{#if data.stapleCount > 0}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span
data-testid="staple-count"
class="font-[var(--font-display)] text-[28px] font-light leading-[1] tracking-[-0.02em] text-[var(--green-dark)]"
>{data.stapleCount}</span>
</p>
{:else}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
Noch keine Vorräte eingerichtet
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{#if data.stapleCount > 0}
Vorräte bearbeiten →
{:else}
Jetzt einrichten →
{/if}
</span>
</a>
<!-- Card 2: Haushalt (inline, needs data-testid on member count) -->
<a
href="/members"
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">Haushalt</span>
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span data-testid="member-count">{data.memberCount}</span> Mitglieder
</p>
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
Mitglieder anzeigen →
</span>
</a>
<!-- Card 3: Profil (uses SettingsCard) -->
<SettingsCard
title="Profil"
href="/profile"
cta="Profil bearbeiten →"
meta={data.userName}
/>
</div>
</div>

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const mockIngredients = [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false },
{ id: 'ing-3', name: 'Salz', isStaple: true }
];
const mockHousehold = {
status: 'OK',
data: {
id: 'hh-1',
name: 'Familie Raddatz',
members: [
{ userId: 'u-1', name: 'Marcel' },
{ userId: 'u-2', name: 'Anna' },
{ userId: 'u-3', name: 'Ben' }
]
}
};
const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } };
describe('settings page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function mockApiResponses() {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
}
it('returns stapleCount as number of ingredients where isStaple=true', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(2);
});
it('returns memberCount as number of household members', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(3);
});
it('returns userName from locals.benutzer.name', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.userName).toBe('Marcel Raddatz');
});
it('fetches ingredients and household in parallel', async () => {
mockApiResponses();
await load({ fetch: vi.fn(), locals: mockLocals } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredients');
expect(calls).toContain('/v1/households/mine');
});
it('defaults stapleCount to 0 when ingredients API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(0);
});
it('defaults memberCount to 0 when household API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(0);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) {
return {
stapleCount: 14,
memberCount: 3,
userName: 'Marcel Raddatz',
...overrides
};
}
describe('settings page — hub', () => {
it('renders the page heading Einstellungen', () => {
render(Page, { props: { data: makeData() } });
expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument();
});
it('renders Vorräte card linking to /household/staples', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples');
expect(vorrateLink).toBeInTheDocument();
});
it('renders Haushalt card linking to /members', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const haushaltLink = links.find((l) => l.getAttribute('href') === '/members');
expect(haushaltLink).toBeInTheDocument();
});
it('renders Profil card linking to /profile', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const profilLink = links.find((l) => l.getAttribute('href') === '/profile');
expect(profilLink).toBeInTheDocument();
});
it('shows stapleCount as a number in the Vorräte card', () => {
render(Page, { props: { data: makeData({ stapleCount: 14 }) } });
expect(screen.getByTestId('staple-count')).toHaveTextContent('14');
});
it('shows memberCount in the Haushalt card', () => {
render(Page, { props: { data: makeData({ memberCount: 3 }) } });
expect(screen.getByTestId('member-count')).toHaveTextContent('3');
});
it('shows userName in the Profil card meta', () => {
render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } });
expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument();
});
it('shows empty state text when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument();
expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument();
});
it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument();
});
it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 5 }) } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { apiClient } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/invites/{code}', {
params: { path: { code: params.token } }
});
if (error || !data?.data) {
return { invalid: true };
}
return {
invalid: false,
householdName: data.data.householdName ?? '',
inviterName: data.data.inviterName ?? ''
};
};
export const actions = {
default: async ({ params, request, fetch, cookies }) => {
const formData = await request.formData();
const name = (formData.get('name') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim();
const password = (formData.get('password') ?? '').toString();
const errors: Record<string, string> = {};
if (!name) {
errors.name = 'Name ist erforderlich';
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
errors.email = 'Ungültige E-Mail-Adresse';
}
if (password.length < 8) {
errors.password = 'Mindestens 8 Zeichen';
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, name, email });
}
const api = apiClient(fetch);
const { error, response } = await api.POST('/v1/invites/{code}/accept', {
params: { path: { code: params.token } },
body: { name, email, password }
});
if (error) {
if (error.status === 409) {
return fail(409, {
errors: {
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
},
name,
email
});
}
return fail(400, {
errors: { form: 'Einladung ungültig oder abgelaufen.' },
name,
email
});
}
const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev
});
}
redirect(303, '/');
}
} satisfies Actions;

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
import JoinForm from './JoinForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Haushalt beitreten — Mealplan</title>
</svelte:head>
{#if data.invalid}
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-6">
<div class="text-center">
<h1 class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
Einladung ungültig oder abgelaufen
</h1>
<p class="mt-3 font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Bitte bitte den Einladenden, einen neuen Link zu senden.
</p>
</div>
</div>
{:else}
<!-- Mobile layout (< 1024px): stacked banner + form -->
<!-- Desktop layout (≥ 1024px): two-column side by side -->
<div class="flex min-h-screen flex-col lg:flex-row">
<!-- Left / top: green-tint panel -->
<div class="bg-[var(--green-dark)] p-6 lg:flex lg:w-[400px] lg:flex-shrink-0 lg:items-center lg:justify-center lg:p-12">
<HouseholdIdentityPanel
householdName={data.householdName ?? ''}
inviterName={data.inviterName ?? ''}
/>
</div>
<!-- Right / bottom: form -->
<div class="flex flex-1 items-center justify-center bg-[var(--color-page)] p-6 lg:p-12">
<div class="w-full max-w-sm">
<h1 class="mb-6 font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
Konto erstellen & beitreten
</h1>
<JoinForm {form} />
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let { householdName, inviterName }: { householdName: string; inviterName: string } = $props();
</script>
<div class="flex flex-col items-center gap-4 rounded-[var(--radius-xl)] bg-[var(--green-dark)] p-6 text-center">
<!-- App logo -->
<span class="text-[48px]" aria-hidden="true">🥗</span>
<!-- Household name -->
<div>
<h2
class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-white"
>
{householdName}
</h2>
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--green-light)]">
Eingeladen von {inviterName}
</p>
</div>
<!-- Permissions info box -->
<div class="w-full rounded-xl bg-white/10 px-4 py-3 text-left">
<p class="mb-2 font-[var(--font-sans)] text-[11px] font-medium uppercase tracking-wide text-[var(--green-light)]">
Als Mitglied kannst du
</p>
<ul aria-label="Als Mitglied kannst du" class="flex flex-col gap-1.5">
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Wochenplan einsehen
</li>
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Einkaufsliste abhaken
</li>
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Artikel zur Liste hinzufügen
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
describe('HouseholdIdentityPanel', () => {
it('renders household name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText('Smith family')).toBeInTheDocument();
});
it('renders inviter name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText(/Sarah/)).toBeInTheDocument();
});
it('renders all three member permissions', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument();
expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument();
});
it('renders app logo', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText('🥗')).toBeInTheDocument();
});
it('permissions list has accessible name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByRole('list', { name: /als mitglied kannst du/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { enhance } from '$app/forms';
type FormData = {
errors?: Record<string, string>;
name?: string;
email?: string;
} | null;
let { form = null }: { form?: FormData } = $props();
let showPassword = $state(false);
</script>
<form method="POST" use:enhance>
<!-- Form-level error -->
{#if form?.errors?.form}
<p
class="mb-4 rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
>
{form.errors.form}
</p>
{/if}
<!-- Name field -->
<div class="mb-4">
<label
for="name"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
Name
</label>
<input
type="text"
id="name"
name="name"
autocomplete="given-name"
value={form?.name ?? ''}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.name ? 'border-[var(--color-error)]' : ''}"
/>
{#if form?.errors?.name}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.name}
</p>
{/if}
</div>
<!-- Email field -->
<div class="mb-4">
<label
for="email"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
E-Mail
</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
value={form?.email ?? ''}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.email ? 'border-[var(--color-error)]' : ''}"
/>
{#if form?.errors?.email}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.email}
</p>
{/if}
</div>
<!-- Password field -->
<div class="mb-6">
<label
for="password"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
Passwort
</label>
<div class="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autocomplete="new-password"
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] pr-[80px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.password ? 'border-[var(--color-error)]' : ''}"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
{#if form?.errors?.password}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.password}
</p>
{/if}
</div>
<!-- Submit button -->
<button
type="submit"
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] font-[var(--font-sans)] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
>
Haushalt beitreten
</button>
</form>

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import JoinForm from './JoinForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('JoinForm', () => {
it('renders name, email and password fields', () => {
render(JoinForm);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
});
it('renders "Haushalt beitreten" submit button', () => {
render(JoinForm);
expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument();
});
it('password field is initially of type password', () => {
render(JoinForm);
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password');
});
it('password toggle switches type to text', async () => {
const user = userEvent.setup();
render(JoinForm);
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text');
});
it('password toggle aria-label updates to "Passwort verbergen" when visible', async () => {
const user = userEvent.setup();
render(JoinForm);
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(screen.getByRole('button', { name: /passwort verbergen/i })).toBeInTheDocument();
});
it('shows form-level error from form prop', () => {
render(JoinForm, {
props: {
form: {
errors: { form: 'Einladung ungültig oder abgelaufen.' }
}
}
});
expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument();
});
it('shows email-taken error with login link', () => {
render(JoinForm, {
props: {
form: {
errors: {
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
}
}
}
});
expect(screen.getByText(/bereits registriert/)).toBeInTheDocument();
});
it('pre-fills name and email from form prop', () => {
render(JoinForm, {
props: {
form: {
errors: {},
name: 'Tom',
email: 'tom@example.com'
}
}
});
expect(screen.getByLabelText('Name')).toHaveValue('Tom');
expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com');
});
});

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
vi.mock('$app/environment', () => ({ dev: false }));
const mockGet = vi.fn();
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost })
}));
describe('join page load function', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function createLoadEvent(token: string) {
return {
params: { token },
fetch: vi.fn()
} as any;
}
it('returns householdName and inviterName for valid token', async () => {
mockGet.mockResolvedValue({
data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } },
error: undefined
});
const result = await load(createLoadEvent('ABC12XYZ'));
expect(result.invalid).toBeFalsy();
expect(result.householdName).toBe('Smith family');
expect(result.inviterName).toBe('Sarah');
});
it('returns invalid:true on 404 (expired/used/unknown token)', async () => {
mockGet.mockResolvedValue({
data: undefined,
error: { status: 404 }
});
const result = await load(createLoadEvent('BADTOKEN'));
expect(result.invalid).toBe(true);
});
});
describe('join page form action', () => {
let actions: any;
beforeEach(async () => {
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
function createRequest(token: string, formData: Record<string, string>) {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
fd.append(key, value);
}
return {
params: { token },
request: { formData: () => Promise.resolve(fd) },
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
}
it('calls POST /v1/invites/{token}/accept with form data', async () => {
mockPost.mockResolvedValue({
data: { data: { householdName: 'Smith family', role: 'member' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
try {
await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', {
params: { path: { code: 'ABC12XYZ' } },
body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' }
});
});
it('sets JSESSIONID cookie and redirects to / on success', async () => {
mockPost.mockResolvedValue({
data: { data: { householdName: 'Smith family', role: 'member' } },
error: undefined,
response: {
headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') }
}
});
const event = createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
});
try {
await actions.default(event);
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/');
}
expect(event.cookies.set).toHaveBeenCalledWith(
'JSESSIONID',
'abc123',
expect.objectContaining({ path: '/', secure: true })
);
});
it('returns 409 fail with email-taken message on conflict', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 409 },
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(409);
expect(result.data.errors.email).toContain('registriert');
});
it('returns 400 fail on invalid token (404 from backend)', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 404 },
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
const result = await actions.default(createRequest('BADTOKEN', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.form).toBeTruthy();
});
it('rejects empty name with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: '',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.name).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects invalid email with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'notanemail',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.email).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects short password with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'short'
}));
expect(result.status).toBe(400);
expect(result.data.errors.password).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — C3 Abwechslungs-Analyse · Implementierungsspezifikation V1</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;
--orange-tint:#FEF0E6;--orange:#E8862A;
--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;
--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#DDDBD5;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:40px 40px 28px;margin:-48px -40px 48px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--color-border);}
.doc-header h1{font-family:var(--font-display);font-size:26px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-ready{background:var(--green-tint);color:var(--green-dark);}
.pill-warn{background:var(--yellow-tint);color:var(--yellow-text);}
.section{margin-bottom:56px;}
.section-label{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:20px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.7;max-width:720px;margin-bottom:16px;}
.prose strong{color:var(--color-text);font-weight:500;}
/* Code blocks */
.code{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:16px 20px;font-family:var(--font-mono);font-size:12px;line-height:1.7;overflow-x:auto;margin-bottom:16px;white-space:pre;}
.code .cm{color:var(--color-text-muted);}
.code .kw{color:var(--purple);}
.code .ty{color:var(--blue-dark);}
.code .st{color:var(--green-dark);}
.code .nu{color:var(--orange);}
/* Tables */
.tbl{width:100%;border-collapse:collapse;font-size:12px;background:var(--color-surface);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--color-border);margin-bottom:16px;}
.tbl thead tr{background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
.tbl th{text-align:left;padding:10px 14px;font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
.tbl td{padding:9px 14px;border-bottom:1px solid var(--color-subtle);vertical-align:top;}
.tbl tr:last-child td{border-bottom:none;}
.tbl td:first-child{font-weight:500;color:var(--color-text-muted);white-space:nowrap;font-size:11px;}
.tbl td.mono{font-family:var(--font-mono);font-size:11px;}
/* Callout boxes */
.box{border-radius:var(--radius-lg);padding:16px 20px;margin-bottom:16px;}
.box-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;}
.box ul{list-style:none;display:flex;flex-direction:column;gap:5px;}
.box li{font-size:12px;line-height:1.5;display:flex;align-items:flex-start;gap:8px;}
.box li::before{font-weight:500;flex-shrink:0;}
.box-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
.box-y .box-lbl,.box-y li::before{color:var(--yellow-text);}
.box-y li{color:var(--yellow-text);}
.box-g{background:var(--green-tint);border:1px solid var(--green-light);}
.box-g .box-lbl,.box-g li::before{color:var(--green-dark);}
.box-g li{color:var(--green-dark);}
.box-b{background:var(--blue-tint);border:1px solid var(--blue-light);}
.box-b .box-lbl,.box-b li::before{color:var(--blue-dark);}
.box-b li{color:var(--blue-dark);}
.box ul.checks li::before{content:'✓';}
.box ul.arrows li::before{content:'→';}
/* State cards */
.state-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
.state-head{background:var(--color-subtle);padding:10px 16px;border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
.state-id{font-family:var(--font-mono);font-size:11px;font-weight:500;color:var(--color-text-muted);}
.state-title{font-size:13px;font-weight:500;}
.state-body{padding:14px 16px;font-size:12px;line-height:1.7;}
/* Device frames (compact preview) */
.prev-row{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap;margin-bottom:16px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:8px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.phone{width:300px;flex-shrink:0;background:var(--color-page);border-radius:32px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.08);border:5px solid #1C1C18;}
.pst{padding:8px 16px 0;display:flex;justify-content:space-between;align-items:center;font-size:10px;background:var(--color-page);}
.pst b{font-weight:600;font-size:11px;}
/* Warning card preview */
.wcard{border-radius:8px;border:1px solid var(--yellow-light);background:var(--yellow-tint);overflow:hidden;margin-bottom:8px;}
.wcard:last-child{margin-bottom:0;}
.wcard-hd{padding:9px 14px;border-bottom:1px solid var(--yellow-light);}
.wcard-hd-t{font-size:13px;font-weight:500;color:var(--yellow-text);}
.wcard-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-bottom:1px solid rgba(249,224,138,.4);}
.wcard-row:last-child{border-bottom:none;}
.wcard-left{display:flex;align-items:center;gap:8px;min-width:0;}
.wcard-day{font-size:11px;font-weight:600;color:var(--yellow-text);width:20px;flex-shrink:0;}
.wcard-recipe{font-size:13px;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.wcard-swap{font-size:12px;font-weight:500;color:var(--yellow-text);white-space:nowrap;flex-shrink:0;}
.divider{border:none;border-top:1px solid var(--color-border);margin:40px 0;}
/* File diff style */
.diff{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);font-family:var(--font-mono);font-size:12px;line-height:1.6;overflow-x:auto;margin-bottom:16px;}
.diff-file{padding:8px 16px;background:var(--color-subtle);border-bottom:1px solid var(--color-border);font-size:11px;font-weight:500;color:var(--color-text-muted);}
.diff-body{padding:12px 16px;white-space:pre;}
.diff-add{color:var(--green-dark);background:rgba(61,140,74,.06);}
.diff-rem{color:var(--red-dark);background:rgba(220,76,62,.06);}
.diff-ctx{color:var(--color-text-muted);}
</style>
</head>
<body>
<div class="doc">
<!-- Header -->
<div class="doc-header">
<div>
<h1>C3 — Abwechslungs-Analyse · Implementierungsspezifikation</h1>
<p>Recipe App · Variation V1 "Erweiterte Karten" · Rezeptnamen + Tausch-Links in Warnkarten</p>
</div>
<div class="doc-meta">
<span class="pill pill-ready">Final</span><br>
Erstellt: 2026-04<br>
Screen: C3<br>
Bezug: c3-variety-rework.html
</div>
</div>
<!-- ── 1. ÜBERBLICK ── -->
<div class="section">
<div class="section-label">1 · Überblick</div>
<p class="prose">Die Seite <strong>/planner/variety</strong> zeigt derzeit Warnkarten mit technischen Tages-Codes (<code style="font-family:var(--font-mono);font-size:11px;">MON, WED — erwäge einen Tausch</code>). Der Planer muss manuell nachschlagen, welches Gericht an diesen Tagen eingeplant ist, und dann zurück zum Planer navigieren um es zu tauschen.</p>
<p class="prose"><strong>V1 "Erweiterte Karten"</strong> löst dies mit minimalem Umbauaufwand: Die Warnkarten erhalten eine strukturierte Zeile pro betroffenem Tag — mit Wochentag-Abkürzung, Rezeptname und direktem "Tauschen →" Link. Score-Hero, Bewertungsdetails und das Gesamt-Layout bleiben unverändert.</p>
<div class="box box-b">
<div class="box-lbl">Scope</div>
<ul class="arrows">
<li>Kein neues Backend-Endpoint — alle nötigen Daten sind bereits im weekPlan-Load vorhanden</li>
<li>Kein Layout-Umbau — nur VarietyWarningCards.svelte und die Datenvorbereitung in +page.svelte ändern sich</li>
<li>Protein-Grid und EffortBar bleiben wie bisher (Desktop)</li>
</ul>
</div>
</div>
<!-- ── 2. PROBLEM IM DETAIL ── -->
<div class="section">
<div class="section-label">2 · Aktueller Ist-Zustand und Problem</div>
<table class="tbl">
<thead><tr><th>Element</th><th>Aktuell</th><th>Soll (V1)</th></tr></thead>
<tbody>
<tr>
<td>Warnkarte Inhalt</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">title + explanation (String)</code></td>
<td>Strukturierte Zeilen: Wochentag · Rezeptname · Tauschen-Link</td>
</tr>
<tr>
<td>Tages-Angabe</td>
<td>API-Code <code style="font-family:var(--font-mono);font-size:11px;">MON, WED</code></td>
<td>Abkürzung <code style="font-family:var(--font-mono);font-size:11px;">Mo, Mi</code></td>
</tr>
<tr>
<td>Rezeptname</td>
<td>Fehlt</td>
<td>Aus <code style="font-family:var(--font-mono);font-size:11px;">weekPlan.slots[].recipe.name</code></td>
</tr>
<tr>
<td>Tausch-Navigation</td>
<td>Fehlt — Nutzer verlässt die Seite manuell</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&amp;swap={slotId}</code></td>
</tr>
<tr>
<td>Datenbasis</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">computeWarnings()</code> aus variety.ts</td>
<td>Inline <code style="font-family:var(--font-mono);font-size:11px;">$derived.by()</code> in +page.svelte, direkt aus API-Daten</td>
</tr>
</tbody>
</table>
</div>
<!-- ── 3. DATENFLUSS ── -->
<div class="section">
<div class="section-label">3 · Datenfluss</div>
<p class="prose">Alle nötigen Daten werden bereits im Server-Load geladen. Kein neuer API-Call erforderlich.</p>
<table class="tbl">
<thead><tr><th>Quelle</th><th>Feld</th><th>Verwendung</th></tr></thead>
<tbody>
<tr>
<td>weekPlan.slots[]</td>
<td class="mono">{ id, dayOfWeek, recipe: { id, name } }</td>
<td>Aufbau der <code style="font-family:var(--font-mono);font-size:11px;">slotsByDay</code>-Map: DayCode → { slotId, recipeName }</td>
</tr>
<tr>
<td>varietyScore.tagRepeats[]</td>
<td class="mono">{ tagType, tagName, days: string[] }</td>
<td>Warnkarten für wiederholte Tags (Protein, Cuisine). days[] enthält API-Codes: "MON", "TUE" …</td>
</tr>
<tr>
<td>varietyScore.ingredientOverlaps[]</td>
<td class="mono">{ ingredientName, days: string[] }</td>
<td>Warnkarten für Zutaten-Überschneidungen</td>
</tr>
<tr>
<td>varietyScore.duplicatesInPlan[]</td>
<td class="mono">string[] (Rezeptnamen)</td>
<td>Warnkarte: "X doppelt geplant". Alle Slots mit diesem Rezeptnamen liefern die Items.</td>
</tr>
<tr>
<td>data.weekStart</td>
<td class="mono">string (YYYY-MM-DD)</td>
<td>Swap-URL-Parameter</td>
</tr>
</tbody>
</table>
<p class="prose">Tag-Code → Abkürzung Mapping (konstant):</p>
<div class="code"><span class="cm">// Day code → German short label</span>
<span class="kw">const</span> DAY_SHORT: Record&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; = {
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
};</div>
</div>
<!-- ── 4. TYPEN ── -->
<div class="section">
<div class="section-label">4 · Typen</div>
<p class="prose">Die bestehende <code style="font-family:var(--font-mono);font-size:11px;">VarietyWarningCards.svelte</code> definiert bereits die korrekten Interfaces. Diese bleiben unverändert:</p>
<div class="code"><span class="cm">// In VarietyWarningCards.svelte (bereits vorhanden, nicht ändern)</span>
<span class="kw">interface</span> <span class="ty">WarningItem</span> {
dayShort: <span class="ty">string</span>; <span class="cm">// 'Mo', 'Di', …</span>
recipeName: <span class="ty">string</span>; <span class="cm">// aus weekPlan.slots[].recipe.name</span>
slotId: <span class="ty">number</span>; <span class="cm">// für Swap-Link</span>
}
<span class="kw">interface</span> <span class="ty">ActionWarning</span> {
title: <span class="ty">string</span>; <span class="cm">// z.B. "Tofu mehrfach diese Woche"</span>
items: <span class="ty">WarningItem</span>[]; <span class="cm">// eine Zeile pro betroffenem Tag</span>
}</div>
<p class="prose">Die alte <code style="font-family:var(--font-mono);font-size:11px;">Warning</code>-Schnittstelle aus <code style="font-family:var(--font-mono);font-size:11px;">variety.ts</code> (<code style="font-family:var(--font-mono);font-size:11px;">{ title, explanation }</code>) wird nicht mehr verwendet.</p>
</div>
<!-- ── 5. IMPLEMENTIERUNG ── -->
<div class="section">
<div class="section-label">5 · Implementierung</div>
<p class="prose">Es gibt drei Änderungen:</p>
<!-- 5.1 slotsByDay -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.1</div>
<div class="state-title">+page.svelte — slotsByDay Map aufbauen</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Füge direkt nach den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">$derived</code>-Deklarationen hinzu:</p>
<div class="code" style="margin-bottom:0"><span class="kw">const</span> DAY_SHORT: Record&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; = {
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
};
<span class="cm">// dayOfWeek (API code) → { slotId, recipeName }</span>
<span class="kw">let</span> slotsByDay = $derived.by(() => {
<span class="kw">const</span> map: Record&lt;<span class="ty">string</span>, { slotId: <span class="ty">number</span>; recipeName: <span class="ty">string</span> }&gt; = {};
<span class="kw">for</span> (<span class="kw">const</span> slot <span class="kw">of</span> weekPlan?.slots ?? []) {
<span class="kw">if</span> (slot.dayOfWeek && slot.recipe?.name && slot.id) {
map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name };
}
}
<span class="kw">return</span> map;
});</div>
</div>
</div>
<!-- 5.2 actionWarnings -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.2</div>
<div class="state-title">+page.svelte — actionWarnings ersetzen computeWarnings()</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Ersetze den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">let warnings = $derived.by(() =&gt; computeWarnings(…))</code>-Block vollständig:</p>
<div class="code" style="margin-bottom:0"><span class="kw">interface</span> <span class="ty">WarningItem</span> { dayShort: <span class="ty">string</span>; recipeName: <span class="ty">string</span>; slotId: <span class="ty">number</span>; }
<span class="kw">interface</span> <span class="ty">ActionWarning</span> { title: <span class="ty">string</span>; items: <span class="ty">WarningItem</span>[]; }
<span class="kw">let</span> actionWarnings = $derived.by((): <span class="ty">ActionWarning</span>[] => {
<span class="kw">const</span> result: <span class="ty">ActionWarning</span>[] = [];
<span class="kw">const</span> vs = varietyScore;
<span class="kw">if</span> (!vs) <span class="kw">return</span> result;
<span class="cm">// Tag repeats (protein, cuisine, …)</span>
<span class="kw">for</span> (<span class="kw">const</span> repeat <span class="kw">of</span> vs.tagRepeats ?? []) {
<span class="kw">if</span> ((repeat.days?.length ?? <span class="nu">0</span>) &lt; <span class="nu">2</span>) <span class="kw">continue</span>;
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (repeat.days ?? [])
.map((day) => {
<span class="kw">const</span> slot = slotsByDay[day];
<span class="kw">return</span> slot
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
: <span class="kw">null</span>;
})
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
}
}
<span class="cm">// Ingredient overlaps</span>
<span class="kw">for</span> (<span class="kw">const</span> overlap <span class="kw">of</span> vs.ingredientOverlaps ?? []) {
<span class="kw">if</span> ((overlap.days?.length ?? <span class="nu">0</span>) &lt; <span class="nu">2</span>) <span class="kw">continue</span>;
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (overlap.days ?? [])
.map((day) => {
<span class="kw">const</span> slot = slotsByDay[day];
<span class="kw">return</span> slot
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
: <span class="kw">null</span>;
})
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items });
}
}
<span class="cm">// Duplicate recipes — find all slots with that recipe name</span>
<span class="kw">for</span> (<span class="kw">const</span> name <span class="kw">of</span> vs.duplicatesInPlan ?? []) {
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = Object.entries(slotsByDay)
.filter(([, s]) => s.recipeName === name)
.map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId }));
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${name} doppelt geplant`, items });
}
}
<span class="kw">return</span> result;
});</div>
</div>
</div>
<!-- 5.3 Template update -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.3</div>
<div class="state-title">+page.svelte — Template: warnings → actionWarnings, weekStart übergeben</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">An beiden Stellen im Template (Mobile + Desktop) ersetzen:</p>
<div class="diff">
<div class="diff-file">+page.svelte (Mobile, ~Zeile 110 / Desktop, ~Zeile 222)</div>
<div class="diff-body"><span class="diff-rem">- {#if warnings.length > 0}</span>
<span class="diff-rem">- &lt;VarietyWarningCards {warnings} /&gt;</span>
<span class="diff-add">+ {#if actionWarnings.length > 0}</span>
<span class="diff-add">+ &lt;VarietyWarningCards warnings={actionWarnings} {weekStart} /&gt;</span></div>
</div>
<p style="font-size:12px;color:var(--color-text-muted);">Achtung: <code style="font-family:var(--font-mono);font-size:11px;">weekStart</code> ist für die Swap-URL erforderlich und muss explizit übergeben werden.</p>
</div>
</div>
<!-- 5.4 Import cleanup -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.4</div>
<div class="state-title">+page.svelte — Import aufräumen</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Entferne den nicht mehr genutzten Import:</p>
<div class="diff">
<div class="diff-file">+page.svelte (Script-Block, oben)</div>
<div class="diff-body"><span class="diff-rem">- import { computeSubScores, computeWarnings } from '$lib/planner/variety';</span>
<span class="diff-add">+ import { computeSubScores } from '$lib/planner/variety';</span></div>
</div>
<p style="font-size:12px;color:var(--color-text-muted);">computeSubScores wird noch für die Score-Breakdown-Anzeige genutzt.</p>
</div>
</div>
</div>
<!-- ── 6. KOMPONENTE: VarietyWarningCards ── -->
<div class="section">
<div class="section-label">6 · VarietyWarningCards.svelte — bereits korrekt</div>
<p class="prose">Die Komponente wurde bereits auf das neue <code style="font-family:var(--font-mono);font-size:11px;">ActionWarning</code>-Format aktualisiert. <strong>Keine Änderung erforderlich.</strong> Zur Referenz die erwartete Props-Schnittstelle:</p>
<div class="code"><span class="cm">// Props (bereits implementiert)</span>
<span class="kw">let</span> { warnings, weekStart }: {
warnings: <span class="ty">ActionWarning</span>[];
weekStart: <span class="ty">string</span>;
} = $props();</div>
<p class="prose">Die Komponente rendert für jede Warnung:</p>
<ul style="font-size:12px;color:var(--color-text-muted);margin-left:20px;margin-bottom:16px;line-height:1.9;">
<li>Gelbe Karte (<code style="font-family:var(--font-mono);font-size:11px;">border: yellow-light, bg: yellow-tint</code>) mit Header-Zeile (Titel)</li>
<li>Pro Item: Zeile mit Wochentag-Abkürzung (W=20px, fixed) · Rezeptname (truncate) · "Tauschen →" Link (rechts)</li>
<li>Swap-URL: <code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&amp;swap={item.slotId}</code></li>
</ul>
<!-- Visual preview -->
<div class="prev-row">
<div class="prev-col">
<div class="bp-lbl">Warnkarte · Referenz-Darstellung</div>
<div style="width:340px;">
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 7. EDGE CASES ── -->
<div class="section">
<div class="section-label">7 · Edge Cases</div>
<table class="tbl">
<thead><tr><th>Fall</th><th>Verhalten</th></tr></thead>
<tbody>
<tr>
<td>Tag im tagRepeat hat keinen Slot</td>
<td>Filter-Schritt (.filter(x => x !== null)) entfernt das Item. Warnkarte erscheint nur wenn ≥1 Item vorhanden.</td>
</tr>
<tr>
<td>weekPlan hat keine Slots (leere Woche)</td>
<td>slotsByDay ist {}, actionWarnings ist []. Keine Warnkarten sichtbar.</td>
</tr>
<tr>
<td>varietyScore ist null</td>
<td>Bestehende {#if !varietyScore}-Guard greift — actionWarnings wird nie gerendert.</td>
</tr>
<tr>
<td>Slot hat kein Rezept (slot.recipe === null)</td>
<td>slot.recipe?.name ist undefined → Slot wird nicht in slotsByDay aufgenommen.</td>
</tr>
<tr>
<td>duplicatesInPlan: Rezeptname kommt in slotsByDay nicht vor</td>
<td>items ist leer → Warnkarte wird nicht gepusht.</td>
</tr>
<tr>
<td>Unbekannter Tag-Code (z.B. zukünftige API-Erweiterung)</td>
<td>DAY_SHORT[day] ?? day — Fallback auf den rohen Code.</td>
</tr>
<tr>
<td>Sehr langer Rezeptname</td>
<td>CSS truncate auf .wcard-recipe — kein Überlauf, Swap-Link bleibt sichtbar.</td>
</tr>
</tbody>
</table>
</div>
<!-- ── 8. ABNAHMEKRITERIEN ── -->
<div class="section">
<div class="section-label">8 · Abnahmekriterien</div>
<div class="box box-g">
<div class="box-lbl">Acceptance Criteria</div>
<ul class="checks">
<li>AC-1: Warnkarte zeigt pro betroffenem Tag eine eigene Zeile (nicht mehr einen langen Erklärungstext)</li>
<li>AC-2: Jede Zeile enthält die deutsche Wochentag-Abkürzung (Mo, Di, Mi, Do, Fr, Sa, So)</li>
<li>AC-3: Jede Zeile enthält den Namen des eingeplanten Rezepts</li>
<li>AC-4: Jede Zeile enthält einen "Tauschen →" Link, der zu /planner?week={weekStart}&amp;swap={slotId} führt</li>
<li>AC-5: Tags mit nur einem betroffenen Tag (days.length &lt; 2) erzeugen keine Warnkarte</li>
<li>AC-6: Score-Hero, Bewertungsdetails und Protein-Grid (Desktop) bleiben unverändert</li>
<li>AC-7: Wenn varietyScore null ist, werden keine Warnkarten gerendert (leere-Woche-State bleibt)</li>
<li>AC-8: Der Import von computeWarnings ist entfernt, TypeScript kompiliert fehlerfrei</li>
<li>AC-9: Auf Mobilgerät sind Tausch-Links touch-freundlich (mind. 44px Zeilenhöhe)</li>
</ul>
</div>
<div class="box box-y">
<div class="box-lbl">Nicht in Scope</div>
<ul class="arrows">
<li>Neues Backend-Endpoint — alle Daten kommen aus dem bestehenden Load</li>
<li>Layout-Umbau der Seite — Score bleibt oben, Warnungen unten wie bisher</li>
<li>Protein-Grid oder EffortBar Änderungen</li>
<li>computeSubScores aus variety.ts — bleibt unverändert</li>
<li>Entfernen von computeWarnings aus variety.ts (Funktion bleibt, wird nur nicht mehr aufgerufen)</li>
</ul>
</div>
</div>
<!-- ── 9. DATEIEN ── -->
<div class="section">
<div class="section-label">9 · Betroffene Dateien</div>
<table class="tbl">
<thead><tr><th>Datei</th><th>Änderung</th></tr></thead>
<tbody>
<tr>
<td class="mono">frontend/src/routes/(app)/planner/variety/+page.svelte</td>
<td>DAY_SHORT-Konstante, slotsByDay-Derived, actionWarnings-Derived, Template-Update (2×), Import-Bereinigung</td>
</tr>
<tr>
<td class="mono">frontend/src/lib/planner/VarietyWarningCards.svelte</td>
<td>Keine — bereits auf ActionWarning-Format aktualisiert</td>
</tr>
<tr>
<td class="mono">frontend/src/lib/planner/variety.ts</td>
<td>Keine — computeWarnings bleibt (ungenutzt, aber nicht entfernen um Regressions-Risiko zu vermeiden)</td>
</tr>
</tbody>
</table>
</div>
<!-- ── LLM AGENT REGION ── -->
<div class="section">
<div class="section-label">LLM-Agent-Lesbereich</div>
<p class="prose">Dieser Abschnitt enthält maschinenlesbare Regeln für einen KI-Agenten der die Implementierung durchführt.</p>
<div class="code"><span class="cm">SCREEN: C3 /planner/variety
VARIATION: V1 "Erweiterte Karten"
STATUS: Final spec — ready for implementation
FILES TO MODIFY:
frontend/src/routes/(app)/planner/variety/+page.svelte
FILES NOT TO MODIFY:
frontend/src/lib/planner/VarietyWarningCards.svelte (already correct)
frontend/src/lib/planner/variety.ts (keep computeWarnings, remove only import)
STEP 1 — Add DAY_SHORT constant (in &lt;script&gt; block, after imports):
const DAY_SHORT: Record&lt;string, string&gt; = {
MON: 'Mo', TUE: 'Di', WED: 'Mi',
THU: 'Do', FRI: 'Fr', SAT: 'Sa', SUN: 'So'
};
STEP 2 — Add slotsByDay derived (after $derived declarations for weekPlan, etc.):
let slotsByDay = $derived.by(() => {
const map: Record&lt;string, { slotId: number; recipeName: string }&gt; = {};
for (const slot of weekPlan?.slots ?? []) {
if (slot.dayOfWeek && slot.recipe?.name && slot.id) {
map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name };
}
}
return map;
});
STEP 3 — Define inline interfaces + actionWarnings derived:
interface WarningItem { dayShort: string; recipeName: string; slotId: number; }
interface ActionWarning { title: string; items: WarningItem[]; }
let actionWarnings = $derived.by((): ActionWarning[] => {
const result: ActionWarning[] = [];
const vs = varietyScore;
if (!vs) return result;
for (const repeat of vs.tagRepeats ?? []) {
if ((repeat.days?.length ?? 0) &lt; 2) continue;
const items: WarningItem[] = (repeat.days ?? [])
.map((day) => {
const slot = slotsByDay[day];
return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null;
})
.filter((x): x is WarningItem => x !== null);
if (items.length &gt; 0) result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
}
for (const overlap of vs.ingredientOverlaps ?? []) {
if ((overlap.days?.length ?? 0) &lt; 2) continue;
const items: WarningItem[] = (overlap.days ?? [])
.map((day) => {
const slot = slotsByDay[day];
return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null;
})
.filter((x): x is WarningItem => x !== null);
if (items.length &gt; 0) result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items });
}
for (const name of vs.duplicatesInPlan ?? []) {
const items: WarningItem[] = Object.entries(slotsByDay)
.filter(([, s]) => s.recipeName === name)
.map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId }));
if (items.length &gt; 0) result.push({ title: `${name} doppelt geplant`, items });
}
return result;
});
STEP 4 — Replace template occurrences (both mobile and desktop sections):
OLD: {#if warnings.length > 0} / &lt;VarietyWarningCards {warnings} /&gt;
NEW: {#if actionWarnings.length > 0} / &lt;VarietyWarningCards warnings={actionWarnings} {weekStart} /&gt;
STEP 5 — Fix import:
OLD: import { computeSubScores, computeWarnings } from '$lib/planner/variety';
NEW: import { computeSubScores } from '$lib/planner/variety';
INVARIANTS (do not change):
- VarietyScoreHero, ScoreBreakdownList, EffortBar remain untouched
- Desktop protein grid (proteinByDay) remains untouched
- Layout structure (score top, warnings bottom) stays identical
- No new server load or API calls</span></div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,790 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — C3 Abwechslungs-Analyse · 3 Mockup-Variationen</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;
--orange-tint:#FEF0E6;--orange:#E8862A;
--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;
--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);
--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#DDDBD5;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:40px 40px 28px;margin:-48px -40px 48px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--color-border);}
.doc-header h1{font-family:var(--font-display);font-size:26px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-draft{background:var(--yellow-tint);color:var(--yellow-text);}
.pill-rec{background:var(--green-tint);color:var(--green-dark);}
/* Section */
.section{margin-bottom:80px;}
.section-label{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.7;max-width:720px;margin-bottom:24px;}
/* Variation header */
.var-head{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:32px;display:flex;align-items:flex-start;gap:16px;}
.var-num{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;flex-shrink:0;}
.var-id{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;margin-bottom:4px;}
.var-title{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;}
.var-desc{font-size:13px;line-height:1.6;max-width:600px;}
.var-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
.var-y .var-num,.var-y .var-id{color:var(--yellow-dark);}
.var-y .var-desc{color:var(--yellow-text);}
.var-g{background:var(--green-tint);border:1px solid var(--green-light);}
.var-g .var-num,.var-g .var-id{color:var(--green);}
.var-g .var-desc{color:var(--green-dark);}
.var-p{background:var(--purple-tint);border:1px solid var(--purple-light);}
.var-p .var-num,.var-p .var-id{color:var(--purple);}
.var-p .var-desc{color:var(--purple-dark);}
/* Device frames */
.previews{display:flex;gap:40px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:28px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.08);border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:11px;background:var(--color-page);}
.pst b{font-weight:600;font-size:12px;}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:500px;}
/* Nav chrome */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
.mtb-back{font-size:20px;color:var(--color-text-muted);line-height:1;}
.mtb-t{font-family:var(--font-display);font-size:18px;font-weight:300;letter-spacing:-.02em;flex:1;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;flex-shrink:0;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}
.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);font-size:11px;display:flex;align-items:center;justify-content:center;}
.mt-i.a .mt-ic{background:var(--yellow-tint);}
.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}
.mt-i.a .mt-l{color:var(--yellow-text);}
/* Desktop sidebar */
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}
.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);font-size:12px;display:flex;align-items:center;justify-content:center;}
.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}
.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}
.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}
.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;}
.dsb-ni.a{background:var(--yellow-tint);color:var(--yellow-text);font-weight:500;}
.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:14px 24px;border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:8px;flex-shrink:0;}
.dtb-bc{font-size:12px;color:var(--color-text-muted);}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:300;letter-spacing:-.02em;}
.dmc{padding:24px;flex:1;overflow-y:auto;}
/* Shared components */
.score-num{font-family:var(--font-display);font-weight:300;letter-spacing:-.02em;line-height:1;}
.prog{height:6px;border-radius:3px;background:var(--color-border);overflow:hidden;margin-top:8px;}
.prog-fill{height:100%;border-radius:3px;background:var(--yellow);}
/* Warning card styles */
.wcard{border-radius:var(--radius-lg);border:1px solid var(--yellow-light);background:var(--yellow-tint);overflow:hidden;margin-bottom:8px;}
.wcard:last-child{margin-bottom:0;}
.wcard-hd{padding:10px 14px;border-bottom:1px solid var(--yellow-light);}
.wcard-hd-t{font-size:13px;font-weight:500;color:var(--yellow-text);}
.wcard-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-bottom:1px solid rgba(249,224,138,.4);}
.wcard-row:last-child{border-bottom:none;}
.wcard-left{display:flex;align-items:baseline;gap:8px;min-width:0;}
.wcard-day{font-size:11px;font-weight:600;color:var(--yellow-text);width:20px;flex-shrink:0;}
.wcard-recipe{font-size:13px;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.wcard-swap{font-size:12px;font-weight:500;color:var(--yellow-text);white-space:nowrap;flex-shrink:0;}
.wcard-swap:hover{text-decoration:underline;}
/* Score breakdown rows */
.sb-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}
.sb-row:last-child{border-bottom:none;}
.sb-label{font-size:12px;color:var(--color-text-muted);}
.sb-val{font-family:var(--font-mono);font-size:12px;font-weight:500;}
/* Collapsible details */
.det summary{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);cursor:default;list-style:none;display:flex;align-items:center;justify-content:space-between;}
.det summary::after{content:'▾';font-size:11px;}
.det[open] summary::after{content:'▴';}
.det-body{padding-top:8px;}
/* Notes block */
.notes{border-radius:var(--radius-lg);padding:16px 20px;margin-top:20px;}
.notes-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;}
.notes ul{list-style:none;display:flex;flex-direction:column;gap:5px;}
.notes li{font-size:12px;line-height:1.5;display:flex;align-items:flex-start;gap:8px;}
.notes li::before{content:'→';font-weight:500;flex-shrink:0;}
.notes-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
.notes-y .notes-lbl,.notes-y li::before{color:var(--yellow-text);}
.notes-y li{color:var(--yellow-text);}
.notes-g{background:var(--green-tint);border:1px solid var(--green-light);}
.notes-g .notes-lbl,.notes-g li::before{color:var(--green-dark);}
.notes-g li{color:var(--green-dark);}
.notes-p{background:var(--purple-tint);border:1px solid var(--purple-light);}
.notes-p .notes-lbl,.notes-p li::before{color:var(--purple-dark);}
.notes-p li{color:var(--purple-dark);}
.divider{border:none;border-top:1px solid var(--color-border);margin:48px 0;}
/* Comparison table */
.ct{width:100%;border-collapse:collapse;font-size:13px;background:var(--color-surface);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--color-border);}
.ct thead tr{background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
.ct th{text-align:left;padding:10px 16px;font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
.ct td{padding:10px 16px;border-bottom:1px solid var(--color-subtle);font-size:12px;vertical-align:top;}
.ct tr:last-child td{border-bottom:none;}
.ct td:first-child{font-weight:500;font-size:11px;color:var(--color-text-muted);white-space:nowrap;}
</style>
</head>
<body>
<div class="doc">
<!-- Header -->
<div class="doc-header">
<div>
<h1>C3 — Abwechslungs-Analyse · Rework</h1>
<p>Recipe App · 3 Mockup-Variationen · Aktuell: technische Tages-Codes, keine Rezeptnamen, kein direkter Tausch</p>
</div>
<div class="doc-meta">
<span class="pill pill-draft">Entwurf</span><br>
Erstellt: 2026-04<br>
Variationen: 3<br>
Screen: C3
</div>
</div>
<!-- Context -->
<div class="section">
<div class="section-label">Problem</div>
<p class="prose">Die aktuelle Seite zeigt Warnungen wie <strong style="font-family:var(--font-mono);font-size:12px;">"MON, WED — erwäge einen Tausch"</strong>. Der Planer muss selbst nachschlagen, welches Gericht an Montag und Mittwoch geplant ist, und dann manuell zum Planer navigieren um zu tauschen. Zwei Probleme:</p>
<p class="prose"><strong>1. Keine Rezeptnamen</strong> — Tag-Codes statt echter Gerichte. <strong>2. Kein direkter Tausch</strong> — der Planer muss die Seite verlassen, zurück zum Planer, das richtige Gericht suchen und dann tauschen.</p>
</div>
<!-- ═══════════════════════════════════════
V1 — ERWEITERTE KARTEN
════════════════════════════════════════ -->
<div class="section">
<div class="var-head var-y">
<div class="var-num">V1</div>
<div>
<div class="var-id">Variation 1</div>
<div class="var-title">Erweiterte Karten</div>
<div class="var-desc">Minimale Änderung: bestehende gelbe Karten bleiben, aber der Text wird durch strukturierte Zeilen ersetzt — eine pro betroffenem Gericht, mit Wochentag, Rezeptname und "Tauschen →" Link. Score-Bereich und Layout bleiben unverändert.</div>
</div>
</div>
<div class="previews">
<!-- Mobile V1 -->
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<!-- topbar -->
<div class="mtb">
<div class="mtb-back"></div>
<div class="mtb-t">Abwechslungs-Analyse</div>
</div>
<div style="flex:1;overflow-y:auto;padding:20px 16px 16px;">
<!-- Score hero -->
<div style="margin-bottom:24px;">
<div style="display:flex;align-items:baseline;gap:8px;">
<span class="score-num" style="font-size:56px;color:var(--color-text);">5.8</span>
<span style="font-size:16px;color:var(--color-text-muted);">/ 10</span>
<span style="font-size:14px;font-weight:500;color:var(--yellow-text);margin-left:4px;">Verbesserbar</span>
</div>
<div class="prog" style="width:120px;"><div class="prog-fill" style="width:58%;"></div></div>
</div>
<!-- Sub-scores -->
<div style="margin-bottom:24px;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Bewertung im Detail</div>
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
</div>
<!-- Warnings — V1 style: same card structure, but rows inside -->
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
</div>
</div>
</div>
<!-- Desktop V1 -->
<div class="prev-col" style="flex:1;min-width:580px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb">
<span class="dtb-bc">Planer /</span>
<span class="dtb-t">Abwechslungs-Analyse</span>
</div>
<div class="dmc">
<!-- 2-col: left score + breakdown, right warnings -->
<div style="display:flex;gap:32px;">
<!-- Left -->
<div style="flex:1;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px;">
<span class="score-num" style="font-size:72px;color:var(--color-text);">5.8</span>
<span style="font-size:18px;color:var(--color-text-muted);">/ 10</span>
<span style="font-size:14px;font-weight:500;color:var(--yellow-text);margin-left:4px;">Verbesserbar</span>
</div>
<div class="prog" style="width:200px;"><div class="prog-fill" style="width:58%;"></div></div>
<div style="margin-top:20px;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Bewertung im Detail</div>
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
</div>
</div>
<!-- Right: warnings -->
<div style="width:340px;flex-shrink:0;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes notes-y">
<div class="notes-lbl">Design-Notizen V1</div>
<ul>
<li>Geringster Umbauaufwand — nur VarietyWarningCards.svelte ändert sich, keine Layout-Umstrukturierung.</li>
<li>Behält die bekannte Score-Hierarchie bei: Zahl oben, dann Detail, dann Hinweise.</li>
<li>Schwachstelle: Hinweise sind trotzdem am Ende der Seite versteckt — auf kurzen Telefon-Bildschirmen muss gescrollt werden, bevor der Planer die Tausch-Links sieht.</li>
<li>Die Sub-Scores bleiben immer sichtbar, auch wenn der Planer nur die Tausch-Aktionen braucht.</li>
</ul>
</div>
</div>
<hr class="divider"/>
<!-- ═══════════════════════════════════════
V2 — AKTIONS-LISTE (EMPFOHLEN)
════════════════════════════════════════ -->
<div class="section">
<div class="var-head var-g">
<div class="var-num">V2</div>
<div>
<div class="var-id">Variation 2 · Empfohlen</div>
<div class="var-title">Aktions-Liste</div>
<div class="var-desc">Hinweise rücken nach oben — direkt unter dem Score. Der Planer sieht sofort, was zu tun ist. Sub-Scores wandern in ein ausklappbares "Bewertung im Detail" (native &lt;details&gt;, kein JS). Kompakterer Score-Hero gibt Hinweisen mehr Raum.</div>
</div>
</div>
<div class="previews">
<!-- Mobile V2 -->
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="mtb">
<div class="mtb-back"></div>
<div class="mtb-t">Abwechslungs-Analyse</div>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;">
<!-- Compact score strip -->
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--color-surface);border-radius:var(--radius-lg);border:1px solid var(--color-border);margin-bottom:16px;">
<div>
<span class="score-num" style="font-size:40px;color:var(--color-text);">5.8</span>
<span style="font-size:13px;color:var(--color-text-muted);margin-left:4px;">/ 10</span>
</div>
<div style="flex:1;">
<div style="font-size:12px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar</div>
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
</div>
</div>
<!-- Warnings — primary content -->
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">2 Hinweise</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard" style="margin-bottom:16px;">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<!-- Sub-scores — collapsed -->
<details class="det" style="border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 14px;background:var(--color-surface);">
<summary>Bewertung im Detail</summary>
<div class="det-body">
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
</div>
</details>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
</div>
</div>
</div>
<!-- Desktop V2 -->
<div class="prev-col" style="flex:1;min-width:580px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb">
<span class="dtb-bc">Planer /</span>
<span class="dtb-t">Abwechslungs-Analyse</span>
</div>
<div class="dmc">
<!-- Top: compact score strip + effort -->
<div style="display:flex;gap:16px;align-items:center;padding:16px;background:var(--color-surface);border-radius:var(--radius-lg);border:1px solid var(--color-border);margin-bottom:24px;">
<div>
<span class="score-num" style="font-size:52px;color:var(--color-text);">5.8</span>
<span style="font-size:14px;color:var(--color-text-muted);margin-left:6px;">/ 10</span>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar — 2 Hinweise</div>
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
</div>
<!-- Sub-scores inline on desktop -->
<div style="border-left:1px solid var(--color-border);padding-left:16px;display:flex;gap:16px;">
<div style="text-align:center;">
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--red-dark);">4</div>
<div style="font-size:10px;color:var(--color-text-muted);">Quellen</div>
</div>
<div style="text-align:center;">
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--yellow-text);">7</div>
<div style="font-size:10px;color:var(--color-text-muted);">Zutaten</div>
</div>
<div style="text-align:center;">
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--green-dark);">8</div>
<div style="font-size:10px;color:var(--color-text-muted);">Aufwand</div>
</div>
</div>
</div>
<!-- Warnings full-width -->
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes notes-g">
<div class="notes-lbl">Design-Notizen V2</div>
<ul>
<li>Hinweise erscheinen direkt unter dem Score — kein Scrollen nötig auf typischen Telefon-Bildschirmen.</li>
<li>Kompakter Score-Strip auf Mobile spart ~80px gegenüber dem aktuellen großen Hero — mehr Raum für die eigentlichen Tausch-Aktionen.</li>
<li>Desktop: Sub-Scores werden als kompakte Zahlen-Spalte in die Score-Leiste integriert — kein separater Abschnitt mehr nötig.</li>
<li>Native &lt;details&gt; auf Mobile braucht kein JavaScript; funktioniert auch ohne hydration.</li>
<li>"2 Hinweise" im Score-Strip auf Desktop gibt dem Planer sofort Kontext, ohne zu scrollen.</li>
</ul>
</div>
</div>
<hr class="divider"/>
<!-- ═══════════════════════════════════════
V3 — HINWEISE ZUERST
════════════════════════════════════════ -->
<div class="section">
<div class="var-head var-p">
<div class="var-num">V3</div>
<div>
<div class="var-id">Variation 3</div>
<div class="var-title">Hinweise zuerst</div>
<div class="var-desc">Invertiertes Layout: die Seite öffnet mit den konkreten Problem-Karten — groß und klar. Score und Breakdown erscheinen darunter als unterstützende Information. Jede Warnung ist eine eigenständige "Aufgaben-Karte" mit prominentem Tausch-Button statt Link.</div>
</div>
</div>
<div class="previews">
<!-- Mobile V3 -->
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="mtb">
<div class="mtb-back"></div>
<div class="mtb-t">Abwechslungs-Analyse</div>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;">
<!-- Problem cards — full width, prominent -->
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Was zu tun ist</div>
<!-- Problem card 1 -->
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:10px;background:var(--yellow-tint);">
<div style="padding:12px 14px;border-bottom:1px solid var(--yellow-light);">
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:2px;">Quellen-Wiederholung</div>
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Tofu an 2 Tagen</div>
</div>
<!-- Row 1 -->
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-bottom:1px solid rgba(249,224,138,.5);">
<div style="display:flex;flex-direction:column;gap:1px;">
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Montag</div>
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Gemüse-Pfanne</div>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
<!-- Row 2 -->
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;">
<div style="display:flex;flex-direction:column;gap:1px;">
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Mittwoch</div>
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</div>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
</div>
<!-- Problem card 2 -->
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:16px;background:var(--yellow-tint);">
<div style="padding:12px 14px;border-bottom:1px solid var(--yellow-light);">
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:2px;">Zutaten-Überschneidung</div>
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Paprika an 2 aufeinanderfolgenden Tagen</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-bottom:1px solid rgba(249,224,138,.5);">
<div style="display:flex;flex-direction:column;gap:1px;">
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Dienstag</div>
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Paprika-Linsen-Eintopf</div>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;">
<div style="display:flex;flex-direction:column;gap:1px;">
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Mittwoch</div>
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</div>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
</div>
<!-- Score — secondary, at bottom -->
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px 16px;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">Gesamt-Score</div>
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span class="score-num" style="font-size:36px;color:var(--color-text);">5.8</span>
<span style="font-size:13px;color:var(--color-text-muted);">/ 10 · Verbesserbar</span>
</div>
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
<div class="det" style="margin-top:10px;">
<details>
<summary style="font-size:11px;color:var(--color-text-muted);cursor:default;">Aufschlüsselung anzeigen</summary>
<div style="padding-top:8px;">
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
</div>
</details>
</div>
</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
</div>
</div>
</div>
<!-- Desktop V3 -->
<div class="prev-col" style="flex:1;min-width:580px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb">
<span class="dtb-bc">Planer /</span>
<span class="dtb-t">Abwechslungs-Analyse</span>
</div>
<div class="dmc">
<div style="display:grid;grid-template-columns:1fr 240px;gap:24px;">
<!-- Left: problem cards -->
<div>
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Was zu tun ist</div>
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:10px;background:var(--yellow-tint);">
<div style="padding:10px 16px;border-bottom:1px solid var(--yellow-light);">
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:1px;">Quellen-Wiederholung</div>
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Tofu an 2 Tagen</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(249,224,138,.5);">
<div style="display:flex;align-items:baseline;gap:10px;">
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Montag</span>
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Gemüse-Pfanne</span>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;">
<div style="display:flex;align-items:baseline;gap:10px;">
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Mittwoch</span>
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</span>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
</div>
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;background:var(--yellow-tint);">
<div style="padding:10px 16px;border-bottom:1px solid var(--yellow-light);">
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:1px;">Zutaten-Überschneidung</div>
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Paprika an 2 aufeinanderfolgenden Tagen</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(249,224,138,.5);">
<div style="display:flex;align-items:baseline;gap:10px;">
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Dienstag</span>
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Paprika-Linsen-Eintopf</span>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;">
<div style="display:flex;align-items:baseline;gap:10px;">
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Mittwoch</span>
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</span>
</div>
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
</div>
</div>
</div>
<!-- Right: score panel -->
<div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;margin-bottom:12px;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">Score</div>
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px;">
<span class="score-num" style="font-size:40px;color:var(--color-text);">5.8</span>
<span style="font-size:13px;color:var(--color-text-muted);">/ 10</span>
</div>
<div style="font-size:12px;font-weight:500;color:var(--yellow-text);margin-bottom:6px;">Verbesserbar</div>
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
</div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Aufschlüsselung</div>
<div class="sb-row"><span class="sb-label">Quellen</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
<div class="sb-row"><span class="sb-label">Zutaten</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
<div class="sb-row"><span class="sb-label">Aufwand</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes notes-p">
<div class="notes-lbl">Design-Notizen V3</div>
<ul>
<li>Klarer Fokus: Das erste, was der Planer sieht, ist "Was zu tun ist" — keine Score-Hierarchie die von der Aktion ablenkt.</li>
<li>Prominente "Tauschen"-Buttons (gefüllt, dunkelgelb) statt Links — erhöht die Tipp-Fläche auf Mobile und macht die Aktion offensichtlicher.</li>
<li>Voller Wochentag ("Montag" statt "Mo") — lesbarer, besonders auf Desktop.</li>
<li>Schwachstelle: Wenn es keine Hinweise gibt (Score ≥ 9), wirkt die Seite leer — der Score müsste dann nach oben rücken. Erfordert einen separaten Empty-State.</li>
<li>Höherer Umbauaufwand gegenüber V1 und V2 — die Page-Struktur ändert sich grundlegend.</li>
</ul>
</div>
</div>
<hr class="divider"/>
<!-- Comparison -->
<div class="section">
<div class="section-label">Vergleich</div>
<table class="ct">
<thead>
<tr>
<th>Kriterium</th>
<th style="color:var(--yellow-text);">V1 Erweiterte Karten</th>
<th style="color:var(--green-dark);">V2 Aktions-Liste ★</th>
<th style="color:var(--purple-dark);">V3 Hinweise zuerst</th>
</tr>
</thead>
<tbody>
<tr>
<td>Rezeptnamen sichtbar</td>
<td>✓ Ja</td>
<td>✓ Ja</td>
<td>✓ Ja, prominent</td>
</tr>
<tr>
<td>Direkter Tausch</td>
<td>Link</td>
<td>Link</td>
<td>Button (größere Tap-Fläche)</td>
</tr>
<tr>
<td>Hinweise sichtbar ohne Scrollen</td>
<td>Nein (Score + Breakdown zuerst)</td>
<td>Ja (direkt unter kompaktem Score)</td>
<td>Ja (ganz oben)</td>
</tr>
<tr>
<td>Umbauaufwand</td>
<td>Niedrig</td>
<td>Mittel</td>
<td>Hoch</td>
</tr>
<tr>
<td>Layout-Änderung</td>
<td>Keine</td>
<td>Score kompakter, Details kollabierbar</td>
<td>Grundlegende Umstrukturierung</td>
</tr>
<tr>
<td>Empfehlung</td>
<td>Wenn schnelle Lieferung Prio</td>
<td><strong>Empfohlen ★</strong></td>
<td>Wenn Aktions-Fokus Prio</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>E1 — Einstellungen · Kachel-Ansicht · Finale Spezifikation</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<!--
spec:agent
document: E1 Einstellungen Kachel-Ansicht, Finale Spezifikation
version: 1.0
journey: J8 Edit pantry staples
routes: /settings (E1 hub) → /household/staples?ctx=settings (D3)
screens: E1, D3
chosen-variation: V2 Kachel-Ansicht (Card sections)
last-updated: 2026-04-09
NAVIGATION STRUCTURE:
E1 (/settings) → Hub with 3 cards:
Card 1 "Vorräte" → navigates to D3 (/household/staples?ctx=settings)
Card 2 "Mitglieder" → navigates to E2 (/members)
Card 3 "Profil" → navigates to /profile (not yet implemented)
DATA:
Vorräte count: derived from GET /v1/ingredient-categories response
(count ingredients where isStaple === true)
Mitglieder count: from layout data (locals.haushalt via GET /v1/households/mine/members)
Profil name/email: from locals.benutzer
NOTE: D3 = A3. StaplesManager component is reused with context="settings".
StaplesManager renders categories as StapleChip pill grids, NOT checkboxes.
Auto-save on toggle (debounced PATCH 300ms). No save button.
-->
<style>
:root {
--color-page: #FAFAF7;
--color-surface: #F5F4EE;
--color-subtle: #EDECEA;
--color-border: #D8D7D0;
--color-text-muted: #6B6A63;
--color-text: #1C1C18;
--green-tint: #E8F5EA;
--green-light: #AEDCB0;
--green: #3D8C4A;
--green-dark: #2E6E39;
--green-deeper: #1E4A26;
--yellow-tint: #FDF6D8;
--yellow-light: #F9E08A;
--yellow-text: #8A6800;
--color-error: #DC4C3E;
--blue-tint: #E6F1FB;
--blue: #185FA5;
--blue-dark: #0C447C;
--font-display: 'Fraunces', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', monospace;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
/* ── Doc layout ── */
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; display: flex; justify-content: space-between; align-items: flex-end; }
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
.intro { font-size: 14px; line-height: 1.75; max-width: 640px; margin-bottom: 40px; }
/* ── State sections ── */
.state { margin-bottom: 64px; }
.state-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.state-id { font-family: var(--font-mono); font-size: 10px; font-weight: 500; background: var(--color-subtle); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-sm); white-space: nowrap; margin-top: 3px; }
.state-title { font-size: 16px; font-weight: 500; letter-spacing: -0.01em; }
.state-desc { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; max-width: 540px; }
/* ── Preview containers ── */
.preview-wrap { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
/* ── Notes ── */
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
/* ── AppShell chrome ── */
.shell { display: flex; min-height: 100vh; background: var(--color-page); font-family: var(--font-sans); }
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
.sidebar-nav { flex: 1; padding: 4px 8px; }
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
.sidebar-item:not(.active):hover { background: var(--color-subtle); }
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
/* ── Page content ── */
.page-content { flex: 1; padding: 32px 40px; }
.page-title { font-family: var(--font-display); font-size: 24px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
.page-subtitle { font-size: 13px; color: var(--color-text-muted); margin-bottom: 28px; }
/* ── Settings card grid ── */
.settings-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
.settings-grid-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
/* ── Setting card ── */
.setting-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px; box-shadow: var(--shadow-card); cursor: pointer; text-decoration: none; color: inherit; display: flex; flex-direction: column; }
.setting-card:hover { box-shadow: var(--shadow-raised); border-color: #C0BFB8; }
.setting-card.primary { border-left: 3px solid var(--green-dark); }
.setting-card.primary:hover { border-left-color: var(--green-dark); }
.card-icon { font-size: 22px; margin-bottom: 12px; }
.card-stat { font-family: var(--font-display); font-size: 36px; font-weight: 500; letter-spacing: -0.02em; color: var(--green-dark); line-height: 1; margin-bottom: 2px; }
.card-stat-label { font-size: 11px; color: var(--color-text-muted); margin-bottom: 12px; }
.card-title { font-size: 15px; font-weight: 500; margin-bottom: 4px; }
.card-desc { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; flex: 1; }
.card-cta { margin-top: 16px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: var(--green-dark); }
.card-cta-secondary { margin-top: 16px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: var(--color-text-muted); }
.card-meta { font-size: 12px; color: var(--color-text-muted); margin-bottom: 4px; }
/* ── D3 Staples page chrome ── */
.breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); margin-bottom: 20px; }
.breadcrumb a { color: var(--color-text-muted); text-decoration: none; }
.breadcrumb a:hover { color: var(--color-text); }
.breadcrumb-sep { font-size: 10px; }
/* ── Staple chips ── */
.category-block { margin-bottom: 24px; }
.category-name { font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 10px; }
.chip-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { padding: 5px 12px; border-radius: var(--radius-full); border: 1px solid var(--color-border); font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; }
.chip.on { background: var(--green-dark); color: white; border-color: var(--green-dark); }
.chip.off { background: transparent; color: var(--color-text-muted); }
.chip.off:hover { border-color: var(--green-light); color: var(--green-dark); }
.save-note { font-size: 11px; color: var(--color-text-muted); margin-top: 16px; font-style: italic; }
/* ── Mobile shell ── */
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
.m-header { padding: 16px; background: white; border-bottom: 1px solid var(--color-border); }
.m-header-title { font-size: 16px; font-weight: 500; }
.m-content { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.m-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 16px; box-shadow: var(--shadow-card); }
.m-card.primary { border-left: 3px solid var(--green-dark); }
.m-card-stat { font-family: var(--font-display); font-size: 28px; font-weight: 500; color: var(--green-dark); line-height: 1; margin-bottom: 2px; }
.m-card-stat-label { font-size: 10px; color: var(--color-text-muted); margin-bottom: 8px; }
.m-card-title { font-size: 14px; font-weight: 500; margin-bottom: 3px; }
.m-card-desc { font-size: 11px; color: var(--color-text-muted); }
.m-card-cta { margin-top: 12px; font-size: 11px; font-weight: 500; color: var(--green-dark); }
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
.m-tab.active { color: var(--green-dark); }
.m-tab-icon { font-size: 20px; }
/* ── Agent section ── */
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
.agent-table tr:last-child td { border-bottom: none; }
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
.agent-table td:nth-child(3) { color: #5A5A55; }
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
</style>
</head>
<body>
<div class="doc">
<!-- Header -->
<div class="doc-header">
<div>
<h1>E1 — Einstellungen</h1>
<p>Kachel-Ansicht · Finale Spezifikation · Route: <code>/settings</code><code>/household/staples?ctx=settings</code></p>
</div>
<div class="doc-meta">
screens: E1, D3<br/>
journey: J8<br/>
variation: Kachel (V2)<br/>
version: 1.0<br/>
date: 2026-04-09
</div>
</div>
<p class="intro">
Die Einstellungsseite dient als Hub mit drei Kacheln: Vorräte (primäre Aktion, navigiert zu D3),
Mitglieder (navigiert zu E2) und Profil. Die Vorräte-Kachel zeigt die aktive Zutatenanzahl als
Display-Font-Zahl. D3 verwendet die bestehende StaplesManager-Komponente mit <code>context="settings"</code>.
</p>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S1 — Hub-Ansicht (E1 /settings)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S1</div>
<div>
<div class="state-title">Einstellungs-Hub — drei Kacheln</div>
<div class="state-desc">Vorräte-Kachel (2fr, primär mit grünem Akzentstreifen), Mitglieder-Kachel (1fr), Profil-Kachel (1fr). Desktop 2-spaltig oben, dann 2-spaltig unten.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar">
<div class="sidebar-brand">
<div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div>
<div class="sidebar-household">Familie Raddatz</div>
</div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Einstellungen</div>
<div class="page-subtitle">Familie Raddatz</div>
<div class="settings-grid">
<!-- Vorräte card (2fr, primary) -->
<a class="setting-card primary" href="#">
<div class="card-icon">🥫</div>
<div class="card-stat">14</div>
<div class="card-stat-label">von 32 Zutaten als Vorrat markiert</div>
<div class="card-title">Vorräte</div>
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind. Sie werden beim Einkaufen automatisch herausgefiltert.</div>
<div class="card-cta">Vorräte bearbeiten →</div>
</a>
<!-- Mitglieder card (1fr) -->
<a class="setting-card" href="#">
<div class="card-icon">👥</div>
<div class="card-title">Mitglieder</div>
<div class="card-meta" style="margin-top:4px;">3 Mitglieder</div>
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
<div class="card-cta-secondary">Mitglieder verwalten →</div>
</a>
</div>
<div class="settings-grid-bottom">
<!-- Profil card -->
<a class="setting-card" href="#">
<div class="card-icon">👤</div>
<div class="card-title">Profil</div>
<div class="card-meta" style="margin-top:4px;">Marcel R.</div>
<div class="card-desc" style="margin-top:8px;">Name und E-Mail-Adresse anpassen.</div>
<div class="card-cta-secondary">Profil bearbeiten →</div>
</a>
<!-- Placeholder / future -->
<div style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 24px; display:flex; align-items:center; justify-content:center; color: var(--color-text-muted); font-size: 12px;">Weitere Einstellungen folgen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-header"><div class="m-header-title">Einstellungen</div></div>
<div class="m-content">
<div class="m-card primary">
<div class="m-card-stat">14</div>
<div class="m-card-stat-label">von 32 Vorräten aktiv</div>
<div class="m-card-title">Vorräte</div>
<div class="m-card-desc">Welche Zutaten hast du immer zu Hause?</div>
<div class="m-card-cta">Vorräte bearbeiten →</div>
</div>
<div class="m-card">
<div class="m-card-title">👥 Mitglieder</div>
<div class="m-card-desc" style="margin-top:4px;">3 Mitglieder · Einladen &amp; Rollen</div>
<div class="m-card-cta">Verwalten →</div>
</div>
<div class="m-card">
<div class="m-card-title">👤 Profil</div>
<div class="m-card-desc" style="margin-top:4px;">Marcel R.</div>
<div class="m-card-cta">Bearbeiten →</div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Vorräte-Kachel: <code>grid-column: span 1</code> aber <code>2fr</code> Spaltenbreite im 2-Spalten-Grid. Grüner Linksstreifen (<code>border-left: 3px solid --green-dark</code>).</li>
<li>Stat-Zahl: Anzahl Zutaten mit <code>isStaple === true</code>, aus dem gleichen Load-Call der D3-Seite</li>
<li>Mitglieder-Karte: Anzahl aus <code>locals.haushalt</code> oder separatem API-Call; navigiert zu <code>/members</code></li>
<li>Profil-Karte: Name aus <code>locals.benutzer.name</code>; Zielseite <code>/profile</code> (noch nicht implementiert — Link disabled oder Placeholder)</li>
<li>Hover: <code>box-shadow: --shadow-raised</code>, leicht dunklerer Border</li>
<li>Alle Kacheln sind <code>&lt;a&gt;</code>-Tags für korrekte Navigation und Accessibility</li>
<li>Mobile: Kacheln stapeln sich vertikal in voller Breite, kein Grid</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S2 — Vorräte-Seite (D3 /household/staples?ctx=settings)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S2</div>
<div>
<div class="state-title">D3 — Vorräte bearbeiten (StaplesManager, context="settings")</div>
<div class="state-desc">Navigiert man von der Vorräte-Kachel aus, erscheint die bestehende StaplesManager-Komponente mit Breadcrumb zurück zu Einstellungen.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<!-- Breadcrumb -->
<div class="breadcrumb">
<a href="#">← Einstellungen</a>
<span class="breadcrumb-sep">/</span>
<span>Vorräte</span>
</div>
<div class="page-title">Vorräte</div>
<div class="page-subtitle">Markierte Zutaten werden beim Einkaufen herausgefiltert.</div>
<!-- StaplesManager content (context="settings") -->
<div class="category-block">
<div class="category-name">Gewürze &amp; Öle</div>
<div class="chip-wrap">
<span class="chip on">Salz</span>
<span class="chip on">Pfeffer</span>
<span class="chip on">Olivenöl</span>
<span class="chip off">Paprika</span>
<span class="chip off">Kreuzkümmel</span>
<span class="chip on">Knoblauch</span>
<span class="chip off">Chili</span>
</div>
</div>
<div class="category-block">
<div class="category-name">Grundnahrung</div>
<div class="chip-wrap">
<span class="chip on">Reis</span>
<span class="chip off">Nudeln</span>
<span class="chip on">Mehl</span>
<span class="chip on">Zucker</span>
<span class="chip off">Linsen</span>
<span class="chip off">Hülsenfrüchte</span>
</div>
</div>
<div class="category-block">
<div class="category-name">Kühlschrank</div>
<div class="chip-wrap">
<span class="chip on">Butter</span>
<span class="chip on">Eier</span>
<span class="chip off">Milch</span>
<span class="chip off">Käse</span>
<span class="chip off">Joghurt</span>
</div>
</div>
<div class="save-note">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-header">
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:2px;">← Einstellungen</div>
<div class="m-header-title">Vorräte</div>
</div>
<div class="m-content" style="gap:16px;">
<div>
<div class="category-name">Gewürze &amp; Öle</div>
<div class="chip-wrap">
<span class="chip on" style="font-size:11px;">Salz</span>
<span class="chip on" style="font-size:11px;">Pfeffer</span>
<span class="chip on" style="font-size:11px;">Olivenöl</span>
<span class="chip off" style="font-size:11px;">Paprika</span>
<span class="chip on" style="font-size:11px;">Knoblauch</span>
</div>
</div>
<div>
<div class="category-name">Grundnahrung</div>
<div class="chip-wrap">
<span class="chip on" style="font-size:11px;">Reis</span>
<span class="chip off" style="font-size:11px;">Nudeln</span>
<span class="chip on" style="font-size:11px;">Mehl</span>
<span class="chip on" style="font-size:11px;">Zucker</span>
</div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Breadcrumb "← Einstellungen" navigiert zurück zu <code>/settings</code></li>
<li>"Einstellungen" bleibt in der Sidebar aktiv (kein eigener Nav-Eintrag für Vorräte)</li>
<li>StaplesManager-Komponente unverändert mit <code>context="settings"</code> (3-spaltig auf md+)</li>
<li>Kein Speichern-Button. Hinweistext "Änderungen werden automatisch gespeichert." unter den Chips</li>
<li>Mobile: Chips statt 3-spaltig 1-spaltig (volle Breite), Flex-Wrap bleibt bestehen</li>
<li>D3 hat eigene <code>+page.server.ts</code> die <code>+page.svelte</code> bei <code>/household/staples</code> gibt es bereits</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S3 — Hover-Zustand der Kacheln</div>
<div class="state">
<div class="state-header">
<div class="state-id">S3</div>
<div>
<div class="state-title">Kachel-Hover — visuelles Feedback</div>
<div class="state-desc">Alle Kacheln sind anklickbare Links. Hover hebt die Kachel visuell an.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — Vorräte-Kachel im Hover</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Einstellungen</div>
<div class="page-subtitle">Familie Raddatz</div>
<div class="settings-grid">
<!-- Hovered Vorräte card -->
<a class="setting-card primary" href="#" style="box-shadow:var(--shadow-raised);border-color:#C0BFB8;cursor:pointer;">
<div class="card-icon">🥫</div>
<div class="card-stat">14</div>
<div class="card-stat-label">von 32 Zutaten als Vorrat markiert</div>
<div class="card-title">Vorräte</div>
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind.</div>
<div class="card-cta">Vorräte bearbeiten →</div>
</a>
<a class="setting-card" href="#">
<div class="card-icon">👥</div>
<div class="card-title">Mitglieder</div>
<div class="card-meta" style="margin-top:4px;">3 Mitglieder</div>
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
<div class="card-cta-secondary">Mitglieder verwalten →</div>
</a>
</div>
<div class="settings-grid-bottom">
<a class="setting-card" href="#"><div class="card-icon">👤</div><div class="card-title">Profil</div><div class="card-meta" style="margin-top:4px;">Marcel R.</div><div class="card-desc" style="margin-top:8px;">Name und E-Mail anpassen.</div><div class="card-cta-secondary">Profil bearbeiten →</div></a>
<div style="border:1.5px dashed var(--color-border);border-radius:var(--radius-xl);padding:24px;display:flex;align-items:center;justify-content:center;color:var(--color-text-muted);font-size:12px;">Weitere folgen</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Hover: <code>box-shadow: --shadow-raised</code> + <code>border-color: #C0BFB8</code></li>
<li>Vorräte-Kachel behält den grünen Linksstreifen auch im Hover</li>
<li>Transition: <code>box-shadow 150ms ease, border-color 150ms ease</code></li>
<li>Cursor: <code>pointer</code> auf allen Kacheln</li>
<li>Focus-visible: <code>outline: 2px solid --green-dark; outline-offset: 2px</code></li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S4 — Leerer Zustand (kein Vorrat gesetzt)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S4</div>
<div>
<div class="state-title">Vorräte-Kachel bei 0 aktiven Vorräten</div>
<div class="state-desc">Wenn noch kein Vorrat gesetzt wurde, zeigt die Kachel eine Einladung zur Aktion statt der Zahl.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — 0 Vorräte</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Einstellungen</div>
<div class="page-subtitle">Familie Raddatz</div>
<div class="settings-grid">
<a class="setting-card primary" href="#">
<div class="card-icon">🥫</div>
<!-- Empty state: no big number, instead prompt -->
<div style="font-size:13px;color:var(--color-text-muted);margin-bottom:8px;">Noch keine Vorräte eingerichtet</div>
<div class="card-title">Vorräte</div>
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind. Sie werden beim Einkaufen automatisch herausgefiltert.</div>
<div class="card-cta">Jetzt einrichten →</div>
</a>
<a class="setting-card" href="#">
<div class="card-icon">👥</div>
<div class="card-title">Mitglieder</div>
<div class="card-meta" style="margin-top:4px;">1 Mitglied</div>
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
<div class="card-cta-secondary">Mitglieder verwalten →</div>
</a>
</div>
<div class="settings-grid-bottom">
<a class="setting-card" href="#"><div class="card-icon">👤</div><div class="card-title">Profil</div><div class="card-meta" style="margin-top:4px;">Marcel R.</div><div class="card-cta-secondary" style="margin-top:8px;">Bearbeiten →</div></a>
<div style="border:1.5px dashed var(--color-border);border-radius:var(--radius-xl);padding:24px;display:flex;align-items:center;justify-content:center;color:var(--color-text-muted);font-size:12px;">Weitere folgen</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Wenn <code>stapleCount === 0</code>: Stat-Zahl weglassen, stattdessen "Noch keine Vorräte eingerichtet" in muted</li>
<li>CTA-Text ändert sich: "Jetzt einrichten →" statt "Vorräte bearbeiten →"</li>
<li>Kachel navigiert weiterhin zu D3 — StaplesManager lädt immer, unabhängig vom Count</li>
</ul>
</div>
</div>
<!-- ─── Machine-readable agent section ─── -->
<div class="agent-section">
<h2>Maschinen-lesbare Spezifikation</h2>
<p>Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.</p>
<pre class="spec-comment">
/* spec:rules — E1 Einstellungen Kachel
*
* ROUTE: /settings (E1 hub)
* DATA LOAD (page.server.ts):
* - GET /v1/ingredient-categories to count stapleCount
* stapleCount = sum of ingredients where isStaple === true
* - member count available from layout data (locals.haushalt)
* or fetch GET /v1/households/mine/members and count length
* - profile name from locals.benutzer.name
*
* LAYOUT: E1 HUB
* grid: 2 columns (2fr 1fr) top row + 2 columns (1fr 1fr) bottom row; gap 16px
* mobile: single column, full-width cards, gap 12px
*
* CARD: all cards are <a> tags (href to target route)
* border-radius: --radius-xl
* border: 1px solid --color-border
* bg: white
* padding: 24px desktop / 16px mobile
* hover: box-shadow --shadow-raised, border-color #C0BFB8
* transition: box-shadow 150ms ease, border-color 150ms ease
* cursor: pointer
* focus-visible: outline 2px solid --green-dark, offset 2px
*
* VORRÄTE CARD (primary)
* border-left: 3px solid --green-dark
* stat number: font-family --font-display, font-size 36px, color --green-dark
* stat label: "von {total} Zutaten als Vorrat markiert", 11px, --color-text-muted
* empty state (stapleCount === 0): hide stat, show "Noch keine Vorräte eingerichtet"
* cta: "Vorräte bearbeiten →" (empty: "Jetzt einrichten →")
* href: /household/staples?ctx=settings
*
* MITGLIEDER CARD
* meta: "{memberCount} Mitglieder"
* href: /members
*
* PROFIL CARD
* meta: locals.benutzer.name
* href: /profile (not yet implemented — render as disabled or placeholder)
*
* ROUTE: /household/staples?ctx=settings (D3)
* component: StaplesManager with context="settings" (already exists)
* breadcrumb: "← Einstellungen" linking back to /settings
* sidebar: "Einstellungen" stays active (no separate nav item for staples)
* no save button — StaplesManager auto-saves via debounced PATCH 300ms
* hint text below grid: "Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste."
* grid: 3-col on md+ (context="settings" already sets this in StaplesManager)
*
* CHIP STYLES (for reference — rendered by StapleChip, do NOT reimplement)
* selected: bg --green-dark, color white, border-color --green-dark
* unselected: bg transparent, color --color-text-muted, border 1px solid --color-border
* hover unselected: border-color --green-light, color --green-dark
*
* CATEGORY LABEL TYPOGRAPHY
* font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase
* color: --color-text-muted; margin-bottom: 10px
*/
</pre>
<table class="agent-table">
<thead>
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
</thead>
<tbody>
<tr class="group-row"><td colspan="3">E1 Hub Layout</td></tr>
<tr><td>grid-desktop</td><td>2fr 1fr / 1fr 1fr</td><td>top row / bottom row</td></tr>
<tr><td>grid-mobile</td><td>1fr</td><td>full-width stack</td></tr>
<tr><td>gap</td><td>16px desktop / 12px mobile</td><td></td></tr>
<tr class="group-row"><td colspan="3">Vorräte Card</td></tr>
<tr><td>stat-font</td><td>--font-display, 36px, --green-dark</td><td>Fraunces</td></tr>
<tr><td>accent-border</td><td>border-left: 3px solid --green-dark</td><td>primary indicator</td></tr>
<tr><td>stat-source</td><td>count isStaple=true from /v1/ingredient-categories</td><td>load in page.server.ts</td></tr>
<tr><td>empty-state</td><td>hide stat; show muted text</td><td>when stapleCount === 0</td></tr>
<tr><td>href</td><td>/household/staples?ctx=settings</td><td>D3 route</td></tr>
<tr class="group-row"><td colspan="3">D3 Staples Page</td></tr>
<tr><td>component</td><td>StaplesManager context="settings"</td><td>existing, do not modify</td></tr>
<tr><td>breadcrumb</td><td>← Einstellungen → /settings</td><td>above page title</td></tr>
<tr><td>active-nav</td><td>Einstellungen in sidebar</td><td>not a separate nav entry</td></tr>
<tr><td>save-hint</td><td>"Änderungen werden automatisch gespeichert."</td><td>below chip grid</td></tr>
<tr><td>debounce</td><td>300ms (in StaplesManager)</td><td>do not add extra debounce</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,905 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>E2 — Mitglieder · Kachel-Ansicht · Finale Spezifikation</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<!--
spec:agent
document: E2 Mitglieder Kachel-Ansicht, Finale Spezifikation
version: 1.0
journey: J7 Manage household members
route: /members
screen: E2
chosen-variation: V2 Kachel-Ansicht (Card grid)
last-updated: 2026-04-09
BACKEND GAPS (must be implemented before this page can ship):
- DELETE /v1/households/mine/members/{userId} → remove member
- PATCH /v1/households/mine/members/{userId} → body: { role: "planer"|"mitglied" }
- GET /v1/households/mine/invites → list active invites with expiry
These endpoints do not exist in the current API schema (schema.d.ts).
Existing: GET /v1/households/mine/members, POST /v1/households/mine/invites
ROLE ACCESS:
- rolle === 'planer': sees kebab menu on all cards except own
- rolle === 'mitglied': sees all cards read-only, no kebab, no invite card CTA
-->
<style>
:root {
--color-page: #FAFAF7;
--color-surface: #F5F4EE;
--color-subtle: #EDECEA;
--color-border: #D8D7D0;
--color-text-muted: #6B6A63;
--color-text: #1C1C18;
--green-tint: #E8F5EA;
--green-light: #AEDCB0;
--green: #3D8C4A;
--green-dark: #2E6E39;
--yellow-tint: #FDF6D8;
--yellow-light: #F9E08A;
--yellow-text: #8A6800;
--color-error: #DC4C3E;
--error-tint: #FDECEA;
--blue-tint: #E6F1FB;
--blue: #185FA5;
--blue-dark: #0C447C;
--font-display: 'Fraunces', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', monospace;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
/* ── Doc layout ── */
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; display: flex; justify-content: space-between; align-items: flex-end; }
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
.intro { font-size: 14px; line-height: 1.75; max-width: 640px; margin-bottom: 40px; }
/* ── State sections ── */
.state { margin-bottom: 64px; }
.state-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.state-id { font-family: var(--font-mono); font-size: 10px; font-weight: 500; background: var(--color-subtle); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-sm); white-space: nowrap; margin-top: 3px; }
.state-title { font-size: 16px; font-weight: 500; letter-spacing: -0.01em; }
.state-desc { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; max-width: 540px; }
/* ── Preview ── */
.preview-wrap { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
/* ── Notes ── */
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
.notes li.warn::before { content: '⚠'; color: var(--yellow-text); }
.notes li.gap::before { content: '✗'; color: var(--color-error); }
/* ── Warning banner ── */
.backend-warning { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 18px; margin-bottom: 40px; }
.backend-warning h3 { font-size: 12px; font-weight: 600; color: var(--yellow-text); margin-bottom: 6px; }
.backend-warning ul { list-style: none; display: flex; flex-direction: column; gap: 3px; }
.backend-warning li { font-family: var(--font-mono); font-size: 11px; color: var(--yellow-text); display: flex; gap: 8px; }
.backend-warning li::before { content: '○'; }
/* ── AppShell chrome ── */
.shell { display: flex; min-height: 100vh; background: var(--color-page); font-family: var(--font-sans); }
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
.sidebar-nav { flex: 1; padding: 4px 8px; }
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
.sidebar-item:not(.active):hover { background: var(--color-subtle); }
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
/* ── Page content ── */
.page-content { flex: 1; padding: 32px 40px; }
.page-title { font-family: var(--font-display); font-size: 24px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
.page-subtitle { font-size: 13px; color: var(--color-text-muted); margin-bottom: 28px; }
/* ── Member card grid ── */
.member-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.member-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px 20px 20px; box-shadow: var(--shadow-card); position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; }
.member-card.hovered { box-shadow: var(--shadow-raised); border-color: #C0BFB8; }
.member-card.own { border-color: var(--green-light); }
.avatar { width: 56px; height: 56px; border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-size: 20px; font-weight: 500; color: white; margin-bottom: 12px; flex-shrink: 0; }
.avatar-planer { background: var(--green-dark); }
.avatar-mitglied { background: var(--blue); }
.member-name { font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.role-badge { font-size: 10px; font-weight: 500; letter-spacing: 0.04em; padding: 2px 8px; border-radius: var(--radius-full); white-space: nowrap; }
.role-badge.planer { background: var(--green-tint); color: var(--green-dark); }
.role-badge.mitglied { background: var(--blue-tint); color: var(--blue-dark); }
.join-date { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
.self-badge { font-size: 10px; font-weight: 500; letter-spacing: 0.04em; padding: 2px 8px; border-radius: var(--radius-full); background: var(--green-tint); color: var(--green-dark); }
/* ── Kebab button ── */
.kebab-btn { position: absolute; top: 12px; right: 12px; width: 28px; height: 28px; border-radius: var(--radius-md); background: transparent; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; color: var(--color-text-muted); }
.kebab-btn:hover, .kebab-btn.open { background: var(--color-subtle); color: var(--color-text); }
/* ── Dropdown menu ── */
.dropdown { position: absolute; top: 44px; right: 12px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); min-width: 160px; z-index: 10; overflow: hidden; }
.dropdown-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; font-size: 13px; color: var(--color-text); cursor: pointer; white-space: nowrap; }
.dropdown-item:hover { background: var(--color-subtle); }
.dropdown-item.danger { color: var(--color-error); }
.dropdown-item.danger:hover { background: var(--error-tint); }
.dropdown-icon { font-size: 14px; width: 16px; text-align: center; }
.dropdown-divider { height: 1px; background: var(--color-border); margin: 2px 0; }
/* ── Role segmented control (inline on card) ── */
.role-control { display: flex; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; margin-top: 8px; width: 100%; }
.role-control-btn { flex: 1; padding: 6px 8px; font-size: 11px; font-weight: 500; background: white; border: none; cursor: pointer; color: var(--color-text-muted); }
.role-control-btn.active { background: var(--green-dark); color: white; }
.role-control-btn:first-child { border-right: 1px solid var(--color-border); }
/* ── Invite card ── */
.invite-card { background: white; border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 24px 20px 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; cursor: pointer; min-height: 180px; gap: 10px; }
.invite-card:hover { border-color: var(--green-light); background: var(--green-tint); }
.invite-plus { width: 44px; height: 44px; border-radius: var(--radius-full); background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 22px; color: var(--color-text-muted); }
.invite-card:hover .invite-plus { background: var(--green-light); color: var(--green-dark); }
.invite-label { font-size: 13px; font-weight: 500; color: var(--color-text-muted); }
.invite-card:hover .invite-label { color: var(--green-dark); }
/* ── Invite panel (expanded inline) ── */
.invite-panel { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px; margin-top: 8px; }
.invite-panel-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.invite-panel-desc { font-size: 12px; color: var(--color-text-muted); margin-bottom: 16px; }
.invite-link-row { display: flex; gap: 8px; align-items: center; }
.invite-link-box { flex: 1; background: var(--color-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 8px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-copy { padding: 8px 14px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; }
.btn-copy:hover { background: var(--color-subtle); }
.invite-expiry { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
.invite-expiry span { background: var(--yellow-tint); color: var(--yellow-text); padding: 1px 6px; border-radius: var(--radius-sm); font-weight: 500; }
.btn-regen { margin-top: 12px; font-size: 12px; color: var(--color-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; }
.btn-regen:hover { color: var(--color-text); }
/* ── Dialog overlay ── */
.overlay { position: absolute; inset: 0; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center; z-index: 50; }
.dialog { background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised); }
.dialog-title { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
.dialog-body { font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px; }
.dialog-body strong { color: var(--color-text); font-weight: 500; }
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }
.btn-cancel { padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer; }
.btn-cancel:hover { background: var(--color-subtle); }
.btn-remove { padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer; }
.btn-remove:hover { background: #C43A2E; }
/* ── Mobile shell ── */
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
.m-header { padding: 16px; background: white; border-bottom: 1px solid var(--color-border); display: flex; align-items: center; justify-content: space-between; }
.m-header-title { font-size: 16px; font-weight: 500; }
.m-header-btn { width: 36px; height: 36px; border-radius: var(--radius-full); background: var(--green-dark); display: flex; align-items: center; justify-content: center; font-size: 18px; color: white; border: none; cursor: pointer; }
.m-content { flex: 1; padding: 16px; }
.m-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.m-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 16px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; box-shadow: var(--shadow-card); }
.m-avatar { width: 44px; height: 44px; border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-size: 16px; font-weight: 500; color: white; margin-bottom: 8px; }
.m-avatar.planer { background: var(--green-dark); }
.m-avatar.mitglied { background: var(--blue); }
.m-name { font-size: 12px; font-weight: 500; margin-bottom: 4px; }
.m-role { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: var(--radius-full); }
.m-role.planer { background: var(--green-tint); color: var(--green-dark); }
.m-role.mitglied { background: var(--blue-tint); color: var(--blue-dark); }
.m-kebab { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--color-text-muted); background: none; border: none; }
.m-invite-card { background: white; border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 120px; gap: 6px; }
.m-invite-plus { width: 36px; height: 36px; border-radius: var(--radius-full); background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 18px; color: var(--color-text-muted); }
.m-invite-label { font-size: 11px; color: var(--color-text-muted); font-weight: 500; }
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
.m-tab.active { color: var(--green-dark); }
.m-tab-icon { font-size: 20px; }
/* ── Agent section ── */
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
.agent-table tr:last-child td { border-bottom: none; }
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
.agent-table td:nth-child(3) { color: #5A5A55; }
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
</style>
</head>
<body>
<div class="doc">
<!-- Header -->
<div class="doc-header">
<div>
<h1>E2 — Mitglieder</h1>
<p>Kachel-Ansicht · Finale Spezifikation · Route: <code>/members</code></p>
</div>
<div class="doc-meta">
screen: E2<br/>
journey: J7<br/>
variation: Kachel (V2)<br/>
version: 1.0<br/>
date: 2026-04-09
</div>
</div>
<p class="intro">
Die Mitgliederseite zeigt alle Haushaltsmitglieder als Kacheln. Der Planer kann Rollen ändern und Mitglieder
entfernen über ein Kebab-Menü auf jeder Kachel. Eine Einladekachel ermöglicht das Generieren und Kopieren des
Einlade-Links. Mitglieder sehen alle Kacheln nur lesend.
</p>
<div class="backend-warning">
<h3>Backend-Lücken — vor Implementierung schließen</h3>
<ul>
<li>DELETE /v1/households/mine/members/{userId} — Mitglied entfernen</li>
<li>PATCH /v1/households/mine/members/{userId} — Rolle ändern (body: { role })</li>
<li>GET /v1/households/mine/invites — aktive Einladungen auflisten (inkl. expiresAt)</li>
</ul>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S1 — Standardansicht (Planer)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S1</div>
<div>
<div class="state-title">Standardansicht — Planer sieht vollständige Kacheln</div>
<div class="state-desc">Alle Mitglieder als Kacheln, dahinter die Einladekachel. Kebab-Button erscheint on hover.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar">
<div class="sidebar-brand">
<div class="sidebar-brand-row">
<div class="sidebar-logo"></div>
<span class="sidebar-app">Mealplan</span>
</div>
<div class="sidebar-household">Familie Raddatz</div>
</div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Mitglieder</div>
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
<div class="member-grid">
<!-- Own card -->
<div class="member-card own">
<div class="avatar avatar-planer">MR</div>
<div class="member-name">Marcel R.</div>
<span class="role-badge planer">Planer</span>
<div class="join-date">seit 02.04.2026</div>
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
</div>
<!-- Member 2 -->
<div class="member-card">
<button class="kebab-btn"></button>
<div class="avatar avatar-mitglied">SR</div>
<div class="member-name">Sandra R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 03.04.2026</div>
</div>
<!-- Member 3 -->
<div class="member-card">
<button class="kebab-btn"></button>
<div class="avatar avatar-mitglied">LR</div>
<div class="member-name">Lena R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 05.04.2026</div>
</div>
<!-- Invite card -->
<div class="invite-card">
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-header">
<span class="m-header-title">Mitglieder</span>
<button class="m-header-btn">+</button>
</div>
<div class="m-content">
<div class="m-grid">
<div class="m-card" style="border-color:var(--green-light);">
<div class="m-avatar planer">MR</div>
<div class="m-name">Marcel R.</div>
<span class="m-role planer">Planer</span>
<div style="margin-top:6px;font-size:10px;color:var(--color-text-muted);">Du</div>
</div>
<div class="m-card">
<button class="m-kebab"></button>
<div class="m-avatar mitglied">SR</div>
<div class="m-name">Sandra R.</div>
<span class="m-role mitglied">Mitglied</span>
</div>
<div class="m-card">
<button class="m-kebab"></button>
<div class="m-avatar mitglied">LR</div>
<div class="m-name">Lena R.</div>
<span class="m-role mitglied">Mitglied</span>
</div>
<div class="m-invite-card">
<div class="m-invite-plus">+</div>
<div class="m-invite-label">Einladen</div>
</div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Eigene Kachel (Du): grüner Kartenrahmen (<code>border: var(--green-light)</code>), "Du"-Badge statt Kebab</li>
<li>Kebab-Button (<code></code>): immer im DOM, <code>opacity:0</code> bis hover/focus, dann <code>opacity:1</code>. Auf Touch-Geräten immer sichtbar.</li>
<li>Avatar-Initialen: erste zwei Buchstaben des displayName. Planer = green-dark, Mitglied = blue</li>
<li>Kachel-Reihenfolge: eigene Kachel immer zuerst, dann joinedAt aufsteigend, Einladekachel immer zuletzt</li>
<li>Mobile: "+" Button in der Header-Zeile öffnet Einlade-Panel. Einladekachel bleibt zusätzlich im Grid.</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S2 — Kebab-Menü offen</div>
<div class="state">
<div class="state-header">
<div class="state-id">S2</div>
<div>
<div class="state-title">Kebab-Menü geöffnet</div>
<div class="state-desc">Klick auf ⋯ öffnet Dropdown mit zwei Aktionen. Klick außerhalb schließt das Menü.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — Menü offen auf "Sandra R."</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar" style="width:224px;min-width:224px;">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Mitglieder</div>
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
<div class="member-grid">
<div class="member-card own">
<div class="avatar avatar-planer">MR</div>
<div class="member-name">Marcel R.</div>
<span class="role-badge planer">Planer</span>
<div class="join-date">seit 02.04.2026</div>
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
</div>
<!-- Card with open menu -->
<div class="member-card hovered" style="z-index:20;">
<button class="kebab-btn open"></button>
<div class="dropdown">
<div class="dropdown-item"><span class="dropdown-icon">🔄</span>Rolle ändern</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item danger"><span class="dropdown-icon"></span>Entfernen</div>
</div>
<div class="avatar avatar-mitglied">SR</div>
<div class="member-name">Sandra R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 03.04.2026</div>
</div>
<div class="member-card">
<button class="kebab-btn"></button>
<div class="avatar avatar-mitglied">LR</div>
<div class="member-name">Lena R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 05.04.2026</div>
</div>
<div class="invite-card">
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- No mobile preview needed for this state; same as desktop but full-screen -->
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Dropdown: <code>position: absolute; top: 44px; right: 12px</code> relativ zur Kachel</li>
<li>Zwei Einträge: "Rolle ändern" (neutrales Icon 🔄) und "Entfernen" (rot, Icon ✕)</li>
<li>Klick außerhalb des Dropdowns schließt diesen (click-away listener)</li>
<li>Nur ein Menü gleichzeitig offen. ESC schließt ebenfalls.</li>
<li>Mobile: Tap auf ⋯ öffnet Bottom Sheet mit denselben zwei Einträgen (44px min-height pro Eintrag)</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S3 — Rolle ändern (inline auf der Kachel)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S3</div>
<div>
<div class="state-title">Rolle ändern — Segmented Control erscheint</div>
<div class="state-desc">Wahl von "Rolle ändern" ersetzt das Rolle-Badge durch einen 2-Button-Schalter [Planer | Mitglied]. Aktive Rolle vorausgewählt. Bestätigung sofort mit PATCH-Request.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — Rolle-Control auf "Sandra R." aktiv</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar" style="width:224px;min-width:224px;">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Mitglieder</div>
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
<div class="member-grid">
<div class="member-card own">
<div class="avatar avatar-planer">MR</div>
<div class="member-name">Marcel R.</div>
<span class="role-badge planer">Planer</span>
<div class="join-date">seit 02.04.2026</div>
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
</div>
<!-- Card in role-edit mode -->
<div class="member-card" style="border-color:#B5D4F4;">
<div class="avatar avatar-mitglied">SR</div>
<div class="member-name">Sandra R.</div>
<!-- Role control replaces badge -->
<div class="role-control" style="width:100%;">
<button class="role-control-btn">Planer</button>
<button class="role-control-btn active">Mitglied</button>
</div>
<div class="join-date">seit 03.04.2026</div>
<button style="margin-top:8px;font-size:11px;color:var(--color-text-muted);background:none;border:none;cursor:pointer;text-decoration:underline;">Abbrechen</button>
</div>
<div class="member-card">
<button class="kebab-btn"></button>
<div class="avatar avatar-mitglied">LR</div>
<div class="member-name">Lena R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 05.04.2026</div>
</div>
<div class="invite-card">
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Role-Control ersetzt das Badge in-place auf der Kachel. Kein Dialog, kein Page-Change.</li>
<li>Klick auf die inaktive Rolle → optimistisches Update → PATCH /v1/households/mine/members/{userId} { role }</li>
<li>Bei Erfolg: Role-Control durch neues Badge ersetzen</li>
<li>Bei Fehler: Rollback + Toast "Rolle konnte nicht geändert werden."</li>
<li>"Abbrechen" bringt ohne PATCH-Call das Badge zurück</li>
<li>Der Planer kann seinen eigenen Planer-Status nicht abgeben, solange er der einzige Planer ist</li>
<li>Kachel bekommt blauen Rahmen (<code>border-color: #B5D4F4</code>) als Editier-Indikator</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S4 — Entfernen-Bestätigung (Dialog)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S4</div>
<div>
<div class="state-title">Bestätigungsdialog "Mitglied entfernen"</div>
<div class="state-desc">Klick auf "Entfernen" im Dropdown öffnet einen modalen Dialog. Kein direktes Löschen ohne Bestätigung.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — Dialog über der Seite</div>
<div class="preview-d-clip">
<div class="preview-d-scale" style="position:relative;">
<div class="shell" style="position:relative;">
<div class="sidebar" style="width:224px;min-width:224px;">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
</div>
</div>
<div class="page-content" style="opacity:0.4;pointer-events:none;">
<div class="page-title">Mitglieder</div>
<div class="member-grid">
<div class="member-card own"><div class="avatar avatar-planer">MR</div><div class="member-name">Marcel R.</div><span class="role-badge planer">Planer</span></div>
<div class="member-card"><div class="avatar avatar-mitglied">SR</div><div class="member-name">Sandra R.</div><span class="role-badge mitglied">Mitglied</span></div>
<div class="member-card"><div class="avatar avatar-mitglied">LR</div><div class="member-name">Lena R.</div><span class="role-badge mitglied">Mitglied</span></div>
<div class="invite-card"><div class="invite-plus">+</div><div class="invite-label">Mitglied einladen</div></div>
</div>
</div>
<!-- Dialog -->
<div class="overlay" style="position:absolute;">
<div class="dialog">
<div class="dialog-title">Mitglied entfernen?</div>
<div class="dialog-body"><strong>Sandra R.</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.</div>
<div class="dialog-actions">
<button class="btn-cancel">Abbrechen</button>
<button class="btn-remove">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;position:relative;">
<div class="m-header"><span class="m-header-title">Mitglieder</span><button class="m-header-btn">+</button></div>
<div class="m-content" style="opacity:0.35;pointer-events:none;">
<div class="m-grid">
<div class="m-card"><div class="m-avatar planer">MR</div><div class="m-name">Marcel R.</div><span class="m-role planer">Planer</span></div>
<div class="m-card"><div class="m-avatar mitglied">SR</div><div class="m-name">Sandra R.</div><span class="m-role mitglied">Mitglied</span></div>
</div>
</div>
<!-- Mobile dialog -->
<div class="overlay" style="position:absolute;align-items:flex-end;padding-bottom:0;">
<div class="dialog" style="border-radius:var(--radius-xl) var(--radius-xl) 0 0;max-width:100%;padding:24px 24px 32px;">
<div class="dialog-title" style="font-size:15px;">Mitglied entfernen?</div>
<div class="dialog-body" style="font-size:12px;"><strong>Sandra R.</strong> wird aus dem Haushalt entfernt.</div>
<div class="dialog-actions">
<button class="btn-cancel" style="font-size:12px;">Abbrechen</button>
<button class="btn-remove" style="font-size:12px;">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Dialog zeigt den <strong>displayName</strong> des Mitglieds explizit</li>
<li>Bestätigung → DELETE /v1/households/mine/members/{userId} → Kachel aus Grid entfernen</li>
<li>Planer kann sich nicht selbst entfernen (eigene Kachel hat kein Kebab-Menü)</li>
<li>Letzter verbleibender Planer kann nicht entfernt werden → Fehlermeldung im Dialog</li>
<li>Mobile: Dialog als Bottom Sheet (<code>border-radius</code> nur oben, kein max-width)</li>
<li>Hintergrund leicht gedimmt: <code>rgba(28,28,24,.45)</code>, Klick außerhalb schließt nicht (explizite Bestätigung erforderlich)</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S5 — Einladekachel: Einlade-Panel</div>
<div class="state">
<div class="state-header">
<div class="state-id">S5</div>
<div>
<div class="state-title">Einlade-Panel — nach Klick auf die Einladekachel</div>
<div class="state-desc">Kachel expandiert zum Panel unterhalb der Grid-Reihe. Zeigt generierten Link + Ablaufdatum + Regenerieren-Option.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar" style="width:224px;min-width:224px;">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Mitglieder</div>
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
<div class="member-grid">
<div class="member-card own"><div class="avatar avatar-planer">MR</div><div class="member-name">Marcel R.</div><span class="role-badge planer">Planer</span><div class="join-date">seit 02.04.2026</div><div style="margin-top:8px;"><span class="self-badge">Du</span></div></div>
<div class="member-card"><button class="kebab-btn"></button><div class="avatar avatar-mitglied">SR</div><div class="member-name">Sandra R.</div><span class="role-badge mitglied">Mitglied</span><div class="join-date">seit 03.04.2026</div></div>
<div class="member-card"><button class="kebab-btn"></button><div class="avatar avatar-mitglied">LR</div><div class="member-name">Lena R.</div><span class="role-badge mitglied">Mitglied</span><div class="join-date">seit 05.04.2026</div></div>
<div class="invite-card" style="border-color:var(--green-light);background:var(--green-tint);">
<div class="invite-plus" style="background:var(--green-light);color:var(--green-dark);">+</div>
<div class="invite-label" style="color:var(--green-dark);">Mitglied einladen</div>
</div>
</div>
<!-- Invite panel below grid -->
<div class="invite-panel" style="margin-top:16px;">
<div class="invite-panel-title">Einladelink teilen</div>
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
<div class="invite-link-row">
<div class="invite-link-box">https://mealplan.app/join/X4K9-RZMQ</div>
<button class="btn-copy">Kopieren</button>
</div>
<div class="invite-expiry">Läuft ab: <span>12.04.2026</span></div>
<button class="btn-regen">Neuen Link generieren</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Klick auf Einladekachel → POST /v1/households/mine/invites (falls kein aktiver Code vorhanden) oder GET /v1/households/mine/invites</li>
<li>Invite-Panel erscheint unterhalb der Grid-Reihe (kein Modal, kein Page-Change)</li>
<li>"Kopieren" → navigator.clipboard.writeText(shareUrl) → Button zeigt kurz "Kopiert ✓"</li>
<li>"Neuen Link generieren" → POST /v1/households/mine/invites → alten Code invalidieren → neuen Code anzeigen</li>
<li>Ablaufdatum <code>expiresAt</code> in gelbem Badge wenn ≤ 24h verbleibend</li>
<li>Nur Planer sehen den Einlade-CTA. Mitglied sieht keine Einladekachel.</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">S6 — Mitglied-Perspektive (read-only)</div>
<div class="state">
<div class="state-header">
<div class="state-id">S6</div>
<div>
<div class="state-title">Ansicht als Haushaltsmitglied (rolle = mitglied)</div>
<div class="state-desc">Mitglieder sehen die Kacheln ohne Kebab-Menü und ohne Einladekachel.</div>
</div>
</div>
<div class="preview-wrap">
<div class="preview-d-wrap">
<div class="preview-label">Desktop — Mitglied-Perspektive</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell">
<div class="sidebar" style="width:224px;min-width:224px;">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
<div class="sidebar-group-label">Haushalt</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
</div>
</div>
<div class="page-content">
<div class="page-title">Mitglieder</div>
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
<div class="member-grid" style="grid-template-columns:repeat(3,1fr);">
<div class="member-card own">
<div class="avatar avatar-mitglied">SR</div>
<div class="member-name">Sandra R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 03.04.2026</div>
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
</div>
<div class="member-card">
<div class="avatar avatar-planer">MR</div>
<div class="member-name">Marcel R.</div>
<span class="role-badge planer">Planer</span>
<div class="join-date">seit 02.04.2026</div>
</div>
<div class="member-card">
<div class="avatar avatar-mitglied">LR</div>
<div class="member-name">Lena R.</div>
<span class="role-badge mitglied">Mitglied</span>
<div class="join-date">seit 05.04.2026</div>
</div>
<!-- No invite card for members -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Mitglied sieht keine Einladekachel und keine Kebab-Buttons auf anderen Kacheln</li>
<li>Eigene Kachel zeigt "Du"-Badge (grüner Rahmen), aber kein Kebab</li>
<li>Grid passt sich an: bei 3 Kacheln → <code>grid-template-columns: repeat(3, 1fr)</code> (kein leerer Slot für Einladen)</li>
<li>Server-seitige Prüfung: Aktionen (DELETE, PATCH) geben 403 für nicht-Planer zurück</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- ─── Machine-readable agent section ─── -->
<div class="agent-section">
<h2>Maschinen-lesbare Spezifikation</h2>
<p>Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.</p>
<pre class="spec-comment">
/* spec:rules — E2 Mitglieder Kachel
*
* LAYOUT
* grid: repeat(4, 1fr) gap 16px desktop; repeat(2, 1fr) gap 12px mobile
* card: bg white, border 1px solid --color-border, border-radius --radius-xl
* card padding: 24px 20px 20px desktop; 16px mobile
*
* AVATAR
* size: 56px desktop / 44px mobile; border-radius 50%
* initials: first two chars of displayName, uppercase
* planer color: --green-dark (#2E6E39)
* mitglied color: --blue (#185FA5)
*
* ROLE BADGE
* planer: bg --green-tint, color --green-dark
* mitglied: bg --blue-tint, color --blue-dark
* font-size 10px, font-weight 500, padding 2px 8px, border-radius --radius-full
*
* OWN CARD (benutzer.id === member.userId)
* border-color: --green-light
* show "Du" badge below join-date
* hide kebab button entirely
*
* KEBAB BUTTON
* position absolute, top 12px, right 12px
* opacity 0 by default; 1 on card:hover, card:focus-within, touch devices always 1
* opens dropdown: [Rolle ändern, divider, Entfernen(danger)]
* click-away closes; ESC closes
*
* ROLE CHANGE (S3)
* replaces badge in-place with segmented control [Planer | Mitglied]
* active button: bg --green-dark, color white
* inactive button: bg white, color --color-text-muted
* on select: PATCH /v1/households/mine/members/{userId} body { role }
* optimistic update; on error: rollback + toast
* Abbrechen link below control: reverts to badge without API call
* guard: planer cannot demote self if sole planer
*
* REMOVE CONFIRM (S4)
* modal dialog, backdrop rgba(28,28,24,.45), backdrop does NOT close on click
* shows member displayName in body text
* confirm → DELETE /v1/households/mine/members/{userId}
* on success: remove card from grid with fade-out
* mobile: bottom-sheet (border-radius top only)
*
* INVITE (S5)
* invite card always last in grid, only visible to planer
* click → POST /v1/households/mine/invites OR GET /v1/households/mine/invites
* panel below grid (not modal)
* copy: navigator.clipboard.writeText(shareUrl) → button text "Kopiert ✓" for 2s
* regenerate: POST new invite → invalidate old
* expiresAt badge yellow if ≤ 24h remaining
*
* MEMBER VIEW (S6)
* rolle === 'mitglied': hide all kebab buttons, hide invite card
* grid auto-adjusts columns (no empty slot)
*
* CARD ORDER
* 1. own card (benutzer.id === userId)
* 2. other members sorted by joinedAt ASC
* 3. invite card (planer only)
*
* BACKEND GAPS (must exist before page ships)
* DELETE /v1/households/mine/members/{userId}
* PATCH /v1/households/mine/members/{userId} body: { role: "planer"|"mitglied" }
* GET /v1/households/mine/invites
*/
</pre>
<table class="agent-table">
<thead>
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
</thead>
<tbody>
<tr class="group-row"><td colspan="3">Component: MemberCard</td></tr>
<tr><td>card-width</td><td>1fr (grid)</td><td>4-col desktop, 2-col mobile</td></tr>
<tr><td>card-min-height</td><td>180px</td><td>desktop; auto mobile</td></tr>
<tr><td>avatar-size</td><td>56px / 44px</td><td>desktop / mobile</td></tr>
<tr><td>avatar-radius</td><td>50%</td><td>full circle</td></tr>
<tr><td>kebab-target</td><td>44×44px</td><td>WCAG 2.2 minimum touch target</td></tr>
<tr><td>dropdown-min-width</td><td>160px</td><td>right-aligned to kebab</td></tr>
<tr class="group-row"><td colspan="3">Role Control</td></tr>
<tr><td>control-height</td><td>32px</td><td>segmented, full card width</td></tr>
<tr><td>active-bg</td><td>--green-dark</td><td>selected role button</td></tr>
<tr><td>api-endpoint</td><td>PATCH /v1/households/mine/members/{userId}</td><td>body: { role }</td></tr>
<tr class="group-row"><td colspan="3">Remove Dialog</td></tr>
<tr><td>confirm-btn-bg</td><td>--color-error (#DC4C3E)</td><td>danger action</td></tr>
<tr><td>api-endpoint</td><td>DELETE /v1/households/mine/members/{userId}</td><td></td></tr>
<tr><td>backdrop</td><td>rgba(28,28,24,.45)</td><td>click-outside does NOT close</td></tr>
<tr class="group-row"><td colspan="3">Invite</td></tr>
<tr><td>api-create</td><td>POST /v1/households/mine/invites</td><td>returns InviteResponse</td></tr>
<tr><td>api-list</td><td>GET /v1/households/mine/invites</td><td>backend gap</td></tr>
<tr><td>copy-feedback</td><td>"Kopiert ✓" for 2000ms</td><td>then revert to "Kopieren"</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,981 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — E4 Vielfalt-Einstellungen · Implementierungsspezifikation</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Journey header */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid var(--purple-light);}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Device frames */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Agent spec block */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM section */
.llm{background:var(--color-page);border:2px solid var(--green);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--green-dark);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* Shared nav chrome */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
.mtb-back{font-size:12px;color:var(--color-text-muted);display:flex;align-items:center;gap:4px;flex-shrink:0;}
.mtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;flex:1;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;flex-shrink:0;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}
.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}
.mt-i.a .mt-ic{background:var(--green-tint);}
.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}
.mt-i.a .mt-l{color:var(--green-dark);}
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}
.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}
.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}
.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}
.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}
.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;}
.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}
.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:14px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
.dtb-bc{font-size:12px;color:var(--color-text-muted);display:flex;align-items:center;gap:6px;margin-bottom:2px;}
.dtb-bc span{color:var(--color-border);}
.dmc{padding:24px;flex:1;overflow-y:auto;}
/* UI components */
.tog{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--color-subtle);}
.tog:last-child{border-bottom:none;}
.tog-l{display:flex;flex-direction:column;gap:2px;}
.tog-name{font-size:13px;font-weight:500;}
.tog-hint{font-size:11px;color:var(--color-text-muted);}
.tog-sw{width:36px;height:20px;border-radius:10px;background:var(--green);flex-shrink:0;position:relative;}
.tog-sw::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;right:2px;box-shadow:0 1px 3px rgba(0,0,0,.2);}
.tog-sw.off{background:var(--color-border);}
.tog-sw.off::after{right:auto;left:2px;}
.seg{display:flex;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;background:var(--color-surface);}
.seg-o{flex:1;text-align:center;font-size:11px;font-weight:500;padding:6px 0;color:var(--color-text-muted);}
.seg-o.a{background:var(--color-page);color:var(--color-text);box-shadow:var(--shadow-card);}
.seg-o.a-r{background:var(--red-tint);color:var(--red-dark);}
.seg-o.a-g{background:var(--green-tint);color:var(--green-dark);}
.grp{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
.grp-hd{padding:10px 14px;border-bottom:1px solid var(--color-border);background:var(--color-subtle);}
.grp-hd-t{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
.grp-b{padding:0 14px;}
.wr{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--color-subtle);}
.wr:last-child{border-bottom:none;}
.wr-l{display:flex;flex-direction:column;gap:1px;min-width:0;}
.wr-name{font-size:12px;font-weight:500;}
.wr-sub{font-size:10px;color:var(--color-text-muted);}
/* Context chips */
.ctx-chips{display:flex;gap:8px;margin-bottom:16px;}
.ctx-chip{flex:1;padding:14px 12px;border-radius:var(--radius-xl);border:1.5px solid var(--color-border);background:var(--color-surface);display:flex;flex-direction:column;gap:3px;cursor:default;}
.ctx-chip.sel{border-color:var(--green-light);background:var(--green-tint);}
.ctx-chip.ind{border-color:var(--purple-light);background:var(--purple-tint);}
.ctx-em{font-size:18px;}
.ctx-name{font-size:12px;font-weight:600;color:var(--color-text);}
.ctx-chip.sel .ctx-name{color:var(--green-dark);}
.ctx-chip.ind .ctx-name{color:var(--purple-dark);}
.ctx-sub{font-size:10px;color:var(--color-text-muted);line-height:1.3;}
.ctx-chip.sel .ctx-sub{color:var(--green-dark);opacity:.7;}
.ctx-chip.ind .ctx-sub{color:var(--purple-dark);opacity:.7;}
/* Summary pills */
.s-pills{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px;}
.s-pill{font-size:10px;font-weight:500;padding:3px 8px;border-radius:20px;display:flex;align-items:center;gap:3px;}
.s-pill.on{background:var(--green-tint);color:var(--green-dark);}
.s-pill.off{background:var(--color-subtle);color:var(--color-text-muted);}
.s-pill.warn{background:var(--yellow-tint);color:var(--yellow-text);}
/* Accordion */
.acc{border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
.acc-hd{padding:12px 14px;display:flex;justify-content:space-between;align-items:center;background:var(--color-surface);}
.acc-hd-t{font-size:13px;font-weight:500;}
.acc-hd-r{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--color-text-muted);}
.acc-b{padding:14px;border-top:1px solid var(--color-border);background:var(--color-page);}
/* Score preview */
.score-banner{background:var(--color-text);border-radius:var(--radius-lg);padding:14px 16px;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between;gap:12px;}
.score-banner-l{}
.score-banner-label{font-size:10px;color:#6B6A63;margin-bottom:2px;}
.score-banner-val{font-family:var(--font-display);font-size:30px;font-weight:300;letter-spacing:-.02em;color:#E8E8E2;line-height:1;}
.score-banner-sub{font-size:10px;margin-top:3px;}
.score-banner-up{color:#6FCF97;}
.score-banner-neutral{color:#6B6A63;}
.score-banner-r{font-size:28px;opacity:.7;}
/* Summary detail rows (desktop right column) */
.sum-rows{display:flex;flex-direction:column;gap:5px;}
.sum-row{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-radius:var(--radius-md);font-size:12px;}
.sum-row.on{background:var(--green-tint);}
.sum-row.off{background:var(--color-subtle);}
.sum-row.warn{background:var(--yellow-tint);}
.sum-row-name{font-weight:500;}
.sum-row.on .sum-row-name{color:var(--green-dark);}
.sum-row.off .sum-row-name{color:var(--color-text-muted);}
.sum-row.warn .sum-row-name{color:var(--yellow-text);}
.sum-row-val{font-size:10px;font-weight:500;}
.sum-row.on .sum-row-val{color:var(--green-dark);}
.sum-row.off .sum-row-val{color:var(--color-text-muted);}
.sum-row.warn .sum-row-val{color:var(--yellow-text);}
/* Reset link */
.reset-link{font-size:12px;color:var(--red-dark);padding:10px 0;display:block;text-align:center;}
/* Divider */
.sec-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
/* Modal overlay */
.overlay{position:relative;border-radius:var(--radius-xl);overflow:hidden;}
.modal-backdrop{position:absolute;inset:0;background:rgba(28,28,24,.4);display:flex;align-items:center;justify-content:center;padding:24px;}
.modal{background:var(--color-page);border-radius:var(--radius-xl);padding:24px;width:100%;max-width:280px;box-shadow:var(--shadow-overlay);}
.modal-title{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;}
.modal-body{font-size:13px;color:var(--color-text-muted);line-height:1.6;margin-bottom:20px;}
.modal-acts{display:flex;flex-direction:column;gap:8px;}
.btn-dest{padding:11px 16px;border-radius:var(--radius-md);background:var(--red);color:#fff;font-weight:500;font-size:13px;text-align:center;}
.btn-ghost{padding:11px 16px;border-radius:var(--radius-md);background:var(--color-surface);border:1px solid var(--color-border);color:var(--color-text-muted);font-weight:500;font-size:13px;text-align:center;}
/* E1 hub card */
.hub-grid{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:12px;}
.hub-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-xl);padding:16px;}
.hub-card.primary{border-left:3px solid var(--green-dark);}
.hub-card.variety{border-left:3px solid var(--purple);}
.hub-stat{font-family:var(--font-display);font-size:36px;font-weight:300;letter-spacing:-.02em;line-height:1;margin-bottom:4px;}
.hub-stat.green{color:var(--green-dark);}
.hub-stat.purple{color:var(--purple);}
.hub-name{font-size:12px;font-weight:500;margin-bottom:2px;}
.hub-sub{font-size:11px;color:var(--color-text-muted);}
.hub-arr{font-size:12px;color:var(--color-text-muted);margin-top:10px;}
/* Settings hub bottom row */
.hub-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
</style>
</head>
<body>
<div class="doc">
<!-- ── Header ── -->
<div class="doc-header">
<div>
<h1>E4 — Vielfalt-Einstellungen</h1>
<p>Implementierungsspezifikation · V2 Kontext-Preset · Journey J9</p>
</div>
<div class="doc-meta">
<span class="pill">v1.0</span><br>
Screens: E1 (Update) + E4<br>
States: 5<br>
Rolle: Planer only
</div>
</div>
<!-- ── Journey context ── -->
<div class="jh jh-p">
<div class="jn">J9</div>
<div>
<h2>Vielfalt-Algorithmus konfigurieren</h2>
<p>Planer passt Bewertungsregeln an den Haushaltskontext an — primär das Deaktivieren der Protein-Prüfung für vegetarische Haushalte.</p>
<div class="fl">E1 → E4 → C3 · Planer only · Auto-Save · Reset benötigt Bestätigung</div>
</div>
</div>
<!-- ════════════════════════════════════════
E1 — SETTINGS HUB UPDATE
═════════════════════════════════════════ -->
<div class="section">
<div class="section-title">E1 — Settings-Hub (Update)</div>
<p class="prose">Der bestehende Settings-Hub (E1) erhält eine dritte Kachel: "Vielfalt-Einstellungen". Die Kachel zeigt den aktuellen Vielfalt-Score als Kennzahl. Das Grid-Layout wird von 2-spaltig zu einem Mix aus Hauptkachel oben und zwei gleichbreiten Kacheln unten angepasst.</p>
<!-- S0: E1 Hub -->
<div class="scr">
<div class="scr-head"><h3>S0 · Settings-Hub mit Vielfalt-Kachel</h3><span class="scr-id">E1</span></div>
<div class="scr-desc">Die neue Vielfalt-Kachel erscheint in der unteren Reihe neben der Haushalt-Kachel. Zeigt den aktuellen Score als lila Kennzahl. Bei Score &lt; 6.0 färbt sich die Kennzahl orange als Aufmerksamkeitshinweis.</div>
<div class="scr-var"><strong>Änderung gegenüber E1 v1:</strong> dritte Kachel + Grid-Anpassung. Vorräte-Kachel bleibt primär (2fr oben).</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-t">Einstellungen</div></div>
<div style="padding:16px;flex:1;">
<!-- Vorräte (primary, full width) -->
<div class="hub-card primary" style="margin-bottom:12px;">
<div class="hub-stat green">12</div>
<div class="hub-name">Vorräte</div>
<div class="hub-sub">Zutaten immer vorrätig</div>
<div class="hub-arr">Bearbeiten →</div>
</div>
<!-- Bottom row: 2 cards -->
<div class="hub-row">
<div class="hub-card">
<div class="hub-stat" style="font-size:28px;color:var(--blue-dark);">3</div>
<div class="hub-name">Haushalt</div>
<div class="hub-sub">Mitglieder</div>
<div class="hub-arr">Verwalten →</div>
</div>
<div class="hub-card variety">
<div class="hub-stat purple" style="font-size:28px;">7.4</div>
<div class="hub-name">Vielfalt</div>
<div class="hub-sub">Diese Woche</div>
<div class="hub-arr">Einstellungen →</div>
</div>
</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col" style="flex:1;min-width:600px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb"><div class="dtb-t">Einstellungen</div></div>
<div class="dmc">
<div style="max-width:640px;">
<!-- Vorräte primary -->
<div class="hub-card primary" style="margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div class="hub-stat green">12</div>
<div class="hub-name">Vorräte</div>
<div class="hub-sub">Zutaten, die immer vorrätig sind und nicht auf die Einkaufsliste kommen</div>
</div>
<div style="font-size:13px;font-weight:500;color:var(--green-dark);">Bearbeiten →</div>
</div>
<!-- Bottom row: 2 cards -->
<div class="hub-row">
<div class="hub-card" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div class="hub-stat" style="font-size:28px;color:var(--blue-dark);">3</div>
<div class="hub-name">Haushalt</div>
<div class="hub-sub">Mitglieder &amp; Rollen</div>
</div>
<div style="font-size:13px;font-weight:500;color:var(--blue-dark);">Verwalten →</div>
</div>
<div class="hub-card variety" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div class="hub-stat purple" style="font-size:28px;">7.4</div>
<div class="hub-name">Vielfalt-Einstellungen</div>
<div class="hub-sub">Algorithmus anpassen</div>
</div>
<div style="font-size:13px;font-weight:500;color:var(--purple-dark);">Einstellungen →</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>E1 Hub Update · S0</h4>
<pre>/* E1 grid: Vorräte (full width, 2fr, border-left: 3px solid --green-dark) on top row.
* Bottom row: 2 equal columns — Haushalt + Vielfalt-Einstellungen.
* Vielfalt card: border-left: 3px solid --purple. Stat color: --purple (7.4).
* If score < 6.0: stat color switches to --orange (Aufmerksamkeit) with no other change.
* Score value: load from GET /v1/week-plans?weekStart=current GET /v1/week-plans/{id}/variety-score.
* If no current plan: show "" as stat value, sub: "Kein Plan".
* Tap/click Vielfalt card navigate to E4. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Vielfalt-Kachel</td></tr>
<tr><td>Kennzahl</td><td>varietyScore.score, 1 Dezimalstelle</td><td>Farbe: --purple normal, --orange wenn &lt; 6.0</td></tr>
<tr><td>Label</td><td>Vielfalt-Einstellungen</td><td>Desktop; Mobile: "Vielfalt"</td></tr>
<tr><td>Sub-Label</td><td>"Diese Woche" / "Kein Plan" / ""</td><td>Kein Plan = kein weekPlan für aktuelle Woche</td></tr>
<tr><td>Rand</td><td>border-left: 3px solid --purple</td><td>Analog zu Vorräte → --green-dark</td></tr>
<tr><td>Aktion</td><td>Tap → navigate /settings/variety</td><td>Route: +page.svelte unter (app)/settings/variety/</td></tr>
<tr class="grp"><td colspan="3">Grid-Layout</td></tr>
<tr><td>Mobile</td><td>Vorräte fullwidth + grid-template-columns: 1fr 1fr unten</td><td>Gap: 12px</td></tr>
<tr><td>Desktop</td><td>Vorräte fullwidth + grid-template-columns: 1fr 1fr unten</td><td>Max-width: 640px, gap: 16px</td></tr>
</tbody></table>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
S1 — DEFAULT (KEIN CUSTOM-CONFIG)
═════════════════════════════════════════ -->
<div class="section">
<div class="section-title">E4 — Vielfalt-Einstellungen · States</div>
<div class="scr">
<div class="scr-head"><h3>S1 · Standard (kein Custom-Config)</h3><span class="scr-id">E4</span></div>
<div class="scr-desc">Erster Aufruf, kein haushaltsindividueller Config-Eintrag. Omnivor-Chip ist ausgewählt (Default-Zustand). Score-Preview zeigt den aktuellen tatsächlichen Score — keine Simulation nötig, da noch nichts geändert wurde. Hinweis-Text erklärt kurz den Zweck der Seite.</div>
<div class="scr-var"><strong>S1</strong> · Omnivor selected · Score-Preview = aktueller Score · Erweiterte Einstellungen eingeklappt</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
<div style="padding:16px;flex:1;overflow-y:auto;">
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:14px;">Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.</div>
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips">
<div class="ctx-chip sel">
<div class="ctx-em">🥩</div>
<div class="ctx-name">Omnivor</div>
<div class="ctx-sub">Alle Regeln aktiv</div>
</div>
<div class="ctx-chip">
<div class="ctx-em">🥦</div>
<div class="ctx-name">Vegetarisch</div>
<div class="ctx-sub">Protein deaktiviert</div>
</div>
<div class="ctx-chip">
<div class="ctx-em">🌱</div>
<div class="ctx-name">Vegan</div>
<div class="ctx-sub">Protein deaktiviert</div>
</div>
</div>
<div class="sec-lbl">Aktive Regeln</div>
<div class="s-pills">
<div class="s-pill on">✓ Protein</div>
<div class="s-pill on">✓ Küche</div>
<div class="s-pill on">✓ Zutaten · Mittel</div>
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
</div>
<div class="acc">
<div class="acc-hd">
<div class="acc-hd-t">Erweiterte Einstellungen</div>
<div class="acc-hd-r"></div>
</div>
</div>
<div class="score-banner">
<div class="score-banner-l">
<div class="score-banner-label">Aktueller Score</div>
<div class="score-banner-val">7.4</div>
<div class="score-banner-sub score-banner-neutral">Keine Änderungen</div>
</div>
<div class="score-banner-r">📊</div>
</div>
<div class="reset-link" style="color:var(--color-text-muted);">Bereits Standard-Einstellungen</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col" style="flex:1;min-width:600px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span></span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
<div class="dmc">
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
<div>
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.</div>
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips" style="margin-bottom:20px;">
<div class="ctx-chip sel"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
<div class="ctx-chip"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
</div>
<div class="acc">
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r"></div></div>
</div>
<div class="reset-link" style="text-align:left;color:var(--color-text-muted);">Bereits Standard-Einstellungen</div>
</div>
<div>
<div class="score-banner">
<div>
<div class="score-banner-label">Aktueller Score</div>
<div class="score-banner-val">7.4 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
<div class="score-banner-sub score-banner-neutral">Keine Änderungen aktiv</div>
</div>
<div class="score-banner-r">📊</div>
</div>
<div class="sec-lbl">Aktive Regeln</div>
<div class="sum-rows">
<div class="sum-row on"><span class="sum-row-name">Protein</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row on"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Niedrig</span></div>
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>E4 · S1 Default</h4>
<pre>/* Load: GET /v1/households/mine/variety-config → 404 if no custom config.
* On 404: use defaults (Omnivor preset), show Omnivor chip as selected.
* Score banner: show actual GET /v1/week-plans/{id}/variety-score (no simulation).
* "Bereits Standard-Einstellungen" replaces reset link if no custom config exists.
* Accordion: closed. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Laden</td></tr>
<tr><td>Config-Load</td><td>GET /v1/households/mine/variety-config</td><td>404 → Defaults verwenden, Omnivor selected</td></tr>
<tr><td>Score-Load</td><td>GET /v1/week-plans/{id}/variety-score</td><td>Nur wenn weekPlan existiert; sonst Score-Banner ausblenden</td></tr>
<tr class="grp"><td colspan="3">Kontext-Chips</td></tr>
<tr><td>Omnivor</td><td>repeatTagTypes: ["protein","cuisine"], alle Gewichte Standard</td><td>Default-Preset = backend defaults</td></tr>
<tr><td>Vegetarisch</td><td>repeatTagTypes: ["cuisine"], wTagRepeat Standard</td><td>Protein deaktiviert</td></tr>
<tr><td>Vegan</td><td>repeatTagTypes: ["cuisine"], wTagRepeat Standard</td><td>Identisch zu Vegetarisch in v1</td></tr>
<tr><td>Individuell</td><td>Erscheint automatisch wenn Advanced abweicht vom Preset</td><td>Kein manuell wählbarer Chip — nur automatisch</td></tr>
<tr class="grp"><td colspan="3">Score-Banner (S1)</td></tr>
<tr><td>Wert</td><td>Aktueller Score (keine Simulation)</td><td>Label: "Aktueller Score"</td></tr>
<tr><td>Sub-Label</td><td>"Keine Änderungen"</td><td>Neutral-Farbe (#6B6A63)</td></tr>
</tbody></table>
</div>
</div>
<!-- S2: Vegetarisch selected -->
<div class="scr">
<div class="scr-head"><h3>S2 · Vegetarisch ausgewählt — Score-Simulation</h3><span class="scr-id">E4</span></div>
<div class="scr-desc">Planer tippt auf "Vegetarisch". Config wird sofort per PATCH gespeichert. Score-Banner lädt die simulierte Punktzahl: wie würde der aktuelle Plan mit der neuen Config abschneiden. Delta wird grün hervorgehoben. Protein-Pill wechselt zu "off". Erweiterte Einstellungen zeigt Protein-Toggle als deaktiviert.</div>
<div class="scr-var"><strong>S2</strong> · Vegetarisch selected · Score-Preview = simuliert · Protein-Pill = off</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
<div style="padding:16px;flex:1;overflow-y:auto;">
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:14px;">Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.</div>
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips">
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
</div>
<div class="sec-lbl">Aktive Regeln</div>
<div class="s-pills">
<div class="s-pill off"> Protein</div>
<div class="s-pill on">✓ Küche</div>
<div class="s-pill on">✓ Zutaten · Mittel</div>
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
</div>
<div class="acc">
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r"></div></div>
</div>
<div class="score-banner">
<div class="score-banner-l">
<div class="score-banner-label">Mit diesen Einstellungen</div>
<div class="score-banner-val">8.9</div>
<div class="score-banner-sub score-banner-up">↑ +1.5 gegenüber vorher</div>
</div>
<div class="score-banner-r">📈</div>
</div>
<div class="reset-link">Auf Standard zurücksetzen</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col" style="flex:1;min-width:600px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span></span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
<div class="dmc">
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
<div>
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.</div>
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips" style="margin-bottom:20px;">
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
</div>
<div class="acc">
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r"></div></div>
</div>
<div class="reset-link" style="text-align:left;">Auf Standard zurücksetzen</div>
</div>
<div>
<div class="score-banner">
<div>
<div class="score-banner-label">Mit diesen Einstellungen</div>
<div class="score-banner-val">8.9 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
<div class="score-banner-sub score-banner-up">↑ +1.5 gegenüber vorher</div>
</div>
<div class="score-banner-r">📈</div>
</div>
<div class="sec-lbl">Aktive Regeln</div>
<div class="sum-rows">
<div class="sum-row off"><span class="sum-row-name">Protein</span><span class="sum-row-val">Deaktiviert</span></div>
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row on"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Niedrig</span></div>
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>E4 · S2 Vegetarisch</h4>
<pre>/* On chip tap (Vegetarisch):
* 1. Optimistic UI: swap selected chip, update pills, update sum-rows immediately.
* 2. PATCH /v1/households/mine/variety-config { repeatTagTypes: ["cuisine"],
* wTagRepeat: 1.5, wIngredientOverlap: 0.3, wRecentRepeat: 1.0, wPlanDuplicate: 2.0 }
* 3. On PATCH success: fire GET /v1/week-plans/{id}/variety-score?simulate=true
* with same config body → update score-banner with simulated score + delta.
* 4. On PATCH error: rollback to previous chip selection + show toast "Fehler beim Speichern".
* Score-Banner during load: show spinner in place of val. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Score-Banner (S2)</td></tr>
<tr><td>Label</td><td>"Mit diesen Einstellungen"</td><td>Statt "Aktueller Score"</td></tr>
<tr><td>Delta</td><td>"↑ +X.X gegenüber vorher"</td><td>Grün (#6FCF97) wenn positiv; rot wenn negativ; neutral wenn = 0</td></tr>
<tr><td>Simulation-Endpoint</td><td>POST /v1/week-plans/{id}/variety-score/simulate</td><td>Body: VarietyScoreConfig-Felder. Neuer Endpoint nötig (Backend-Task).</td></tr>
<tr><td>Kein Plan</td><td>Score-Banner ausblenden</td><td>Kein simulierter Score ohne Plan möglich</td></tr>
<tr class="grp"><td colspan="3">Chip-Preset Vegetarisch</td></tr>
<tr><td>repeatTagTypes</td><td>["cuisine"]</td><td>Protein entfernt</td></tr>
<tr><td>wTagRepeat</td><td>1.5 (Standard)</td><td>Unverändert</td></tr>
<tr><td>wIngredientOverlap</td><td>0.3 (Standard)</td><td>Unverändert</td></tr>
<tr><td>wRecentRepeat</td><td>1.0 (Standard)</td><td>Unverändert</td></tr>
<tr><td>wPlanDuplicate</td><td>2.0 (Standard)</td><td>Unverändert</td></tr>
</tbody></table>
</div>
</div>
<!-- S3: Erweiterte Einstellungen -->
<div class="scr">
<div class="scr-head"><h3>S3 · Erweiterte Einstellungen geöffnet</h3><span class="scr-id">E4</span></div>
<div class="scr-desc">Planer öffnet das Accordion "Erweiterte Einstellungen". Er sieht Segmented Controls (Niedrig / Mittel / Hoch) für jeden Gewichts-Parameter. Ändert er einen Wert, der nicht mehr dem aktuellen Preset entspricht, erscheint automatisch ein vierter Chip "Individuell" (lila) und ersetzt den aktiven Preset-Chip. Score-Banner aktualisiert sich nach jeder Änderung.</div>
<div class="scr-var"><strong>S3</strong> · Erweiterte Einstellungen offen · "Individuell"-Chip erschienen (Planer hat Zutaten-Gewicht angepasst)</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
<div style="padding:16px;flex:1;overflow-y:auto;">
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips" style="flex-wrap:wrap;">
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div></div>
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div>
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div></div>
<div class="ctx-chip ind" style="flex:1;min-width:60px;"><div class="ctx-em"></div><div class="ctx-name">Individuell</div></div>
</div>
<div class="s-pills" style="margin-top:10px;">
<div class="s-pill off"> Protein</div>
<div class="s-pill on">✓ Küche</div>
<div class="s-pill on">✓ Zutaten · Hoch</div>
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
</div>
<div class="acc">
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r"></div></div>
<div class="acc-b">
<div style="font-size:10px;color:var(--color-text-muted);margin-bottom:10px;">Protein ist über den Kontext deaktiviert. Die übrigen Gewichte kannst du hier anpassen.</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Küche</div><div class="wr-sub">Tag-Wiederholung</div></div>
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Zutaten</div><div class="wr-sub">Überschneidung</div></div>
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Letzte Wochen</div><div class="wr-sub">Kochverlauf</div></div>
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Duplikate</div><div class="wr-sub">Im Plan</div></div>
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
</div>
</div>
</div>
<div class="score-banner">
<div class="score-banner-l">
<div class="score-banner-label">Mit diesen Einstellungen</div>
<div class="score-banner-val">8.1</div>
<div class="score-banner-sub score-banner-up">↑ +0.7 gegenüber vorher</div>
</div>
<div class="score-banner-r">📈</div>
</div>
<div class="reset-link">Auf Standard zurücksetzen</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col" style="flex:1;min-width:600px;">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
<div class="dsb-nav">
<div class="dsb-nl">Planung</div>
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
</div>
</div>
<div class="dm">
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span></span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
<div class="dmc">
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
<div>
<div class="sec-lbl">Haushaltskontext</div>
<div class="ctx-chips" style="margin-bottom:20px;">
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln</div></div>
<div class="ctx-chip"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein aus</div></div>
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein aus</div></div>
<div class="ctx-chip ind"><div class="ctx-em"></div><div class="ctx-name">Individuell</div><div class="ctx-sub">Benutzerdefiniert</div></div>
</div>
<div class="acc">
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r"></div></div>
<div class="acc-b">
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:12px;">Protein ist über den Haushaltskontext deaktiviert. Passe die Stärke der übrigen Regeln an.</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Küchen-Wiederholung</div><div class="wr-sub">Gleiche Küche an aufeinanderfolgenden Tagen</div></div>
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Zutaten-Überschneidung</div><div class="wr-sub">Gleiche Zutaten an aufeinanderfolgenden Tagen</div></div>
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Letzte Wochen</div><div class="wr-sub">Kochverlauf (14 Tage)</div></div>
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
</div>
<div class="wr">
<div class="wr-l"><div class="wr-name">Doppelte Rezepte</div><div class="wr-sub">Gleiches Rezept mehrfach im Plan</div></div>
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
</div>
</div>
</div>
<div class="reset-link" style="text-align:left;">Auf Standard zurücksetzen</div>
</div>
<div>
<div class="score-banner">
<div>
<div class="score-banner-label">Mit diesen Einstellungen</div>
<div class="score-banner-val">8.1 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
<div class="score-banner-sub score-banner-up">↑ +0.7 gegenüber vorher</div>
</div>
<div class="score-banner-r">📈</div>
</div>
<div class="sec-lbl">Aktive Regeln</div>
<div class="sum-rows">
<div class="sum-row off"><span class="sum-row-name">Protein</span><span class="sum-row-val">Deaktiviert</span></div>
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row warn"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Hoch ↑</span></div>
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>E4 · S3 Erweiterte Einstellungen</h4>
<pre>/* Accordion öffnet sich per Click/Tap auf acc-hd. Keine Animation nötig — display toggle reicht.
* Erweiterte Einstellungen zeigt NUR aktive Tag-Typen als Gewichts-Rows.
* Wenn Protein deaktiviert (über Preset): Protein-Row wird in acc-b NICHT angezeigt.
* "Individuell"-Chip: erscheint automatisch wenn die Kombination repeatTagTypes+weights
* nicht exakt einem der drei Presets entspricht. Kein manueller Auslöser.
* Gewichts-Änderung → PATCH → Score-Simulation → Banner-Update.
* Debounce der Simulation: 300ms nach letzter Interaktion. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Gewicht-Mapping</td></tr>
<tr><td>Niedrig</td><td>Faktor 0.5 × Standard-Gewicht</td><td>wTagRepeat: 0.75, wIngredient: 0.15, wRecent: 0.5, wDuplicate: 1.0</td></tr>
<tr><td>Mittel</td><td>Faktor 1.0 (Standard)</td><td>wTagRepeat: 1.5, wIngredient: 0.3, wRecent: 1.0, wDuplicate: 2.0</td></tr>
<tr><td>Hoch</td><td>Faktor 1.5 × Standard-Gewicht</td><td>wTagRepeat: 2.25, wIngredient: 0.45, wRecent: 1.5, wDuplicate: 3.0</td></tr>
<tr class="grp"><td colspan="3">Individuell-Chip</td></tr>
<tr><td>Trigger</td><td>Wenn gespeicherter Config ≠ Omnivor, Vegetarisch, oder Vegan Preset</td><td>Lila Border + Hintergrund</td></tr>
<tr><td>Symbol</td><td>✦ (U+2726)</td><td>Statt Emoji</td></tr>
<tr><td>Label</td><td>Individuell</td><td>Nicht anklickbar — nur Status-Indikator</td></tr>
<tr class="grp"><td colspan="3">Simulation-Debounce</td></tr>
<tr><td>Delay</td><td>300ms</td><td>Nach letzter Segmented-Control-Interaktion</td></tr>
<tr><td>Während Laden</td><td>Score-Wert zeigt Spinner (CSS animation)</td><td>Kein Skeleton — nur val-Bereich</td></tr>
</tbody></table>
</div>
</div>
<!-- S4: Reset confirmation -->
<div class="scr">
<div class="scr-head"><h3>S4 · Reset-Bestätigung</h3><span class="scr-id">E4</span></div>
<div class="scr-desc">Planer tippt "Auf Standard zurücksetzen". Ein Dialog erscheint und benennt explizit, was zurückgesetzt wird. Bestätigung löscht den Custom-Config-Eintrag (DELETE) und stellt die Omnivor-Defaults wieder her. Kein Backdrop-Dismiss — der Planer muss explizit wählen.</div>
<div class="scr-var"><strong>S4</strong> · Modal über S2-Zustand · Backdrop nicht anklickbar · Mobile: Bottom Sheet</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px (Bottom Sheet)</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●●</span></div>
<div class="pb">
<!-- Blurred background state -->
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
<div style="padding:16px;flex:1;overflow-y:auto;opacity:.35;pointer-events:none;">
<div class="ctx-chips">
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div>
</div>
</div>
<!-- Bottom sheet -->
<div style="background:var(--color-page);border-radius:20px 20px 0 0;padding:20px;border-top:1px solid var(--color-border);box-shadow:0 -4px 24px rgba(0,0,0,.12);">
<div style="width:36px;height:4px;background:var(--color-border);border-radius:2px;margin:0 auto 16px;"></div>
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;margin-bottom:8px;">Auf Standard zurücksetzen?</div>
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Alle individuellen Einstellungen werden gelöscht. Der Algorithmus verwendet dann wieder:<br><br>• Protein: Aktiv<br>• Küche: Aktiv<br>• Alle Gewichte: Mittel</div>
<div class="btn-dest" style="margin-bottom:8px;">Zurücksetzen</div>
<div class="btn-ghost">Abbrechen</div>
</div>
<div class="mbt">
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col" style="flex:1;min-width:600px;">
<div class="bp-lbl">Desktop · 1040px (Centered Modal)</div>
<div class="desk overlay">
<div class="dsb" style="opacity:.35;">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div></div>
</div>
<div class="dm" style="opacity:.35;">
<div class="dtb"><div class="dtb-t">Vielfalt-Einstellungen</div></div>
<div class="dmc"><div class="ctx-chips"><div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div></div></div>
</div>
<div class="modal-backdrop">
<div class="modal">
<div class="modal-title">Auf Standard zurücksetzen?</div>
<div class="modal-body">Alle individuellen Einstellungen werden gelöscht. Der Algorithmus verwendet dann wieder die Standard-Werte:<br><br>
<strong>Protein-Prüfung:</strong> Aktiv<br>
<strong>Küchen-Vielfalt:</strong> Aktiv<br>
<strong>Alle Gewichte:</strong> Mittel</div>
<div class="modal-acts">
<div class="btn-dest">Zurücksetzen</div>
<div class="btn-ghost">Abbrechen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>E4 · S4 Reset-Bestätigung</h4>
<pre>/* Reset-Link Tap → Dialog öffnet (kein Backdrop-Dismiss, kein Escape-Dismiss).
* "Zurücksetzen" → DELETE /v1/households/mine/variety-config
* On success: optimistic reset von UI zu S1 (Omnivor), Score-Banner zeigt echten Score.
* On error: Toast "Fehler beim Zurücksetzen".
* Mobile: Bottom Sheet (position:fixed, bottom 0, border-radius 20px 20px 0 0).
* Desktop: centered modal, backdrop rgba(28,28,24,0.4), max-width 380px. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Dialog-Inhalt</td></tr>
<tr><td>Titel</td><td>"Auf Standard zurücksetzen?"</td><td>Fraunces 18px (Mobile), 20px (Desktop)</td></tr>
<tr><td>Body</td><td>Auflistung der zurückgesetzten Werte</td><td>Muss konkret benennen: Protein aktiv, Küche aktiv, alle Gewichte Mittel</td></tr>
<tr><td>Primär-Aktion</td><td>"Zurücksetzen" → DELETE</td><td>Hintergrund: --red, Text: weiß</td></tr>
<tr><td>Sekundär-Aktion</td><td>"Abbrechen"</td><td>Ghost-Button, schließt Dialog</td></tr>
<tr class="grp"><td colspan="3">API</td></tr>
<tr><td>Endpoint</td><td>DELETE /v1/households/mine/variety-config</td><td>Löscht Custom-Config-Row; Backend fällt auf Defaults zurück</td></tr>
<tr><td>On Success</td><td>UI reset zu S1</td><td>Omnivor chip selected, Score-Banner: echter Score</td></tr>
</tbody></table>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
LLM / AGENT REGION
═════════════════════════════════════════ -->
<div class="llm">
<h2>Maschinenlesbare Spezifikation — E4 Vielfalt-Einstellungen</h2>
<h3>Screens</h3>
<table>
<thead><tr><th>Screen</th><th>Route</th><th>Zugriff</th><th>Zweck</th></tr></thead>
<tbody>
<tr><td>E1 (Update)</td><td>/settings</td><td>Planer</td><td>Settings-Hub: dritte Kachel "Vielfalt-Einstellungen" mit aktuellem Score</td></tr>
<tr><td>E4</td><td>/settings/variety</td><td>Planer only</td><td>Vielfalt-Algorithmus per Kontext-Preset und Feineinstellungen konfigurieren</td></tr>
</tbody>
</table>
<h3>States</h3>
<table>
<thead><tr><th>State</th><th>Trigger</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td>S0</td><td>E1 load</td><td>Settings-Hub zeigt Score-Kachel (lila Kennzahl)</td></tr>
<tr><td>S1</td><td>E4 load, kein Custom-Config</td><td>Omnivor chip selected, Score = aktueller echter Score, Reset-Link = deaktiviert/neutral</td></tr>
<tr><td>S2</td><td>Preset-Chip tap</td><td>Chip wechselt, PATCH, Score-Simulation lädt und zeigt Delta</td></tr>
<tr><td>S3</td><td>Accordion öffnen + Gewicht ändern</td><td>Individuell-Chip erscheint, Score-Simulation mit Debounce 300ms</td></tr>
<tr><td>S4</td><td>Reset-Link tap</td><td>Modal/Bottom Sheet — Bestätigung vor DELETE</td></tr>
</tbody>
</table>
<h3>API-Endpoints (neu + bestehend)</h3>
<table>
<thead><tr><th>Method</th><th>Endpoint</th><th>Neu?</th><th>Zweck</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Aktuellen Config laden; 404 = Defaults verwenden</td></tr>
<tr><td>PATCH</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Config speichern (auto-save bei jedem Preset/Gewicht-Wechsel)</td></tr>
<tr><td>DELETE</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Custom-Config löschen, Backend fällt auf Defaults zurück</td></tr>
<tr><td>POST</td><td>/v1/week-plans/{id}/variety-score/simulate</td><td>Neu</td><td>Score simulieren mit temporärem Config-Body (nicht persistiert)</td></tr>
<tr><td>GET</td><td>/v1/week-plans/{id}/variety-score</td><td>Bestehend</td><td>Aktuellen Score laden (für S1 Banner + E1 Kachel)</td></tr>
</tbody>
</table>
<h3>Kontext-Preset Mapping</h3>
<table>
<thead><tr><th>Preset</th><th>repeatTagTypes</th><th>wTagRepeat</th><th>wIngredientOverlap</th><th>wRecentRepeat</th><th>wPlanDuplicate</th></tr></thead>
<tbody>
<tr><td>Omnivor (Default)</td><td>["protein","cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
<tr><td>Vegetarisch</td><td>["cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
<tr><td>Vegan</td><td>["cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
<tr><td>Individuell</td><td>Beliebig (≠ obige Presets)</td><td>Beliebig</td><td>Beliebig</td><td>Beliebig</td><td>Beliebig</td></tr>
</tbody>
</table>
<h3>Gewicht-Preset Mapping</h3>
<table>
<thead><tr><th>Stufe</th><th>Faktor</th><th>wTagRepeat</th><th>wIngredientOverlap</th><th>wRecentRepeat</th><th>wPlanDuplicate</th></tr></thead>
<tbody>
<tr><td>Niedrig</td><td>×0.5</td><td>0.75</td><td>0.15</td><td>0.5</td><td>1.0</td></tr>
<tr><td>Mittel</td><td>×1.0</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
<tr><td>Hoch</td><td>×1.5</td><td>2.25</td><td>0.45</td><td>1.5</td><td>3.0</td></tr>
</tbody>
</table>
<h3>Implementierungsregeln (für Agenten)</h3>
<ul>
<li>E4 ist nur für <code>rolle === 'planer'</code> zugänglich. Mitglieder werden auf E1 redirected.</li>
<li>Auto-Save auf jede Preset- oder Gewicht-Änderung. Kein expliziter Speichern-Button.</li>
<li>Optimistic Update: UI wechselt sofort; Rollback mit Toast bei API-Fehler.</li>
<li>Score-Simulation: Debounce 300ms. Während Laden: Spinner im Score-Wert-Bereich (nicht Skeleton).</li>
<li>"Individuell"-Chip ist nicht anklickbar — er ist ein reiner Status-Indikator.</li>
<li>Reset-Bestätigung: Backdrop-Dismiss deaktiviert (nicht schließbar durch Klick/Tap auf Overlay).</li>
<li>Mobile Reset: Bottom Sheet mit Handle-Bar (36×4px, --color-border, border-radius 2px). Kein Backdrop-Dismiss.</li>
<li>Desktop Reset: Zentriertes Modal, max-width 380px. Backdrop rgba(28,28,24,0.4).</li>
<li>E1 Vielfalt-Kachel: Score < 6.0 Kennzahl in --orange; Score 6.0 Kennzahl in --purple.</li>
<li>E4-Route: <code>(app)/settings/variety/+page.svelte</code>. Load-Funktion: <code>+page.server.ts</code> → Promise.all([GET variety-config, GET variety-score]).</li>
</ul>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,841 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Variety Page Rework · 3 Variationen</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<!--
spec:agent
document: /planner/variety — Variety Page Rework, 3 Variationen
version: 1.0
journey: J4 Swap (adjacent)
route: /planner/variety
screen: C2
variations: V1 Recipe Pills | V2 Action Rows | V3 Week Grid
last-updated: 2026-04-09
PROBLEMS ADDRESSED:
1. Warnings show day abbreviations ("MON, WED") — replace with recipe names
2. No swap action reachable from warnings — add inline swap CTA per recipe
3. Protein score is meat-centric for vegetarian households (backend concern, noted below)
FRONTEND-ONLY CHANGE (no backend schema changes required for items 1+2):
weekPlan.slots has { dayOfWeek: "MON", recipe: { id, name } }
tagRepeats.days[] contains day keys matching dayOfWeek
→ build slotsByDay map frontend-side, look up recipeName + slotId per day
→ swap CTA links to /planner?week={weekStart}&swap={slotId}
PROTEIN SCORE — VEGETARIAN HOUSEHOLDS (backend concern, TBD):
Current: proteinDiversity = 10 - proteinRepeats * 2
Problem: vegetarian protein sources (Tofu, Linsen, Ei) may repeat more than
omnivore households; penalty of -2 per repeat is calibrated for meat variety.
Backend discussed: tag filtering or weight adjustment needed.
Frontend impact: if backend changes tagRepeats to exclude non-meat or adjusts score,
the frontend ScoreBreakdownList label "Protein-Vielfalt" may need renaming.
Until resolved: the rework does NOT change protein score display — only warnings.
-->
<style>
:root {
--color-page: #FAFAF7;
--color-surface: #F5F4EE;
--color-subtle: #EDECEA;
--color-border: #D8D7D0;
--color-text-muted: #6B6A63;
--color-text: #1C1C18;
--green-tint: #E8F5EA;
--green-light: #AEDCB0;
--green: #3D8C4A;
--green-dark: #2E6E39;
--yellow-tint: #FDF6D8;
--yellow-light: #F9E08A;
--yellow: #E8B400;
--yellow-text: #8A6800;
--color-error: #DC4C3E;
--blue-tint: #E6F1FB;
--blue: #185FA5;
--font-display: 'Fraunces', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', monospace;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; }
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
.intro { font-size: 14px; line-height: 1.75; max-width: 680px; margin-bottom: 16px; }
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
/* Notice box */
.notice { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 18px; margin-bottom: 40px; }
.notice h3 { font-size: 12px; font-weight: 600; color: var(--yellow-text); margin-bottom: 4px; }
.notice p { font-size: 12px; color: var(--yellow-text); line-height: 1.6; }
.notice code { font-family: var(--font-mono); background: rgba(0,0,0,.07); padding: 1px 4px; border-radius: 3px; }
/* Variation sections */
.variation { margin-bottom: 72px; }
.var-header { display: flex; align-items: flex-start; gap: 20px; margin-bottom: 24px; }
.var-num { font-family: var(--font-display); font-size: 44px; font-weight: 300; color: var(--yellow-light); line-height: 1; flex-shrink: 0; width: 56px; letter-spacing: -0.03em; }
.var-meta { flex: 1; padding-top: 4px; }
.var-title { font-size: 18px; font-weight: 500; letter-spacing: -0.01em; margin-bottom: 4px; }
.var-desc { font-size: 13px; color: var(--color-text-muted); line-height: 1.6; max-width: 540px; }
.var-tag { font-size: 10px; font-weight: 500; letter-spacing: 0.07em; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); background: var(--color-subtle); color: var(--color-text-muted); margin-top: 6px; display: inline-block; }
.var-tag.rec { background: var(--green-tint); color: var(--green-dark); }
.var-tag.amb { background: var(--blue-tint); color: var(--blue); }
/* Preview containers */
.preview-pair { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
/* Notes */
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
/* ── AppShell chrome ── */
.shell { display: flex; background: var(--color-page); font-family: var(--font-sans); overflow: hidden; }
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
.sidebar-nav { flex: 1; padding: 4px 8px; }
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
/* Page chrome */
.topbar { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--color-border); background: var(--color-page); padding: 14px 24px; }
.topbar-back { font-size: 13px; color: var(--color-text-muted); text-decoration: none; }
.topbar-sep { font-size: 13px; color: var(--color-text-muted); }
.topbar-title { font-family: var(--font-display); font-size: 20px; font-weight: 300; }
.main { flex: 1; padding: 24px; overflow: hidden; }
/* Score hero (shared across variations) */
.score-hero { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-bottom: 20px; }
.score-num { font-family: var(--font-display); font-size: 64px; font-weight: 300; line-height: 1; letter-spacing: -0.03em; }
.score-denom { font-family: var(--font-display); font-size: 24px; font-weight: 300; color: var(--color-text-muted); }
.score-label { font-size: 13px; font-weight: 500; color: var(--yellow-text); }
.score-bar { height: 6px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; width: 160px; }
.score-fill { height: 100%; border-radius: var(--radius-full); }
.score-fill.good { background: var(--green-dark); }
.score-fill.warn { background: var(--yellow); }
/* Sub-scores */
.sub-scores { border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; background: white; margin-bottom: 20px; }
.sub-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--color-border); }
.sub-row:last-child { border-bottom: none; }
.sub-label { font-size: 13px; }
.sub-val { font-size: 13px; font-weight: 500; color: var(--yellow-text); }
.sub-val.ok { color: var(--green-dark); }
/* Section heading */
.section-hd { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 10px; }
/* ────────────────────────────────────────────
V1: Recipe Pill Warning Cards
──────────────────────────────────────────── */
.warn-card { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 10px; }
.warn-title { font-size: 13px; font-weight: 500; color: var(--yellow-text); margin-bottom: 8px; }
.pill-row { display: flex; flex-wrap: wrap; gap: 6px; }
.recipe-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 12px; border-radius: var(--radius-full); background: white; border: 1px solid var(--yellow-light); font-size: 12px; font-weight: 500; color: var(--color-text); }
.recipe-pill-day { font-size: 10px; color: var(--color-text-muted); font-weight: 400; }
.pill-swap-btn { width: 22px; height: 22px; border-radius: var(--radius-full); background: var(--color-subtle); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--color-text-muted); flex-shrink: 0; }
.pill-swap-btn:hover { background: var(--green-tint); color: var(--green-dark); }
/* ────────────────────────────────────────────
V2: Action Rows
──────────────────────────────────────────── */
/* Compact score header for V2 */
.score-compact { display: flex; align-items: center; gap: 14px; padding: 14px 20px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); margin-bottom: 20px; }
.score-compact-num { font-family: var(--font-display); font-size: 36px; font-weight: 300; line-height: 1; }
.score-compact-denom { font-family: var(--font-display); font-size: 16px; font-weight: 300; color: var(--color-text-muted); }
.score-compact-right { flex: 1; }
.score-compact-label { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 4px; }
.score-compact-bar { height: 5px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; }
.score-compact-fill { height: 100%; border-radius: var(--radius-full); background: var(--yellow); }
.action-row { display: flex; align-items: flex-start; gap: 14px; padding: 14px 16px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); margin-bottom: 8px; }
.action-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--yellow-tint); display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
.action-body { flex: 1; }
.action-title { font-size: 13px; font-weight: 500; margin-bottom: 6px; }
.action-recipe-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--color-subtle); border-radius: var(--radius-md); margin-bottom: 4px; }
.action-recipe-name { font-size: 12px; font-weight: 500; }
.action-recipe-day { font-size: 10px; color: var(--color-text-muted); margin-left: 4px; }
.btn-swap { padding: 4px 10px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-md); font-size: 11px; font-weight: 500; color: var(--color-text-muted); cursor: pointer; white-space: nowrap; }
.btn-swap:hover { border-color: var(--green-light); color: var(--green-dark); }
/* ────────────────────────────────────────────
V3: Week Grid + Side Panel
──────────────────────────────────────────── */
.v3-layout { display: flex; gap: 0; height: 680px; }
.v3-main { flex: 1; padding: 24px; overflow-y: auto; }
.v3-panel { width: 280px; min-width: 280px; border-left: 1px solid var(--color-border); background: white; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; }
.week-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; margin-bottom: 20px; }
.day-col { display: flex; flex-direction: column; gap: 4px; }
.day-header { font-size: 10px; font-weight: 500; color: var(--color-text-muted); text-align: center; padding-bottom: 4px; }
.recipe-slot { border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; padding: 6px 5px; min-height: 52px; font-size: 10px; font-weight: 500; text-align: center; display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1.3; }
.recipe-slot.warn { border-color: var(--yellow); background: var(--yellow-tint); color: var(--yellow-text); box-shadow: 0 0 0 2px rgba(232,180,0,.25); }
.recipe-slot.warn:hover { box-shadow: 0 0 0 2px var(--yellow); }
.recipe-slot.selected { border-color: var(--green-dark); box-shadow: 0 0 0 2px var(--green-light); }
.recipe-slot.empty { background: var(--color-subtle); color: var(--color-text-muted); font-weight: 400; font-size: 9px; }
.warn-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--yellow); display: inline-block; margin-left: 3px; vertical-align: middle; }
.panel-score { display: flex; align-items: baseline; gap: 4px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--color-border); }
.panel-score-num { font-family: var(--font-display); font-size: 48px; font-weight: 300; line-height: 1; }
.panel-score-denom { font-family: var(--font-display); font-size: 18px; font-weight: 300; color: var(--color-text-muted); }
.panel-warn-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
.panel-warn-desc { font-size: 12px; color: var(--color-text-muted); margin-bottom: 14px; line-height: 1.5; }
.panel-recipe-entry { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; background: var(--color-subtle); border-radius: var(--radius-md); margin-bottom: 6px; }
.panel-recipe-name { font-size: 12px; font-weight: 500; }
.panel-recipe-day { font-size: 10px; color: var(--color-text-muted); }
.btn-swap-primary { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; background: var(--green-dark); color: white; border-radius: var(--radius-md); font-size: 12px; font-weight: 500; border: none; cursor: pointer; width: 100%; margin-top: 12px; }
.btn-swap-primary:hover { background: var(--green); }
.panel-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
.panel-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 8px; }
.panel-empty-icon { font-size: 24px; opacity: .4; }
.panel-empty-text { font-size: 12px; color: var(--color-text-muted); }
/* ── Mobile ── */
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
.m-topbar { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--color-border); background: var(--color-page); padding: 12px 16px; position: sticky; top: 0; z-index: 10; }
.m-back { font-size: 20px; color: var(--color-text-muted); }
.m-title { font-family: var(--font-display); font-size: 16px; font-weight: 300; }
.m-content { flex: 1; padding: 16px; overflow-y: auto; }
.m-score-hero { display: flex; align-items: baseline; gap: 4px; margin-bottom: 16px; }
.m-score-num { font-family: var(--font-display); font-size: 52px; font-weight: 300; line-height: 1; }
.m-score-denom { font-family: var(--font-display); font-size: 20px; font-weight: 300; color: var(--color-text-muted); }
.m-score-bar { height: 5px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; margin-bottom: 4px; }
.m-score-fill { height: 100%; border-radius: var(--radius-full); background: var(--yellow); }
.m-score-label { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 16px; }
.m-section-hd { font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
.m-warn-card { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 12px 14px; margin-bottom: 8px; }
.m-warn-title { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 6px; }
.m-pill-row { display: flex; flex-wrap: wrap; gap: 6px; }
.m-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px 4px 10px; background: white; border: 1px solid var(--yellow-light); border-radius: var(--radius-full); font-size: 11px; font-weight: 500; }
.m-pill-swap { width: 18px; height: 18px; border-radius: 50%; background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; }
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
.m-tab.active { color: var(--green-dark); }
.m-tab-icon { font-size: 20px; }
/* Agent section */
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
.agent-table tr:last-child td { border-bottom: none; }
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
.agent-table td:nth-child(3) { color: #5A5A55; }
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>Variety Page — Rework</h1>
<p>3 Design-Variationen · Route: <code>/planner/variety</code></p>
</div>
<div class="doc-meta">
screen: C2<br/>
journey: J4<br/>
version: 1.0<br/>
date: 2026-04-09
</div>
</div>
<p class="intro">
Zwei Kernprobleme werden adressiert: (1) Warnungen zeigen aktuell Wochentag-Kürzel ("MON, WED")
statt Rezeptnamen — rein frontend-seitig lösbar über <code>weekPlan.slots</code>-Mapping.
(2) Es gibt keine Swap-Aktion direkt aus den Warnungen heraus. Das Protein-Score-Problem
für vegetarische Haushalte ist ein Backend-Thema und separat zu behandeln.
</p>
<div class="notice">
<h3>Protein-Score: Vegetarische Haushalte — Backend TBD</h3>
<p>
Die aktuelle Formel <code>proteinDiversity = 10 repeats × 2</code> bestraft vegetarische
Proteinquellen (Tofu, Linsen, Ei) stärker als in omnivoren Haushalten üblich.
Frontend-seitig ändert sich das Label "Protein-Vielfalt" ggf. zu "Quellen-Vielfalt" sobald
das Backend die Score-Gewichtung anpasst. Bis dahin: keine Änderung an <code>ScoreBreakdownList</code>.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">V1 — Rezept-Pills in Warnkarten</div>
<div class="variation">
<div class="var-header">
<div class="var-num">1</div>
<div class="var-meta">
<div class="var-title">Rezept-Pills in Warnkarten</div>
<div class="var-desc">Minimale Änderung an der bestehenden Seitenstruktur. Warnkarten zeigen statt "MON, WED" konkrete Rezept-Pills mit Tauschen-Button. Seitenaufbau und Score-Hero bleiben identisch.</div>
<span class="var-tag">Vertraut · Geringer Aufwand</span>
</div>
</div>
<div class="preview-pair">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell" style="min-height:680px;">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="topbar">
<a class="topbar-back" href="#">Planer</a>
<span class="topbar-sep">/</span>
<span class="topbar-title">Abwechslungs-Analyse</span>
</div>
<div class="main" style="display:flex;gap:32px;align-items:flex-start;overflow-y:auto;">
<!-- Left -->
<div style="flex:1;">
<div class="score-hero">
<div><span class="score-num" style="color:var(--yellow-text);">6.5</span><span class="score-denom">/10</span></div>
<div class="score-bar" style="width:200px;"><div class="score-fill warn" style="width:65%;"></div></div>
<div class="score-label">Verbesserbar</div>
</div>
<div class="section-hd">Bewertung im Detail</div>
<div class="sub-scores">
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
</div>
<!-- Warnings with recipe pills -->
<div class="section-hd" style="margin-top:20px;">Hinweise</div>
<div class="warn-card">
<div class="warn-title">Tofu mehrfach diese Woche</div>
<div class="pill-row">
<span class="recipe-pill"><span class="recipe-pill-day">Mo</span>Tofu-Curry<button class="pill-swap-btn"></button></span>
<span class="recipe-pill"><span class="recipe-pill-day">Mi</span>Tofu-Bowl<button class="pill-swap-btn"></button></span>
</div>
</div>
<div class="warn-card">
<div class="warn-title">Linsen in mehreren Gerichten</div>
<div class="pill-row">
<span class="recipe-pill"><span class="recipe-pill-day">Di</span>Linsen-Suppe<button class="pill-swap-btn"></button></span>
<span class="recipe-pill"><span class="recipe-pill-day">Fr</span>Linsen-Dal<button class="pill-swap-btn"></button></span>
</div>
</div>
</div>
<!-- Right -->
<div style="width:280px;flex-shrink:0;">
<div class="section-hd">Quellen-Verteilung</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:5px;margin-bottom:16px;">
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Di</span><div style="width:100%;height:40px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--green-dark);">LIN</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Do</span><div style="width:100%;height:40px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--green-dark);">GEM</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">LIN</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Sa</span><div style="width:100%;height:40px;background:var(--color-subtle);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">So</span><div style="width:100%;height:40px;background:var(--color-subtle);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);"></div></div>
</div>
<div class="section-hd">Aufwandsverteilung</div>
<div style="display:flex;height:16px;border-radius:var(--radius-full);overflow:hidden;gap:2px;">
<div style="flex:3;background:var(--green-dark);"></div>
<div style="flex:2;background:var(--yellow);"></div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:6px;font-size:10px;color:var(--color-text-muted);">
<span>Einfach ×3</span><span>Mittel ×2</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-topbar"><span class="m-back"></span><span class="m-title">Abwechslungs-Analyse</span></div>
<div class="m-content">
<div class="m-score-hero"><span class="m-score-num" style="color:var(--yellow-text);">6.5</span><span class="m-score-denom">/10</span></div>
<div class="m-score-bar"><div class="m-score-fill" style="width:65%;"></div></div>
<div class="m-score-label">Verbesserbar</div>
<div class="m-section-hd">Hinweise</div>
<div class="m-warn-card">
<div class="m-warn-title">Tofu mehrfach diese Woche</div>
<div class="m-pill-row">
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span>Tofu-Curry<span class="m-pill-swap"></span></span>
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span>Tofu-Bowl<span class="m-pill-swap"></span></span>
</div>
</div>
<div class="m-warn-card">
<div class="m-warn-title">Linsen in mehreren Gerichten</div>
<div class="m-pill-row">
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Di</span>Linsen-Suppe<span class="m-pill-swap"></span></span>
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span>Linsen-Dal<span class="m-pill-swap"></span></span>
</div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Kein Backend-Change nötig. Frontend mappt <code>tagRepeat.days[]</code><code>weekPlan.slots.find(s => s.dayOfWeek === day)</code><code>recipe.name</code></li>
<li>Pill-Swap-Button (↔): navigiert zu <code>/planner?week={weekStart}&amp;swap={slotId}</code> — öffnet RecipePicker für den betreffenden Slot</li>
<li>Pill-Label links: Wochentag-Kürzel (Mo, Di, …) aus <code>dayOfWeek</code>-Mapping</li>
<li>Wenn ein Slot leer ist (Rezept wurde bereits entfernt): Pill zeigt nur den Wochentag, kein Swap-Button</li>
<li>Geringe Änderung: nur <code>VarietyWarningCards.svelte</code> + <code>variety.ts</code> anpassen; Rest der Seite bleibt</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">V2 — Aktions-Zeilen (Action-first)</div>
<div class="variation">
<div class="var-header">
<div class="var-num">2</div>
<div class="var-meta">
<div class="var-title">Aktions-Zeilen</div>
<div class="var-desc">Warnungen stehen oben, Score-Hero wird kompakt. Pro Warnung gibt es eine vollständige Rezept-Zeile mit Wochentag und dediziertem "Tauschen"-Button. Fokus auf sofortige Handlung statt auf Metrik-Verständnis.</div>
<span class="var-tag rec">Empfohlen · Aktionsfokus</span>
</div>
</div>
<div class="preview-pair">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell" style="min-height:680px;">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="topbar">
<a class="topbar-back" href="#">Planer</a>
<span class="topbar-sep">/</span>
<span class="topbar-title">Abwechslungs-Analyse</span>
</div>
<div class="main" style="overflow-y:auto;">
<!-- Compact score -->
<div class="score-compact">
<div><span class="score-compact-num" style="color:var(--yellow-text);">6.5</span><span class="score-compact-denom">/10</span></div>
<div class="score-compact-right">
<div class="score-compact-label">Verbesserbar — 2 Hinweise</div>
<div class="score-compact-bar"><div class="score-compact-fill" style="width:65%;"></div></div>
</div>
</div>
<div style="display:flex;gap:24px;align-items:flex-start;">
<div style="flex:1;">
<div class="section-hd">Empfehlenswerte Tausche</div>
<!-- Action row 1 -->
<div class="action-row">
<div class="action-icon">🔄</div>
<div class="action-body">
<div class="action-title">Tofu mehrfach diese Woche</div>
<div class="action-recipe-row">
<span><span class="action-recipe-name">Tofu-Curry</span><span class="action-recipe-day">· Montag</span></span>
<button class="btn-swap">Tauschen →</button>
</div>
<div class="action-recipe-row">
<span><span class="action-recipe-name">Tofu-Bowl</span><span class="action-recipe-day">· Mittwoch</span></span>
<button class="btn-swap">Tauschen →</button>
</div>
</div>
</div>
<!-- Action row 2 -->
<div class="action-row">
<div class="action-icon">🔄</div>
<div class="action-body">
<div class="action-title">Linsen in mehreren Gerichten</div>
<div class="action-recipe-row">
<span><span class="action-recipe-name">Linsen-Suppe</span><span class="action-recipe-day">· Dienstag</span></span>
<button class="btn-swap">Tauschen →</button>
</div>
<div class="action-recipe-row">
<span><span class="action-recipe-name">Linsen-Dal</span><span class="action-recipe-day">· Freitag</span></span>
<button class="btn-swap">Tauschen →</button>
</div>
</div>
</div>
<!-- Collapsible detail scores -->
<details style="margin-top:16px;">
<summary style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);cursor:pointer;list-style:none;padding:8px 0;">Bewertung im Detail ▾</summary>
<div class="sub-scores" style="margin-top:10px;">
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
</div>
</details>
</div>
<div style="width:240px;flex-shrink:0;">
<div class="section-hd">Quellen-Verteilung</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:12px;">
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Di</span><div style="width:100%;height:36px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--green-dark);">LIN</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Do</span><div style="width:100%;height:36px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--green-dark);">GEM</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">LIN</div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Sa</span><div style="width:100%;height:36px;background:var(--color-subtle);border-radius:3px;"></div></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">So</span><div style="width:100%;height:36px;background:var(--color-subtle);border-radius:3px;"></div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-topbar"><span class="m-back"></span><span class="m-title">Abwechslungs-Analyse</span></div>
<div class="m-content">
<!-- Compact score mobile -->
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);margin-bottom:16px;">
<div><span style="font-family:var(--font-display);font-size:32px;font-weight:300;color:var(--yellow-text);">6.5</span><span style="font-family:var(--font-display);font-size:14px;font-weight:300;color:var(--color-text-muted);">/10</span></div>
<div style="flex:1;"><div style="font-size:11px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar</div><div style="height:4px;background:var(--color-subtle);border-radius:99px;overflow:hidden;"><div style="width:65%;height:100%;background:var(--yellow);border-radius:99px;"></div></div></div>
</div>
<div class="m-section-hd">Empfehlenswerte Tausche</div>
<!-- Action row mobile -->
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;">
<div style="font-size:12px;font-weight:500;margin-bottom:8px;">🔄 Tofu mehrfach diese Woche</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);margin-bottom:4px;"><span style="font-size:11px;font-weight:500;">Tofu-Curry <span style="color:var(--color-text-muted);font-weight:400;">Mo</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Tofu-Bowl <span style="color:var(--color-text-muted);font-weight:400;">Mi</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
</div>
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;">
<div style="font-size:12px;font-weight:500;margin-bottom:8px;">🔄 Linsen in mehreren Gerichten</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);margin-bottom:4px;"><span style="font-size:11px;font-weight:500;">Linsen-Suppe <span style="color:var(--color-text-muted);font-weight:400;">Di</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Linsen-Dal <span style="color:var(--color-text-muted);font-weight:400;">Fr</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Score-Hero wird kompakt: Zahl + Label + Balken in einer horizontal komprimierten Leiste oben</li>
<li>Sub-Scores in aufklappbarem <code>&lt;details&gt;</code>-Element — zugänglich, kein JavaScript nötig</li>
<li>Jeder "Tauschen"-Button navigiert zum Planer mit dem spezifischen Slot vorselektiert</li>
<li>Wochentag als ausgeschriebenes Wort ("Montag") — nicht Kürzel — für bessere Lesbarkeit</li>
<li>Mobile: Score-Hero bleibt kompakt oben, Action-Rows nehmen den Hauptraum ein</li>
<li>Größerer Aufwand als V1: <code>VarietyWarningCards</code> grundlegend neu strukturieren</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<div class="section-label">V3 — Wochenraster mit Kontext-Panel</div>
<div class="variation">
<div class="var-header">
<div class="var-num">3</div>
<div class="var-meta">
<div class="var-title">Wochenraster mit Kontext-Panel</div>
<div class="var-desc">Das bestehende Protein-Raster wird zum Haupt-Interface. Alle 7 Tage zeigen das vollständige Rezept. Problematische Slots sind gelb markiert — Klick öffnet das rechte Panel mit Erklärung und Swap-CTA.</div>
<span class="var-tag amb">Ambitiös · Meiste Übersicht</span>
</div>
</div>
<div class="preview-pair">
<div class="preview-d-wrap">
<div class="preview-label">Desktop</div>
<div class="preview-d-clip">
<div class="preview-d-scale">
<div class="shell" style="min-height:680px;">
<div class="sidebar">
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
<div class="sidebar-nav">
<div class="sidebar-group-label">Plan</div>
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="topbar">
<a class="topbar-back" href="#">Planer</a>
<span class="topbar-sep">/</span>
<span class="topbar-title">Abwechslungs-Analyse</span>
<!-- Score badge in topbar -->
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;color:var(--color-text-muted);">Abwechslung</span>
<span style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--yellow-text);">6.5</span>
<span style="font-family:var(--font-display);font-size:12px;color:var(--color-text-muted);">/10</span>
</div>
</div>
<div style="display:flex;flex:1;overflow:hidden;">
<!-- Main: week grid -->
<div class="v3-main">
<div class="section-hd">Wochenübersicht — gelb markierte Gerichte haben Hinweise</div>
<div class="week-grid">
<!-- Mon - Tofu-Curry WARN -->
<div class="day-col">
<div class="day-header">Mo</div>
<div class="recipe-slot warn selected">Tofu-Curry</div>
</div>
<!-- Tue - Linsen-Suppe WARN -->
<div class="day-col">
<div class="day-header">Di</div>
<div class="recipe-slot warn">Linsen-Suppe</div>
</div>
<!-- Wed - Tofu-Bowl WARN -->
<div class="day-col">
<div class="day-header">Mi</div>
<div class="recipe-slot warn">Tofu-Bowl</div>
</div>
<!-- Thu - Gemüse OK -->
<div class="day-col">
<div class="day-header">Do</div>
<div class="recipe-slot">Gemüse-Stir-Fry</div>
</div>
<!-- Fri - Linsen-Dal WARN -->
<div class="day-col">
<div class="day-header">Fr</div>
<div class="recipe-slot warn">Linsen-Dal</div>
</div>
<!-- Sat - empty -->
<div class="day-col">
<div class="day-header">Sa</div>
<div class="recipe-slot empty"></div>
</div>
<!-- Sun - empty -->
<div class="day-col">
<div class="day-header">So</div>
<div class="recipe-slot empty"></div>
</div>
</div>
<div class="section-hd" style="margin-top:16px;">Aufwandsverteilung</div>
<div style="display:flex;height:18px;border-radius:var(--radius-full);overflow:hidden;gap:2px;max-width:280px;">
<div style="flex:3;background:var(--green-dark);"></div>
<div style="flex:2;background:var(--yellow);"></div>
</div>
<div style="display:flex;gap:16px;margin-top:6px;font-size:11px;color:var(--color-text-muted);">
<span>Einfach ×3</span><span>Mittel ×2</span>
</div>
<div class="sub-scores" style="margin-top:20px;max-width:360px;">
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
</div>
</div>
<!-- Right panel: context for selected slot -->
<div class="v3-panel">
<div class="panel-score">
<span class="panel-score-num" style="color:var(--yellow-text);">6.5</span>
<span class="panel-score-denom">/10</span>
</div>
<div class="panel-warn-title">Tofu-Curry — Montag</div>
<div class="panel-warn-desc">Tofu taucht diese Woche auch am Mittwoch auf (Tofu-Bowl). Ein Tausch würde die Quellen-Vielfalt verbessern.</div>
<div class="section-hd">Andere betroffene Gerichte</div>
<div class="panel-recipe-entry">
<div><div class="panel-recipe-name">Tofu-Bowl</div><div class="panel-recipe-day">Mittwoch</div></div>
</div>
<button class="btn-swap-primary">↔ Tofu-Curry tauschen</button>
<div class="panel-hint">Öffnet den Rezept-Picker für Montag.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="preview-m-wrap">
<div class="preview-label">Mobile (Tab-Navigation)</div>
<div class="preview-m-clip">
<div class="preview-m-scale">
<div class="m-shell" style="min-height:680px;">
<div class="m-topbar"><span class="m-back"></span><span class="m-title">Abwechslungs-Analyse</span><span style="margin-left:auto;font-family:var(--font-display);font-size:18px;font-weight:300;color:var(--yellow-text);">6.5<span style="font-size:12px;color:var(--color-text-muted);">/10</span></span></div>
<div class="m-content">
<!-- Tab switcher for mobile (Übersicht | Hinweise) -->
<div style="display:flex;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;margin-bottom:14px;">
<button style="flex:1;padding:7px;font-size:11px;font-weight:500;background:var(--color-subtle);color:var(--color-text-muted);border:none;">Übersicht</button>
<button style="flex:1;padding:7px;font-size:11px;font-weight:500;background:var(--green-dark);color:white;border:none;">Hinweise (2)</button>
</div>
<!-- Hinweise tab active -->
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px;margin-bottom:10px;">
<div style="font-size:12px;font-weight:500;margin-bottom:8px;color:var(--yellow-text);">Tofu mehrfach diese Woche</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);margin-bottom:5px;"><span style="font-size:11px;font-weight:500;">Tofu-Curry <span style="color:var(--color-text-muted);font-weight:400;">Mo</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Tofu-Bowl <span style="color:var(--color-text-muted);font-weight:400;">Mi</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
</div>
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px;">
<div style="font-size:12px;font-weight:500;margin-bottom:8px;color:var(--yellow-text);">Linsen in mehreren Gerichten</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);margin-bottom:5px;"><span style="font-size:11px;font-weight:500;">Linsen-Suppe <span style="color:var(--color-text-muted);font-weight:400;">Di</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Linsen-Dal <span style="color:var(--color-text-muted);font-weight:400;">Fr</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
</div>
</div>
<div class="m-tabbar">
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="notes">
<div class="notes-label">Notizen</div>
<ul>
<li>Wochenraster ersetzt das bisherige Protein-Grid (7 Spalten, Rezeptname statt Kürzel, größere Zellen)</li>
<li>Gelber Slot = mindestens ein Hinweis vorhanden. Klick selektiert den Slot, Panel rechts aktualisiert sich.</li>
<li>Panel zeigt: betroffenes Rezept + Wochentag + Erklärung + andere betroffene Slots + primären "Tauschen"-Button</li>
<li>Score-Zahl wandert in die Topbar-Leiste (kompakt, immer sichtbar)</li>
<li>Mobile: kein Panel — stattdessen Tab-Switcher "Übersicht | Hinweise (N)" mit aufklappbaren Einträgen</li>
<li>Größter Umbau: <code>+page.svelte</code> Struktur und alle beteiligten Komponenten müssen neu aufgebaut werden</li>
</ul>
</div>
</div>
<!-- ─── Agent section ─── -->
<div class="agent-section">
<h2>Maschinen-lesbare Spezifikation</h2>
<p>Gilt für alle drei Variationen. Implementierungs-Details werden nach Variantenwahl konkretisiert.</p>
<pre class="spec-comment">
/* spec:rules — Variety Page Rework (alle Variationen)
*
* RECIPE NAME MAPPING (frontend, no backend change)
* Source: weekPlan.slots[] → { dayOfWeek: "MON"|"TUE"|..., recipe: { id, name } }
* tagRepeats[].days[] contains dayOfWeek keys (e.g. "MON")
* slotsByDay = Object.fromEntries(weekPlan.slots.map(s => [s.dayOfWeek, s]))
* recipeName = slotsByDay[day]?.recipe?.name ?? day
* slotId = slotsByDay[day]?.id
*
* SWAP NAVIGATION
* "Tauschen" button href: /planner?week={weekStart}&swap={slotId}
* weekStart available in page data
* slotId from weekPlan.slots mapping above
* Opens RecipePicker for that slot (existing functionality in planner page)
*
* DAY LABEL MAPPING (for display)
* MON → "Montag" TUE → "Dienstag" WED → "Mittwoch" THU → "Donnerstag"
* FRI → "Freitag" SAT → "Samstag" SUN → "Sonntag"
* Short: Mo, Di, Mi, Do, Fr, Sa, So
*
* EMPTY SLOT HANDLING
* If slotsByDay[day] is undefined: show day key only, no swap button
* This can happen if slot was deleted since varietyScore was computed
*
* PROTEIN SCORE — VEGETARIAN NOTE
* Label "Protein-Vielfalt" in ScoreBreakdownList may change to "Quellen-Vielfalt"
* pending backend decision on scoring weight adjustment.
* No frontend change required until backend ships the updated score.
*
* VARIATION-SPECIFIC
* V1: Modify VarietyWarningCards + Warning type (add slots: { day, recipeName, slotId }[])
* computeWarnings() now returns slots[] instead of string days[]
* V2: Restructure VarietyWarningCards to ActionRows; VarietyScoreHero → compact variant
* <details> for sub-scores (no JS needed)
* V3: Replace protein grid with full week grid (recipe names); add side panel component
* Mobile: tab switcher (Übersicht | Hinweise) using $state activeTab
*/
</pre>
<table class="agent-table">
<thead>
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
</thead>
<tbody>
<tr class="group-row"><td colspan="3">Shared: Recipe Mapping</td></tr>
<tr><td>data-source</td><td>weekPlan.slots[].dayOfWeek + recipe</td><td>already in page data</td></tr>
<tr><td>swap-url</td><td>/planner?week={weekStart}&amp;swap={slotId}</td><td>RecipePicker pre-selects slot</td></tr>
<tr><td>day-long</td><td>MON→Montag, TUE→Dienstag…</td><td>for V2 display</td></tr>
<tr><td>day-short</td><td>MON→Mo, TUE→Di…</td><td>for V1 pills + V3 grid</td></tr>
<tr class="group-row"><td colspan="3">V1 Recipe Pills</td></tr>
<tr><td>pill-padding</td><td>5px 10px 5px 12px</td><td>left more for text</td></tr>
<tr><td>swap-btn-size</td><td>22×22px, border-radius 50%</td><td>within pill</td></tr>
<tr><td>pill-bg</td><td>white, border --yellow-light</td><td>on yellow-tint card</td></tr>
<tr class="group-row"><td colspan="3">V2 Action Rows</td></tr>
<tr><td>score-compact-height</td><td>~64px</td><td>replaces 180px hero</td></tr>
<tr><td>details-summary</td><td>native &lt;details&gt;, no JS</td><td>sub-scores hidden by default</td></tr>
<tr><td>recipe-row-bg</td><td>--color-subtle</td><td>within white action card</td></tr>
<tr class="group-row"><td colspan="3">V3 Week Grid</td></tr>
<tr><td>slot-height</td><td>52px min</td><td>enough for 2-line recipe name</td></tr>
<tr><td>warn-slot-ring</td><td>2px solid --yellow + yellow-tint bg</td><td>problem indicator</td></tr>
<tr><td>selected-slot-ring</td><td>2px solid --green-dark</td><td>active selection</td></tr>
<tr><td>panel-width</td><td>280px</td><td>fixed, right side</td></tr>
<tr><td>mobile-tab-active-bg</td><td>--green-dark</td><td>selected tab button</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Planner — Flip Tiles</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@300;400&family=DM+Sans:wght@400;500;600&family=DM+Mono&display=swap" rel="stylesheet">
<style>
:root {
--page: #fafaf7;
--surface: #f5f4ee;
--subtle: #edecea;
--border: #d8d7d0;
--text: #1c1c18;
--muted: #6b6a63;
--gt: #e8f5ea; --gl: #aedcb0; --g: #3d8c4a; --gd: #2e6e39;
--yt: #fdf6d8; --yl: #f9e08a; --y: #f2c12e; --yx: #8a6800;
--pt: #eeedfe; --p: #534ab7;
--ot: #fef0e6; --od: #b46820;
--err: #dc4c3e;
--r-sm: 4px; --r-md: 6px; --r-lg: 10px; --r-full: 9999px;
--sh-card: 0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);
--sh-raised: 0 6px 18px rgba(28,28,24,.14),0 2px 6px rgba(28,28,24,.08);
--fd: 'Fraunces', Georgia, serif;
--fs: 'DM Sans', system-ui, sans-serif;
--fm: 'DM Mono', monospace;
}
/* ── Ingredient / cuisine colour palette ──────────────────────── */
/* Protein-based */
--col-haehnchen: linear-gradient(160deg,#d4923a 0%,#a85e1a 50%,#7a3d0c 100%);
--col-rind: linear-gradient(160deg,#c04545 0%,#8b2020 50%,#5a1010 100%);
--col-fisch: linear-gradient(160deg,#5b9fd4 0%,#2868a0 50%,#10406e 100%);
--col-tofu: linear-gradient(160deg,#5fa85e 0%,#2e7031 50%,#1a4a1e 100%);
--col-veg: linear-gradient(160deg,#7bc47b 0%,#3d8c3d 50%,#1e5a1e 100%);
--col-schwein: linear-gradient(160deg,#d4785a 0%,#a04535 50%,#6e2418 100%);
--col-lamm: linear-gradient(160deg,#9e6b3a 0%,#6b3f1a 50%,#3e2208 100%);
--col-ei: linear-gradient(160deg,#d4b832 0%,#a07010 50%,#6e4800 100%);
--col-linsen: linear-gradient(160deg,#8b6b3a 0%,#5e421a 50%,#3a2408 100%);
/* Cuisine-based */
--col-italienisch: linear-gradient(160deg,#c04545 0%,#7a1e1e 50%,#4a0f0f 100%);
--col-asiatisch: linear-gradient(160deg,#3a6e3a 0%,#1e4a1e 50%,#0e2e0e 100%);
--col-mexikanisch: linear-gradient(160deg,#d4923a 0%,#8b4e10 50%,#5a2e00 100%);
--col-indisch: linear-gradient(160deg,#c49010 0%,#8b5e00 50%,#5a3800 100%);
--col-mediterran: linear-gradient(160deg,#5b9fd4 0%,#1e5a8b 50%,#0a3456 100%);
*{box-sizing:border-box;margin:0;padding:0;}
body{
font-family:var(--fs);background:#dddcd7;color:var(--text);
padding:40px 24px 80px;line-height:1.4;
}
.eyebrow{font-family:var(--fs);font-size:11px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
.pg-title{font-family:var(--fd);font-size:34px;font-weight:300;margin-bottom:6px;}
.pg-sub{font-family:var(--fs);font-size:14px;color:var(--muted);max-width:700px;line-height:1.65;margin-bottom:44px;}
.block{margin-bottom:60px;}
.bl-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;}
.bl-num{font-family:var(--fm);font-size:11px;background:var(--subtle);color:var(--muted);padding:3px 8px;border-radius:var(--r-sm);}
.bl-name{font-family:var(--fd);font-size:22px;font-weight:300;}
.bl-sub{font-family:var(--fs);font-size:12px;color:var(--muted);margin-left:auto;}
.note{font-family:var(--fs);font-size:12px;color:var(--muted);border-left:3px solid var(--border);padding:10px 14px;margin-top:16px;line-height:1.6;}
.note strong{color:var(--text);font-weight:500;}
/* ── Colour palette swatches ─────────────────────────────────── */
.swatch-grid{display:flex;flex-wrap:wrap;gap:8px;}
.swatch{width:88px;border-radius:var(--r-md);overflow:hidden;box-shadow:var(--sh-card);}
.swatch-color{height:52px;}
.swatch-label{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--text);padding:5px 7px;background:var(--page);border-top:1px solid var(--border);}
.swatch-sub{font-family:var(--fs);font-size:9px;color:var(--muted);padding:0 7px 5px;}
/* ── Frame ───────────────────────────────────────────────────── */
.frame{display:flex;flex-direction:column;background:var(--page);border:1px solid var(--border);border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sh-raised);}
.tb{display:flex;align-items:center;gap:7px;padding:11px 18px;border-bottom:1px solid var(--border);background:var(--page);flex-shrink:0;}
.tb-h1{font-family:var(--fd);font-size:17px;font-weight:300;}
.tb-range{font-family:var(--fs);font-size:11px;color:var(--muted);}
.tb-arr{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:var(--r-md);font-size:13px;color:var(--muted);}
.tb-btn{height:28px;padding:0 10px;border:1px solid var(--border);border-radius:var(--r-md);font-family:var(--fs);font-size:11px;font-weight:500;letter-spacing:.04em;color:var(--text);background:var(--page);}
.tb-ml{margin-left:auto;}
.tb-pri{background:var(--gd);color:#fff;border:none;}
.body{display:flex;flex:1;overflow:hidden;}
/* Sidebar */
.sb{width:184px;flex-shrink:0;border-right:1px solid var(--border);background:var(--surface);padding:13px;display:flex;flex-direction:column;gap:13px;}
.sb-lbl{font-family:var(--fs);font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
.score-box{background:var(--yt);border:1px solid var(--yl);border-radius:var(--r-md);padding:10px;}
.sc-big{font-family:var(--fd);font-size:27px;font-weight:300;line-height:1;}
.sc-den{font-family:var(--fs);font-size:11px;color:var(--muted);}
.pbar{height:4px;border-radius:var(--r-full);overflow:hidden;margin-top:6px;}
.pb-y{background:var(--yl);} .pb-t{background:var(--border);}
.pb-fill{height:100%;border-radius:var(--r-full);}
.pb-fg-y{background:var(--y);} .pb-fg-g{background:var(--g);}
.sr{display:flex;align-items:center;gap:6px;margin-top:6px;}
.sr-l{font-family:var(--fs);font-size:10px;color:var(--muted);width:68px;flex-shrink:0;}
.sr-b{flex:1;height:3px;border-radius:var(--r-full);background:var(--border);overflow:hidden;}
.sr-f{height:100%;border-radius:var(--r-full);}
.sr-v{font-family:var(--fm);font-size:9px;color:var(--muted);width:18px;text-align:right;}
.w-item{font-family:var(--fs);font-size:10px;color:var(--yx);margin-top:4px;line-height:1.4;}
.dp{display:flex;gap:2px;margin-top:5px;}
.dp-s{flex:1;height:4px;border-radius:var(--r-full);}
.sb-link{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--yx);display:block;margin-top:8px;}
/* Main */
.main{flex:1;overflow-y:auto;padding:12px;}
.grid7{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;}
/* ═══════════════════════════════════════════════
CARD FLIP SYSTEM
Each tile is a .scene > .card > .front + .back
═══════════════════════════════════════════════ */
.scene{
border-radius:var(--r-lg);
/* Perspective for 3D depth */
perspective:900px;
cursor:pointer;
}
.card{
position:relative;
width:100%;height:100%;
transform-style:preserve-3d;
transition:transform .45s cubic-bezier(.4,0,.2,1);
border-radius:var(--r-lg);
}
.card.flipped{transform:rotateY(180deg);}
/* Both faces */
.card-front,
.card-back{
position:absolute;inset:0;
border-radius:var(--r-lg);
overflow:hidden;
backface-visibility:hidden;
-webkit-backface-visibility:hidden;
}
/* ── FRONT face: full-bleed image ─── */
.card-front{
background-size:cover;
background-position:center;
}
/* Gradient: dark top (header), clear middle, dark bottom (text) */
.front-overlay{
position:absolute;inset:0;
background:
linear-gradient(to bottom,
rgba(0,0,0,.38) 0%,
rgba(0,0,0,0) 28%,
rgba(0,0,0,0) 48%,
rgba(0,0,0,.62) 100%
);
border-radius:inherit;
}
.front-head{
position:absolute;top:0;left:0;right:0;
display:flex;align-items:center;justify-content:space-between;
padding:8px 9px;z-index:2;
}
.front-abbr{font-family:var(--fs);font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgba(255,255,255,.85);font-weight:500;}
.front-badge{
width:20px;height:20px;border-radius:var(--r-full);
display:flex;align-items:center;justify-content:center;
font-family:var(--fs);font-size:10px;font-weight:500;
color:rgba(255,255,255,.9);background:rgba(255,255,255,.22);
}
.fb-today{background:var(--y) !important;color:#fff !important;}
.front-info{
position:absolute;bottom:0;left:0;right:0;
padding:8px 9px 10px;z-index:2;
}
.front-name{
font-family:var(--fd);font-size:13px;font-weight:300;
color:#fff;line-height:1.3;
text-shadow:0 1px 4px rgba(0,0,0,.5);
}
.front-meta{font-family:var(--fs);font-size:10px;color:rgba(255,255,255,.78);margin-top:2px;}
.front-tags{display:flex;gap:3px;flex-wrap:wrap;margin-top:5px;}
.ftag{
font-family:var(--fs);font-size:8px;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);
}
/* State rings via box-shadow (no layout shift) */
.card-front.st-default{box-shadow:var(--sh-card);}
.card-front.st-today{box-shadow:0 0 0 2px var(--y), var(--sh-card);}
.card-front.st-sel{box-shadow:0 0 0 2px var(--g), var(--sh-raised);}
.card-back.st-today{box-shadow:0 0 0 2px var(--y), var(--sh-raised);}
.card-back.st-sel{box-shadow:0 0 0 2px var(--g), var(--sh-raised);}
/* ── BACK face: recipe detail ─── */
.card-back{
transform:rotateY(180deg);
background:var(--page);
display:flex;flex-direction:column;
padding:0;
}
/* Thin colour strip at top of back = recipe's colour accent */
.back-strip{height:5px;flex-shrink:0;border-radius:var(--r-lg) var(--r-lg) 0 0;}
.back-inner{
display:flex;flex-direction:column;
flex:1;padding:8px 9px 9px;overflow:hidden;
}
.back-head{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:6px;flex-shrink:0;
}
.back-day{font-family:var(--fs);font-size:9px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);}
.back-close{
width:18px;height:18px;border-radius:var(--r-full);
display:flex;align-items:center;justify-content:center;
background:var(--subtle);font-size:11px;line-height:1;
color:var(--muted);cursor:pointer;flex-shrink:0;
border:none;font-family:var(--fs);
}
.back-close:hover{background:var(--border);}
.back-name{
font-family:var(--fd);font-size:15px;font-weight:300;
line-height:1.25;color:var(--text);
margin-bottom:3px;flex-shrink:0;
}
.back-meta{font-family:var(--fs);font-size:10px;color:var(--muted);margin-bottom:8px;flex-shrink:0;}
.back-ings{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:8px;flex-shrink:0;}
.bing{
font-family:var(--fs);font-size:9px;
background:var(--surface);border:1px solid var(--border);
border-radius:var(--r-full);padding:2px 6px;color:var(--text);
}
.bing-s{background:var(--subtle);border-color:var(--subtle);color:var(--muted);}
.back-actions{display:flex;flex-direction:column;gap:4px;margin-top:auto;}
.bact{
display:block;width:100%;padding:6px 8px;
border-radius:var(--r-md);border:1px solid var(--border);
background:var(--page);font-family:var(--fs);
font-size:10px;font-weight:500;letter-spacing:.04em;
text-align:center;color:var(--text);cursor:pointer;
}
.bact-pri{background:var(--gd);color:#fff;border:none;}
.bact-err{color:var(--err);border-color:var(--err);background:transparent;margin-top:2px;}
/* Tile faded (non-selected state) */
.scene-faded{opacity:.38;pointer-events:none;}
/* ── EMPTY TILE (no flip needed) ─── */
.tile-empty{
border-radius:var(--r-lg);
border:1.5px dashed var(--border);
background:var(--surface);
display:flex;flex-direction:column;
overflow:hidden;box-shadow:var(--sh-card);
cursor:pointer;
}
.te-sel{border:2px dashed var(--g);background:rgba(232,245,234,.5);}
.te-faded{opacity:.22;pointer-events:none;}
.te-head{display:flex;align-items:center;justify-content:space-between;padding:7px 8px 0;flex-shrink:0;}
.te-abbr{font-family:var(--fs);font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);}
.te-num{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--muted);}
.te-cta{display:flex;flex-direction:column;align-items:center;padding:7px 6px 5px;gap:2px;flex-shrink:0;border-bottom:1px solid var(--border);}
.te-plus{font-size:17px;color:var(--border);}
.te-label{font-family:var(--fs);font-size:9px;color:var(--muted);}
.sug-list{display:flex;flex-direction:column;padding:5px 7px 5px;flex:1;overflow:hidden;}
.sug-hd{font-family:var(--fs);font-size:8px;font-weight:500;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);padding:3px 0 4px;border-bottom:1px solid var(--subtle);margin-bottom:2px;}
.sug-row{display:flex;align-items:center;gap:4px;padding:5px 0;border-bottom:1px solid var(--subtle);cursor:pointer;}
.sug-row:last-of-type{border-bottom:none;}
.sug-name{font-family:var(--fd);font-size:11px;font-weight:300;color:var(--text);flex:1;line-height:1.2;}
.stag{font-family:var(--fs);font-size:8px;font-weight:500;padding:1px 4px;border-radius:2px;white-space:nowrap;flex-shrink:0;}
.st-g{background:var(--gt);color:var(--gd);}
.st-y{background:var(--yt);color:var(--yx);}
.sug-more{font-family:var(--fs);font-size:9px;font-weight:500;color:var(--yx);text-align:center;padding-top:4px;margin-top:auto;}
/* ── Image backgrounds ───────────────────────── */
.img-haehnchen{background:linear-gradient(160deg,#d4923a 0%,#a85e1a 50%,#7a3d0c 100%);}
.img-rind {background:linear-gradient(160deg,#c04545 0%,#8b2020 50%,#5a1010 100%);}
.img-stirfry {background:linear-gradient(160deg,#5fa85e 0%,#2e7031 50%,#1a4a1e 100%);}
.img-fisch {background:linear-gradient(160deg,#5b9fd4 0%,#2868a0 50%,#10406e 100%);}
.img-pizza {background:linear-gradient(160deg,#d4a832 0%,#a07010 50%,#6e4a00 100%);}
/* Accent strip matches image colours */
.strip-haehnchen{background:linear-gradient(90deg,#d4923a,#a85e1a);}
.strip-rind {background:linear-gradient(90deg,#c04545,#8b2020);}
.strip-stirfry {background:linear-gradient(90deg,#5fa85e,#2e7031);}
.strip-fisch {background:linear-gradient(90deg,#5b9fd4,#2868a0);}
.strip-pizza {background:linear-gradient(90deg,#d4a832,#a07010);}
/* ── Demo controls ───────────────────────────── */
.demo-hint{
font-family:var(--fs);font-size:11px;color:var(--muted);
text-align:center;margin-bottom:10px;
}
.demo-hint span{
background:var(--subtle);border-radius:var(--r-sm);
padding:2px 8px;font-weight:500;color:var(--text);
}
.specimen-row{display:flex;gap:14px;margin-bottom:8px;flex-wrap:wrap;align-items:flex-start;}
.specimen-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;}
.specimen-label{font-family:var(--fs);font-size:10px;font-weight:500;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);text-align:center;}
</style>
</head>
<body>
<p class="eyebrow">Mealplan · Planer · Flip Tiles</p>
<h1 class="pg-title">Kachel-Flip + Zutaten-Farben</h1>
<p class="pg-sub">
Klick auf eine gefüllte Kachel → sie dreht sich um. Auf der Rückseite: Rezeptname, Hauptzutaten, Aktionen.
Kein Expansion-Panel mehr. Leere Kacheln bleiben unverändert mit Inline-Vorschlägen.
</p>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- SEKTION 1: FARB-PALETTE -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="block">
<div class="bl-hd">
<span class="bl-num">Palette</span>
<span class="bl-name">Farben nach Hauptzutat / Küchenstil</span>
<span class="bl-sub">Fallback wenn heroImageUrl fehlt</span>
</div>
<div class="swatch-grid">
<!-- Proteins -->
<div class="swatch"><div class="swatch-color img-haehnchen"></div><div class="swatch-label">Hähnchen</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color img-rind"></div><div class="swatch-label">Rind</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color img-fisch"></div><div class="swatch-label">Fisch</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color img-stirfry"></div><div class="swatch-label">Tofu</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#7bc47b 0%,#3d8c3d 50%,#1e5a1e 100%);"></div><div class="swatch-label">vegetarisch</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4785a 0%,#a04535 50%,#6e2418 100%);"></div><div class="swatch-label">Schwein</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#9e6b3a 0%,#6b3f1a 50%,#3e2208 100%);"></div><div class="swatch-label">Lamm</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4b832 0%,#a07010 50%,#6e4800 100%);"></div><div class="swatch-label">Ei</div><div class="swatch-sub">Protein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#8b6b3a 0%,#5e421a 50%,#3a2408 100%);"></div><div class="swatch-label">Hülsenfrüchte</div><div class="swatch-sub">Protein</div></div>
<!-- Cuisine overrides -->
<div class="swatch"><div class="swatch-color img-pizza"></div><div class="swatch-label">Italienisch</div><div class="swatch-sub">Küche</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#3a6e3a 0%,#1e4a1e 50%,#0e2e0e 100%);"></div><div class="swatch-label">Asiatisch</div><div class="swatch-sub">Küche</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c49010 0%,#8b5e00 50%,#5a3800 100%);"></div><div class="swatch-label">Indisch</div><div class="swatch-sub">Küche</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545 0%,#7a1e1e 50%,#4a0f0f 100%);"></div><div class="swatch-label">Mexikanisch</div><div class="swatch-sub">Küche</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#4a90b8 0%,#1e5a8b 50%,#0a3456 100%);"></div><div class="swatch-label">Mediterran</div><div class="swatch-sub">Küche</div></div>
</div>
<div class="note">
<strong>Priorität:</strong> Wenn <code>heroImageUrl</code> vorhanden → echtes Foto.
Sonst: Farbe nach erstem Protein-Tag (z.B. <code>tagType=protein</code>, <code>tagName=Hähnchen</code>).
Wenn kein Protein-Tag → Farbe nach Küchenstil-Tag (<code>tagType=cuisine</code>).
Fallback auf <code>--color-surface</code> neutral.
Die Farbwerte werden als CSS-Klassen gemappt: <code>protein-haehnchen</code>, <code>cuisine-asiatisch</code> etc.
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- SEKTION 2: INTERACTIVE FLIP DEMO -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="block">
<div class="bl-hd">
<span class="bl-num">Demo</span>
<span class="bl-name">Flip-Interaktion — zum Klicken</span>
<span class="bl-sub">Echte CSS-3D-Transition</span>
</div>
<p class="demo-hint">Klicke auf eine Kachel um sie umzudrehen. <span>×</span> auf der Rückseite klappt zurück.</p>
<div class="specimen-row">
<!-- Tile 1: Hähnchen-Curry (normal) -->
<div class="specimen-wrap">
<div class="specimen-label">Standard</div>
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
<div class="card">
<div class="card-front img-haehnchen st-default">
<div class="front-overlay"></div>
<div class="front-head">
<span class="front-abbr">Mo</span>
<span class="front-badge">7</span>
</div>
<div class="front-info">
<div class="front-name">Hähnchen-Curry</div>
<div class="front-meta">35 Min · mittel</div>
<div class="front-tags">
<span class="ftag">Hähnchen</span>
<span class="ftag">4 Port.</span>
</div>
</div>
</div>
<div class="card-back st-default">
<div class="back-strip strip-haehnchen"></div>
<div class="back-inner">
<div class="back-head">
<span class="back-day">Mo · 7. Apr</span>
<button class="back-close" onclick="unflip(event,this)">×</button>
</div>
<div class="back-name">Hähnchen-Curry</div>
<div class="back-meta">35 Min · mittel · 4 Port.</div>
<div class="back-ings">
<span class="bing">Hähnchen</span>
<span class="bing">Kokosmilch</span>
<span class="bing">Paprika</span>
<span class="bing">Spinat</span>
<span class="bing-s">Curry</span>
<span class="bing-s">Knoblauch</span>
</div>
<div class="back-actions">
<button class="bact bact-pri">Koch-Modus</button>
<button class="bact">Rezept ansehen</button>
<button class="bact">Gericht tauschen</button>
<button class="bact bact-err">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tile 2: Pasta Bolognese (today) -->
<div class="specimen-wrap">
<div class="specimen-label">Heute</div>
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
<div class="card">
<div class="card-front img-rind st-today">
<div class="front-overlay"></div>
<div class="front-head">
<span class="front-abbr">Di</span>
<span class="front-badge fb-today">8</span>
</div>
<div class="front-info">
<div class="front-name">Pasta Bolognese</div>
<div class="front-meta">45 Min · mittel</div>
<div class="front-tags">
<span class="ftag" style="background:rgba(242,193,46,.35);">Rind</span>
<span class="ftag" style="background:rgba(242,193,46,.35);">Heute</span>
</div>
</div>
</div>
<div class="card-back st-today">
<div class="back-strip strip-rind"></div>
<div class="back-inner">
<div class="back-head">
<span class="back-day" style="color:var(--yx);">Di · Heute</span>
<button class="back-close" onclick="unflip(event,this)">×</button>
</div>
<div class="back-name">Pasta Bolognese</div>
<div class="back-meta">45 Min · mittel · 4 Port.</div>
<div class="back-ings">
<span class="bing">Rinderhack</span>
<span class="bing">Pasta</span>
<span class="bing">Tomaten</span>
<span class="bing">Zwiebeln</span>
<span class="bing-s">Olivenöl</span>
<span class="bing-s">Knoblauch</span>
</div>
<div class="back-actions">
<button class="bact bact-pri">Koch-Modus</button>
<button class="bact">Rezept ansehen</button>
<button class="bact">Gericht tauschen</button>
<button class="bact bact-err">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tile 3: Gemüse-Stir-fry (selected + flipped by default) -->
<div class="specimen-wrap">
<div class="specimen-label">Ausgewählt (bereits umgedreht)</div>
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
<div class="card flipped">
<div class="card-front img-stirfry st-sel">
<div class="front-overlay"></div>
<div class="front-head">
<span class="front-abbr">Mi</span>
<span class="front-badge" style="background:var(--g);color:#fff;">9</span>
</div>
<div class="front-info">
<div class="front-name">Gemüse-Stir-fry</div>
<div class="front-meta">20 Min · einfach</div>
<div class="front-tags"><span class="ftag" style="background:rgba(61,140,74,.4);">Tofu</span></div>
</div>
</div>
<div class="card-back st-sel">
<div class="back-strip strip-stirfry"></div>
<div class="back-inner">
<div class="back-head">
<span class="back-day" style="color:var(--gd);">Mi · 9. Apr</span>
<button class="back-close" onclick="unflip(event,this)">×</button>
</div>
<div class="back-name">Gemüse-Stir-fry</div>
<div class="back-meta">20 Min · einfach · 2 Port.</div>
<div class="back-ings">
<span class="bing">Tofu</span>
<span class="bing">Paprika</span>
<span class="bing">Brokkoli</span>
<span class="bing">Karotten</span>
<span class="bing-s">Sesamöl</span>
<span class="bing-s">Sojasauce</span>
</div>
<div class="back-actions">
<button class="bact bact-pri">Koch-Modus</button>
<button class="bact">Rezept ansehen</button>
<button class="bact">Gericht tauschen</button>
<button class="bact bact-err">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty tile with suggestions -->
<div class="specimen-wrap">
<div class="specimen-label">Leer — kein Flip</div>
<div class="tile-empty" style="width:150px;height:240px;">
<div class="te-head">
<span class="te-abbr">Sa</span>
<span class="te-num">12</span>
</div>
<div class="te-cta">
<div class="te-plus">+</div>
<div class="te-label">Gericht wählen</div>
</div>
<div class="sug-list">
<div class="sug-hd">Vorschläge</div>
<div class="sug-row"><span class="sug-name">Ramen mit Ei</span><span class="stag st-g">Neues Protein</span></div>
<div class="sug-row"><span class="sug-name">Shakshuka</span><span class="stag st-g">Kein Overlap</span></div>
<div class="sug-row"><span class="sug-name">Tacos</span><span class="stag st-y">Aufwand: leicht</span></div>
<div class="sug-more">Alle Rezepte →</div>
</div>
</div>
</div>
</div>
<div class="note">
<strong>Flip-Mechanik:</strong> CSS <code>transform:rotateY(180deg)</code> auf dem <code>.card</code> wrapper,
<code>backface-visibility:hidden</code> auf beiden Faces, <code>perspective:900px</code> auf der Scene.
Transition: <code>.45s cubic-bezier(.4,0,.2,1)</code> (Material-Easing — schnell herein, weich heraus).
Der <code>×</code> Button auf der Rückseite stoppt den Klick-Event mit <code>stopPropagation()</code>
und dreht die Karte zurück. Kein zusätzlicher State nötig — die Karte ist selbst das State-Element.
<br><br>
<strong>Farbstreifen</strong> oben auf der Rückseite = 5px Gradient, identisch mit der Front-Farbe.
Gibt visuelle Kontinuität zwischen Vorder- und Rückseite.
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- SEKTION 3: VOLLSTÄNDIGE SEITENANSICHT — Mi UMGEDREHT -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="block">
<div class="bl-hd">
<span class="bl-num">Seite</span>
<span class="bl-name">Vollansicht — Mittwoch umgedreht</span>
<span class="bl-sub">Kein rechtes Panel. Kacheln bis zum Rand.</span>
</div>
<div class="frame" style="height:560px;">
<div class="tb">
<span class="tb-h1">Wochenplaner</span>
<span class="tb-range">7.13. Apr</span>
<div class="tb-arr"></div><div class="tb-arr"></div>
<button class="tb-btn tb-ml">Heute</button>
</div>
<div class="body">
<!-- Sidebar -->
<div class="sb">
<div class="score-box">
<div class="sb-lbl">Abwechslungs-Score</div>
<div style="display:flex;align-items:baseline;gap:4px;"><span class="sc-big">7.8</span><span class="sc-den">/10</span></div>
<div class="pbar pb-y"><div class="pb-fill pb-fg-y" style="width:78%;"></div></div>
<div class="sr"><span class="sr-l">Protein</span><div class="sr-b"><div class="sr-f" style="width:80%;background:var(--g);"></div></div><span class="sr-v">8.0</span></div>
<div class="sr"><span class="sr-l">Zutaten</span><div class="sr-b"><div class="sr-f" style="width:72%;background:var(--y);"></div></div><span class="sr-v">7.2</span></div>
<div class="sr"><span class="sr-l">Aufwand</span><div class="sr-b"><div class="sr-f" style="width:82%;background:var(--g);"></div></div><span class="sr-v">8.2</span></div>
<a class="sb-link">Variety-Analyse →</a>
</div>
<div>
<div class="sb-lbl">Überschneidungen</div>
<div class="w-item">⚠ Hähnchen an Mo + Do</div>
<div class="w-item">⚠ Tomaten an Di + Do</div>
</div>
<div>
<div class="sb-lbl">Geplant</div>
<div style="display:flex;align-items:baseline;gap:3px;"><span style="font-family:var(--fd);font-size:20px;font-weight:300;">5</span><span style="font-family:var(--fs);font-size:10px;color:var(--muted);">/ 7 Tage</span></div>
<div class="dp" style="margin-top:5px;">
<div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--border);"></div><div class="dp-s" style="background:var(--border);"></div>
</div>
</div>
</div>
<!-- MAIN: grid fills full height, Mi is flipped -->
<div class="main">
<div class="grid7" style="height:100%;">
<!-- Mo: faded -->
<div class="scene scene-faded" style="height:100%;">
<div class="card">
<div class="card-front img-haehnchen st-default">
<div class="front-overlay"></div>
<div class="front-head"><span class="front-abbr">Mo</span><span class="front-badge">7</span></div>
<div class="front-info"><div class="front-name">Hähnchen-Curry</div><div class="front-meta">35 Min · mittel</div></div>
</div>
</div>
</div>
<!-- Di: today, faded -->
<div class="scene scene-faded" style="height:100%;">
<div class="card">
<div class="card-front img-rind st-today">
<div class="front-overlay"></div>
<div class="front-head"><span class="front-abbr">Di</span><span class="front-badge fb-today">8</span></div>
<div class="front-info"><div class="front-name">Pasta Bolognese</div><div class="front-meta">45 Min · mittel</div></div>
</div>
</div>
</div>
<!-- Mi: SELECTED + FLIPPED -->
<div class="scene" style="height:100%;" onclick="flip(this)">
<div class="card flipped">
<div class="card-front img-stirfry st-sel">
<div class="front-overlay"></div>
<div class="front-head">
<span class="front-abbr">Mi</span>
<span class="front-badge" style="background:var(--g);color:#fff;">9</span>
</div>
<div class="front-info">
<div class="front-name">Gemüse-Stir-fry</div>
<div class="front-meta">20 Min · einfach</div>
<div class="front-tags"><span class="ftag" style="background:rgba(61,140,74,.4);">Tofu</span></div>
</div>
</div>
<div class="card-back st-sel">
<div class="back-strip strip-stirfry"></div>
<div class="back-inner">
<div class="back-head">
<span class="back-day" style="color:var(--gd);">Mi · 9. Apr</span>
<button class="back-close" onclick="unflip(event,this)">×</button>
</div>
<div class="back-name">Gemüse-Stir-fry</div>
<div class="back-meta">20 Min · einfach · 2 Port.</div>
<div class="back-ings">
<span class="bing">Tofu</span>
<span class="bing">Paprika</span>
<span class="bing">Brokkoli</span>
<span class="bing">Karotten</span>
<span class="bing">Ingwer</span>
<span class="bing-s">Sesamöl</span>
<span class="bing-s">Sojasauce</span>
</div>
<div class="back-actions">
<button class="bact bact-pri">Koch-Modus</button>
<button class="bact">Rezept ansehen</button>
<button class="bact">Gericht tauschen</button>
<button class="bact bact-err">Entfernen</button>
</div>
</div>
</div>
</div>
</div>
<!-- Do: faded -->
<div class="scene scene-faded" style="height:100%;">
<div class="card">
<div class="card-front img-fisch st-default">
<div class="front-overlay"></div>
<div class="front-head"><span class="front-abbr">Do</span><span class="front-badge">10</span></div>
<div class="front-info"><div class="front-name">Lachs mit Kartoffeln</div><div class="front-meta">30 Min · einfach</div></div>
</div>
</div>
</div>
<!-- Fr: faded -->
<div class="scene scene-faded" style="height:100%;">
<div class="card">
<div class="card-front img-pizza st-default">
<div class="front-overlay"></div>
<div class="front-head"><span class="front-abbr">Fr</span><span class="front-badge">11</span></div>
<div class="front-info"><div class="front-name">Pizza Margherita</div><div class="front-meta">50 Min · aufwändig</div></div>
</div>
</div>
</div>
<!-- Sa: empty with suggestions -->
<div class="tile-empty te-faded" style="height:100%;">
<div class="te-head"><span class="te-abbr">Sa</span><span class="te-num">12</span></div>
<div class="te-cta"><div class="te-plus">+</div><div class="te-label">Gericht wählen</div></div>
<div class="sug-list">
<div class="sug-hd">Vorschläge</div>
<div class="sug-row"><span class="sug-name">Ramen mit Ei</span><span class="stag st-g">Neues Protein</span></div>
<div class="sug-row"><span class="sug-name">Shakshuka</span><span class="stag st-g">Kein Overlap</span></div>
<div class="sug-more">Alle Rezepte →</div>
</div>
</div>
<!-- So: empty with suggestions -->
<div class="tile-empty te-faded" style="height:100%;">
<div class="te-head"><span class="te-abbr">So</span><span class="te-num">13</span></div>
<div class="te-cta"><div class="te-plus">+</div><div class="te-label">Gericht wählen</div></div>
<div class="sug-list">
<div class="sug-hd">Vorschläge</div>
<div class="sug-row"><span class="sug-name">Grünes Thai-Curry</span><span class="stag st-g">Neues Protein</span></div>
<div class="sug-row"><span class="sug-name">Tacos</span><span class="stag st-y">Aufwand: leicht</span></div>
<div class="sug-more">Alle Rezepte →</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
<strong>Layout:</strong> Linke Sidebar (Variety-Score) bleibt. Kein rechtes Panel mehr.
Die Kacheln füllen den gesamten verbleibenden Platz (<code>flex:1</code>) — 7 gleich breite Spalten,
volle Höhe (<code>height:100%</code> auf Grid und Kacheln). Kein Layout-Shift, kein After-Scroll.
<br><br>
<strong>Dimm-Effekt:</strong> Beim Flip werden alle anderen Kacheln auf 38% gedimmt.
Kein neuer API-Aufruf nötig — reine CSS-Klasse per JS.
<br><br>
<strong>„Gericht tauschen":</strong> Öffnet den Rezept-Picker als Slide-in-Drawer von rechts
(kein persistentes Panel). Drawer schließt sich nach Auswahl oder Abbruch.
<br><br>
<strong>Leere Kacheln:</strong> Zeigen Inline-Vorschläge auch im gedimmten Zustand (wenn
eine andere Kachel geflippt ist). Kein Flip auf leeren Kacheln.
</div>
</div>
<script>
function flip(scene) {
const card = scene.querySelector('.card');
const isFlipped = card.classList.toggle('flipped');
// Dim all other scenes in the same grid
const grid = scene.closest('.grid7');
if (!grid) return;
grid.querySelectorAll('.scene, .tile-empty').forEach(el => {
if (el === scene) return;
if (isFlipped) {
el.style.opacity = '0.38';
el.style.pointerEvents = 'none';
} else {
el.style.opacity = '';
el.style.pointerEvents = '';
}
});
}
function unflip(event, btn) {
event.stopPropagation();
const scene = btn.closest('.scene');
const card = scene.querySelector('.card');
card.classList.remove('flipped');
// Un-dim everything
const grid = scene.closest('.grid7');
if (!grid) return;
grid.querySelectorAll('.scene, .tile-empty').forEach(el => {
el.style.opacity = '';
el.style.pointerEvents = '';
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Planner Redesign — Flip Tiles · Final Spec</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<!--
spec:agent
document: Planner Desktop Redesign — Flip Tiles
version: 1.0
route: /planner (desktop)
screens: Planner main area — tile grid, sidebar, recipe picker drawer
key-decisions:
- Full-bleed color/image tiles (no blank body space)
- CSS 3D card flip replaces expansion panel
- No persistent right panel — tiles fill full remaining width
- Ingredient/cuisine color palette as heroImageUrl fallback
- Inline suggestions on empty tiles (reasoning tags, no delta numbers)
- No "Gericht hinzufügen" toolbar button (empty tile CTA handles it)
- Recipe picker opens as slide-in drawer (on demand only)
last-updated: 2026-04
reference-mockups:
- specs/planner-flip-tiles.html (interactive demo, color palette)
-->
<style>
:root {
--color-page: #FAFAF7;
--color-surface: #F5F4EE;
--color-subtle: #EDECEA;
--color-border: #D8D7D0;
--color-text-muted: #6B6A63;
--color-text: #1C1C18;
--green-tint: #E8F5EA;
--green-light: #AEDCB0;
--green: #3D8C4A;
--green-dark: #2E6E39;
--yellow-tint: #FDF6D8;
--yellow-light: #F9E08A;
--yellow: #F2C12E;
--yellow-text: #8A6800;
--color-error: #DC4C3E;
--font-display: 'Fraunces', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', monospace;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-full: 9999px;
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.06);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
.doc { max-width: 900px; margin: 0 auto; padding: 48px 40px 96px; }
.doc-header { display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; }
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
.doc-header p { font-size: 13px; color: var(--color-text-muted); }
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
.intro { font-size: 14px; line-height: 1.75; color: var(--color-text); max-width: 700px; margin-bottom: 48px; }
.intro p + p { margin-top: 12px; }
.section { margin-bottom: 56px; }
.section-label { font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 28px; }
h2 { font-family: var(--font-display); font-size: 20px; font-weight: 400; margin-bottom: 14px; }
h3 { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--color-text); }
p { margin-bottom: 10px; font-size: 14px; line-height: 1.7; }
ul { padding-left: 20px; margin-bottom: 12px; }
li { font-size: 14px; line-height: 1.65; margin-bottom: 4px; }
code { font-family: var(--font-mono); font-size: 12px; background: var(--color-subtle); border-radius: 3px; padding: 1px 5px; }
pre { font-family: var(--font-mono); font-size: 12px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 14px 16px; margin: 12px 0; overflow-x: auto; line-height: 1.6; }
.callout { background: var(--color-surface); border-left: 3px solid var(--color-border); border-radius: 0 var(--radius-md) var(--radius-md) 0; padding: 12px 16px; margin: 16px 0; font-size: 13px; line-height: 1.65; }
.callout.green { border-color: var(--green); background: var(--green-tint); }
.callout.yellow { border-color: var(--yellow); background: var(--yellow-tint); }
.callout strong { font-weight: 600; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 16px 0; }
th { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--color-border); }
td { padding: 9px 12px; border-bottom: 1px solid var(--color-subtle); vertical-align: top; }
tr:last-child td { border-bottom: none; }
.swatch-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.swatch { width: 80px; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-card); }
.swatch-color { height: 44px; }
.swatch-name { font-size: 10px; font-weight: 500; padding: 4px 6px; background: var(--color-page); border-top: 1px solid var(--color-border); }
.swatch-sub { font-size: 9px; color: var(--color-text-muted); padding: 0 6px 4px; }
.state-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; }
.state-card { border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 16px; }
.state-name { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 6px; }
.state-desc { font-size: 13px; line-height: 1.6; }
.component-row { display: flex; gap: 8px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--color-subtle); }
.component-row:last-child { border-bottom: none; }
.comp-file { font-family: var(--font-mono); font-size: 12px; color: var(--color-text); flex: 0 0 auto; min-width: 280px; }
.comp-action { font-size: 13px; color: var(--color-text-muted); }
.badge { display: inline-block; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: var(--radius-full); }
.badge-new { background: var(--green-tint); color: var(--green-dark); }
.badge-mod { background: var(--yellow-tint); color: var(--yellow-text); }
.badge-del { background: #fde8e8; color: var(--color-error); }
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>Planner Desktop Redesign</h1>
<p>Flip Tiles · Final Spec · Route: <code>/planner</code></p>
</div>
<div class="doc-meta">
Version 1.0<br>
2026-04<br>
Mockup: <code>specs/planner-flip-tiles.html</code>
</div>
</div>
<div class="intro">
<p>
Der Wochenplaner hat auf Desktop aktuell ~80 % vertikalen Leerraum unterhalb des 7-Spalten-Kalenders.
Zusätzlich ist das rechte Panel im Leerlauf nicht genutzt. Dieses Spec beschreibt ein vollständiges
Redesign der Desktop-Hauptfläche: Die Kacheln füllen die volle Höhe und Breite, Rezeptdetails werden
über einen CSS-3D-Flip direkt in der Kachel angezeigt, und leere Tage zeigen Inline-Vorschläge.
</p>
<p>
Das rechte Panel entfällt dauerhaft. Der Rezept-Picker öffnet sich als Slide-in-Drawer ausschließlich
auf Anfrage (Aktion „Gericht tauschen" auf der Kachel-Rückseite). Der Toolbar-Button
„Gericht hinzufügen" entfällt, da jede leere Kachel eine eigene CTA hat.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">01 · Layout</div>
<h2>Seitenstruktur</h2>
<p>Desktop-Layout: 2 Spalten. Kein persistentes rechtes Panel mehr.</p>
<pre>┌─────────────────────────────────────────────────────────────┐
│ Toolbar (Wochenplaner · 7.13. Apr Heute) │
├──────────┬──────────────────────────────────────────────────┤
│ Sidebar │ 7-Spalten-Kachelgrid (flex: 1, height: 100%) │
│ 184 px │ │
│ Variety │ Mo Di Mi Do Fr Sa So │
│ Score │ ████ ████ ████ ████ ████ ░░░░ ░░░░ │
│ │ ████ ████ ████ ████ ████ ░+░░ ░+░░ │
│ │ ████ ████ ████ ████ ████ ░Vor░ ░Vor░ │
└──────────┴──────────────────────────────────────────────────┘</pre>
<ul>
<li><strong>Sidebar (184 px, flex-shrink: 0):</strong> Variety-Score-Card, Sub-Scores, Überschneidungs-Warnungen, Link zur Variety-Analyse. Unverändert.</li>
<li><strong>Main (flex: 1):</strong> <code>display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; height: 100%</code>. Kacheln füllen die gesamte verbleibende Breite und Höhe.</li>
<li><strong>Toolbar:</strong> Nur Navigation — Wochenbezeichnung, Zurück/Vor-Pfeile, Heute-Button. Kein „+ Gericht hinzufügen" mehr.</li>
</ul>
<div class="callout yellow">
<strong>Entfernt:</strong> Das rechte Panel (<code>width: 228px</code>) mit der „Heute Abend"-Karte und dem Leerlauf-Hinweis entfällt vollständig. Koch-Modus ist auf der Kachel-Rückseite zugänglich.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">02 · Kachel-Zustände</div>
<h2>Tile States</h2>
<div class="state-grid">
<div class="state-card">
<div class="state-name">Standard (gefüllt)</div>
<div class="state-desc">
Vollbild-Farbhintergrund (Gradient nach Zutat/Küche) oder <code>heroImageUrl</code>.
Dual-Gradient-Overlay (oben + unten dunkel, Mitte klar).
Oben: Tageskürzel + Datumsziffer. Unten: Rezeptname, Kochzeit, Tags.
<br><br>
<code>box-shadow: var(--sh-card)</code> — kein sichtbarer Ring.
</div>
</div>
<div class="state-card">
<div class="state-name">Heute (gefüllt)</div>
<div class="state-desc">
Identisch wie Standard, aber mit gelbem Ring via
<code>box-shadow: 0 0 0 2px var(--yellow), var(--sh-card)</code>.
Datumsziffer-Badge in <code>--yellow</code>. Tag-Label „Heute" zusätzlich als frosted Tag.
</div>
</div>
<div class="state-card">
<div class="state-name">Ausgewählt / Geflippt</div>
<div class="state-desc">
Grüner Ring: <code>box-shadow: 0 0 0 2px var(--green), var(--sh-raised)</code>.
Karte dreht sich 180° (CSS 3D, siehe §04). Alle anderen Kacheln werden auf 38 % Deckkraft
gedimmt und sind nicht klickbar.
</div>
</div>
<div class="state-card">
<div class="state-name">Leer</div>
<div class="state-desc">
Kein Flip. Gestrichelter Rahmen (<code>border: 1.5px dashed var(--color-border)</code>),
<code>background: var(--color-surface)</code>. Oben: Tageskürzel + Datum.
Darunter: <code>+</code> Icon + „Gericht wählen". Rest der Kachel: Inline-Vorschläge (§05).
</div>
</div>
</div>
<div class="callout">
<strong>box-shadow statt border:</strong> Statusringe werden via <code>box-shadow</code> gesetzt, nicht via <code>border</code>,
um Layout-Shift zu vermeiden. Die Kacheln behalten identische Außenmaße in allen Zuständen.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">03 · Farb-Palette</div>
<h2>Ingredient &amp; Cuisine Colors</h2>
<p>
Wenn <code>heroImageUrl</code> vorhanden ist, wird das echte Foto als <code>background-image</code> gesetzt.
Fehlt es, greift die folgende Prioritätskette:
</p>
<ol style="padding-left:20px;margin-bottom:16px;">
<li>Ersten Tag mit <code>tagType = "protein"</code> finden → Protein-Farbe</li>
<li>Ersten Tag mit <code>tagType = "cuisine"</code> finden → Küchenstil-Farbe</li>
<li>Fallback: <code>background: var(--color-surface)</code> (neutral)</li>
</ol>
<h3>Protein-Farben</h3>
<div class="swatch-row">
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4923a,#a85e1a,#7a3d0c)"></div><div class="swatch-name">Hähnchen</div><div class="swatch-sub">protein-haehnchen</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545,#8b2020,#5a1010)"></div><div class="swatch-name">Rind</div><div class="swatch-sub">protein-rind</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#5b9fd4,#2868a0,#10406e)"></div><div class="swatch-name">Fisch</div><div class="swatch-sub">protein-fisch</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#5fa85e,#2e7031,#1a4a1e)"></div><div class="swatch-name">Tofu</div><div class="swatch-sub">protein-tofu</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#7bc47b,#3d8c3d,#1e5a1e)"></div><div class="swatch-name">Vegetarisch</div><div class="swatch-sub">protein-veg</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4785a,#a04535,#6e2418)"></div><div class="swatch-name">Schwein</div><div class="swatch-sub">protein-schwein</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#9e6b3a,#6b3f1a,#3e2208)"></div><div class="swatch-name">Lamm</div><div class="swatch-sub">protein-lamm</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4b832,#a07010,#6e4800)"></div><div class="swatch-name">Ei</div><div class="swatch-sub">protein-ei</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#8b6b3a,#5e421a,#3a2408)"></div><div class="swatch-name">Hülsen­früchte</div><div class="swatch-sub">protein-huelsenfruechte</div></div>
</div>
<h3>Küchenstil-Farben</h3>
<div class="swatch-row">
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545,#7a1e1e,#4a0f0f)"></div><div class="swatch-name">Italienisch</div><div class="swatch-sub">cuisine-italienisch</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#3a6e3a,#1e4a1e,#0e2e0e)"></div><div class="swatch-name">Asiatisch</div><div class="swatch-sub">cuisine-asiatisch</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c49010,#8b5e00,#5a3800)"></div><div class="swatch-name">Indisch</div><div class="swatch-sub">cuisine-indisch</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4923a,#8b4e10,#5a2e00)"></div><div class="swatch-name">Mexikanisch</div><div class="swatch-sub">cuisine-mexikanisch</div></div>
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#4a90b8,#1e5a8b,#0a3456)"></div><div class="swatch-name">Mediterran</div><div class="swatch-sub">cuisine-mediterran</div></div>
</div>
<p>
Die CSS-Klassen (<code>protein-haehnchen</code>, <code>cuisine-asiatisch</code>, …) werden
serverseitig aus den Rezept-Tags abgeleitet und als Svelte-Prop übergeben, z.B.
<code>colorClass="protein-haehnchen"</code>. Das Component setzt die Klasse auf dem Kachel-Wrapper.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">04 · Flip-Mechanik</div>
<h2>CSS 3D Card Flip</h2>
<p>Jede gefüllte Kachel besteht aus drei verschachtelten Elementen:</p>
<pre>.scene → perspective: 900px; border-radius: var(--radius-lg); cursor: pointer
.card → position: relative; transform-style: preserve-3d
transition: transform .45s cubic-bezier(.4,0,.2,1)
.card.flipped → transform: rotateY(180deg)
.card-front → backface-visibility: hidden; position: absolute; inset: 0
.card-back → backface-visibility: hidden; transform: rotateY(180deg)
position: absolute; inset: 0; background: var(--color-page)</pre>
<h3>Vorderseite</h3>
<ul>
<li>Vollbild-Farbe oder <code>background-image: url(heroImageUrl)</code> mit <code>background-size: cover</code></li>
<li>Dual-Gradient-Overlay als absolutes <code>::after</code>-Pseudo-Element:<br>
<code>linear-gradient(to bottom, rgba(0,0,0,.38) 0%, transparent 28%, transparent 48%, rgba(0,0,0,.62) 100%)</code></li>
<li>Oben links: Tageskürzel (9px uppercase). Oben rechts: Datums-Badge (Kreis)</li>
<li>Unten: Rezeptname (Fraunces 13px), Meta-Zeile (Kochzeit · Aufwand), Tag-Chips</li>
</ul>
<h3>Rückseite</h3>
<ul>
<li><strong>Farbstreifen (5 px)</strong> oben — identischer Gradient wie die Vorderseite. Gibt visuelle Kontinuität.</li>
<li>Tageskürzel + Datum (links) · × Schließen-Button (rechts)</li>
<li>Rezeptname (Fraunces 15px)</li>
<li>Meta: Kochzeit · Aufwand · Portionen</li>
<li>Zutaten-Pills: normale Zutaten als <code>.ingredient</code>, Vorrats-Zutaten (Staples) gedimmt als <code>.ingredient--staple</code></li>
<li>Aktionen (gestapelt, volle Breite):</li>
</ul>
<table>
<thead><tr><th>Aktion</th><th>Stil</th><th>Verhalten</th></tr></thead>
<tbody>
<tr><td>Koch-Modus starten</td><td>Primary (grün ausgefüllt)</td><td>Navigiert zu <code>/planner/cook/[slotId]</code></td></tr>
<tr><td>Rezept ansehen</td><td>Secondary (Rahmen)</td><td>Navigiert zu <code>/recipes/[recipeId]</code></td></tr>
<tr><td>Gericht tauschen</td><td>Secondary (Rahmen)</td><td>Öffnet Rezept-Picker-Drawer (§06)</td></tr>
<tr><td>Entfernen</td><td>Danger (roter Text, transparenter BG)</td><td>Löscht den Slot, Kachel wird leer</td></tr>
</tbody>
</table>
<h3>Interaction Flow</h3>
<ul>
<li>Klick auf <code>.scene</code><code>.card.classList.toggle('flipped')</code></li>
<li>Alle Geschwister-Kacheln im Grid → <code>opacity: 0.38; pointer-events: none</code></li>
<li>× Button auf Rückseite → <code>event.stopPropagation()</code>, <code>classList.remove('flipped')</code>, Geschwister-Opacity zurücksetzen</li>
<li>Escape-Taste → aktive Kachel zurückdrehen</li>
</ul>
<div class="callout green">
<strong>Kein API-Aufruf beim Flip.</strong> Alle dargestellten Daten (Name, Zutaten, Aktionen) sind bereits
im vorhandenen <code>slotMap</code>-State vorhanden. Der Flip ist eine rein visuelle Operation.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">05 · Leere Kacheln</div>
<h2>Empty Tile — Inline Suggestions</h2>
<p>Leere Kacheln haben denselben <code>height: 100%</code> wie gefüllte Kacheln. Kein Flip.</p>
<pre>┌─────────────────┐
│ Sa 12 │ ← Tageskürzel + Datum
│─────────────────│
│ + │
│ Gericht wählen │ ← Klick öffnet Rezept-Picker-Drawer
│─────────────────│
│ VORSCHLÄGE │
│ Ramen mit Ei [Neues Protein] │
│ Shakshuka [Kein Overlap] │
│ Tacos [Aufwand: leicht]│
│ │
│ Alle Rezepte → │
└────────────────────────────────┘</pre>
<h3>Vorschlag-Tags (Reasoning)</h3>
<p>Anstelle numerischer Score-Deltas (die für leere Slots immer positiv sind und daher keine Information tragen)
werden Begründungs-Tags angezeigt:</p>
<table>
<thead><tr><th>Tag</th><th>Farbe</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Neues Protein</td><td>Grün</td><td>Proteinquelle kommt diese Woche noch nicht vor</td></tr>
<tr><td>Kein Overlap</td><td>Grün</td><td>Keine Zutaten-Überschneidung mit anderen Tagen</td></tr>
<tr><td>Aufwand: leicht</td><td>Gelb</td><td>Kochzeit &lt; 30 Min oder Aufwand = einfach</td></tr>
<tr><td>Aufwand: mittel</td><td>Neutral</td><td>Mittlerer Aufwand</td></tr>
</tbody>
</table>
<div class="callout">
<strong>Datenquelle:</strong> Die vorhandene <code>GET /api/suggestions?weekId=&amp;dayOfWeek=</code> API liefert
<code>SuggestionItem { recipe, scoreDelta, hasConflict }</code>. Die Reasoning-Tags werden frontend-seitig
aus den Rezept-Tags und dem vorhandenen <code>slotMap</code> abgeleitet, kein Backend-Änderungsbedarf.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">06 · Rezept-Picker</div>
<h2>Recipe Picker Drawer</h2>
<p>
Der Rezept-Picker öffnet sich als Slide-in-Drawer von rechts — ausschließlich auf explizite Anfrage.
Er hat keinen persistenten Platz im Layout mehr.
</p>
<h3>Trigger</h3>
<ul>
<li>Klick auf <strong>„Gericht tauschen"</strong> auf der Kachel-Rückseite</li>
<li>Klick auf <strong>„Gericht wählen"</strong> CTA oder Vorschlag-Zeile auf einer leeren Kachel</li>
</ul>
<h3>Drawer-Verhalten</h3>
<ul>
<li>Slide-in von rechts, überlagert den Inhalt (kein Layout-Shift)</li>
<li>Breite: <code>min(480px, 90vw)</code></li>
<li>Backdrop (halbtransparent) schließt den Drawer bei Klick</li>
<li>Nach Auswahl: Drawer schließt sich, Slot wird aktualisiert, Kachel zeigt neues Rezept</li>
</ul>
<div class="callout">
Der bestehende <code>RecipePicker</code>-Komponente (aktuell im rechten Panel) wird in einen
generischen Drawer gewrappt. Der Drawer-Wrapper ist neu; der Picker selbst bleibt unverändert.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">07 · Mobile</div>
<h2>Mobile — Out of Scope</h2>
<p>
Dieses Spec betrifft ausschließlich die Desktop-Ansicht (<code>≥ 768px</code>).
Das mobile Layout (vertikaler Stack, DayMealCard, ActionSheet) bleibt unverändert.
CSS-3D-Flips auf Touch-Geräten haben bekannte Rendering-Unterschiede auf älteren Android-Browsern —
ein separates Issue sollte die mobile Interaktion (ggf. Slide-up Sheet statt Flip) spezifizieren.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">08 · Komponenten</div>
<h2>Komponenten-Übersicht</h2>
<div class="component-row">
<span class="comp-file">src/routes/(app)/planner/+page.svelte</span>
<span class="badge badge-mod">Ändern</span>
<span class="comp-action">Rechtes Panel entfernen. Layout auf 2-spaltig (sidebar + main) umstellen. Toolbar-Button entfernen. Grid-Höhe auf 100% setzen.</span>
</div>
<div class="component-row">
<span class="comp-file">src/lib/planner/DayMealCard.svelte</span>
<span class="badge badge-mod">Ersetzen / umbenennen</span>
<span class="comp-action">Zur Flip-Kachel umbauen: .scene → .card → .card-front + .card-back. Farb-Klassen-Prop, Gradient-Overlay, Back-Face mit Aktionen.</span>
</div>
<div class="component-row">
<span class="comp-file">src/lib/planner/EmptyDayTile.svelte</span>
<span class="badge badge-new">Neu</span>
<span class="comp-action">Leere Kachel: + CTA + Inline-Suggestion-Liste mit Reasoning-Tags. Ersetzt den bisherigen leeren Slot-Platzhalter.</span>
</div>
<div class="component-row">
<span class="comp-file">src/lib/planner/RecipePickerDrawer.svelte</span>
<span class="badge badge-new">Neu</span>
<span class="comp-action">Drawer-Wrapper um den bestehenden RecipePicker. Slide-in von rechts, Backdrop, Schließ-Logik.</span>
</div>
<div class="component-row">
<span class="comp-file">src/lib/planner/RecipePicker.svelte</span>
<span class="badge badge-mod">Ändern</span>
<span class="comp-action">Aus dem rechten Panel lösen. Bekommt slotId als Prop. Keine Änderung an der Such-/Auswahl-Logik nötig.</span>
</div>
<div class="component-row">
<span class="comp-file">src/app.css</span>
<span class="badge badge-mod">Ergänzen</span>
<span class="comp-action">14 Farb-Klassen für Protein- und Küchenstil-Gradients hinzufügen (<code>.protein-haehnchen</code>, <code>.cuisine-asiatisch</code>, …).</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="section">
<div class="section-label">09 · Accessibility</div>
<h2>A11y-Anforderungen</h2>
<ul>
<li><code>.scene</code>: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-expanded="false|true"</code>, <code>aria-label="[Rezeptname] — Details anzeigen"</code></li>
<li><code>.card-back</code>: <code>aria-hidden="true"</code> solange nicht geflippt</li>
<li>× Schließen-Button: <code>aria-label="Schließen"</code>, <code>type="button"</code></li>
<li>Keyboard: <code>Enter</code> / <code>Space</code> flippt, <code>Escape</code> dreht zurück</li>
<li>Dimming: gedimmte Kacheln bekommen <code>aria-hidden="true"</code> wenn eine andere geflippt ist</li>
</ul>
</div>
</div>
</body>
</html>

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