feat: D1 — Shopping list (Issue #30) #43

Merged
marcel merged 24 commits from feat/issue-30-shopping-list into master 2026-04-08 22:22:02 +02:00
31 changed files with 1515 additions and 70 deletions

View File

@@ -0,0 +1,7 @@
package com.recipeapp.common;
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}

View File

@@ -32,6 +32,12 @@ public class GlobalExceptionHandler {
.body(ApiError.of("CONFLICT", ex.getMessage())); .body(ApiError.of("CONFLICT", ex.getMessage()));
} }
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<ApiError> handleForbidden(ForbiddenException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiError.of("FORBIDDEN", ex.getMessage()));
}
@ExceptionHandler(ValidationException.class) @ExceptionHandler(ValidationException.class)
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) { public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)

View File

@@ -0,0 +1,43 @@
package com.recipeapp.common;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class HouseholdRoleInterceptor implements HandlerInterceptor {
private final HouseholdResolver householdResolver;
public HouseholdRoleInterceptor(HouseholdResolver householdResolver) {
this.householdResolver = householdResolver;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class);
if (annotation == null) {
return true;
}
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
throw new ForbiddenException("Not authenticated");
}
String actualRole = householdResolver.resolveRole(auth.getName());
if (!annotation.value().equals(actualRole)) {
throw new ForbiddenException("Insufficient permissions");
}
return true;
}
}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresHouseholdRole {
String value();
}

View File

@@ -0,0 +1,20 @@
package com.recipeapp.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final HouseholdRoleInterceptor householdRoleInterceptor;
public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) {
this.householdRoleInterceptor = householdRoleInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(householdRoleInterceptor);
}
}

View File

