From 2ad75cc1b7cae4e869195a1674f436ea2c4c87fe Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 20:28:51 +0200 Subject: [PATCH 01/11] spec(staples): tile redesign, seed catalog & add-ingredient flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- specs/staples-settings-redesign.html | 1334 +++++++++++++++++++++++ specs/staples-settings-tile.html | 1456 ++++++++++++++++++++++++++ 2 files changed, 2790 insertions(+) create mode 100644 specs/staples-settings-redesign.html create mode 100644 specs/staples-settings-tile.html diff --git a/specs/staples-settings-redesign.html b/specs/staples-settings-redesign.html new file mode 100644 index 0000000..4a39324 --- /dev/null +++ b/specs/staples-settings-redesign.html @@ -0,0 +1,1334 @@ + + + + + + Vorräte Settings — 5 Redesign Concepts + + + + +

Vorräte Settings — Redesign Concepts

+

Atlas · 2026-04-10 · Mealplan Design System · 5 Concepts

+ +
+

Diagnosed Problems (current A3/D3)

+
    +
  • Chip clouds grow unbounded — categories with 20+ ingredients (Gewürze, Gemüse) swamp the page
  • +
  • 3-column grid creates severe visual imbalance between fat and lean categories
  • +
  • No search — finding "Parmesan" requires scanning every chip in Käse+Milchprodukte
  • +
  • No bulk actions — selecting an entire category (e.g. all oils) takes N taps
  • +
  • No count feedback — user can't assess their selection at a glance ("how many staples? Which categories are empty?")
  • +
  • Auto-save hint buried at bottom — user anxiety about whether changes stuck
  • +
+
+ + +
+
+ Concept 01 + Accordion Categories +
+

+ Categories are collapsed by default. A count badge shows selected/total at a glance. + Tap to expand any category — all others stay collapsed. One category in focus at a time. +

+ +
+ + +
+
Mobile — 320px
+
+ ← Einstellungen +
Vorräte
+

Wähle deine regelmäßig vorhandenen Zutaten.

+ + +
+ + +
+
+
+ Gemüse + 7 / 18 +
+ +
+
+ +
+ + +
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Aubergine + Paprika + Spinat + Tomaten + Lauch + Brokkoli + Kohl + Sellerie + Gurke +
+
+
+ +
+
+
+ Milchprodukte + 4 / 12 +
+ +
+
+ +
+
+
+ Gewürze + 0 / 24 +
+ +
+
+ +
+
+
+ Öle & Fette + 2 / 6 +
+ +
+
+ +
+
+
+ Getreide & Körner + 0 / 9 +
+ +
+
+ +
+ +

Automatisch gespeichert.

+
+
+ + +
+
Desktop — 640px (settings column)
+
+ ← Einstellungen +
Vorräte
+

Wähle deine regelmäßig vorhandenen Zutaten. Automatisch gespeichert.

+ +
+ +
+
+
+ Gemüse + 7 / 18 +
+
+ + + +
+
+
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Aubergine + Paprika + Spinat + Tomaten + Lauch + Brokkoli + Kohl + Sellerie +
+
+
+ +
+
+
+ Milchprodukte + 4 / 12 +
+ +
+
+ +
+
+
+ Gewürze + 0 / 24 +
+ +
+
+ +
+
+
+ Öle & Fette + 2 / 6 +
+ +
+
+ +
+
+
+
+ +

+ Trade-off: Solves visual overload immediately — only one category is in view at a time. + The Alle/Keine actions eliminate bulk-select friction. Count badges answer "where do I still need to configure?" + at a glance. Risk: accordion interaction adds a tap per category visit; power users who want + a full overview lose it. Works best when categories are <8. Keyboard nav via Enter/Space on row headers. +

+
+ + +
+
+ Concept 02 + Category Sidebar +
+

+ Left panel: sticky category list with count badges — acts as a mini-nav. + Right panel: the selected category's full chip cloud. One category in focus, full list visible. + Mobile collapses to top tabs (scrollable). +

+ +
+ + +
+
Mobile — horizontal tab strip
+
+
+ ← Einstellungen +
Vorräte
+
+ + +
+
+ Gemüse + 7 +
+
+ Milch + 4 +
+
+ Gewürze + 0 +
+
+ Öle + 2 +
+
+ + +
+
+ Gemüse — 7 gewählt +
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Aubergine + Paprika + Spinat + Tomaten + Lauch + Brokkoli + Kohl + Sellerie + Gurke + Blumenkohl +
+
+
+
+ + +
+
Desktop — sidebar + content panel
+
+ + +
+
+ Kategorien +
+ + +
+ Gemüse + 7 +
+
+ Milchprodukte + 4 +
+
+ Gewürze + 0 +
+
+ Öle & Fette + 2 +
+
+ Getreide + 0 +
+
+ Fleisch & Fisch + 3 +
+
+ Backwaren + 1 +
+
+ + +
+
+ Gemüse — 7 von 18 gewählt +
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Aubergine + Paprika + Spinat + Tomaten + Lauch + Brokkoli + Kohl + Sellerie + Gurke + Blumenkohl + Kohlrabi + Rote Bete + Fenchel + Erbsen +
+
+
+
+ Automatisch gespeichert · 23 Vorräte insgesamt +
+
+
+ +

+ Trade-off: Best scalability — the sidebar grows gracefully to 15+ categories without + visual breakdown. The "23 Vorräte insgesamt" footer answers the comprehension question for the whole household. + Active/inactive category coloring (green-tint bg + green-dark text) matches the existing nav convention exactly. + Risk: Two-panel layouts can feel over-engineered for simple tasks. Mobile tab strip + truncates long category names — needs abbreviation strategy. +

+
+ + +
+
+ Concept 03 + Search-First +
+

+ Search bar dominates the top. Below it: a single scrollable list with sticky category headers (like iOS Contacts). + Searching filters in real-time — category headers collapse when their section is empty. + For power users and anyone who knows what they want. +

