Compare commits

...

10 Commits

Author SHA1 Message Date
741141168b feat(shopping): build main +page.svelte with responsive layout and empty states
Mobile/desktop responsive shopping list page with:
- Three empty states (no plan, no list, all checked)
- Unchecked/checked item sections with divider
- Add custom item form
- Desktop right panel with recipe references
- Filtered staples info

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:30:41 +02:00
14 changed files with 856 additions and 28 deletions

View File

@@ -1,11 +1,14 @@
package com.recipeapp.shopping;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.UUID;
@RestController
@@ -19,8 +22,21 @@ public class ShoppingListController {
this.householdResolver = householdResolver;
}
@GetMapping("/v1/shopping-list")
public ShoppingListResponse getByWeekStart(
@RequestParam(required = false) LocalDate weekStart,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart);
if (response == null) {
throw new ResourceNotFoundException("No shopping list for this week");
}
return response;
}
@PostMapping("/v1/week-plans/{id}/shopping-list")
@ResponseStatus(HttpStatus.CREATED)
@RequiresHouseholdRole("planner")
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.generateFromPlan(householdId, id);

View File

@@ -18,10 +18,11 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;
import java.util.Set;
import java.util.Map;
@Service
@Transactional
@@ -52,6 +53,18 @@ public class ShoppingService {
}
@Transactional(readOnly = true)
public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) {
if (weekStart == null) {
weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart)
.map(this::toResponse)
.orElse(null);
}
public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) {
WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId)
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
@@ -60,25 +73,23 @@ public class ShoppingService {
throw new ResourceNotFoundException("Week plan not found");
}
var household = weekPlan.getHousehold();
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
shoppingList = shoppingListRepository.save(shoppingList);
// Find or create the shopping list
ShoppingList shoppingList = shoppingListRepository
.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
.orElseGet(() -> {
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
return shoppingListRepository.save(newList);
});
// Aggregate ingredients across all slots/recipes
// Key: ingredientId + unit -> merged data
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
for (var slot : weekPlan.getSlots()) {
var recipe = slot.getRecipe();
for (RecipeIngredient ri : recipe.getIngredients()) {
Ingredient ingredient = ri.getIngredient();
// Filter out staples
if (ingredient.isStaple()) {
continue;
}
String key = ingredient.getId().toString() + "|" + ri.getUnit();
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
.addQuantity(ri.getQuantity())
@@ -86,19 +97,46 @@ public class ShoppingService {
}
}
// Create shopping list items
for (MergedIngredient mi : merged.values()) {
ShoppingListItem item = new ShoppingListItem(
shoppingList,
mi.ingredient,
null,
mi.totalQuantity,
mi.unit,
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
);
shoppingList.getItems().add(item);
// Build index of existing generated items by merge key
Map<String, ShoppingListItem> existingByKey = new HashMap<>();
List<ShoppingListItem> customItems = new ArrayList<>();
for (ShoppingListItem item : shoppingList.getItems()) {
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
// Generated item
String key = (item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit();
existingByKey.put(key, item);
} else {
customItems.add(item);
}
}
// Merge: update existing, add new, collect keys to keep
Set<String> mergedKeys = new HashSet<>();
for (MergedIngredient mi : merged.values()) {
String key = mi.ingredient.getId().toString() + "|" + mi.unit;
mergedKeys.add(key);
ShoppingListItem existing = existingByKey.get(key);
if (existing != null) {
// Update quantity and sources, preserve check state
existing.setQuantity(mi.totalQuantity);
existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new));
} else {
// New item
ShoppingListItem item = new ShoppingListItem(
shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit,
mi.recipeIds.stream().distinct().toArray(UUID[]::new));
shoppingList.getItems().add(item);
}
}
// Remove generated items no longer in the plan
shoppingList.getItems().removeIf(item ->
item.getSourceRecipes() != null && item.getSourceRecipes().length > 0
&& !mergedKeys.contains(
(item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit()));
shoppingList.setGeneratedAt(java.time.Instant.now());
shoppingListRepository.save(shoppingList);
return toResponse(shoppingList);

View File

@@ -0,0 +1,4 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/seed
out-of-order: true

View File

@@ -1,2 +1,2 @@
ALTER TABLE shopping_list
ADD COLUMN generated_at timestamptz NOT NULL DEFAULT now();
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();

View File

@@ -49,6 +49,32 @@ class ShoppingListControllerTest {
.build();
}
@Test
void getByWeekStartShouldReturn200() throws Exception {
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response);
mockMvc.perform(get("/v1/shopping-list")
.param("weekStart", "2026-04-06")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(LIST_ID.toString()))
.andExpect(jsonPath("$.filteredStaplesCount").value(3));
}
@Test
void getByWeekStartShouldReturn404WhenNoListExists() throws Exception {
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null);
mockMvc.perform(get("/v1/shopping-list")
.param("weekStart", "2026-04-06")
.principal(() -> "sarah@example.com"))
.andExpect(status().isNotFound());
}
@Test
void generateFromPlanShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();