@@ -24,6 +24,10 @@ public class HouseholdResolver {
return findMembership(userEmail).getUser().getId(); return findMembership(userEmail).getUser().getId();
} }
public String resolveRole(String userEmail) {
return findMembership(userEmail).getRole();
}
private HouseholdMember findMembership(String userEmail) { private HouseholdMember findMembership(String userEmail) {
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail) return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household")); .orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));

View File

@@ -1,11 +1,15 @@
package com.recipeapp.shopping; package com.recipeapp.shopping;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@@ -19,8 +23,21 @@ public class ShoppingListController {
this.householdResolver = householdResolver; 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") @PostMapping("/v1/week-plans/{id}/shopping-list")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@RequiresHouseholdRole("planner")
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) { public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.generateFromPlan(householdId, id); return shoppingService.generateFromPlan(householdId, id);
@@ -45,7 +62,7 @@ public class ShoppingListController {
@PostMapping("/v1/shopping-lists/{id}/items") @PostMapping("/v1/shopping-lists/{id}/items")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public ShoppingListItemResponse addItem(@PathVariable UUID id, public ShoppingListItemResponse addItem(@PathVariable UUID id,
@RequestBody AddItemRequest request, @Valid @RequestBody AddItemRequest request,
Principal principal) { Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.addItem(householdId, id, request); return shoppingService.addItem(householdId, id, request);

View File

@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
import com.recipeapp.shopping.entity.ShoppingList; import com.recipeapp.shopping.entity.ShoppingList;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> { public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
Optional<ShoppingList> findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart);
} }

View File

@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.planning.WeekPlanRepository; import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.Recipe;
import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import com.recipeapp.shopping.entity.ShoppingList; import com.recipeapp.shopping.entity.ShoppingList;
@@ -16,6 +18,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -29,19 +34,34 @@ public class ShoppingService {
private final HouseholdRepository householdRepository; private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository; private final IngredientRepository ingredientRepository;
private final UserAccountRepository userAccountRepository; private final UserAccountRepository userAccountRepository;
private final RecipeRepository recipeRepository;
public ShoppingService(ShoppingListRepository shoppingListRepository, public ShoppingService(ShoppingListRepository shoppingListRepository,
ShoppingListItemRepository shoppingListItemRepository, ShoppingListItemRepository shoppingListItemRepository,
WeekPlanRepository weekPlanRepository, WeekPlanRepository weekPlanRepository,
HouseholdRepository householdRepository, HouseholdRepository householdRepository,
IngredientRepository ingredientRepository, IngredientRepository ingredientRepository,
UserAccountRepository userAccountRepository) { UserAccountRepository userAccountRepository,
RecipeRepository recipeRepository) {
this.shoppingListRepository = shoppingListRepository; this.shoppingListRepository = shoppingListRepository;
this.shoppingListItemRepository = shoppingListItemRepository; this.shoppingListItemRepository = shoppingListItemRepository;
this.weekPlanRepository = weekPlanRepository; this.weekPlanRepository = weekPlanRepository;
this.householdRepository = householdRepository; this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository; this.ingredientRepository = ingredientRepository;
this.userAccountRepository = userAccountRepository; this.userAccountRepository = userAccountRepository;
this.recipeRepository = recipeRepository;
}
@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);
} }
@@ -53,45 +73,71 @@ public class ShoppingService {
throw new ResourceNotFoundException("Week plan not found"); throw new ResourceNotFoundException("Week plan not found");
} }
var household = weekPlan.getHousehold(); // Find or create the shopping list
ShoppingList shoppingList = shoppingListRepository
ShoppingList shoppingList = new ShoppingList(household, weekPlan); .findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
shoppingList = shoppingListRepository.save(shoppingList); .orElseGet(() -> {
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
return shoppingListRepository.save(newList);
});
// Aggregate ingredients across all slots/recipes // Aggregate ingredients across all slots/recipes
// Key: ingredientId + unit -> merged data
Map<String, MergedIngredient> merged = new LinkedHashMap<>(); Map<String, MergedIngredient> merged = new LinkedHashMap<>();
for (var slot : weekPlan.getSlots()) { for (var slot : weekPlan.getSlots()) {
var recipe = slot.getRecipe(); var recipe = slot.getRecipe();
for (RecipeIngredient ri : recipe.getIngredients()) { for (RecipeIngredient ri : recipe.getIngredients()) {
Ingredient ingredient = ri.getIngredient(); Ingredient ingredient = ri.getIngredient();
// Filter out staples
if (ingredient.isStaple()) { if (ingredient.isStaple()) {
continue; continue;
} }
String key = mergeKey(ingredient.getId(), ri.getUnit());
String key = ingredient.getId().toString() + "|" + ri.getUnit();
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit())) merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
.addQuantity(ri.getQuantity()) .addQuantity(ri.getQuantity())
.addRecipeId(recipe.getId()); .addRecipeId(recipe.getId());
} }
} }
// Create shopping list items // Build index of existing generated items by merge key
for (MergedIngredient mi : merged.values()) { Map<String, ShoppingListItem> existingByKey = new HashMap<>();
ShoppingListItem item = new ShoppingListItem( List<ShoppingListItem> customItems = new ArrayList<>();
shoppingList, for (ShoppingListItem item : shoppingList.getItems()) {
mi.ingredient, if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
null, // Generated item
mi.totalQuantity, String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit());
mi.unit, existingByKey.put(key, item);
mi.recipeIds.stream().distinct().toArray(UUID[]::new) } else {
); customItems.add(item);
shoppingList.getItems().add(item); }
} }
// Merge: update existing, add new, collect keys to keep
Set<String> mergedKeys = new HashSet<>();
for (MergedIngredient mi : merged.values()) {
String key = mergeKey(mi.ingredient.getId(), 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(mergeKey(
item.getIngredient() != null ? item.getIngredient().getId() : null,
item.getUnit())));
shoppingList.setGeneratedAt(java.time.Instant.now());
shoppingListRepository.save(shoppingList); shoppingListRepository.save(shoppingList);
return toResponse(shoppingList); return toResponse(shoppingList);
@@ -121,7 +167,7 @@ public class ShoppingService {
} }
shoppingListItemRepository.save(item); shoppingListItemRepository.save(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -146,7 +192,7 @@ public class ShoppingService {
item = shoppingListItemRepository.save(item); item = shoppingListItemRepository.save(item);
list.getItems().add(item); list.getItems().add(item);
return toItemResponse(item); return toItemResponseWithNames(item);
} }
@@ -178,18 +224,53 @@ public class ShoppingService {
} }
private ShoppingListResponse toResponse(ShoppingList list) { private ShoppingListResponse toResponse(ShoppingList list) {
// Batch-fetch recipe names for source references
Set<UUID> allRecipeIds = list.getItems().stream()
.filter(i -> i.getSourceRecipes() != null)
.flatMap(i -> Arrays.stream(i.getSourceRecipes()))
.collect(Collectors.toSet());
Map<UUID, String> recipeNames = allRecipeIds.isEmpty()
? Map.of()
: recipeRepository.findAllById(allRecipeIds).stream()
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
List<ShoppingListItemResponse> items = list.getItems().stream() List<ShoppingListItemResponse> items = list.getItems().stream()
.map(this::toItemResponse) .map(item -> toItemResponse(item, recipeNames))
.toList(); .toList();
// Count filtered staples from the week plan
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
return new ShoppingListResponse( return new ShoppingListResponse(
list.getId(), list.getId(),
list.getWeekPlan().getId(), list.getWeekPlan().getId(),
list.getGeneratedAt(),
filteredStaplesCount,
items items
); );
} }
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) { private int countFilteredStaples(WeekPlan weekPlan) {
return (int) weekPlan.getSlots().stream()
.flatMap(slot -> slot.getRecipe().getIngredients().stream())
.map(RecipeIngredient::getIngredient)
.filter(Ingredient::isStaple)
.map(Ingredient::getId)
.distinct()
.count();
}
private ShoppingListItemResponse toItemResponseWithNames(ShoppingListItem item) {
Map<UUID, String> recipeNames = Map.of();
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
recipeNames = recipeRepository.findAllById(Arrays.asList(item.getSourceRecipes())).stream()
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
}
return toItemResponse(item, recipeNames);
}
private ShoppingListItemResponse toItemResponse(ShoppingListItem item, Map<UUID, String> recipeNames) {
String name; String name;
ShoppingListItemResponse.CategoryRef categoryRef = null; ShoppingListItemResponse.CategoryRef categoryRef = null;
UUID ingredientId = null; UUID ingredientId = null;
@@ -207,6 +288,14 @@ public class ShoppingService {
name = item.getCustomName(); name = item.getCustomName();
} }
List<ShoppingListItemResponse.RecipeRef> sourceRefs = item.getSourceRecipes() != null
? Arrays.stream(item.getSourceRecipes())
.distinct()
.filter(recipeNames::containsKey)
.map(id -> new ShoppingListItemResponse.RecipeRef(id, recipeNames.get(id)))
.toList()
: List.of();
return new ShoppingListItemResponse( return new ShoppingListItemResponse(
item.getId(), item.getId(),
ingredientId, ingredientId,
@@ -216,10 +305,14 @@ public class ShoppingService {
item.getUnit(), item.getUnit(),
item.isChecked(), item.isChecked(),
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null, item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of() sourceRefs
); );
} }
private static String mergeKey(UUID ingredientId, String unit) {
return (ingredientId != null ? ingredientId.toString() : "") + "|" + unit;
}
private static class MergedIngredient { private static class MergedIngredient {
final Ingredient ingredient; final Ingredient ingredient;
final String unit; final String unit;

View File

@@ -1,11 +1,15 @@
package com.recipeapp.shopping.dto; package com.recipeapp.shopping.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
public record AddItemRequest( public record AddItemRequest(
UUID ingredientId, UUID ingredientId,
String customName, @NotBlank @Size(max = 255) String customName,
BigDecimal quantity, @Positive BigDecimal quantity,
String unit String unit
) {} ) {}

View File

@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
String unit, String unit,
boolean isChecked, boolean isChecked,
UUID checkedBy, UUID checkedBy,
List<UUID> sourceRecipes List<RecipeRef> sourceRecipes
) { ) {
public record CategoryRef(UUID id, String name) {} public record CategoryRef(UUID id, String name) {}
public record RecipeRef(UUID id, String name) {}
} }

