feat: D1 — Shopping list (Issue #30) #43
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
public class ForbiddenException extends RuntimeException {
|
||||
public ForbiddenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ public class GlobalExceptionHandler {
|
||||
.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)
|
||||
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ public class HouseholdResolver {
|
||||
return findMembership(userEmail).getUser().getId();
|
||||
}
|
||||
|
||||
public String resolveRole(String userEmail) {
|
||||
return findMembership(userEmail).getRole();
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String userEmail) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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 jakarta.validation.Valid;
|
||||
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 +23,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);
|
||||
@@ -45,7 +62,7 @@ public class ShoppingListController {
|
||||
@PostMapping("/v1/shopping-lists/{id}/items")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ShoppingListItemResponse addItem(@PathVariable UUID id,
|
||||
@RequestBody AddItemRequest request,
|
||||
@Valid @RequestBody AddItemRequest request,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.addItem(householdId, id, request);
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
|
||||
Optional<ShoppingList> findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
@@ -16,6 +18,9 @@ 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;
|
||||
|
||||
@@ -29,19 +34,34 @@ public class ShoppingService {
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
|
||||
public ShoppingService(ShoppingListRepository shoppingListRepository,
|
||||
ShoppingListItemRepository shoppingListItemRepository,
|
||||
WeekPlanRepository weekPlanRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
UserAccountRepository userAccountRepository,
|
||||
RecipeRepository recipeRepository) {
|
||||
this.shoppingListRepository = shoppingListRepository;
|
||||
this.shoppingListItemRepository = shoppingListItemRepository;
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
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");
|
||||
}
|
||||
|
||||
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();
|
||||
String key = mergeKey(ingredient.getId(), ri.getUnit());
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
.addRecipeId(recipe.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, 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 = 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);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
@@ -121,7 +167,7 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
shoppingListItemRepository.save(item);
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +192,7 @@ public class ShoppingService {
|
||||
item = shoppingListItemRepository.save(item);
|
||||
list.getItems().add(item);
|
||||
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -178,18 +224,53 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
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()
|
||||
.map(this::toItemResponse)
|
||||
.map(item -> toItemResponse(item, recipeNames))
|
||||
.toList();
|
||||
|
||||
// Count filtered staples from the week plan
|
||||
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
|
||||
|
||||
return new ShoppingListResponse(
|
||||
list.getId(),
|
||||
list.getWeekPlan().getId(),
|
||||
list.getGeneratedAt(),
|
||||
filteredStaplesCount,
|
||||
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;
|
||||
ShoppingListItemResponse.CategoryRef categoryRef = null;
|
||||
UUID ingredientId = null;
|
||||
@@ -207,6 +288,14 @@ public class ShoppingService {
|
||||
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(
|
||||
item.getId(),
|
||||
ingredientId,
|
||||
@@ -216,10 +305,14 @@ public class ShoppingService {
|
||||
item.getUnit(),
|
||||
item.isChecked(),
|
||||
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 {
|
||||
final Ingredient ingredient;
|
||||
final String unit;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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.util.UUID;
|
||||
|
||||
public record AddItemRequest(
|
||||
UUID ingredientId,
|
||||
String customName,
|
||||
BigDecimal quantity,
|
||||
@NotBlank @Size(max = 255) String customName,
|
||||
@Positive BigDecimal quantity,
|
||||
String unit
|
||||
) {}
|
||||
|
||||
@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
|
||||
String unit,
|
||||
boolean isChecked,
|
||||
UUID checkedBy,
|
||||
List<UUID> sourceRecipes
|
||||
List<RecipeRef> sourceRecipes
|
||||
) {
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
public record RecipeRef(UUID id, String name) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ShoppingListResponse(
|
||||
UUID id,
|
||||
UUID weekPlanId,
|
||||
Instant generatedAt,
|
||||
int filteredStaplesCount,
|
||||
List<ShoppingListItemResponse> items
|
||||
) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.recipeapp.shopping.entity;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -23,6 +24,9 @@ public class ShoppingList {
|
||||
@JoinColumn(name = "week_plan_id", nullable = false)
|
||||
private WeekPlan weekPlan;
|
||||
|
||||
@Column(name = "generated_at", nullable = false)
|
||||
private Instant generatedAt = Instant.now();
|
||||
|
||||
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<ShoppingListItem> items = new ArrayList<>();
|
||||
|
||||
@@ -36,5 +40,7 @@ public class ShoppingList {
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public WeekPlan getWeekPlan() { return weekPlan; }
|
||||
public Instant getGeneratedAt() { return generatedAt; }
|
||||
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
|
||||
public List<ShoppingListItem> getItems() { return items; }
|
||||
}
|
||||
|
||||
4
backend/src/main/resources/application-docker.yml
Normal file
4
backend/src/main/resources/application-docker.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
out-of-order: true
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE shopping_list
|
||||
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.HouseholdRoleInterceptor;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
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.setup.MockMvcBuilders;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
|
||||
.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
|
||||
void generateFromPlanShouldReturn201() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
var item = new ShoppingListItemResponse(
|
||||
ITEM_ID, UUID.randomUUID(), "Tomatoes",
|
||||
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
|
||||
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID()));
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item));
|
||||
new BigDecimal("4.00"), "pcs", false, null,
|
||||
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(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
|
||||
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
|
||||
|
||||
@Test
|
||||
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(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
|
||||
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
|
||||
void checkItemShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
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.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
|
||||
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
|
||||
void addItemShouldReturn201() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
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(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
|
||||
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.planning.entity.WeekPlanSlot;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private IngredientRepository ingredientRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private RecipeRepository recipeRepository;
|
||||
|
||||
@InjectMocks private ShoppingService shoppingService;
|
||||
|
||||
@@ -90,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
|
||||
@@ -119,26 +161,84 @@ 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));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
|
||||
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
|
||||
|
||||
var tomatoItem = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
|
||||
assertThat(tomatoItem.sourceRecipes()).hasSize(2);
|
||||
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
|
||||
|
||||
var cheeseItem = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
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();
|
||||
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
|
||||
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
assertThat(result.generatedAt()).isNotNull();
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
|
||||
}
|
||||
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
|
||||
.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 ──
|
||||
|
||||
@Test
|
||||
@@ -376,9 +568,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
49
frontend/src/lib/api/schema.d.ts
vendored
49
frontend/src/lib/api/schema.d.ts
vendored
@@ -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?: {
|
||||
|
||||
88
frontend/src/lib/shopping/AddCustomItem.svelte
Normal file
88
frontend/src/lib/shopping/AddCustomItem.svelte
Normal 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}
|
||||
58
frontend/src/lib/shopping/AddCustomItem.test.ts
Normal file
58
frontend/src/lib/shopping/AddCustomItem.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
81
frontend/src/lib/shopping/ChecklistItem.svelte
Normal file
81
frontend/src/lib/shopping/ChecklistItem.svelte
Normal 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>
|
||||
88
frontend/src/lib/shopping/ChecklistItem.test.ts
Normal file
88
frontend/src/lib/shopping/ChecklistItem.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal file
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal 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>
|
||||
92
frontend/src/lib/shopping/ShoppingChecklist.svelte
Normal file
92
frontend/src/lib/shopping/ShoppingChecklist.svelte
Normal 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}
|
||||
67
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal file
67
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal 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>
|
||||
71
frontend/src/lib/shopping/ShoppingHeader.test.ts
Normal file
71
frontend/src/lib/shopping/ShoppingHeader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
86
frontend/src/routes/(app)/shopping/+page.server.ts
Normal file
86
frontend/src/routes/(app)/shopping/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<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 class="doc-meta">
|
||||
v1.0<br/>
|
||||
@@ -143,12 +143,12 @@
|
||||
<!-- ═══ J5 SHOPPING ═══ -->
|
||||
<div class="jh jh-b">
|
||||
<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>
|
||||
|
||||
<!-- ═══ D1 SHOPPING LIST ═══ -->
|
||||
<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-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="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="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 class="eye" style="margin-bottom:4px;">5 items remaining</div>
|
||||
<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>
|
||||
<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;">
|
||||
<!-- Left: checklist -->
|
||||
<div style="flex:1;padding:20px 24px;overflow-y:auto;">
|
||||
@@ -237,20 +237,20 @@
|
||||
</div>
|
||||
<div class="agent">
|
||||
<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
|
||||
* 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.
|
||||
* 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>
|
||||
<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><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 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>Badge (desktop)</td><td>blue-tint pill in topbar</td><td>Compact: dot + "N members 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 + "Shared with household"</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,22 +269,23 @@
|
||||
|
||||
<h3>1. Journey flow</h3>
|
||||
<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).
|
||||
* Preconditions: J1 (recipes exist) + J2 (week is planned) for generating a list.
|
||||
* 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:
|
||||
* 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)
|
||||
* + "+ Add custom item" link (blue-dark, centred)
|
||||
* + bottom tabs (Planner | Recipes | Shopping [active] | Settings)
|
||||
*
|
||||
* 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:
|
||||
* Left: checklist (flex:1, page bg, 20px 24px padding)
|
||||
* - 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>
|
||||
</ul>
|
||||
|
||||
<h3>4. Real-time sync</h3>
|
||||
<pre>/* Real-time rules:
|
||||
* - is_checked updates broadcast to ALL connected clients instantly
|
||||
* - "N members online" indicator shows who is currently viewing the shopping list
|
||||
* - Prevents double-buying when multiple family members shop simultaneously or at different times
|
||||
* - Blue accent colour for all shared-state UI:
|
||||
* Mobile: blue-tint banner with blue dot
|
||||
* Desktop: blue-tint badge in topbar with blue dot
|
||||
* - WebSocket or SSE for real-time — implementation choice, but must be instant */</pre>
|
||||
<h3>4. Sync model</h3>
|
||||
<pre>/* Sync rules:
|
||||
* - Server-authoritative: all mutations (check, uncheck, add) go through form actions / API calls
|
||||
* - No WebSocket or SSE — other users see changes on page refresh
|
||||
* - Each check/uncheck is a single server round-trip (form action with use:enhance)
|
||||
* - Blue accent colour for shared-state UI:
|
||||
* Mobile: blue-tint banner with blue dot ("Shared with household")
|
||||
* Desktop: blue-tint badge in topbar with blue dot ("Shared with household") */</pre>
|
||||
|
||||
<h3>5. Custom items</h3>
|
||||
<ul>
|
||||
@@ -352,22 +352,20 @@
|
||||
* UPDATE shopping_list_item
|
||||
* SET is_checked = true/false
|
||||
* WHERE id = :item_id
|
||||
* → broadcast change to all connected clients via real-time channel
|
||||
*
|
||||
* Add custom:
|
||||
* INSERT INTO shopping_list_item (name, quantity, is_custom, is_checked, shopping_list_id)
|
||||
* VALUES (:name, :quantity, true, false, :list_id)
|
||||
* → broadcast new item to all connected clients */</pre>
|
||||
* VALUES (:name, :quantity, true, false, :list_id) */</pre>
|
||||
|
||||
<h3>7. Design constraints</h3>
|
||||
<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>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>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>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>
|
||||
</ul>
|
||||
|
||||
@@ -375,7 +373,7 @@
|
||||
<pre>/* Precondition chain:
|
||||
* J1 (recipes exist) — cannot generate a shopping list without recipes
|
||||
* 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 household members: list works for solo planner, shared banner is hidden */</pre>
|
||||
|
||||
Reference in New Issue
Block a user