+ +
+ + +
+
Mobile — with active search
+
+ ← Einstellungen +
Vorräte
+ + +
+ + + + Par + + +
+ + +
+ + +
+ Käse & Milchprodukte +
+ + +
+ Parmesan +
+ +
+
+
+ Parmesan gerieben +
+
+ +
+ Gewürze +
+
+ Paprika edelsüß +
+
+ +
+ 3 Treffer für „Par" +
+ +
+ +

Automatisch gespeichert.

+
+
+ + +
+
Desktop — idle state (no search)
+
+ ← Einstellungen +
+
Vorräte
+ 23 gewählt +
+

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

+ + +
+ + + + Zutat suchen … +
+ + +
+ +
+ Gemüse +
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Aubergine + Paprika + Spinat + Tomaten +
+ +
+ Milchprodukte +
+
+ Butter + Milch + Joghurt + Sahne + Parmesan + Mozzarella +
+ +
+ Gewürze +
+
+ Salz + Pfeffer + Kreuzkümmel + Paprika edelsüß + Kurkuma +
+ +
+
+
+
+ +

+ Trade-off: Fastest path for users who know what they want. Search cuts through all category + hierarchy instantly. The checklist row style in search results (vs chips) is intentional — it's denser and + keyboard-navigable (arrow keys + Space). The idle state preserves the chip cloud so casual browsing stays familiar. + Risk: Two different interaction patterns (chips vs checklist rows in search) add cognitive load. + The sticky-header list can be hard to test cross-browser. +

+
+ + +
+
+ Concept 04 + Checklist Table +
+

+ Abandon chips entirely. A single scrollable table: category, ingredient name, checkbox. + Sorted by category. Extremely keyboard-friendly, screen-reader native, and + far denser — 40 ingredients fit in the same space as 10 chips. +

+ +
+ + +
+
Mobile — 320px
+
+ ← Einstellungen +
Vorräte
+

23 gewählt · automatisch gespeichert

+ + +
+ + Suchen … +
+ + +
+ + +
+ Gemüse + 7 / 18 +
+ +
+ Möhren + +
+
+ Zwiebeln + +
+
+ Aubergine + +
+
+ Spinat + +
+ + +
+ Milchprodukte + 4 / 12 +
+
+ Butter + +
+
+ Joghurt + +
+ +
+
+
+ + +
+
Desktop — two-column table
+
+ ← Einstellungen +
+
Vorräte
+ 23 gewählt +
+

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

+ + +
+
+ + Zutat suchen … +
+ +
+ + +
+ + +
+
+ Gemüse + 7 / 18 +
+
+ Möhren + +
+
+ Zwiebeln + +
+
+ Aubergine + +
+
+ Spinat + +
+
+ Zucchini + +
+
+ + +
+
+ Milchprodukte + 4 / 12 +
+
+ Butter + +
+
+ Milch + +
+
+ Joghurt + +
+
+ Sahne + +
+
+ +
+ +
+
+
+ +

+ Trade-off: Highest information density. Excellent keyboard navigation (tab to checkbox, space to toggle). + Screen readers get a native checklist — no ARIA juggling needed. The two-column desktop layout keeps categories + side by side without the chip-cloud imbalance problem. + Risk: Loses the "selection feels satisfying" quality of chip toggling — a checkbox is functional, + not delightful. Row highlight (green-tint bg) on checked rows restores some of that signal. + May feel clinical for a consumer cooking app. +

+
+ + +
+
+ Concept 05 + Enhanced Current +
+

+ Keep the chip paradigm — it works. Add three targeted fixes: + (1) category count badges + Alle/Keine micro-actions inline, + (2) a collapsible "overflow" per category beyond 8 chips, + (3) a global count in the header. Zero new navigation patterns to learn. +

+ +
+ + +
+
Mobile — current feel, fixed overload
+
+ ← Einstellungen +
+
Vorräte
+ 23 gewählt +
+

Automatisch gespeichert.

+ + +
+
+
+ Gemüse + 7 / 18 +
+
+ + +
+
+ +
+ Möhren + Zwiebeln + Knoblauch + Paprika + Tomaten + Brokkoli + Zucchini + Aubergine + + +
+
+ + +
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kreuzkümmel + Kurkuma + Zimt + Paprika + Koriander + Thymian + +
+
+ + +
+
+
+ Öle & Fette + 2 / 6 +
+
+ + +
+
+
+ Olivenöl + Rapsöl + Kokosöl + Butter + Ghee + Sesamöl +
+
+
+
+ + +
+
Desktop — 3-col grid, enhanced
+
+ ← Einstellungen +
+
Vorräte
+ 23 Vorräte gewählt +
+

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

+ +
+ + +
+
+
+ Gemüse + 7/18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Paprika + Tomaten + Brokkoli + Zucchini + Aubergine + +
+
+ + +
+
+
+ Gewürze + 0/24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kreuzkümmel + Kurkuma + Zimt + Paprika + Thymian + Oregano + +
+
+ + +
+
+
+ Öle & Fette + 2/6 +
+
+ + +
+
+
+ Olivenöl + Rapsöl + Kokosöl + Butter + Ghee + Sesamöl +
+
+ +
+
+
+
+ +

+ Trade-off: Lowest risk, smallest diff — the three components (CategorySection, StapleChip, StaplesManager) + need only minor additions. No new navigation paradigm to learn. Count badges on category headers answer the + comprehension question. The "+N weitere…" overflow trigger caps visual height per category. + Alle/Keine bulk actions land where the eye already is (next to the label). + This is the recommendation for a first iteration — it fixes the acute pain without a redesign. + Concepts 01 or 02 are step-2 if the dataset grows substantially larger. +

+
+ + + + + + diff --git a/specs/staples-settings-tile.html b/specs/staples-settings-tile.html new file mode 100644 index 0000000..5bb9ae5 --- /dev/null +++ b/specs/staples-settings-tile.html @@ -0,0 +1,1456 @@ + + + + + + A3/D3 Vorräte Settings — Tile Redesign + + + + + +
+ + +
+
+

A3 / D3 — Vorräte Settings

+

Tile redesign · unified with Settings + Members page language

+
+
+ route /household/staples
+ context settings
+ Atlas · 2026-04-10 +
+
+ + + +
+