View File

@@ -1,10 +1,13 @@
package com.recipeapp.shopping.dto; package com.recipeapp.shopping.dto;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public record ShoppingListResponse( public record ShoppingListResponse(
UUID id, UUID id,
UUID weekPlanId, UUID weekPlanId,
Instant generatedAt,
int filteredStaplesCount,
List<ShoppingListItemResponse> items List<ShoppingListItemResponse> items
) {} ) {}

View File

@@ -3,6 +3,7 @@ package com.recipeapp.shopping.entity;
import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.Household;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -23,6 +24,9 @@ public class ShoppingList {
@JoinColumn(name = "week_plan_id", nullable = false) @JoinColumn(name = "week_plan_id", nullable = false)
private WeekPlan weekPlan; private WeekPlan weekPlan;
@Column(name = "generated_at", nullable = false)
private Instant generatedAt = Instant.now();
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ShoppingListItem> items = new ArrayList<>(); private List<ShoppingListItem> items = new ArrayList<>();
@@ -36,5 +40,7 @@ public class ShoppingList {
public UUID getId() { return id; } public UUID getId() { return id; }
public Household getHousehold() { return household; } public Household getHousehold() { return household; }
public WeekPlan getWeekPlan() { return weekPlan; } public WeekPlan getWeekPlan() { return weekPlan; }
public Instant getGeneratedAt() { return generatedAt; }
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
public List<ShoppingListItem> getItems() { return items; } public List<ShoppingListItem> getItems() { return items; }
} }

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
package com.recipeapp.common;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.method.HandlerMethod;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class HouseholdRoleInterceptorTest {
@Mock private HouseholdResolver householdResolver;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@InjectMocks private HouseholdRoleInterceptor interceptor;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(String email) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(email, null));
}
@Test
void shouldAllowWhenUserHasRequiredRole() throws Exception {
authenticateAs("planner@example.com");
when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldThrowForbiddenWhenUserLacksRequiredRole() {
authenticateAs("member@example.com");
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod))
.isInstanceOf(ForbiddenException.class)
.hasMessage("Insufficient permissions");
}
@Test
void shouldPassThroughWhenNoAnnotation() throws Exception {
var handlerMethod = mock(HandlerMethod.class);
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldPassThroughWhenNotHandlerMethod() throws Exception {
boolean result = interceptor.preHandle(request, response, new Object());
assertThat(result).isTrue();
}
}

View File

@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*; import com.recipeapp.shopping.dto.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
.build(); .build();
} }
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@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 @Test
void generateFromPlanShouldReturn201() throws Exception { void generateFromPlanShouldReturn201() throws Exception {
var recipeId = UUID.randomUUID();
var item = new ShoppingListItemResponse( var item = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", ITEM_ID, UUID.randomUUID(), "Tomatoes",
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"), new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID())); new BigDecimal("4.00"), "pcs", false, null,
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item)); List.of(new ShoppingListItemResponse.RecipeRef(recipeId, "Spaghetti")));
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 2, List.of(item));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response); when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
@Test @Test
void getShoppingListShouldReturn200() throws Exception { void getShoppingListShouldReturn200() throws Exception {
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of()); var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 0, List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response); when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
void checkItemShouldReturn200() throws Exception { void checkItemShouldReturn200() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, UUID.randomUUID(), "Tomatoes", null, ITEM_ID, UUID.randomUUID(), "Tomatoes", null,
new BigDecimal("4.00"), "pcs", true, USER_ID, List.of()); new BigDecimal("4.00"), "pcs", true, USER_ID,
List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID); when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
void addItemShouldReturn201() throws Exception { void addItemShouldReturn201() throws Exception {
var response = new ShoppingListItemResponse( var response = new ShoppingListItemResponse(
ITEM_ID, null, "Paper towels", null, ITEM_ID, null, "Paper towels", null,
new BigDecimal("1"), "", false, null, List.of()); new BigDecimal("1"), "", false, null,
List.of());
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class))) when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
.principal(() -> "sarah@example.com")) .principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@Test
void addItemShouldReturn400WhenCustomNameIsBlank() throws Exception {
mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new AddItemRequest(null, " ", new BigDecimal("1"), ""))))
.andExpect(status().isBadRequest());
}
@Test
void generateFromPlanShouldReturn403ForNonPlanner() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(shoppingListController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID)
.principal(() -> "member@example.com"))
.andExpect(status().isForbidden());
}
} }