View File

@@ -92,6 +92,46 @@ class ShoppingServiceTest {
} catch (Exception e) { throw new RuntimeException(e); }
}
// ── Get by week start ──
@Test
void getByWeekStartShouldReturnListForGivenWeek() {
var household = testHousehold();
var plan = testWeekPlan(household);
var list = testShoppingList(household, plan);
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.of(list));
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
assertThat(result.id()).isEqualTo(list.getId());
}
@Test
void getByWeekStartShouldDefaultToCurrentWeekWhenNull() {
var household = testHousehold();
var plan = testWeekPlan(household);
var list = testShoppingList(household, plan);
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class)))
.thenReturn(Optional.of(list));
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null);
assertThat(result).isNotNull();
}
@Test
void getByWeekStartShouldReturnNullWhenNoListExists() {
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
assertThat(result).isNull();
}
// ── Generate ──
@Test
@@ -121,9 +161,11 @@ class ShoppingServiceTest {
plan.getSlots().add(slot2);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID());
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
@@ -144,6 +186,59 @@ class ShoppingServiceTest {
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
}
@Test
void generateFromPlanShouldMergeWhenListAlreadyExists() {
var household = testHousehold();
var plan = testWeekPlan(household);
var existingList = testShoppingList(household, plan);
// Existing generated item: 2 tomatoes
var tomato = testIngredient(household, "Tomatoes", false);
var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(existingItem);
// Existing custom item (should be preserved)
var customItem = new ShoppingListItem(existingList, null, "Paper towels",
new BigDecimal("1"), "", new UUID[0]);
setId(customItem, ShoppingListItem.class, UUID.randomUUID());
customItem.setChecked(true);
existingList.getItems().add(customItem);
// New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added)
var recipe = testRecipe(household, "Pasta");
var cheese = testIngredient(household, "Cheese", false);
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1));
recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2));
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart()))
.thenReturn(Optional.of(existingList));
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
// Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom)
assertThat(result.items()).hasSize(3);
var tomatoResult = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00"));
var cheeseResult = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
// Custom item preserved with check state
var customResult = result.items().stream()
.filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow();
assertThat(customResult.isChecked()).isTrue();
}
@Test
void generateFromPlanShouldThrowWhenPlanNotFound() {
var planId = UUID.randomUUID();
@@ -382,9 +477,11 @@ class ShoppingServiceTest {
// no slots added
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
.thenReturn(Optional.empty());
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID());
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});

File diff suppressed because one or more lines are too long

View File