Why the current page looks out of place

+
+
+

Current — plain divs

+
    +
  • No surface background, no border, no shadow — floats naked on the page
  • +
  • Feels like a different app than /settings and /members
  • +
  • 3-col grid has no visual container to balance uneven category sizes
  • +
  • No count feedback on category level
  • +
  • Chip overflow creates page-length sprawl for large categories
  • +
+
+
+

Tile approach — matches SettingsCard shell

+
    +
  • Each category in a tile: radius-xl, shadow-card, surface bg — identical to SettingsCard
  • +
  • Tile acts as a visual container that naturally caps chip overflow
  • +
  • Count badge + Alle/Keine in tile header replaces raw category label
  • +
  • Grid: grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px] — identical to /settings
  • +
  • Zero new patterns — reuses the language the user already trusts
  • +
+
+
+
+ + + +
+
CategoryTile — token mapping
+
+ + +
+
+
+
+ Gemüse + 7 / 18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Paprika + Zucchini + Aubergine + Tomaten + Spinat + +
+
+
+ + +
+
--radius-xlborder-radius (same as SettingsCard)
+
--color-surfacetile background
+
--color-borderborder (rest state)
+
--color-border-hoverborder on hover
+
--shadow-cardelevation (rest)
+
--shadow-raisedelevation (hover)
+
28pxpadding (all sides, same as SettingsCard)
+
16px / 500category title (same as SettingsCard)
+
--green-tint / --green-darkbadge when selected > 0
+
--color-subtle / mutedbadge when selected === 0
+
+

+ Tile does not have a link/href — it is an interactive container, not a navigation element. + Hover state is retained for visual feedback (user can see the tile is a unit), but no cursor:pointer on the tile itself. +

+
+
+
+
+ + + +
+ + +
+
Empty (0 selected)
+
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Zimt + Thymian + Oregano + Paprika + Koriander + +
+
+
+ + +
+
Partial
+
+
+
+ Öle & Fette + 2 / 6 +
+
+ + +
+
+
+ Olivenöl + Rapsöl + Kokosöl + Butter + Ghee + Sesamöl +
+
+
+ + +
+
All selected
+
+
+
+ Milchprodukte + 4 / 4 +
+
+ + +
+
+
+ Butter + Milch + Joghurt + Parmesan +
+
+
+ +
+ + + + +
+ + +
+
Desktop — /settings page header + 2-col tile grid
+
+
+
+ + + ← Einstellungen + + +
+

Vorräte

+ 23 gewählt +
+

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

+ + +
+ + +
+
+
+ Gemüse + 7 / 18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Paprika + Tomaten + Zucchini + Aubergine + Brokkoli + +
+
+ + +
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Zimt + Thymian + Oregano + Paprika + Koriander + +
+
+ + +
+
+
+ Öle & Fette + 2 / 6 +
+
+ + +
+
+
+ Olivenöl + Rapsöl + Kokosöl + Butter + Ghee + Sesamöl +
+
+ + +
+
+
+ Milchprodukte + 4 / 4 +
+
+ + +
+
+
+ Butter + Milch + Joghurt + Parmesan +
+
+ + +
+
+
+ Getreide & Körner + 0 / 9 +
+
+ + +
+
+
+ Reis + Nudeln + Quinoa + Haferflocken + Linsen + Kichererbsen + +
+
+ + +
+
+
+ Fleisch & Fisch + 3 / 11 +
+
+ + +
+
+
+ Hühnerbrust + Hackfleisch + Lachs + Thunfisch + Eier + Speck + +
+
+ +
+
+
+
+
+ + +
+
Mobile — single col
+
+
+
+ + ← Einstellungen + +
+

Vorräte

+ 23 gewählt +
+

Automatisch gespeichert.

+ + +
+ +
+
+
+ Gemüse + 7/18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Paprika + Aubergine + Tomaten + Spinat + +
+
+ +
+
+
+ Gewürze + 0/24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + Oregano + Zimt + Paprika + Koriander + +
+
+ +
+
+
+ Öle & Fette + 2/6 +
+
+ + +
+
+
+ Olivenöl + Rapsöl + Kokosöl + Butter + Sesamöl +
+
+ +
+
+
+
+
+ +
+ + + +
+
+ /settings — same tile shell, consistent visual weight +
+
+

Einstellungen

+ +
+
+ /household/staples — same tile shell applied to CategorySection +
+
+
+

Vorräte

+ 23 gewählt +
+
+ +
+
+
+ Gemüse + 7 / 18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Zucchini + Paprika + Tomaten + Aubergine + Brokkoli + +
+
+ +
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + Oregano + Paprika + Koriander + Zimt + +
+
+ +
+
+
+ + + +
+

Wann und wie wird der Katalog befüllt?

+
+
+

Backend — automatisch beim Erstellen des Haushalts

+
    +
  • Flyway-Migration oder HouseholdService seed bei POST /v1/households
  • +
  • Seed-Daten: ~100 typisch deutsche Grundzutaten in 8 Kategorien
  • +
  • Alle Zutaten mit isStaple = false — Nutzer entscheidet selbst
  • +
  • Kategorien werden mitgeneriert (gleicher Haushalt-Scope)
  • +
  • Danach normal editierbar: toggle, add, PATCH name/kategorie
  • +
+
+
+

Seed-Katalog — 8 Kategorien (Auswahl)