View File

@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan; import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.planning.entity.WeekPlanSlot; import com.recipeapp.planning.entity.WeekPlanSlot;
import com.recipeapp.recipe.IngredientRepository; import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.Recipe;
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
@Mock private HouseholdRepository householdRepository; @Mock private HouseholdRepository householdRepository;
@Mock private IngredientRepository ingredientRepository; @Mock private IngredientRepository ingredientRepository;
@Mock private UserAccountRepository userAccountRepository; @Mock private UserAccountRepository userAccountRepository;
@Mock private RecipeRepository recipeRepository;
@InjectMocks private ShoppingService shoppingService; @InjectMocks private ShoppingService shoppingService;
@@ -90,6 +92,46 @@ class ShoppingServiceTest {
} catch (Exception e) { throw new RuntimeException(e); } } 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 ── // ── Generate ──
@Test @Test
@@ -119,26 +161,84 @@ class ShoppingServiceTest {
plan.getSlots().add(slot2); plan.getSlots().add(slot2);
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); 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 -> { when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0); ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID()); if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl; return sl;
}); });
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered) assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
var tomatoItem = result.items().stream() var tomatoItem = result.items().stream()
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3 assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
assertThat(tomatoItem.sourceRecipes()).hasSize(2); assertThat(tomatoItem.sourceRecipes()).hasSize(2);
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
var cheeseItem = result.items().stream() var cheeseItem = result.items().stream()
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); 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 @Test
void generateFromPlanShouldThrowWhenPlanNotFound() { void generateFromPlanShouldThrowWhenPlanNotFound() {
var planId = UUID.randomUUID(); var planId = UUID.randomUUID();
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId()); ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
assertThat(result.id()).isEqualTo(list.getId()); assertThat(result.id()).isEqualTo(list.getId());
assertThat(result.generatedAt()).isNotNull();
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes"); assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
} }
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
// ── Generate removes stale items ──
@Test
void generateFromPlanShouldRemoveStaleGeneratedItems() {
var household = testHousehold();
var plan = testWeekPlan(household);
var existingList = testShoppingList(household, plan);
var tomato = testIngredient(household, "Tomatoes", false);
var onion = testIngredient(household, "Onions", false);
// Existing list has both tomatoes and onions (generated)
var tomatoItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
tomatoItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(tomatoItem);
var onionItem = testItem(existingList, onion, new BigDecimal("1.00"), "pcs");
onionItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
existingList.getItems().add(onionItem);
// New plan only has tomatoes — onions removed from recipes
var recipe = testRecipe(household, "Sauce");
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("3.00"), "pcs", (short) 1));
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, WEEK_START))
.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());
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
}
// ── Source recipes deduplication ──
@Test
void generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots() {
var household = testHousehold();
var plan = testWeekPlan(household);
var recipe = testRecipe(household, "Pasta");
var tomato = testIngredient(household, "Tomatoes", false);
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("2.00"), "pcs", (short) 1));
// Same recipe in two slots
var slot1 = new WeekPlanSlot(plan, recipe, WEEK_START);
setId(slot1, WeekPlanSlot.class, UUID.randomUUID());
var slot2 = new WeekPlanSlot(plan, recipe, WEEK_START.plusDays(2));
setId(slot2, WeekPlanSlot.class, UUID.randomUUID());
plan.getSlots().add(slot1);
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);
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl;
});
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().sourceRecipes()).hasSize(1); // deduplicated
}
// ── checkItem household isolation ──
@Test
void checkItemShouldThrowWhenHouseholdMismatch() {
var otherHousehold = new Household("Other family", null);
setId(otherHousehold, Household.class, UUID.randomUUID());
var plan = new WeekPlan(otherHousehold, WEEK_START);
setId(plan, WeekPlan.class, UUID.randomUUID());
var list = new ShoppingList(otherHousehold, plan);
setId(list, ShoppingList.class, UUID.randomUUID());
when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list));
assertThatThrownBy(() -> shoppingService.checkItem(
HOUSEHOLD_ID, list.getId(), UUID.randomUUID(), new CheckItemRequest(true), UUID.randomUUID()))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── Generate from plan with empty slots ── // ── Generate from plan with empty slots ──
@Test @Test
@@ -376,9 +568,11 @@ class ShoppingServiceTest {
// no slots added // no slots added
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); 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 -> { when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
ShoppingList sl = i.getArgument(0); ShoppingList sl = i.getArgument(0);
setId(sl, ShoppingList.class, UUID.randomUUID()); if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
return sl; return sl;
}); });

File diff suppressed because one or more lines are too long

View File