@@ -452,6 +452,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/shopping-list": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getByWeekStart"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/ingredients": {
parameters: {
query?: never;
@@ -624,6 +640,11 @@ export interface components {
/** Format: uuid */
recipeId: string;
};
RecipeRef: {
/** Format: uuid */
id?: string;
name?: string;
};
ShoppingListItemResponse: {
/** Format: uuid */
id?: string;
@@ -636,13 +657,17 @@ export interface components {
isChecked?: boolean;
/** Format: uuid */
checkedBy?: string;
sourceRecipes?: string[];
sourceRecipes?: components["schemas"]["RecipeRef"][];
};
ShoppingListResponse: {
/** Format: uuid */
id?: string;
/** Format: uuid */
weekPlanId?: string;
/** Format: date-time */
generatedAt?: string;
/** Format: int32 */
filteredStaplesCount?: number;
items?: components["schemas"]["ShoppingListItemResponse"][];
};
TagCreateRequest: {
@@ -1902,6 +1927,28 @@ export interface operations {
};
};
};
getByWeekStart: {
parameters: {
query?: {
weekStart?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ShoppingListResponse"];
};
};
};
};
searchIngredients: {
parameters: {
query?: {

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
listId: string;
}
let { listId }: Props = $props();
let expanded = $state(false);
let customName = $state('');
let quantity = $state('1');
let unit = $state('');
</script>
{#if !expanded}
<button
type="button"
onclick={() => (expanded = true)}
class="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:border-[var(--green-light)] hover:text-[var(--color-text)]"
>
<span class="text-[16px]">+</span>
Artikel hinzufügen
</button>
{:else}
<form
method="POST"
action="?/addItem"
use:enhance={() => {
return async ({ update }) => {
await update();
customName = '';
quantity = '1';
unit = '';
expanded = false;
};
}}
class="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
>
<input type="hidden" name="listId" value={listId} />
<input
type="text"
name="customName"
bind:value={customName}
placeholder="Artikelname"
required
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
/>
<div class="flex gap-2">
<input
type="number"
name="quantity"
bind:value={quantity}
min="0"
step="any"
class="w-20 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] focus:border-[var(--green)] focus:outline-none"
/>
<input
type="text"
name="unit"
bind:value={unit}
placeholder="Einheit"
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (expanded = false)}
class="rounded-[var(--radius-md)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!customName.trim()}
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] font-medium text-white disabled:opacity-50"
>
Hinzufügen
</button>
</div>
</form>
{/if}

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface RecipeRef {
id?: string;
name?: string;
}
interface Props {
listId: string;
itemId: string;
name: string;
quantity: number | null;
unit: string | null;
isChecked: boolean;
sourceRecipes: RecipeRef[];
}
let { listId, itemId, name, quantity, unit, isChecked, sourceRecipes }: Props = $props();
let recipeLabel = $derived(
sourceRecipes.length > 0
? sourceRecipes
.map((r) => r.name)
.filter(Boolean)
.join(', ')
: null
);
let quantityLabel = $derived(
quantity ? `${quantity}${unit ? ` ${unit}` : ''}` : null
);
</script>
<form method="POST" action="?/check" use:enhance class="group flex items-center gap-3 py-2">
<input type="hidden" name="listId" value={listId} />
<input type="hidden" name="itemId" value={itemId} />
<input type="hidden" name="isChecked" value={!isChecked} />
<button
type="submit"
aria-label="{isChecked ? 'Abhaken rückgängig' : 'Abhaken'}: {name}"
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border
{isChecked
? 'border-[var(--green)] bg-[var(--green)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--green-light)]'}"
>
{#if isChecked}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 6l3 3 5-5" />
</svg>
{/if}
</button>
<div class="min-w-0 flex-1">
<p
class="font-[var(--font-sans)] text-[14px] {isChecked
? 'text-[var(--color-text-muted)] line-through'
: 'text-[var(--color-text)]'}"
>
{name}
</p>
{#if recipeLabel && !isChecked}
<p class="truncate font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
Für: {recipeLabel}
</p>
{/if}
</div>
{#if quantityLabel}
<span
class="flex-shrink-0 font-[var(--font-sans)] text-[13px] {isChecked
? 'text-[var(--color-text-muted)]'
: 'text-[var(--color-text)]'}"
>
{quantityLabel}
</span>
{/if}
</form>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { formatDayAbbr } from '$lib/planner/week';
interface Slot {
slotDate?: string;
recipe?: {
id?: string;
name?: string;
};
}
interface Props {
slots: Slot[];
filteredStaplesCount: number;
}
let { slots, filteredStaplesCount }: Props = $props();
let filledSlots = $derived(slots.filter((s) => s.recipe));
</script>
<aside class="flex flex-col gap-4">
<div>
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Rezepte dieser Woche
</h2>
<div class="mt-2 space-y-1.5">
{#each filledSlots as slot}
<a
href="/recipes/{slot.recipe?.id}"
class="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2 hover:border-[var(--green-light)]"
>
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
{slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : ''}
</span>
<span class="flex-1 truncate font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{slot.recipe?.name}
</span>
</a>
{/each}
{#if filledSlots.length === 0}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
Keine Gerichte geplant.
</p>
{/if}
</div>
</div>
{#if filteredStaplesCount > 0}
<div class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2">
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{filteredStaplesCount} Grundzutaten automatisch ausgeblendet
</p>
<a
href="/pantry"
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] hover:underline"
>
Vorrat bearbeiten
</a>
</div>
{/if}
</aside>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
totalItems: number;
checkedCount: number;
generatedAt: string | null;
weekPlanId: string | null;
isPlanner: boolean;
hasShoppingList: boolean;
}
let { totalItems, checkedCount, generatedAt, weekPlanId, isPlanner, hasShoppingList }: Props = $props();
let remainingCount = $derived(totalItems - checkedCount);
let formattedTime = $derived(
generatedAt
? new Date(generatedAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: null
);
</script>
<header class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
Einkaufsliste
</h1>
{#if isPlanner && weekPlanId}
<form method="POST" action="?/generate" use:enhance>
<input type="hidden" name="weekPlanId" value={weekPlanId} />
<button
type="submit"
class="rounded-[var(--radius-md)] {hasShoppingList
? 'border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]'
: 'bg-[var(--green-dark)] text-white'} px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)]"
>
{hasShoppingList ? 'Neu generieren' : 'Liste generieren'}
</button>
</form>
{/if}
</div>
{#if hasShoppingList}
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{remainingCount} Artikel übrig · {checkedCount} abgehakt
{#if formattedTime}
<span class="ml-1">· erstellt {formattedTime}</span>
{/if}
</p>
{/if}
</header>

View File

@@ -0,0 +1,86 @@
import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week');
const weekStart = weekParam ?? getWeekStart(new Date());
const api = apiClient(fetch);
const [shoppingResult, weekPlanResult] = await Promise.all([
api.GET('/v1/shopping-list', {
params: { query: { weekStart } }
}),
api.GET('/v1/week-plans', {
params: { query: { weekStart } }
})
]);
return {
shoppingList: shoppingResult.data ?? null,
weekPlan: weekPlanResult.data ?? null,
weekStart
};
};
export const actions: Actions = {
check: async ({ fetch, request }) => {
const formData = await request.formData();
const listId = formData.get('listId') as string;
const itemId = formData.get('itemId') as string;
const isChecked = formData.get('isChecked') === 'true';
const api = apiClient(fetch);
const { data, error } = await api.PATCH('/v1/shopping-lists/{listId}/items/{itemId}', {
params: { path: { listId, itemId } },
body: { isChecked }
});
if (error || !data) {
return { success: false, error: 'Status konnte nicht geändert werden.' };
}
return { success: true, item: data };
},
addItem: async ({ fetch, request }) => {
const formData = await request.formData();
const listId = formData.get('listId') as string;
const customName = formData.get('customName') as string;
const quantity = parseFloat(formData.get('quantity') as string) || 1;
const unit = (formData.get('unit') as string) ?? '';
const api = apiClient(fetch);
const { data, error } = await api.POST('/v1/shopping-lists/{id}/items', {
params: { path: { id: listId } },
body: { customName, quantity, unit }
});
if (error || !data) {
return { success: false, error: 'Artikel konnte nicht hinzugefügt werden.' };
}
return { success: true, item: data };
},
generate: async ({ fetch, request, locals }) => {
if (locals.benutzer?.rolle !== 'planer') {
return { success: false, error: 'Keine Berechtigung.' };
}
const formData = await request.formData();
const weekPlanId = formData.get('weekPlanId') as string;
const api = apiClient(fetch);
const { data, error } = await api.POST('/v1/week-plans/{id}/shopping-list', {
params: { path: { id: weekPlanId } }
});
if (error || !data) {
return { success: false, error: 'Einkaufsliste konnte nicht erstellt werden.' };
}
return { success: true };
}
};

View File

@@ -1 +1,230 @@
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1>
<script lang="ts">
import ShoppingHeader from '$lib/shopping/ShoppingHeader.svelte';
import ChecklistItem from '$lib/shopping/ChecklistItem.svelte';
import AddCustomItem from '$lib/shopping/AddCustomItem.svelte';
import RecipeReferencePanel from '$lib/shopping/RecipeReferencePanel.svelte';
let { data } = $props();
let shoppingList = $derived(data.shoppingList);
let weekPlan = $derived(data.weekPlan);
let isPlanner = $derived(data.benutzer?.rolle === 'planer');
let items = $derived(shoppingList?.items ?? []);
let uncheckedItems = $derived(items.filter((i) => !i.isChecked));
let checkedItems = $derived(items.filter((i) => i.isChecked));
let totalItems = $derived(items.length);
let checkedCount = $derived(checkedItems.length);
let slots = $derived(weekPlan?.slots ?? []);
let filteredStaplesCount = $derived(shoppingList?.filteredStaplesCount ?? 0);
let listId = $derived(shoppingList?.id ?? '');
</script>
<!-- Mobile layout -->
<div class="flex h-full flex-col lg:hidden">
<header class="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
<ShoppingHeader
{totalItems}
{checkedCount}
generatedAt={shoppingList?.generatedAt ?? null}
weekPlanId={weekPlan?.id ?? null}
{isPlanner}
hasShoppingList={!!shoppingList}
/>
</header>
<main class="flex-1 overflow-y-auto px-4 py-3">
{#if !weekPlan}
<!-- Empty state: no week plan -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch kein Wochenplan für diese Woche.
</p>
<a
href="/planner"
class="mt-3 font-[var(--font-sans)] text-[13px] font-medium text-[var(--green-dark)] hover:underline"
>
Zum Wochenplaner
</a>
</div>
{:else if !shoppingList}
<!-- Empty state: plan exists, no shopping list -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Einkaufsliste noch nicht erstellt.
</p>
{#if isPlanner}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Generiere die Liste aus dem Wochenplan.
</p>
{:else}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Der Planer muss die Liste zuerst erstellen.
</p>
{/if}
</div>
{:else}
<!-- Unchecked items -->
{#if uncheckedItems.length > 0}
<div class="divide-y divide-[var(--color-border)]">
{#each uncheckedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={false}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
{:else if totalItems > 0}
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Alles erledigt!
</p>
{/if}
<!-- Add custom item -->
<div class="mt-3">
<AddCustomItem {listId} />
</div>
<!-- Filtered staples info (mobile) -->
{#if filteredStaplesCount > 0}
<div class="mt-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
{filteredStaplesCount} Grundzutaten ausgeblendet ·
<a href="/pantry" class="font-medium text-[var(--green-dark)] hover:underline">Vorrat bearbeiten</a>
</p>
</div>
{/if}
<!-- Checked items -->
{#if checkedItems.length > 0}
<div class="mt-4">
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Abgehakt ({checkedCount})
</p>
<div class="divide-y divide-[var(--color-border)]">
{#each checkedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={true}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
</div>
{/if}
{/if}
</main>
</div>
<!-- Desktop layout -->
<div class="hidden h-screen lg:flex lg:flex-col">
<header class="border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
<ShoppingHeader
{totalItems}
{checkedCount}
generatedAt={shoppingList?.generatedAt ?? null}
weekPlanId={weekPlan?.id ?? null}
{isPlanner}
hasShoppingList={!!shoppingList}
/>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left panel: checklist -->
<main class="flex-1 overflow-y-auto px-6 py-5">
{#if !weekPlan}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch kein Wochenplan für diese Woche.
</p>
<a
href="/planner"
class="mt-3 font-[var(--font-sans)] text-[13px] font-medium text-[var(--green-dark)] hover:underline"
>
Zum Wochenplaner
</a>
</div>
{:else if !shoppingList}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Einkaufsliste noch nicht erstellt.
</p>
{#if isPlanner}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Generiere die Liste aus dem Wochenplan.
</p>
{:else}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Der Planer muss die Liste zuerst erstellen.
</p>
{/if}
</div>
{:else}
<!-- Unchecked items -->
{#if uncheckedItems.length > 0}
<div class="divide-y divide-[var(--color-border)]">
{#each uncheckedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={false}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
{:else if totalItems > 0}
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Alles erledigt!
</p>
{/if}
<!-- Add custom item -->
<div class="mt-4">
<AddCustomItem {listId} />
</div>
<!-- Checked items -->
{#if checkedItems.length > 0}
<div class="mt-6 border-t border-[var(--color-border)] pt-4">
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Abgehakt ({checkedCount})
</p>
<div class="divide-y divide-[var(--color-border)]">
{#each checkedItems as item (item.id)}
<ChecklistItem
{listId}
itemId={item.id ?? ''}
name={item.name ?? ''}
quantity={item.quantity ?? null}
unit={item.unit ?? null}
isChecked={true}
sourceRecipes={item.sourceRecipes ?? []}
/>
{/each}
</div>
</div>
{/if}
{/if}
</main>
<!-- Right panel: recipe reference (desktop only) -->
{#if weekPlan}
<aside class="w-[280px] flex-shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<RecipeReferencePanel {slots} {filteredStaplesCount} />
</aside>
{/if}
</div>
</div>