+
    +
  • Gemüse: Möhren, Zwiebeln, Knoblauch, Kartoffeln, Tomaten, Paprika, Spinat, Brokkoli, Zucchini, Lauch, Sellerie, Kohl, Blumenkohl, Kürbis, Süßkartoffeln …
  • +
  • Gewürze: Salz, Pfeffer, Kurkuma, Kreuzkümmel, Paprikapulver, Zimt, Thymian, Oregano, Rosmarin, Lorbeer, Muskat, Curry, Chili …
  • +
  • Öle & Essig: Olivenöl, Rapsöl, Sesamöl, Kokosöl, Apfelessig, Balsamico …
  • +
  • Milch & Käse: Butter, Milch, Sahne, Joghurt, Parmesan, Mozzarella, Quark …
  • +
  • Getreide & Hülsenfrüchte: Reis, Nudeln, Linsen, Kichererbsen, Haferflocken, Quinoa, Couscous …
  • +
  • Fleisch & Fisch: Hähnchenbrust, Hackfleisch, Lachs, Thunfisch (Dose), Eier, Speck …
  • +
  • Backzutaten: Mehl, Zucker, Backpulver, Hefe, Speisestärke, Vanille …
  • +
  • Saucen & Würzmittel: Tomatenmark, Sojasoße, Senf, Ketchup, Brühe, Kokosmilch …
  • +
+
+
+
+ + + +

+ Jedes Category-Tile hat am unteren Ende einen stillen „+ Zutat hinzufügen" Trigger. + Der Flow bleibt inline — kein Modal, kein Sheet. Neue Zutat erscheint sofort als ausgewählter Chip. +

+ + +
+ + +
+
1 — Rest (Trigger sichtbar)
+
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + +
+ +
+ +
+
+
+ + +
+
2 — Eingabe aktiv
+
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + +
+ +
+
+ + + +
+

Enter zum Speichern · Esc zum Abbrechen

+
+
+
+ + +
+
3 — Zutat hinzugefügt (optimistic)
+
+
+
+ Gewürze + 1 / 25 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + + Safran + +
+
+ +
+
+
+ + +
+
4 — Duplikat-Fehler
+
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + + Pfeffer + Kurkuma + Thymian + +
+
+
+ + + +
+

„Pfeffer" existiert bereits in dieser Kategorie.

+
+
+
+ +
+ + +
+
+
Desktop — Tile mit aktiver Eingabe (Gemüse-Tile, Eingabe offen)
+
+
+
+
+

Vorräte

+ 23 gewählt +
+

Automatisch gespeichert.

+ +
+ + +
+
+
+ Gemüse + 7 / 18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Knoblauch + Paprika + Zucchini + Tomaten + Aubergine + Brokkoli + +
+ +
+
+ + + +
+
+
+ + +
+
+
+ Gewürze + 0 / 24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + Thymian + Oregano + Paprika + +
+
+ +
+
+ +
+
+
+
+
+ +
+
Mobile — Tile mit Eingabe
+
+
+
+
+

Vorräte

+ 23 gewählt +
+
+
+
+
+ Gemüse + 7/18 +
+
+ + +
+
+
+ Möhren + Zwiebeln + Zucchini + Paprika + +
+
+
+ + + +
+
+
+
+
+
+ Gewürze + 0/24 +
+
+ + +
+
+
+ Salz + Pfeffer + Kurkuma + +
+
+ +
+
+
+
+
+
+
+
+ + + + + ───────────────────────────────────────────────────────────────── + + ## 2. StaplesManager.svelte — grid + selectAll/deselectAll wiring + + GRID CLASS CHANGE: + BEFORE: "grid grid-cols-1 gap-[24px_32px] md:grid-cols-3" + AFTER: "grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]" + // matches /settings page exactly + + TOTAL COUNT COMPUTATION (new): + totalSelected = $derived(Object.values(stapleState).filter(Boolean).length) + + SELECT ALL HANDLER (new): + function handleSelectAll(categoryId: string) { + const category = categories.find(c => c.id === categoryId); + if (!category) return; + for (const ing of category.ingredients) { + if (!stapleState[ing.id]) { + stapleState[ing.id] = true; + getPatcher(ing.id)(ing.id, true); + } + } + } + + DESELECT ALL HANDLER (new): + function handleDeselectAll(categoryId: string) { + const category = categories.find(c => c.id === categoryId); + if (!category) return; + for (const ing of category.ingredients) { + if (stapleState[ing.id]) { + stapleState[ing.id] = false; + getPatcher(ing.id)(ing.id, false); + } + } + } + + PASS TO CategorySection: + onSelectAll={() => handleSelectAll(category.id)} + onDeselectAll={() => handleDeselectAll(category.id)} + + EXPOSE totalSelected as prop or slot for +page.svelte to render in header. + + ───────────────────────────────────────────────────────────────── + + ## 3. +page.svelte (settings context) — h1 row + + CHANGE header to match /settings and /members exactly: + + ← Einstellungen +
+

Vorräte

+ + {totalSelected} gewählt + +
+

+ Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste. +