@@ -452,6 +452,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/v1/ingredients": {
parameters: { parameters: {
query?: never; query?: never;
@@ -624,6 +640,11 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
recipeId: string; recipeId: string;
}; };
RecipeRef: {
/** Format: uuid */
id?: string;
name?: string;
};
ShoppingListItemResponse: { ShoppingListItemResponse: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
@@ -636,13 +657,17 @@ export interface components {
isChecked?: boolean; isChecked?: boolean;
/** Format: uuid */ /** Format: uuid */
checkedBy?: string; checkedBy?: string;
sourceRecipes?: string[]; sourceRecipes?: components["schemas"]["RecipeRef"][];
}; };
ShoppingListResponse: { ShoppingListResponse: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
/** Format: uuid */ /** Format: uuid */
weekPlanId?: string; weekPlanId?: string;
/** Format: date-time */
generatedAt?: string;
/** Format: int32 */
filteredStaplesCount?: number;
items?: components["schemas"]["ShoppingListItemResponse"][]; items?: components["schemas"]["ShoppingListItemResponse"][];
}; };
TagCreateRequest: { 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: { searchIngredients: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -0,0 +1,88 @@
<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 ({ result, update }) => {
if (result.type === 'success') {
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,58 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import AddCustomItem from './AddCustomItem.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('AddCustomItem', () => {
it('shows collapsed trigger button initially', () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
expect(screen.getByText(/Artikel hinzufügen/)).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
});
it('expands form when trigger button is clicked', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
});
it('collapses form when Abbrechen is clicked', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
await userEvent.click(screen.getByText('Abbrechen'));
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
});
it('submit button is disabled when name is empty', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
expect(screen.getByRole('button', { name: /Hinzufügen/i })).toBeDisabled();
});
it('submit button is enabled when name is entered', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
await userEvent.type(screen.getByPlaceholderText('Artikelname'), 'Papier');
expect(screen.getByRole('button', { name: /Hinzufügen/i })).not.toBeDisabled();
});
it('form submits to ?/addItem action', async () => {
render(AddCustomItem, { props: { listId: 'list-1' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
expect(form).toHaveAttribute('action', '?/addItem');
});
it('form includes listId as hidden input', async () => {
render(AddCustomItem, { props: { listId: 'list-42' } });
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
expect(listIdInput.value).toBe('list-42');
});
});

View File

@@ -0,0 +1,81 @@
<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={() => async ({ update }) => update({ reset: false })} 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"
role="checkbox"
aria-checked={isChecked}
aria-label="{isChecked ? 'Abhaken rückgängig' : 'Abhaken'}: {name}"
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--green)]
{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,88 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ChecklistItem from './ChecklistItem.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('ChecklistItem', () => {
const baseProps = {
listId: 'list-1',
itemId: 'item-1',
name: 'Tomaten',
quantity: null,
unit: null,
isChecked: false,
sourceRecipes: []
};
it('renders the item name', () => {
render(ChecklistItem, { props: baseProps });
expect(screen.getByText('Tomaten')).toBeInTheDocument();
});
it('renders quantity and unit when provided', () => {
render(ChecklistItem, { props: { ...baseProps, quantity: 3, unit: 'Stück' } });
expect(screen.getByText('3 Stück')).toBeInTheDocument();
});
it('renders quantity without unit when unit is null', () => {
render(ChecklistItem, { props: { ...baseProps, quantity: 2, unit: null } });
expect(screen.getByText('2')).toBeInTheDocument();
});
it('applies line-through style when checked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
const nameEl = screen.getByText('Tomaten');
expect(nameEl.className).toContain('line-through');
});
it('does not apply line-through when unchecked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
const nameEl = screen.getByText('Tomaten');
expect(nameEl.className).not.toContain('line-through');
});
it('shows recipe label for source recipes when unchecked', () => {
render(ChecklistItem, {
props: {
...baseProps,
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
}
});
expect(screen.getByText(/Für: Spaghetti/)).toBeInTheDocument();
});
it('hides recipe label when item is checked', () => {
render(ChecklistItem, {
props: {
...baseProps,
isChecked: true,
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
}
});
expect(screen.queryByText(/Für:/)).not.toBeInTheDocument();
});
it('sets aria-checked to false when unchecked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
const button = screen.getByRole('checkbox');
expect(button).toHaveAttribute('aria-checked', 'false');
});
it('sets aria-checked to true when checked', () => {
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
const button = screen.getByRole('checkbox');
expect(button).toHaveAttribute('aria-checked', 'true');
});
it('passes listId and itemId as hidden inputs', () => {
render(ChecklistItem, { props: baseProps });
const form = screen.getByRole('checkbox').closest('form')!;
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
const itemIdInput = form.querySelector('input[name="itemId"]') as HTMLInputElement;
expect(listIdInput.value).toBe('list-1');
expect(itemIdInput.value).toBe('item-1');
});
});

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-[12px] 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,92 @@
<script lang="ts">
import ChecklistItem from '$lib/shopping/ChecklistItem.svelte';
import AddCustomItem from '$lib/shopping/AddCustomItem.svelte';
interface RecipeRef {
id?: string;
name?: string;
}
interface Item {
id?: string;
name?: string;
quantity?: number;
unit?: string;
isChecked?: boolean;
sourceRecipes?: RecipeRef[];
}
interface Props {
listId: string;
uncheckedItems: Item[];
checkedItems: Item[];
totalItems: number;
filteredStaplesCount?: number;
showFilteredStaples?: boolean;
}
let {
listId,
uncheckedItems,
checkedItems,
totalItems,
filteredStaplesCount = 0,
showFilteredStaples = false
}: Props = $props();
let checkedCount = $derived(checkedItems.length);
</script>
{#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}
<div class="mt-3">
<AddCustomItem {listId} />
</div>
{#if showFilteredStaples && 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}
{#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}

View File

@@ -0,0 +1,67 @@
<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 generating = $state(false);
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={() => {
generating = true;
return async ({ update }) => {
await update();
generating = false;
};
}}>
<input type="hidden" name="weekPlanId" value={weekPlanId} />
<button
type="submit"
disabled={generating}
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)] disabled:opacity-50"
>
{generating ? '…' : 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,71 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ShoppingHeader from './ShoppingHeader.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('ShoppingHeader', () => {
const baseProps = {
totalItems: 0,
checkedCount: 0,
generatedAt: null,
weekPlanId: null,
isPlanner: false,
hasShoppingList: false
};
it('renders the heading', () => {
render(ShoppingHeader, { props: baseProps });
expect(screen.getByText('Einkaufsliste')).toBeInTheDocument();
});
it('shows counts when hasShoppingList is true', () => {
render(ShoppingHeader, {
props: { ...baseProps, totalItems: 5, checkedCount: 2, hasShoppingList: true }
});
expect(screen.getByText(/3 Artikel übrig/)).toBeInTheDocument();
expect(screen.getByText(/2 abgehakt/)).toBeInTheDocument();
});
it('does not show counts when hasShoppingList is false', () => {
render(ShoppingHeader, { props: { ...baseProps, hasShoppingList: false } });
expect(screen.queryByText(/Artikel übrig/)).not.toBeInTheDocument();
});
it('shows generate button for planner with weekPlanId', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1' }
});
expect(screen.getByRole('button', { name: /Liste generieren/i })).toBeInTheDocument();
});
it('shows regenerate button when planner already has a list', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1', hasShoppingList: true, totalItems: 3, checkedCount: 0 }
});
expect(screen.getByRole('button', { name: /Neu generieren/i })).toBeInTheDocument();
});
it('hides generate button for non-planner', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: false, weekPlanId: 'plan-1' }
});
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
});
it('hides generate button when weekPlanId is null', () => {
render(ShoppingHeader, {
props: { ...baseProps, isPlanner: true, weekPlanId: null }
});
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
});
it('shows formatted timestamp when generatedAt is provided', () => {
render(ShoppingHeader, {
props: { ...baseProps, hasShoppingList: true, generatedAt: '2026-04-06T10:30:00Z', totalItems: 1, checkedCount: 0 }
});
expect(screen.getByText(/erstellt/)).toBeInTheDocument();
});
});

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,139 @@
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1> <script lang="ts">
import ShoppingHeader from '$lib/shopping/ShoppingHeader.svelte';
import ShoppingChecklist from '$lib/shopping/ShoppingChecklist.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}
<ShoppingChecklist
{listId}
{uncheckedItems}
{checkedItems}
{totalItems}
{filteredStaplesCount}
showFilteredStaples={true}
/>
{/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}
<ShoppingChecklist
{listId}
{uncheckedItems}
{checkedItems}
{totalItems}
/>
{/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>

View File

@@ -132,7 +132,7 @@
<div class="doc-header"> <div class="doc-header">
<div> <div>
<h1>J5 — Shopping list</h1> <h1>J5 — Shopping list</h1>
<p>Journey spec — Generate shopping list, real-time shared checklist</p> <p>Journey spec — Generate shopping list, shared checklist</p>
</div> </div>
<div class="doc-meta"> <div class="doc-meta">
v1.0<br/> v1.0<br/>
@@ -143,12 +143,12 @@
<!-- ═══ J5 SHOPPING ═══ --> <!-- ═══ J5 SHOPPING ═══ -->
<div class="jh jh-b"> <div class="jh jh-b">
<div class="jn">J5</div> <div class="jn">J5</div>
<div><h2>Generate shopping list</h2><p>Merge ingredients, filter staples. Always live and shared with household.</p><div class="fl">C1 → D1 (always live) · Planner generates · All members add/remove/check off</div></div> <div><h2>Generate shopping list</h2><p>Merge ingredients, filter staples. Shared with household.</p><div class="fl">C1 → D1 · Planner generates · All members add/remove/check off</div></div>
</div> </div>
<!-- ═══ D1 SHOPPING LIST ═══ --> <!-- ═══ D1 SHOPPING LIST ═══ -->
<div class="scr" id="d1"> <div class="scr" id="d1">
<div class="scr-head"><h3>Shopping list (live)</h3><span class="scr-id">D1</span></div> <div class="scr-head"><h3>Shopping list</h3><span class="scr-id">D1</span></div>
<div class="scr-desc">V1 Checklist with sources. Desktop: sidebar + topbar + two-column content — checklist on the left, a "This week's recipes" reference panel on the right that shows which recipes contributed which items. The panel is a page section (surface bg), not a card.</div> <div class="scr-desc">V1 Checklist with sources. Desktop: sidebar + topbar + two-column content — checklist on the left, a "This week's recipes" reference panel on the right that shows which recipes contributed which items. The panel is a page section (surface bg), not a card.</div>
<div class="scr-var"><strong>V1 · Checklist with sources</strong> — desktop: list left, recipe reference right</div> <div class="scr-var"><strong>V1 · Checklist with sources</strong> — desktop: list left, recipe reference right</div>
@@ -162,7 +162,7 @@
<div style="padding:8px 12px;"> <div style="padding:8px 12px;">
<div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-lg);padding:8px 10px;display:flex;align-items:center;gap:8px;margin-bottom:10px;"> <div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-lg);padding:8px 10px;display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--blue);"></div> <div style="width:8px;height:8px;border-radius:50%;background:var(--blue);"></div>
<div style="font-size:11px;color:var(--blue-dark);">Shared with household · 2 members online</div> <div style="font-size:11px;color:var(--blue-dark);">Shared with household</div>
</div> </div>
<div class="eye" style="margin-bottom:4px;">5 items remaining</div> <div class="eye" style="margin-bottom:4px;">5 items remaining</div>
<div style="padding:0 4px;"> <div style="padding:0 4px;">
@@ -188,7 +188,7 @@
<div class="dsb-nav"><div><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni a"><span class="dsb-nc">🛒</span>Shopping</div></div></div> <div class="dsb-nav"><div><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni a"><span class="dsb-nc">🛒</span>Shopping</div></div></div>
</div> </div>
<div class="dm"> <div class="dm">
<div class="dtb"><div class="dtb-t">Shopping list</div><div class="dtb-r"><div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-md);padding:5px 12px;font-size:11px;color:var(--blue-dark);display:flex;align-items:center;gap:6px;"><div style="width:6px;height:6px;border-radius:50%;background:var(--blue);"></div>2 members online</div></div></div> <div class="dtb"><div class="dtb-t">Shopping list</div><div class="dtb-r"><div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-md);padding:5px 12px;font-size:11px;color:var(--blue-dark);display:flex;align-items:center;gap:6px;"><div style="width:8px;height:8px;border-radius:50%;background:var(--blue);"></div>Shared with household</div></div></div>
<div style="flex:1;display:flex;overflow:hidden;"> <div style="flex:1;display:flex;overflow:hidden;">
<!-- Left: checklist --> <!-- Left: checklist -->
<div style="flex:1;padding:20px 24px;overflow-y:auto;"> <div style="flex:1;padding:20px 24px;overflow-y:auto;">
@@ -237,20 +237,20 @@
</div> </div>
<div class="agent"> <div class="agent">
<h4>D1 · Shopping list</h4> <h4>D1 · Shopping list</h4>
<pre>/* Desktop: 224px sidebar + topbar (title + shared status badge) + 2-col content: <pre>/* Desktop: 224px sidebar + topbar (title + shared badge) + 2-col content:
* Left (flex:1, page bg): remaining count + checklist rows + "checked off" section + add custom * Left (flex:1, page bg): remaining count + checklist rows + "checked off" section + add custom
* Right (280px, surface bg): recipe reference cards + filtered staples list + edit staples link * Right (280px, surface bg): recipe reference cards + filtered staples list + edit staples link
* Recipe reference panel: page-section with surface bg, not a floating card. * Recipe reference panel: page-section with surface bg, not a floating card.
* Mobile: full-width checklist + shared banner + bottom tabs. * Mobile: full-width checklist + shared banner + bottom tabs.
* Real-time sync: is_checked updates broadcast to all connected clients. * Sync model: server-authoritative — check/uncheck persists via form action, other users see changes on page refresh.
* Both roles: planner + member can view and check off. Only planner can regenerate. */</pre> * Both roles: planner + member can view and check off. Only planner can regenerate. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody> <table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop</td></tr> <tr class="grp"><td colspan="3">Desktop</td></tr>
<tr><td>Checklist area</td><td>flex:1, page bg, 20px 24px padding</td><td>Remaining items + divider + checked items</td></tr> <tr><td>Checklist area</td><td>flex:1, page bg, 20px 24px padding</td><td>Remaining items + divider + checked items</td></tr>
<tr><td>Recipe reference</td><td>280px, surface bg, border-left</td><td>Recipe name + day + ingredient count. Filtered staples below.</td></tr> <tr><td>Recipe reference</td><td>280px, surface bg, border-left</td><td>Recipe name + day + ingredient count. Filtered staples below.</td></tr>
<tr class="grp"><td colspan="3">Shared state</td></tr> <tr class="grp"><td colspan="3">Shared state</td></tr>
<tr><td>Banner (mobile)</td><td>blue-tint, blue dot, radius-lg</td><td>"Shared · N online"</td></tr> <tr><td>Banner (mobile)</td><td>blue-tint, blue dot, radius-lg</td><td>"Shared with household"</td></tr>
<tr><td>Badge (desktop)</td><td>blue-tint pill in topbar</td><td>Compact: dot + "N members online"</td></tr> <tr><td>Badge (desktop)</td><td>blue-tint pill in topbar</td><td>Compact: dot + "Shared with household"</td></tr>
</tbody></table> </tbody></table>
</div> </div>
</div> </div>
@@ -269,22 +269,23 @@
<h3>1. Journey flow</h3> <h3>1. Journey flow</h3>
<pre>/* J5 flow <pre>/* J5 flow
* C1 (week confirmed) → D1 (shopping list, always live). * C1 (week confirmed) → D1 (shopping list).
* Actor: Planner generates the list. All household members shop (view, check off, add items). * Actor: Planner generates the list. All household members shop (view, check off, add items).
* Preconditions: J1 (recipes exist) + J2 (week is planned) for generating a list. * Preconditions: J1 (recipes exist) + J2 (week is planned) for generating a list.
* J6 (household setup) for shared access. * J6 (household setup) for shared access.
* There is NO draft/publish workflow — the list is always live. */</pre> * Sync model: server-authoritative. Changes persist via form actions.
* Other users see updates on page refresh (no WebSocket/SSE). */</pre>
<h3>2. Screen D1 — Shopping list (live shared)</h3> <h3>2. Screen D1 — Shopping list</h3>
<pre>/* Mobile layout: <pre>/* Mobile layout:
* topbar (title + settings icon) * topbar (title + settings icon)
* + blue-tint shared banner ("Shared with household · N members online", blue dot) * + blue-tint shared banner ("Shared with household", blue dot)
* + checklist (unchecked items, then checked items below divider) * + checklist (unchecked items, then checked items below divider)
* + "+ Add custom item" link (blue-dark, centred) * + "+ Add custom item" link (blue-dark, centred)
* + bottom tabs (Planner | Recipes | Shopping [active] | Settings) * + bottom tabs (Planner | Recipes | Shopping [active] | Settings)
* *
* Desktop layout: * Desktop layout:
* sidebar (224px, dsb) + topbar (dtb: title + blue-tint "N members online" badge) * sidebar (224px, dsb) + topbar (dtb: title + blue-tint "Shared with household" badge)
* + split content area: * + split content area:
* Left: checklist (flex:1, page bg, 20px 24px padding) * Left: checklist (flex:1, page bg, 20px 24px padding)
* - eyebrow "N items remaining · N checked off" * - eyebrow "N items remaining · N checked off"
@@ -319,15 +320,14 @@
<li>Filtered staples are listed in the recipe reference panel (desktop) for transparency</li> <li>Filtered staples are listed in the recipe reference panel (desktop) for transparency</li>
</ul> </ul>
<h3>4. Real-time sync</h3> <h3>4. Sync model</h3>
<pre>/* Real-time rules: <pre>/* Sync rules:
* - is_checked updates broadcast to ALL connected clients instantly * - Server-authoritative: all mutations (check, uncheck, add) go through form actions / API calls
* - "N members online" indicator shows who is currently viewing the shopping list * - No WebSocket or SSE — other users see changes on page refresh
* - Prevents double-buying when multiple family members shop simultaneously or at different times * - Each check/uncheck is a single server round-trip (form action with use:enhance)
* - Blue accent colour for all shared-state UI: * - Blue accent colour for shared-state UI:
* Mobile: blue-tint banner with blue dot * Mobile: blue-tint banner with blue dot ("Shared with household")
* Desktop: blue-tint badge in topbar with blue dot * Desktop: blue-tint badge in topbar with blue dot ("Shared with household") */</pre>
* - WebSocket or SSE for real-time — implementation choice, but must be instant */</pre>
<h3>5. Custom items</h3> <h3>5. Custom items</h3>
<ul> <ul>
@@ -352,22 +352,20 @@
* UPDATE shopping_list_item * UPDATE shopping_list_item
* SET is_checked = true/false * SET is_checked = true/false
* WHERE id = :item_id * WHERE id = :item_id
* → broadcast change to all connected clients via real-time channel
* *
* Add custom: * Add custom:
* INSERT INTO shopping_list_item (name, quantity, is_custom, is_checked, shopping_list_id) * INSERT INTO shopping_list_item (name, quantity, is_custom, is_checked, shopping_list_id)
* VALUES (:name, :quantity, true, false, :list_id) * VALUES (:name, :quantity, true, false, :list_id) */</pre>
* → broadcast new item to all connected clients */</pre>
<h3>7. Design constraints</h3> <h3>7. Design constraints</h3>
<ul> <ul>
<li>List is ALWAYS live — no draft/publish workflow, no approval step</li> <li>List is shared — all household members see the same list on refresh</li>
<li>Both planner and member can: view, check off, add custom items, remove items</li> <li>Both planner and member can: view, check off, add custom items, remove items</li>
<li>Only planner can: generate list, regenerate list</li> <li>Only planner can: generate list, regenerate list</li>
<li>"Edit staples" link navigates to D3 (same component as A3 — build once, reference from two entry points)</li> <li>"Edit staples" link navigates to D3 (same component as A3 — build once, reference from two entry points)</li>
<li>CalDAV export is future scope (E3) — do not build in v1</li> <li>CalDAV export is future scope (E3) — do not build in v1</li>
<li>Recipe reference panel is a page section with surface bg — NOT a floating card</li> <li>Recipe reference panel is a page section with surface bg — NOT a floating card</li>
<li>Blue accent colour is reserved for shared/collaborative state indicators</li> <li>Blue accent colour for shared-state banner/badge ("Shared with household")</li>
<li>Checked items must visually separate from unchecked via a divider and "Checked off" label</li> <li>Checked items must visually separate from unchecked via a divider and "Checked off" label</li>
</ul> </ul>
@@ -375,7 +373,7 @@
<pre>/* Precondition chain: <pre>/* Precondition chain:
* J1 (recipes exist) — cannot generate a shopping list without recipes * J1 (recipes exist) — cannot generate a shopping list without recipes
* J2 (week is planned) — cannot generate a shopping list without planned meals * J2 (week is planned) — cannot generate a shopping list without planned meals
* J6 (household setup) — required for shared access (multiple members online) * J6 (household setup) — required for shared access
* *
* If no meals are planned: show empty state on D1 with prompt to plan the week first * If no meals are planned: show empty state on D1 with prompt to plan the week first
* If no household members: list works for solo planner, shared banner is hidden */</pre> * If no household members: list works for solo planner, shared banner is hidden */</pre>