+ + NOTE: mb-8 (32px) before grid matches /settings page spacing. + + ───────────────────────────────────────────────────────────────── + + ## 4. Token reference table + + | Token | Value | Applied to | + |--------------------------|----------|-------------------------------| + | --radius-xl | 16px | tile border-radius | + | --color-surface | #F5F4EE | tile background | + | --color-border | #D8D7D0 | tile border (rest) | + | --color-border-hover | #C0BFB8 | tile border (hover) | + | --shadow-card | ... | tile elevation (rest) | + | --shadow-raised | ... | tile elevation (hover) | + | 28px | — | tile padding (desktop) | + | 20px | — | tile padding (mobile, md:28px)| + | --green-tint/dark | — | badge when selected > 0 | + | --color-subtle/muted | — | badge when selected === 0 | + | --color-text-muted | #6B6A63 | action btn text, overflow btn | + | --color-border | #D8D7D0 | action btn border | + | 16px / 500 / --font-sans | — | category title (= SettingsCard title) | + | gap-4 (16px) | — | grid gap (= /settings grid) | + | sm:grid-cols-2 | — | grid breakpoint | + | max-w-[820px] | — | grid max-width | + + ## 5. No-op: StapleChip.svelte + No changes needed. Chip tokens and hover/focus styles remain identical. + + ## 6. Mobile adjustment + On mobile (< sm), tile padding reduces to 20px: + class="p-[20px] md:p-[28px]" + Title font-size stays 16px/500 (no mobile reduction — tile is smaller but label stays legible). + + ═══════════════════════════════════════════════════════════════════ + SEED FLOW + ═══════════════════════════════════════════════════════════════════ + + ## 7. Backend — Household seed on creation + + TRIGGER: after successful household creation (HouseholdService or Flyway data migration) + SCOPE: per-household (all seeded rows carry the new household's ID) + DEFAULT: isStaple = false for all seeded ingredients + + CATEGORIES TO SEED (with sortOrder): + 1 Gemüse + 2 Gewürze & Kräuter + 3 Öle & Essig + 4 Milch & Käse + 5 Getreide & Hülsenfrüchte + 6 Fleisch & Fisch + 7 Backzutaten + 8 Saucen & Würzmittel + + SEED INGREDIENTS (representative list — full list in seed SQL/Java constant): + Gemüse: Möhren, Zwiebeln, Knoblauch, Kartoffeln, Tomaten, Paprika (rot), + Paprika (gelb), Spinat, Brokkoli, Zucchini, Lauch, Sellerie, + Blumenkohl, Kohl, Kürbis, Süßkartoffeln, Aubergine, Gurke, + Erbsen (TK), Mais (Dose) + Gewürze & Kräuter: Salz, Pfeffer (schwarz), Kurkuma, Kreuzkümmel, Paprikapulver (edelsüß), + Paprikapulver (scharf), Zimt, Thymian, Oregano, Rosmarin, Lorbeerblätter, + Muskatnuss, Currypulver, Chilliflocken, Koriander (gemahlen), + Petersilie, Schnittlauch, Basilikum + Öle & Essig: Olivenöl, Rapsöl, Sesamöl, Kokosöl, Apfelessig, Balsamico-Essig, + Weinessig + Milch & Käse: Butter, Milch (3,5%), Sahne, Schmand, Joghurt (natur), Parmesan, + Mozzarella, Gouda, Quark, Frischkäse, Feta + Getreide & Hülsenfrüchte: Reis (Langkorn), Basmati-Reis, Nudeln, Spaghetti, + Linsen (rote), Linsen (grüne), Kichererbsen (Dose), Kidneybohnen (Dose), + Haferflocken, Quinoa, Couscous, Bulgur, Polenta + Fleisch & Fisch: Hähnchenbrust, Hähnchenkeule, Hackfleisch (gemischt), Rinderhack, + Lachs (Filet), Thunfisch (Dose), Garnelen (TK), Speck, Eier + Backzutaten: Mehl (Typ 405), Mehl (Typ 550), Zucker, Brauner Zucker, + Backpulver, Natron, Hefe (trocken), Speisestärke, Vanillezucker, + Puderzucker, Kakaopulver, Schokolade (Zartbitter) + Saucen & Würzmittel: Tomatenmark, Passierte Tomaten, Tomaten (Dose, ganz), + Sojasoße, Senf (mittelscharf), Ketchup, Mayonnaise, + Gemüsebrühe, Hühnerbrühe, Kokosmilch, Worcestershire-Sauce, + Tabasco, Honig, Ahornsirup + + IMPLEMENTATION NOTE: + Option A (preferred): Java constant in HouseholdSeedService.java, called from + HouseholdService.createHousehold() after persisting the household entity. + Option B: Flyway repeatable migration R__seed_ingredients.sql — only if global + seed makes sense (it doesn't here: ingredients are household-scoped). + Deduplication: before inserting, check if category/ingredient with same name + (citext) already exists for the household — skip if found. This makes the + seed idempotent (safe to re-run in dev). + + ═══════════════════════════════════════════════════════════════════ + ADD INGREDIENT FLOW + ═══════════════════════════════════════════════════════════════════ + + ## 8. Backend — new POST /v1/ingredients endpoint + + REQUEST: POST /v1/ingredients + Content-Type: application/json + { "name": "Fenchel", "categoryId": "uuid-of-category" } + categoryId is optional — if omitted, ingredient has no category + + RESPONSE: 201 Created + { "id": "new-uuid", "name": "Fenchel", "isStaple": true, "categoryId": "..." } + NOTE: newly user-created ingredients default to isStaple = true + (user explicitly added it — reasonable to assume they want it tracked) + + ERROR 409 Conflict: + { "error": "DUPLICATE_INGREDIENT", "message": "..." } + Backend uses citext so "fenchel" == "Fenchel" == "FENCHEL" + + AUTHORIZATION: planner role (same as PATCH) + + ## 9. Frontend — CategorySection.svelte additions + + NEW PROP: + onAddIngredient: (name: string) => Promise<{ id: string; name: string } | 'duplicate' | 'error'> + + NEW STATE: + addMode: boolean = false + addInput: string = '' + addError: '' | 'duplicate' | 'error' = '' + addHighlightId: string | null = null // ID of duplicate chip to highlight briefly + + TILE FOOTER (below chip cloud, always rendered): +
+ + {#if !addMode} + + + {:else} +
+ { + if (e.key === 'Enter') handleAdd(); + if (e.key === 'Escape') { addMode = false; addError = ''; } + }} + class="flex-1 min-w-0 + font-[var(--font-sans)] text-[13px] text-[var(--color-text)] + bg-[var(--color-page)] + border rounded-[var(--radius-md)] px-[10px] py-[6px] + {addError ? 'border-[var(--color-error)]' : 'border-[var(--green-light)]'} + focus:outline-none" + /> + + +
+ + {#if addError === 'duplicate'} +

+ „{addInput}" existiert bereits in dieser Kategorie. +

+ {:else if addError === 'error'} +

+ Konnte nicht gespeichert werden. +

+ {/if} + {/if} + +
+ + ADD HANDLER: + async function handleAdd() { + const name = addInput.trim(); + if (!name) return; + addError = ''; + const result = await onAddIngredient(name); + if (result === 'duplicate') { + addError = 'duplicate'; + // find matching ingredient by name (citext: case-insensitive compare) + const existing = ingredients.find(i => i.name.toLowerCase() === name.toLowerCase()); + if (existing) { + addHighlightId = existing.id; + setTimeout(() => { addHighlightId = null; }, 2000); + } + } else if (result === 'error') { + addError = 'error'; + } else { + // success — optimistically add chip (selected = true, from POST response) + // parent (StaplesManager) handles state update + addMode = false; + addInput = ''; + addError = ''; + } + } + + DUPLICATE CHIP HIGHLIGHT: + In StapleChip.svelte, accept optional highlight prop: + let { ..., highlight = false }: { ...; highlight?: boolean } = $props(); + add class: highlight ? 'outline-2 outline-offset-2 outline-[var(--green-light)]' : '' + + ## 10. StaplesManager.svelte — onAddIngredient handler + + async function handleAddIngredient(categoryId: string, name: string) { + const res = await fetch('/household/staples/ingredients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, categoryId }) + }); + + if (res.status === 409) return 'duplicate'; + if (!res.ok) return 'error'; + + const created: { id: string; name: string; isStaple: boolean } = await res.json(); + + // Optimistically add to categories state + categories = categories.map(cat => + cat.id === categoryId + ? { ...cat, ingredients: [...cat.ingredients, created] } + : cat + ); + // Mark as staple (POST returns isStaple: true) + stapleState[created.id] = true; + + return created; + } + + PASS TO CategorySection: + onAddIngredient={(name) => handleAddIngredient(category.id, name)} + + ## 11. New SvelteKit API route: +server.ts at /household/staples/ingredients + + File: frontend/src/routes/(app)/household/staples/ingredients/+server.ts + + export const POST: RequestHandler = async ({ request, locals }) => { + const { name, categoryId } = await request.json(); + const res = await fetch(`${BACKEND}/v1/ingredients`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: ... }, + body: JSON.stringify({ name, categoryId }) + }); + return new Response(await res.text(), { status: res.status, + headers: { 'Content-Type': 'application/json' } }); + }; + --> +
+ + + + From 27b7058d31123490c2ce541d54d6420dd703a71e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:41:38 +0200 Subject: [PATCH 02/11] 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 --- .../household/HouseholdController.java | 25 +++ .../household/HouseholdInviteRepository.java | 1 + .../household/HouseholdMemberRepository.java | 2 + .../recipeapp/household/HouseholdService.java | 69 ++++++- .../household/dto/ChangeRoleRequest.java | 10 + .../household/entity/HouseholdInvite.java | 5 + .../V006__add_invite_invalidated_at.sql | 6 + .../household/HouseholdControllerTest.java | 54 ++++++ .../household/HouseholdServiceTest.java | 181 ++++++++++++++++++ 9 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java create mode 100644 backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 7f704d7..46c8556 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.util.List; +import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/v1") @@ -40,12 +42,35 @@ public class HouseholdController { return ResponseEntity.ok(members); } + @GetMapping("/households/mine/invites") + public ResponseEntity> getActiveInvite(Principal principal) { + Optional invite = householdService.getActiveInvite(principal.getName()); + return invite + .map(r -> ResponseEntity.ok(ApiResponse.success(r))) + .orElse(ResponseEntity.noContent().build()); + } + @PostMapping("/households/mine/invites") public ResponseEntity> createInvite(Principal principal) { InviteResponse response = householdService.createInvite(principal.getName()); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } + @DeleteMapping("/households/mine/members/{userId}") + public ResponseEntity removeMember(Principal principal, @PathVariable UUID userId) { + householdService.removeMember(principal.getName(), userId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/households/mine/members/{userId}") + public ResponseEntity> 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)); + } + @PostMapping("/invites/{code}/accept") public ResponseEntity> acceptInvite( Principal principal, diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java index 7486172..03dd511 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface HouseholdInviteRepository extends JpaRepository { Optional findByInviteCode(String inviteCode); + Optional findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java index 80853b1..32a24de 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java @@ -10,4 +10,6 @@ import java.util.UUID; public interface HouseholdMemberRepository extends JpaRepository { Optional findByUserEmailIgnoreCase(String email); List findByHouseholdId(UUID householdId); + Optional findByHouseholdIdAndUserId(UUID householdId, UUID userId); + long countByHouseholdIdAndRole(UUID householdId, String role); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 242f1d5..678bf67 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.Instant; import java.util.List; +import java.util.Optional; +import java.util.UUID; @Service public class HouseholdService { @@ -91,21 +93,73 @@ public class HouseholdService { .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"); + } + + householdMemberRepository.delete(target); + } + + @Transactional(readOnly = true) + public Optional getActiveInvite(String userEmail) { + HouseholdMember member = findMembership(userEmail); + return householdInviteRepository + .findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId()) + .filter(invite -> invite.getExpiresAt().isAfter(Instant.now())) + .map(this::toInviteResponse); + } + @Transactional public InviteResponse createInvite(String userEmail) { HouseholdMember member = findMembership(userEmail); Household household = member.getHousehold(); + householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) + .ifPresent(existing -> { + existing.setInvalidatedAt(Instant.now()); + householdInviteRepository.save(existing); + }); + String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); HouseholdInvite invite = householdInviteRepository.save( new HouseholdInvite(household, code, expiresAt)); - return new InviteResponse( - invite.getInviteCode(), - "https://yourapp.com/join/" + invite.getInviteCode(), - invite.getExpiresAt()); + return toInviteResponse(invite); } @Transactional @@ -204,4 +258,11 @@ public class HouseholdService { member.getRole(), member.getJoinedAt()); } + + private InviteResponse toInviteResponse(HouseholdInvite invite) { + return new InviteResponse( + invite.getInviteCode(), + "https://yourapp.com/join/" + invite.getInviteCode(), + invite.getExpiresAt()); + } } diff --git a/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java new file mode 100644 index 0000000..80e9c95 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 916fac1..04246e6 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -25,6 +25,9 @@ public class HouseholdInvite { @Column(name = "expires_at", nullable = false) private Instant expiresAt; + @Column(name = "invalidated_at") + private Instant invalidatedAt; + protected HouseholdInvite() {} public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { @@ -39,4 +42,6 @@ public class HouseholdInvite { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public Instant getExpiresAt() { return expiresAt; } + public Instant getInvalidatedAt() { return invalidatedAt; } + public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; } } diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql new file mode 100644 index 0000000..c9a5511 --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql @@ -0,0 +1,6 @@ +ALTER TABLE household_invite + ADD COLUMN invalidated_at timestamptz; + +CREATE UNIQUE INDEX uq_household_invite_active + ON household_invite (household_id) + WHERE invalidated_at IS NULL; diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index ceee167..1a33db3 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -103,6 +105,58 @@ class HouseholdControllerTest { .andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ")); } + @Test + void getActiveInviteShouldReturn200WithInvite() throws Exception { + var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12", + Instant.now().plusSeconds(172800)); + + 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 acceptInviteShouldReturn200() throws Exception { var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index baf3c32..9b00fbb 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -223,6 +224,169 @@ class HouseholdServiceTest { .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 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 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 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 result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + @Test void getMembersShouldReturnAllMembers() { var user1 = testUser(); @@ -256,4 +420,21 @@ class HouseholdServiceTest { assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) .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.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); + + householdService.createInvite("sarah@example.com"); + + assertThat(existingInvite.getInvalidatedAt()).isNotNull(); + verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class)); + } } From 6aef12fa3c95284c316a68cc1a184cc59cd1f641 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:42:52 +0200 Subject: [PATCH 03/11] feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/schema.d.ts | 119 ++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index aadfed1..a6cc337 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -203,7 +203,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["getActiveInvite"]; put?: never; post: operations["createInvite"]; delete?: never; @@ -212,6 +212,24 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -763,6 +781,14 @@ export interface components { /** Format: date-time */ joinedAt?: string; }; + ChangeRoleRequest: { + role: string; + }; + ApiResponseMemberResponse: { + status?: string; + data?: components["schemas"]["MemberResponse"]; + meta?: components["schemas"]["Meta"]; + }; ApiResponseInviteResponse: { status?: string; data?: components["schemas"]["InviteResponse"]; @@ -2010,6 +2036,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: { parameters: { query?: { From 9ccd367d74295a626353e97199a715e01ff919d3 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:01:08 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(members):=20implement=20/members=20p?= =?UTF-8?q?age=20=E2=80=94=20Kachel-Ansicht=20(E2,=20issue=20#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ql => V026__add_invite_invalidated_at.sql} | 0 .../lib/components/SegmentedControl.svelte | 50 ++++ .../lib/components/SegmentedControl.test.ts | 30 +++ frontend/src/lib/components/Toast.svelte | 31 +++ frontend/src/lib/components/Toast.test.ts | 23 ++ .../src/routes/(app)/members/+page.server.ts | 17 ++ .../src/routes/(app)/members/+page.svelte | 98 +++++++- .../routes/(app)/members/InviteCard.svelte | 17 ++ .../routes/(app)/members/InviteCard.test.ts | 23 ++ .../routes/(app)/members/InvitePanel.svelte | 50 ++++ .../routes/(app)/members/InvitePanel.test.ts | 34 +++ .../routes/(app)/members/MemberCard.svelte | 215 ++++++++++++++++++ .../routes/(app)/members/MemberCard.test.ts | 147 ++++++++++++ .../routes/(app)/members/MemberGrid.svelte | 67 ++++++ .../routes/(app)/members/MemberGrid.test.ts | 73 ++++++ .../routes/(app)/members/RemoveDialog.svelte | 86 +++++++ .../routes/(app)/members/RemoveDialog.test.ts | 56 +++++ .../routes/(app)/members/[userId]/+server.ts | 21 ++ .../(app)/members/[userId]/server.test.ts | 50 ++++ .../routes/(app)/members/invites/+server.ts | 9 + .../routes/(app)/members/page.server.test.ts | 74 ++++++ 21 files changed, 1170 insertions(+), 1 deletion(-) rename backend/src/main/resources/db/migration/{V006__add_invite_invalidated_at.sql => V026__add_invite_invalidated_at.sql} (100%) create mode 100644 frontend/src/lib/components/SegmentedControl.svelte create mode 100644 frontend/src/lib/components/SegmentedControl.test.ts create mode 100644 frontend/src/lib/components/Toast.svelte create mode 100644 frontend/src/lib/components/Toast.test.ts create mode 100644 frontend/src/routes/(app)/members/+page.server.ts create mode 100644 frontend/src/routes/(app)/members/InviteCard.svelte create mode 100644 frontend/src/routes/(app)/members/InviteCard.test.ts create mode 100644 frontend/src/routes/(app)/members/InvitePanel.svelte create mode 100644 frontend/src/routes/(app)/members/InvitePanel.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberCard.svelte create mode 100644 frontend/src/routes/(app)/members/MemberCard.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberGrid.svelte create mode 100644 frontend/src/routes/(app)/members/MemberGrid.test.ts create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.svelte create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.test.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/+server.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/server.test.ts create mode 100644 frontend/src/routes/(app)/members/invites/+server.ts create mode 100644 frontend/src/routes/(app)/members/page.server.test.ts diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql similarity index 100% rename from backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql rename to backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte new file mode 100644 index 0000000..a83104b --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -0,0 +1,50 @@ + + +
+ {#each options as option (option.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/SegmentedControl.test.ts b/frontend/src/lib/components/SegmentedControl.test.ts new file mode 100644 index 0000000..708e21c --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..47fb71a --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,31 @@ + + +{#if visible} +
+ {message} + +
+{/if} diff --git a/frontend/src/lib/components/Toast.test.ts b/frontend/src/lib/components/Toast.test.ts new file mode 100644 index 0000000..114b29b --- /dev/null +++ b/frontend/src/lib/components/Toast.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts new file mode 100644 index 0000000..6e63458 --- /dev/null +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -0,0 +1,17 @@ +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 + }; +}; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index a4722af..5e54c8f 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -1 +1,97 @@ -

Mitglieder

+ + +Mitglieder — Mealprep + +
+

Mitglieder

+ + (showInvitePanel = !showInvitePanel)} + /> + + {#if showInvitePanel && isPlanner && activeInvite} + + {/if} + + (removeTarget = null)} + /> + + (toastVisible = false)} /> +
diff --git a/frontend/src/routes/(app)/members/InviteCard.svelte b/frontend/src/routes/(app)/members/InviteCard.svelte new file mode 100644 index 0000000..d5d0e55 --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/routes/(app)/members/InviteCard.test.ts b/frontend/src/routes/(app)/members/InviteCard.test.ts new file mode 100644 index 0000000..5974e5d --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte new file mode 100644 index 0000000..0eb9075 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -0,0 +1,50 @@ + + +
+

+ {invite.shareUrl || invite.inviteCode} +

+ +
+ + + +
+ +

+ Läuft ab: {formatExpiry(invite.expiresAt)} +

+
diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts new file mode 100644 index 0000000..77ba4f3 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -0,0 +1,34 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte new file mode 100644 index 0000000..cabcd40 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -0,0 +1,215 @@ + + +
+ +
+ {initials} +
+ + +
+ {member.displayName} + {#if isCurrentUser} + Du + {/if} +
+ + + {#if editingRole} + { + onrolechange(member, newValue); + editingRole = false; + }} + /> + {:else} + + {member.role === 'planner' ? 'Planer' : 'Mitglied'} + + {/if} + + + {#if isPlanner && !isCurrentUser} + + + + {#if menuOpen} +
+ + +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberCard.test.ts b/frontend/src/routes/(app)/members/MemberCard.test.ts new file mode 100644 index 0000000..a63bf41 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.test.ts @@ -0,0 +1,147 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberGrid.svelte b/frontend/src/routes/(app)/members/MemberGrid.svelte new file mode 100644 index 0000000..9e994ca --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.svelte @@ -0,0 +1,67 @@ + + +
+ {#each sortedMembers as m (m.userId)} + onrolechange(m, role)} + /> + {/each} + {#if isPlanner && showInviteCard} + + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberGrid.test.ts b/frontend/src/routes/(app)/members/MemberGrid.test.ts new file mode 100644 index 0000000..1745123 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/RemoveDialog.svelte b/frontend/src/routes/(app)/members/RemoveDialog.svelte new file mode 100644 index 0000000..996db2f --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.svelte @@ -0,0 +1,86 @@ + + +{#if show} + {#if isMobile()} + +
+

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {:else} +
+
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {/if} +{/if} diff --git a/frontend/src/routes/(app)/members/RemoveDialog.test.ts b/frontend/src/routes/(app)/members/RemoveDialog.test.ts new file mode 100644 index 0000000..9da8f9b --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/[userId]/+server.ts b/frontend/src/routes/(app)/members/[userId]/+server.ts new file mode 100644 index 0000000..d53d711 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/+server.ts @@ -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 }); +}; diff --git a/frontend/src/routes/(app)/members/[userId]/server.test.ts b/frontend/src/routes/(app)/members/[userId]/server.test.ts new file mode 100644 index 0000000..6bc2b06 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/server.test.ts @@ -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); + }); +}); diff --git a/frontend/src/routes/(app)/members/invites/+server.ts b/frontend/src/routes/(app)/members/invites/+server.ts new file mode 100644 index 0000000..76d1f44 --- /dev/null +++ b/frontend/src/routes/(app)/members/invites/+server.ts @@ -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, { status: response?.status ?? 201 }); +}; diff --git a/frontend/src/routes/(app)/members/page.server.test.ts b/frontend/src/routes/(app)/members/page.server.test.ts new file mode 100644 index 0000000..55f2bc5 --- /dev/null +++ b/frontend/src/routes/(app)/members/page.server.test.ts @@ -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).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).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(); + }); +}); From d6bfd2cb4615229d203eeeea8d16ddac858fe3ab Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:45:32 +0200 Subject: [PATCH 05/11] fix(members): match settings page padding and h1 typography Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/members/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 5e54c8f..0f1591e 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -69,8 +69,8 @@ Mitglieder — Mealprep -
-

Mitglieder

+
+

Mitglieder

(toastVisible = false)} /> -
+ From dbf2951f095c7b8095c6a959bd001253031d3455 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:54:26 +0200 Subject: [PATCH 06/11] =?UTF-8?q?feat(members):=20align=20grid=20UI=20to?= =?UTF-8?q?=20spec=20=E2=80=94=20avatar=20colors,=20badges,=20join=20date,?= =?UTF-8?q?=20invite=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/routes/(app)/members/+page.server.ts | 3 +- .../src/routes/(app)/members/+page.svelte | 3 +- .../routes/(app)/members/InviteCard.svelte | 73 +++- .../routes/(app)/members/InvitePanel.svelte | 123 +++++- .../routes/(app)/members/InvitePanel.test.ts | 5 + .../routes/(app)/members/MemberCard.svelte | 383 +++++++++++++----- .../routes/(app)/members/MemberCard.test.ts | 28 ++ .../routes/(app)/members/RemoveDialog.svelte | 30 +- 8 files changed, 505 insertions(+), 143 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts index 6e63458..7324498 100644 --- a/frontend/src/routes/(app)/members/+page.server.ts +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { return { members: membersRes.data ?? [], currentUserId: locals.benutzer!.id, - activeInvite: inviteRes.data?.data ?? null + activeInvite: inviteRes.data?.data ?? null, + householdName: locals.haushalt?.name ?? '' }; }; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 0f1591e..5c2dd64 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -70,7 +70,8 @@ Mitglieder — Mealprep
-

Mitglieder

+

Mitglieder

+

{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}

- - Einladen +
+
+
Mitglied einladen
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte index 0eb9075..e7bd100 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.svelte +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -23,28 +23,117 @@ 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 + ); -
-

- {invite.shareUrl || invite.inviteCode} -

+
+
Einladelink teilen
+
Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.
-
- - -
-

- Läuft ab: {formatExpiry(invite.expiresAt)} -

+
+ Läuft ab: {formatExpiry(invite.expiresAt)} +
+ +
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts index 77ba4f3..5212215 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.test.ts +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -31,4 +31,9 @@ describe('InvitePanel', () => { 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(); + }); }); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte index cabcd40..7e2cfa3 100644 --- a/frontend/src/routes/(app)/members/MemberCard.svelte +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -1,6 +1,5 @@