Compare commits
118 Commits
e5cdce164a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 32bfcd5c16 | |||
| 9a644b5640 | |||
| df0d453b69 | |||
| 26256ef492 | |||
| ccfc72ab38 | |||
| 230ee5a067 | |||
| 0b182a33fd | |||
| 73af11e84b | |||
| 0ab1ba0b1b | |||
| 44fd398701 | |||
| 6aed303627 | |||
| c5ec3396b2 | |||
| 6950b3d8db | |||
| 92f25e56fc | |||
| b577b7a0f8 | |||
| 69d695b2c4 | |||
| 43227b2265 | |||
| a6683d06bb | |||
| ed0f3c21fe | |||
| dbf2951f09 | |||
| d6bfd2cb46 | |||
| 9ccd367d74 | |||
| 6aef12fa3c | |||
| 27b7058d31 | |||
| 60d84c0c94 | |||
| 9d3be84a0c | |||
| 2ad75cc1b7 | |||
| eb5699577b | |||
| 05476ecaab | |||
| c40b0fe095 | |||
| 4e67ff4258 | |||
| df3b774f0c | |||
| 1b5704c8b5 | |||
| b04f2c51d2 | |||
| d1e4b6c49e | |||
| 27163e3d72 | |||
| 5904102b1a | |||
| d66120b191 | |||
| 98c8aa9610 | |||
| af275642b0 | |||
| dde78baa84 | |||
| 6e559d9f9d | |||
| ef39a97f57 | |||
| 824bb9445f | |||
| b0fc9f55c1 | |||
| 2ed5186ac8 | |||
| 48802a04f7 | |||
| 0b3d062ed1 | |||
| 109b41b434 | |||
| 3f9fb900c4 | |||
| 33cccd3d63 | |||
| cfbde18435 | |||
| 4835231f6d | |||
| 3f9bd2b226 | |||
| 9423cd673c | |||
| 4c87d9c134 | |||
| e5c361fe42 | |||
| a8a781f1e9 | |||
| b0800ca4f3 | |||
| 66447a7ea0 | |||
| 7f4413852d | |||
| eb3f6fad25 | |||
| fc682bfc54 | |||
| 38528a50e5 | |||
| a43a8ec33f | |||
| 8679ebc6e3 | |||
| 0ae1767649 | |||
| d54ac6a37a | |||
| d901310897 | |||
| ed4cdbf230 | |||
| 75228058a6 | |||
| b919a716f5 | |||
| 389500c1dd | |||
| 8709e85d80 | |||
| 358edb9a12 | |||
| f97cf49bd0 | |||
| 2cebf504f2 | |||
| d20cd53be2 | |||
| 2b7a7cceec | |||
| f37f20d34e | |||
| f2071ca5d8 | |||
| 16e1539ac0 | |||
| f139dce82c | |||
| 0596fddcd3 | |||
| 008c725813 | |||
| 1739b70d54 | |||
| 3b829325f2 | |||
| d139e5e28c | |||
| c9d6564fbe | |||
| ba79cff4e7 | |||
| 55285e7d5d | |||
| 055ae11fa3 | |||
| bf18f2bd84 | |||
| da21a12222 | |||
| e9dc04b2a5 | |||
| 8dfc3df06b | |||
| ea070b4760 | |||
| aecdf249d6 | |||
| e4345350ad | |||
| 56decf155d | |||
| 1de4b15e34 | |||
| ccec0baa99 | |||
| 9928591b48 | |||
| 89a549a1c8 | |||
| c24281dd4c | |||
| 8051fcbe22 | |||
| b45ab0fd46 | |||
| 2bbc3762e2 | |||
| a751b0758a | |||
| 8234c2f162 | |||
| 257808016d | |||
| cd7f4a1ea0 | |||
| b673a466e9 | |||
| e3066ec3e5 | |||
| bd1604fc1d | |||
| c297403506 | |||
| fa4a4c9ef7 | |||
| 6dd0b7ac93 |
@@ -7,6 +7,7 @@ COPY src src
|
|||||||
RUN ./mvnw package -DskipTests -B
|
RUN ./mvnw package -DskipTests -B
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
RUN apk add --no-cache libwebp
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/target/*.jar app.jar
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContext;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1/auth")
|
@RequestMapping("/v1/auth")
|
||||||
@@ -32,7 +27,7 @@ public class AuthController {
|
|||||||
@Valid @RequestBody SignupRequest request,
|
@Valid @RequestBody SignupRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.signup(request);
|
UserResponse user = authService.signup(request);
|
||||||
authenticateInSession(user.email(), "user", httpRequest);
|
authService.authenticateInSession(user.email(), "user", httpRequest);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,30 +36,10 @@ public class AuthController {
|
|||||||
@Valid @RequestBody LoginRequest request,
|
@Valid @RequestBody LoginRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.login(request);
|
UserResponse user = authService.login(request);
|
||||||
// Session fixation protection: invalidate old session before creating new one
|
authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
||||||
var oldSession = httpRequest.getSession(false);
|
|
||||||
if (oldSession != null) {
|
|
||||||
oldSession.invalidate();
|
|
||||||
}
|
|
||||||
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(user));
|
return ResponseEntity.ok(ApiResponse.success(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an authenticated Spring Security context and stores it in the HTTP session
|
|
||||||
* so that subsequent requests from the same session are recognised as authenticated.
|
|
||||||
* We do this manually because we are not using Spring Security's built-in form login.
|
|
||||||
*/
|
|
||||||
private void authenticateInSession(String email, String role, HttpServletRequest request) {
|
|
||||||
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
|
||||||
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
|
|
||||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
|
||||||
context.setAuthentication(auth);
|
|
||||||
SecurityContextHolder.setContext(context);
|
|
||||||
request.getSession(true).setAttribute(
|
|
||||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||||
HttpSession session = httpRequest.getSession(false);
|
HttpSession session = httpRequest.getSession(false);
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException;
|
|||||||
import com.recipeapp.common.ValidationException;
|
import com.recipeapp.common.ValidationException;
|
||||||
import com.recipeapp.household.HouseholdMemberRepository;
|
import com.recipeapp.household.HouseholdMemberRepository;
|
||||||
import com.recipeapp.household.entity.HouseholdMember;
|
import com.recipeapp.household.entity.HouseholdMember;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
@@ -82,6 +90,24 @@ public class AuthService {
|
|||||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes an authenticated Spring Security session for the given user.
|
||||||
|
* Invalidates any existing session first (session fixation protection).
|
||||||
|
*/
|
||||||
|
public void authenticateInSession(String email, String role, HttpServletRequest request) {
|
||||||
|
var oldSession = request.getSession(false);
|
||||||
|
if (oldSession != null) {
|
||||||
|
oldSession.invalidate();
|
||||||
|
}
|
||||||
|
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
|
||||||
|
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||||
|
context.setAuthentication(auth);
|
||||||
|
SecurityContextHolder.setContext(context);
|
||||||
|
request.getSession(true).setAttribute(
|
||||||
|
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||||
|
}
|
||||||
|
|
||||||
private UserResponse toUserResponse(UserAccount user) {
|
private UserResponse toUserResponse(UserAccount user) {
|
||||||
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
||||||
.map(member -> UserResponse.withHousehold(
|
.map(member -> UserResponse.withHousehold(
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.requestMatchers("/v1/invites/**").permitAll()
|
||||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.exceptionHandling(ex -> ex
|
.exceptionHandling(ex -> ex
|
||||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
|
.sessionFixation().changeSessionId()
|
||||||
.maximumSessions(1));
|
.maximumSessions(1));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.recipeapp.household;
|
package com.recipeapp.household;
|
||||||
|
|
||||||
|
import com.recipeapp.auth.AuthService;
|
||||||
import com.recipeapp.common.ApiResponse;
|
import com.recipeapp.common.ApiResponse;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -9,15 +11,19 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1")
|
@RequestMapping("/v1")
|
||||||
public class HouseholdController {
|
public class HouseholdController {
|
||||||
|
|
||||||
private final HouseholdService householdService;
|
private final HouseholdService householdService;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
public HouseholdController(HouseholdService householdService) {
|
public HouseholdController(HouseholdService householdService, AuthService authService) {
|
||||||
this.householdService = householdService;
|
this.householdService = householdService;
|
||||||
|
this.authService = authService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/households")
|
@PostMapping("/households")
|
||||||
@@ -40,17 +46,49 @@ public class HouseholdController {
|
|||||||
return ResponseEntity.ok(members);
|
return ResponseEntity.ok(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/households/mine/invites")
|
||||||
|
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
|
||||||
|
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
|
||||||
|
return invite
|
||||||
|
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/households/mine/invites")
|
@PostMapping("/households/mine/invites")
|
||||||
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
|
||||||
InviteResponse response = householdService.createInvite(principal.getName());
|
InviteResponse response = householdService.createInvite(principal.getName());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/households/mine/members/{userId}")
|
||||||
|
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
|
||||||
|
householdService.removeMember(principal.getName(), userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/households/mine/members/{userId}")
|
||||||
|
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
|
||||||
|
Principal principal,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
@Valid @RequestBody ChangeRoleRequest request) {
|
||||||
|
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/invites/{code}")
|
||||||
|
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
|
||||||
|
InviteInfoResponse response = householdService.getInviteInfo(code);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/invites/{code}/accept")
|
@PostMapping("/invites/{code}/accept")
|
||||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||||
Principal principal,
|
@PathVariable String code,
|
||||||
@PathVariable String code) {
|
@Valid @RequestBody AcceptInviteRequest request,
|
||||||
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
|
HttpServletRequest httpRequest) {
|
||||||
|
AcceptInviteResponse response = householdService.acceptInvite(
|
||||||
|
code, request.name(), request.email(), request.password());
|
||||||
|
authService.authenticateInSession(request.email(), "user", httpRequest);
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
||||||
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
||||||
|
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ import java.util.UUID;
|
|||||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||||
|
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
|
||||||
|
long countByHouseholdIdAndRole(UUID householdId, String role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
|
|||||||
import com.recipeapp.common.ResourceNotFoundException;
|
import com.recipeapp.common.ResourceNotFoundException;
|
||||||
import com.recipeapp.common.ValidationException;
|
import com.recipeapp.common.ValidationException;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import com.recipeapp.household.entity.Household;
|
import com.recipeapp.household.entity.Household;
|
||||||
import com.recipeapp.household.entity.HouseholdInvite;
|
import com.recipeapp.household.entity.HouseholdInvite;
|
||||||
import com.recipeapp.household.entity.HouseholdMember;
|
import com.recipeapp.household.entity.HouseholdMember;
|
||||||
@@ -17,12 +18,15 @@ import com.recipeapp.recipe.TagRepository;
|
|||||||
import com.recipeapp.recipe.entity.Ingredient;
|
import com.recipeapp.recipe.entity.Ingredient;
|
||||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||||
import com.recipeapp.recipe.entity.Tag;
|
import com.recipeapp.recipe.entity.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class HouseholdService {
|
public class HouseholdService {
|
||||||
@@ -35,6 +39,10 @@ public class HouseholdService {
|
|||||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Value("${app.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
@@ -46,7 +54,8 @@ public class HouseholdService {
|
|||||||
IngredientRepository ingredientRepository,
|
IngredientRepository ingredientRepository,
|
||||||
IngredientCategoryRepository ingredientCategoryRepository,
|
IngredientCategoryRepository ingredientCategoryRepository,
|
||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
VarietyScoreConfigRepository varietyScoreConfigRepository,
|
||||||
|
PasswordEncoder passwordEncoder) {
|
||||||
this.userAccountRepository = userAccountRepository;
|
this.userAccountRepository = userAccountRepository;
|
||||||
this.householdRepository = householdRepository;
|
this.householdRepository = householdRepository;
|
||||||
this.householdMemberRepository = householdMemberRepository;
|
this.householdMemberRepository = householdMemberRepository;
|
||||||
@@ -55,6 +64,7 @@ public class HouseholdService {
|
|||||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -91,42 +101,121 @@ public class HouseholdService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
|
||||||
|
HouseholdMember requester = findMembership(requesterEmail);
|
||||||
|
UUID householdId = requester.getHousehold().getId();
|
||||||
|
|
||||||
|
HouseholdMember target = householdMemberRepository
|
||||||
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||||
|
|
||||||
|
if (target.getRole().equals(newRole)) {
|
||||||
|
return toMemberResponse(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
|
||||||
|
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
||||||
|
if (plannerCount <= 1) {
|
||||||
|
throw new ConflictException("Cannot degrade the last planner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.setRole(newRole);
|
||||||
|
return toMemberResponse(householdMemberRepository.save(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeMember(String requesterEmail, UUID targetUserId) {
|
||||||
|
HouseholdMember requester = findMembership(requesterEmail);
|
||||||
|
UUID householdId = requester.getHousehold().getId();
|
||||||
|
|
||||||
|
HouseholdMember target = householdMemberRepository
|
||||||
|
.findByHouseholdIdAndUserId(householdId, targetUserId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
|
||||||
|
|
||||||
|
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
|
||||||
|
throw new ConflictException("Planner cannot remove yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("planner".equals(target.getRole())) {
|
||||||
|
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
|
||||||
|
if (plannerCount <= 1) {
|
||||||
|
throw new ConflictException("Cannot remove the last planner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
householdMemberRepository.delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<InviteResponse> getActiveInvite(String userEmail) {
|
||||||
|
HouseholdMember member = findMembership(userEmail);
|
||||||
|
return householdInviteRepository
|
||||||
|
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
|
||||||
|
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
|
||||||
|
.map(this::toInviteResponse);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public InviteResponse createInvite(String userEmail) {
|
public InviteResponse createInvite(String userEmail) {
|
||||||
HouseholdMember member = findMembership(userEmail);
|
HouseholdMember member = findMembership(userEmail);
|
||||||
Household household = member.getHousehold();
|
Household household = member.getHousehold();
|
||||||
|
|
||||||
|
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
|
||||||
|
.ifPresent(existing -> {
|
||||||
|
existing.setInvalidatedAt(Instant.now());
|
||||||
|
householdInviteRepository.saveAndFlush(existing);
|
||||||
|
});
|
||||||
|
|
||||||
String code = generateInviteCode();
|
String code = generateInviteCode();
|
||||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||||
|
|
||||||
HouseholdInvite invite = householdInviteRepository.save(
|
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
|
||||||
new HouseholdInvite(household, code, expiresAt));
|
invite.setInvitedBy(member.getUser());
|
||||||
|
householdInviteRepository.save(invite);
|
||||||
|
|
||||||
return new InviteResponse(
|
return toInviteResponse(invite);
|
||||||
invite.getInviteCode(),
|
}
|
||||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
|
||||||
invite.getExpiresAt());
|
@Transactional(readOnly = true)
|
||||||
|
public InviteInfoResponse getInviteInfo(String code) {
|
||||||
|
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||||
|
|
||||||
|
if ("used".equals(invite.getStatus())
|
||||||
|
|| invite.getInvalidatedAt() != null
|
||||||
|
|| invite.getExpiresAt().isBefore(Instant.now())) {
|
||||||
|
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
String inviterName = invite.getInvitedBy() != null
|
||||||
|
? invite.getInvitedBy().getDisplayName()
|
||||||
|
: invite.getHousehold().getCreatedBy().getDisplayName();
|
||||||
|
|
||||||
|
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
|
||||||
UserAccount user = findUser(userEmail);
|
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
|
||||||
|
throw new ConflictException("Email already registered");
|
||||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
|
||||||
throw new ConflictException("User is already in a household");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||||
|
|
||||||
if ("used".equals(invite.getStatus())) {
|
if ("used".equals(invite.getStatus())
|
||||||
throw new ConflictException("Invite code already used");
|
|| invite.getInvalidatedAt() != null
|
||||||
}
|
|| invite.getExpiresAt().isBefore(Instant.now())) {
|
||||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||||
throw new ValidationException("Invite code has expired");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserAccount user = userAccountRepository.save(
|
||||||
|
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
|
||||||
|
|
||||||
invite.setStatus("used");
|
invite.setStatus("used");
|
||||||
|
invite.setInvalidatedAt(Instant.now());
|
||||||
householdInviteRepository.save(invite);
|
householdInviteRepository.save(invite);
|
||||||
|
|
||||||
Household household = invite.getHousehold();
|
Household household = invite.getHousehold();
|
||||||
@@ -204,4 +293,11 @@ public class HouseholdService {
|
|||||||
member.getRole(),
|
member.getRole(),
|
||||||
member.getJoinedAt());
|
member.getJoinedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private InviteResponse toInviteResponse(HouseholdInvite invite) {
|
||||||
|
return new InviteResponse(
|
||||||
|
invite.getInviteCode(),
|
||||||
|
baseUrl + "/join/" + invite.getInviteCode(),
|
||||||
|
invite.getExpiresAt());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.recipeapp.household.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record AcceptInviteRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank @Size(min = 8) String password
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.recipeapp.household.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
public record ChangeRoleRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
|
||||||
|
String role
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.recipeapp.household.dto;
|
||||||
|
|
||||||
|
public record InviteInfoResponse(
|
||||||
|
String householdName,
|
||||||
|
String inviterName
|
||||||
|
) {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.recipeapp.household.entity;
|
package com.recipeapp.household.entity;
|
||||||
|
|
||||||
|
import com.recipeapp.auth.entity.UserAccount;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -16,6 +17,10 @@ public class HouseholdInvite {
|
|||||||
@JoinColumn(name = "household_id", nullable = false)
|
@JoinColumn(name = "household_id", nullable = false)
|
||||||
private Household household;
|
private Household household;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "invited_by")
|
||||||
|
private UserAccount invitedBy;
|
||||||
|
|
||||||
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
||||||
private String inviteCode;
|
private String inviteCode;
|
||||||
|
|
||||||
@@ -25,6 +30,9 @@ public class HouseholdInvite {
|
|||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "invalidated_at")
|
||||||
|
private Instant invalidatedAt;
|
||||||
|
|
||||||
protected HouseholdInvite() {}
|
protected HouseholdInvite() {}
|
||||||
|
|
||||||
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
|
||||||
@@ -35,8 +43,12 @@ public class HouseholdInvite {
|
|||||||
|
|
||||||
public UUID getId() { return id; }
|
public UUID getId() { return id; }
|
||||||
public Household getHousehold() { return household; }
|
public Household getHousehold() { return household; }
|
||||||
|
public UserAccount getInvitedBy() { return invitedBy; }
|
||||||
|
public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; }
|
||||||
public String getInviteCode() { return inviteCode; }
|
public String getInviteCode() { return inviteCode; }
|
||||||
public String getStatus() { return status; }
|
public String getStatus() { return status; }
|
||||||
public void setStatus(String status) { this.status = status; }
|
public void setStatus(String status) { this.status = status; }
|
||||||
public Instant getExpiresAt() { return expiresAt; }
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
public Instant getInvalidatedAt() { return invalidatedAt; }
|
||||||
|
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ public class PlanningService {
|
|||||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||||
double scoreDelta = simulatedScore - currentScore;
|
double scoreDelta = simulatedScore - currentScore;
|
||||||
boolean hasConflict = scoreDelta < 0;
|
boolean hasConflict = scoreDelta < 0;
|
||||||
return new SuggestionResponse.SuggestionItem(toSuggestionRecipe(candidate), scoreDelta, hasConflict);
|
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
|
||||||
})
|
})
|
||||||
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -422,11 +422,6 @@ public class PlanningService {
|
|||||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
private SuggestionResponse.SuggestionRecipe toSuggestionRecipe(Recipe recipe) {
|
|
||||||
return new SuggestionResponse.SuggestionRecipe(recipe.getId(), recipe.getName(),
|
|
||||||
recipe.getEffort(), recipe.getCookTimeMin());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||||
if (days.size() < 2) return false;
|
if (days.size() < 2) return false;
|
||||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package com.recipeapp.planning.dto;
|
package com.recipeapp.planning.dto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||||
|
|
||||||
public record SuggestionRecipe(UUID id, String name, String effort, short cookTimeMin) {}
|
|
||||||
|
|
||||||
public record SuggestionItem(
|
public record SuggestionItem(
|
||||||
SuggestionRecipe recipe,
|
SlotResponse.SlotRecipe recipe,
|
||||||
double scoreDelta,
|
double scoreDelta,
|
||||||
boolean hasConflict
|
boolean hasConflict
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.recipeapp.recipe;
|
package com.recipeapp.recipe;
|
||||||
|
|
||||||
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
|
|
||||||
import com.recipeapp.recipe.entity.Recipe;
|
import com.recipeapp.recipe.entity.Recipe;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
@@ -17,9 +16,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
|||||||
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
SELECT r FROM Recipe r
|
||||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview)
|
LEFT JOIN FETCH r.tags
|
||||||
FROM Recipe r
|
|
||||||
WHERE r.household.id = :householdId
|
WHERE r.household.id = :householdId
|
||||||
AND r.deletedAt IS NULL
|
AND r.deletedAt IS NULL
|
||||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||||
@@ -27,7 +25,7 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
|||||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||||
ORDER BY r.createdAt DESC
|
ORDER BY r.createdAt DESC
|
||||||
""")
|
""")
|
||||||
List<RecipeSummaryResponse> findFiltered(
|
List<Recipe> findFiltered(
|
||||||
@Param("householdId") UUID householdId,
|
@Param("householdId") UUID householdId,
|
||||||
@Param("search") String search,
|
@Param("search") String search,
|
||||||
@Param("effort") String effort,
|
@Param("effort") String effort,
|
||||||
|
|||||||
@@ -42,7 +42,15 @@ public class RecipeService {
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||||
Integer cookTimeMaxMin, String sort, int limit, int offset) {
|
Integer cookTimeMaxMin, String sort, int limit, int offset) {
|
||||||
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
|
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset)
|
||||||
|
.stream()
|
||||||
|
.map(r -> new RecipeSummaryResponse(
|
||||||
|
r.getId(), r.getName(), r.getServes(), r.getCookTimeMin(), r.getEffort(),
|
||||||
|
r.getHeroImageUrl(),
|
||||||
|
r.getTags().stream()
|
||||||
|
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
|
||||||
|
.toList()))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.recipeapp.recipe.dto;
|
package com.recipeapp.recipe.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record RecipeSummaryResponse(
|
public record RecipeSummaryResponse(
|
||||||
@@ -8,5 +9,6 @@ public record RecipeSummaryResponse(
|
|||||||
short serves,
|
short serves,
|
||||||
short cookTimeMin,
|
short cookTimeMin,
|
||||||
String effort,
|
String effort,
|
||||||
String heroImagePreview
|
String heroImageUrl,
|
||||||
|
List<TagResponse> tags
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ spring:
|
|||||||
flyway:
|
flyway:
|
||||||
locations: classpath:db/migration,classpath:db/seed
|
locations: classpath:db/migration,classpath:db/seed
|
||||||
out-of-order: true
|
out-of-order: true
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: ${APP_BASE_URL:http://localhost:5173}
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ spring:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: http://localhost:5173
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE household_invite
|
||||||
|
ADD COLUMN invalidated_at timestamptz;
|
||||||
|
|
||||||
|
-- Mark all but the most-recent invite per household as invalidated,
|
||||||
|
-- so the unique partial index below can be created on dev databases
|
||||||
|
-- that accumulated multiple pending invites before this migration was added.
|
||||||
|
UPDATE household_invite
|
||||||
|
SET invalidated_at = NOW()
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT ON (household_id) id
|
||||||
|
FROM household_invite
|
||||||
|
ORDER BY household_id, expires_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_household_invite_active
|
||||||
|
ON household_invite (household_id)
|
||||||
|
WHERE invalidated_at IS NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE household_invite
|
||||||
|
ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL;
|
||||||
@@ -10,19 +10,17 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -100,7 +98,7 @@ class AuthControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signupShouldStoreSecurityContextInSession() throws Exception {
|
void signupShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
||||||
|
|
||||||
@@ -109,14 +107,13 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/v1/auth/signup")
|
mockMvc.perform(post("/v1/auth/signup")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated());
|
||||||
.andExpect(request().sessionAttribute(
|
|
||||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||||
notNullValue()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginShouldStoreSecurityContextInSession() throws Exception {
|
void loginShouldDelegateSessionCreationToAuthService() throws Exception {
|
||||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||||
var response = UserResponse.withHousehold(
|
var response = UserResponse.withHousehold(
|
||||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||||
@@ -127,10 +124,9 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/v1/auth/login")
|
mockMvc.perform(post("/v1/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk());
|
||||||
.andExpect(request().sessionAttribute(
|
|
||||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
|
||||||
notNullValue()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.recipeapp.auth;
|
||||||
|
|
||||||
|
import com.recipeapp.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
|
||||||
|
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
class SecurityConfigTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebApplicationContext context;
|
||||||
|
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||||
|
.apply(springSecurity())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception {
|
||||||
|
// 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401
|
||||||
|
mockMvc.perform(get("/v1/invites/ANYCODE"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception {
|
||||||
|
// 400 = validation error (empty body), but NOT 401 — proves the path is permitted
|
||||||
|
mockMvc.perform(post("/v1/invites/ANYCODE/accept")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedEndpointRequiresAuthentication() throws Exception {
|
||||||
|
mockMvc.perform(get("/v1/households/mine"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.recipeapp.household;
|
package com.recipeapp.household;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.recipeapp.auth.AuthService;
|
||||||
import com.recipeapp.common.GlobalExceptionHandler;
|
import com.recipeapp.common.GlobalExceptionHandler;
|
||||||
|
import com.recipeapp.common.ResourceNotFoundException;
|
||||||
|
import com.recipeapp.common.ConflictException;
|
||||||
import com.recipeapp.household.dto.*;
|
import com.recipeapp.household.dto.*;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -15,10 +18,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -32,6 +37,9 @@ class HouseholdControllerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private HouseholdService householdService;
|
private HouseholdService householdService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private HouseholdController householdController;
|
private HouseholdController householdController;
|
||||||
|
|
||||||
@@ -104,16 +112,119 @@ class HouseholdControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldReturn200() throws Exception {
|
void getActiveInviteShouldReturn200WithInvite() throws Exception {
|
||||||
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
|
||||||
|
Instant.now().plusSeconds(172800));
|
||||||
|
|
||||||
when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response);
|
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/households/mine/invites")
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
|
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
|
||||||
|
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/households/mine/invites")
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteMemberShouldReturn204() throws Exception {
|
||||||
|
var memberId = UUID.randomUUID();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
|
||||||
|
.principal(() -> "sarah@example.com"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(householdService).removeMember("sarah@example.com", memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patchMemberRoleShouldReturn200() throws Exception {
|
||||||
|
var memberId = UUID.randomUUID();
|
||||||
|
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
|
||||||
|
var request = new ChangeRoleRequest("planner");
|
||||||
|
|
||||||
|
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
|
||||||
|
.thenReturn(memberResponse);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
|
||||||
|
.principal(() -> "sarah@example.com")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
|
.andExpect(jsonPath("$.data.role").value("planner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception {
|
||||||
|
var response = new InviteInfoResponse("Smith family", "Sarah");
|
||||||
|
|
||||||
|
when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/invites/ABC12XYZ"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
|
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
||||||
|
.andExpect(jsonPath("$.data.inviterName").value("Sarah"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldReturn404WhenInvalid() throws Exception {
|
||||||
|
when(householdService.getInviteInfo("BADTOKEN"))
|
||||||
|
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/invites/BADTOKEN"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldReturn200AndCreateSession() throws Exception {
|
||||||
|
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
|
||||||
|
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||||
|
|
||||||
|
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||||
|
.thenReturn(response);
|
||||||
|
|
||||||
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
||||||
.principal(() -> "tom@example.com"))
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.status").value("success"))
|
.andExpect(jsonPath("$.status").value("success"))
|
||||||
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
|
||||||
.andExpect(jsonPath("$.data.role").value("member"));
|
.andExpect(jsonPath("$.data.role").value("member"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception {
|
||||||
|
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||||
|
|
||||||
|
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||||
|
.thenThrow(new ConflictException("Email already registered"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception {
|
||||||
|
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
|
||||||
|
|
||||||
|
when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123"))
|
||||||
|
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/v1/invites/BADTOKEN/accept")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -38,10 +41,16 @@ class HouseholdServiceTest {
|
|||||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||||
@Mock private TagRepository tagRepository;
|
@Mock private TagRepository tagRepository;
|
||||||
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
|
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||||
|
@Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private HouseholdService householdService;
|
private HouseholdService householdService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
|
||||||
|
}
|
||||||
|
|
||||||
private UserAccount testUser() {
|
private UserAccount testUser() {
|
||||||
return new UserAccount("sarah@example.com", "Sarah", "hashed");
|
return new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||||
}
|
}
|
||||||
@@ -132,85 +141,164 @@ class HouseholdServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldAddUserAsMember() {
|
void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
InviteResponse result = householdService.createInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result.shareUrl()).startsWith("http://localhost:5173/join/");
|
||||||
|
assertThat(result.shareUrl()).endsWith(result.inviteCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── getInviteInfo ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldReturnHouseholdNameAndInviterName() {
|
||||||
var owner = testUser();
|
var owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
|
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
|
||||||
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
|
||||||
|
|
||||||
AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ");
|
InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ");
|
||||||
|
|
||||||
assertThat(result.householdName()).isEqualTo("Smith family");
|
assertThat(result.householdName()).isEqualTo("Smith family");
|
||||||
assertThat(result.role()).isEqualTo("member");
|
assertThat(result.inviterName()).isEqualTo("Sarah");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenAlreadyInHousehold() {
|
void getInviteInfoShouldThrow404WhenCodeNotFound() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||||
var household = new Household("Other", user);
|
|
||||||
var member = new HouseholdMember(household, user, "member");
|
assertThatThrownBy(() -> householdService.getInviteInfo("INVALID"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldThrow404WhenCodeExpired() {
|
||||||
|
var owner = testUser();
|
||||||
|
var household = new Household("Smith family", owner);
|
||||||
|
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
|
||||||
|
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() {
|
||||||
|
var owner = testUser();
|
||||||
|
var household = new Household("Smith family", owner);
|
||||||
|
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setStatus("used");
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
|
||||||
|
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.getInviteInfo("USED123"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInviteInfoShouldThrow404WhenInviteIsInvalidated() {
|
||||||
|
var owner = testUser();
|
||||||
|
var household = new Household("Smith family", owner);
|
||||||
|
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
|
||||||
|
|
||||||
|
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── acceptInvite (new: creates account + joins) ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldCreateAccountAndAddAsMember() {
|
||||||
|
var owner = testUser();
|
||||||
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setInvitedBy(owner);
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member));
|
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
|
||||||
|
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
when(passwordEncoder.encode("secret123")).thenReturn("hashed");
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ"))
|
AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123");
|
||||||
|
|
||||||
|
assertThat(result.householdName()).isEqualTo("Smith family");
|
||||||
|
assertThat(result.role()).isEqualTo("member");
|
||||||
|
verify(userAccountRepository).save(any(UserAccount.class));
|
||||||
|
verify(householdMemberRepository).save(any(HouseholdMember.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() {
|
||||||
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
|
||||||
.isInstanceOf(ConflictException.class);
|
.isInstanceOf(ConflictException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenCodeExpired() {
|
void acceptInviteShouldThrow404WhenCodeExpired() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
|
||||||
var owner = testUser();
|
var owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED"))
|
assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123"))
|
||||||
.isInstanceOf(ValidationException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenCodeAlreadyUsed() {
|
void acceptInviteShouldThrow404WhenCodeAlreadyUsed() {
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
|
||||||
var owner = testUser();
|
var owner = testUser();
|
||||||
var household = new Household("Smith family", owner);
|
var household = new Household("Smith family", owner);
|
||||||
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
|
||||||
invite.setStatus("used");
|
invite.setStatus("used");
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123"))
|
assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123"))
|
||||||
.isInstanceOf(ConflictException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptInviteShouldThrowWhenInviteNotFound() {
|
|
||||||
var user = new UserAccount("tom@example.com", "Tom", "hashed");
|
|
||||||
|
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
|
|
||||||
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID"))
|
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptInviteShouldThrowWhenUserNotFound() {
|
void acceptInviteShouldThrow404WhenInviteNotFound() {
|
||||||
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
|
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ"))
|
assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptInviteShouldThrow404WhenInviteIsInvalidated() {
|
||||||
|
var owner = testUser();
|
||||||
|
var household = new Household("Smith family", owner);
|
||||||
|
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
|
||||||
|
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
|
||||||
|
|
||||||
|
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
|
||||||
|
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +311,187 @@ class HouseholdServiceTest {
|
|||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── changeMemberRole ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldUpdateRole() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
|
||||||
|
|
||||||
|
assertThat(result.role()).isEqualTo("planner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
|
||||||
|
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
|
||||||
|
|
||||||
|
assertThat(result.role()).isEqualTo("member");
|
||||||
|
verify(householdMemberRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
|
||||||
|
.isInstanceOf(ConflictException.class)
|
||||||
|
.hasMessageContaining("last planner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var unknownId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── removeMember ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldDeleteMember() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "member");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
|
||||||
|
householdService.removeMember("sarah@example.com", targetId);
|
||||||
|
|
||||||
|
verify(householdMemberRepository).delete(targetMembership);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var plannerId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
|
||||||
|
.isInstanceOf(ConflictException.class)
|
||||||
|
.hasMessageContaining("cannot remove yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldThrow409WhenRemovingLastPlanner() {
|
||||||
|
var planner = testUser();
|
||||||
|
var target = new UserAccount("tom@example.com", "Tom", "hashed");
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var targetMembership = new HouseholdMember(household, target, "planner");
|
||||||
|
var targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
|
||||||
|
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", targetId))
|
||||||
|
.isInstanceOf(ConflictException.class)
|
||||||
|
.hasMessageContaining("last planner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
|
||||||
|
var planner = testUser();
|
||||||
|
var household = new Household("Smith family", planner);
|
||||||
|
var plannerMembership = new HouseholdMember(household, planner, "planner");
|
||||||
|
var unknownId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
|
||||||
|
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
|
||||||
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── getActiveInvite ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnActiveInviteResponse() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveInviteShouldReturnEmptyWhenExpired() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
|
||||||
|
|
||||||
|
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getMembersShouldReturnAllMembers() {
|
void getMembersShouldReturnAllMembers() {
|
||||||
var user1 = testUser();
|
var user1 = testUser();
|
||||||
@@ -256,4 +525,23 @@ class HouseholdServiceTest {
|
|||||||
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInviteShouldInvalidatePreviousActiveInvite() {
|
||||||
|
var user = testUser();
|
||||||
|
var household = new Household("Smith family", user);
|
||||||
|
var member = new HouseholdMember(household, user, "planner");
|
||||||
|
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
|
||||||
|
|
||||||
|
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||||
|
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
|
||||||
|
when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
householdService.createInvite("sarah@example.com");
|
||||||
|
|
||||||
|
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
|
||||||
|
verify(householdInviteRepository).saveAndFlush(existingInvite);
|
||||||
|
verify(householdInviteRepository).save(any(HouseholdInvite.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class WeekPlanControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSuggestionsShouldReturn200() throws Exception {
|
void getSuggestionsShouldReturn200() throws Exception {
|
||||||
var recipe = new SuggestionResponse.SuggestionRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15);
|
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
|
||||||
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
||||||
var response = new SuggestionResponse(List.of(item));
|
var response = new SuggestionResponse(List.of(item));
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ class RecipeControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listRecipesShouldReturn200WithPagination() throws Exception {
|
void listRecipesShouldReturn200WithPagination() throws Exception {
|
||||||
|
var tag = new TagResponse(UUID.randomUUID(), "Rind", "protein");
|
||||||
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
||||||
(short) 4, (short) 45, "medium", null);
|
(short) 4, (short) 45, "medium", "https://example.com/img.jpg", List.of(tag));
|
||||||
|
|
||||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
|
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
|
||||||
@@ -62,6 +63,9 @@ class RecipeControllerTest {
|
|||||||
.param("offset", "0"))
|
.param("offset", "0"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
|
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
|
||||||
|
.andExpect(jsonPath("$.data[0].heroImageUrl").value("https://example.com/img.jpg"))
|
||||||
|
.andExpect(jsonPath("$.data[0].tags[0].name").value("Rind"))
|
||||||
|
.andExpect(jsonPath("$.data[0].tags[0].tagType").value("protein"))
|
||||||
.andExpect(jsonPath("$.meta.pagination.total").value(1))
|
.andExpect(jsonPath("$.meta.pagination.total").value(1))
|
||||||
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--color-surface: #f5f4ee;
|
--color-surface: #f5f4ee;
|
||||||
--color-subtle: #edecea;
|
--color-subtle: #edecea;
|
||||||
--color-border: #d8d7d0;
|
--color-border: #d8d7d0;
|
||||||
|
--color-border-hover: #c0bfb8;
|
||||||
--color-text: #1c1c18;
|
--color-text: #1c1c18;
|
||||||
--color-text-muted: #6b6a63;
|
--color-text-muted: #6b6a63;
|
||||||
|
|
||||||
@@ -86,4 +87,28 @@
|
|||||||
--btn-font-size: 13px;
|
--btn-font-size: 13px;
|
||||||
--btn-font-weight: 500;
|
--btn-font-weight: 500;
|
||||||
--btn-letter-spacing: 0.04em;
|
--btn-letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
/* ── Planner flip-tile semantic tokens ──────────────────────────── */
|
||||||
|
--color-ring-today: var(--yellow-text);
|
||||||
|
--color-ring-selected: var(--green-dark);
|
||||||
|
--opacity-dimmed: 0.38;
|
||||||
|
|
||||||
|
/* ── Protein gradient tokens ────────────────────────────────────── */
|
||||||
|
--gradient-protein-haehnchen: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||||
|
--gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||||
|
--gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
|
--gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
|
||||||
|
--gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
|
||||||
|
--gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
|
||||||
|
--gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%);
|
||||||
|
--gradient-protein-eier: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
--gradient-protein-kaese: linear-gradient(135deg, #fcd34d 0%, #d97706 100%);
|
||||||
|
--gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%);
|
||||||
|
|
||||||
|
/* ── Cuisine gradient tokens ────────────────────────────────────── */
|
||||||
|
--gradient-cuisine-deutsch: linear-gradient(135deg, #78716c 0%, #44403c 100%);
|
||||||
|
--gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%);
|
||||||
|
--gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%);
|
||||||
|
--gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
|
||||||
|
--gradient-cuisine-mediterran: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
|||||||
expect(resolve).toHaveBeenCalledWith(event);
|
expect(resolve).toHaveBeenCalledWith(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])(
|
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])(
|
||||||
'allows public route %s without auth',
|
'allows public route %s without auth',
|
||||||
async (path) => {
|
async (path) => {
|
||||||
const { event, resolve } = createEvent(path);
|
const { event, resolve } = createEvent(path);
|
||||||
@@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('redirects authenticated user on /join/[token] to /', async () => {
|
||||||
|
const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session');
|
||||||
|
try {
|
||||||
|
await handle({ event, resolve });
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.status).toBe(302);
|
||||||
|
expect(e.location).toBe('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
|
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
|
||||||
'allows static asset %s without auth',
|
'allows static asset %s without auth',
|
||||||
async (path) => {
|
async (path) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite'];
|
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join'];
|
||||||
|
|
||||||
const STATIC_PREFIXES = ['/_app/', '/favicon'];
|
const STATIC_PREFIXES = ['/_app/', '/favicon'];
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never {
|
|||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (isPublicRoute(event.url.pathname)) {
|
if (isPublicRoute(event.url.pathname)) {
|
||||||
|
const isJoinRoute = event.url.pathname.startsWith('/join/');
|
||||||
|
if (isJoinRoute && event.cookies.get('JSESSIONID')) {
|
||||||
|
throw redirect(302, '/');
|
||||||
|
}
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
192
frontend/src/lib/api/schema.d.ts
vendored
192
frontend/src/lib/api/schema.d.ts
vendored
@@ -148,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/invites/{code}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getInviteInfo"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/invites/{code}/accept": {
|
"/v1/invites/{code}/accept": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -203,7 +219,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
get: operations["getActiveInvite"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post: operations["createInvite"];
|
post: operations["createInvite"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -212,6 +228,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/households/mine/members/{userId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["removeMember"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["changeMemberRole"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/cooking-logs": {
|
"/v1/cooking-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -721,6 +755,20 @@ export interface components {
|
|||||||
data?: components["schemas"]["AcceptInviteResponse"];
|
data?: components["schemas"]["AcceptInviteResponse"];
|
||||||
meta?: components["schemas"]["Meta"];
|
meta?: components["schemas"]["Meta"];
|
||||||
};
|
};
|
||||||
|
InviteInfoResponse: {
|
||||||
|
householdName?: string;
|
||||||
|
inviterName?: string;
|
||||||
|
};
|
||||||
|
ApiResponseInviteInfoResponse: {
|
||||||
|
status?: string;
|
||||||
|
data?: components["schemas"]["InviteInfoResponse"];
|
||||||
|
meta?: components["schemas"]["Meta"];
|
||||||
|
};
|
||||||
|
AcceptInviteRequest: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
Meta: {
|
Meta: {
|
||||||
pagination?: components["schemas"]["Pagination"];
|
pagination?: components["schemas"]["Pagination"];
|
||||||
};
|
};
|
||||||
@@ -763,6 +811,14 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
joinedAt?: string;
|
joinedAt?: string;
|
||||||
};
|
};
|
||||||
|
ChangeRoleRequest: {
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
ApiResponseMemberResponse: {
|
||||||
|
status?: string;
|
||||||
|
data?: components["schemas"]["MemberResponse"];
|
||||||
|
meta?: components["schemas"]["Meta"];
|
||||||
|
};
|
||||||
ApiResponseInviteResponse: {
|
ApiResponseInviteResponse: {
|
||||||
status?: string;
|
status?: string;
|
||||||
data?: components["schemas"]["InviteResponse"];
|
data?: components["schemas"]["InviteResponse"];
|
||||||
@@ -1319,7 +1375,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
acceptInvite: {
|
getInviteInfo: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -1329,6 +1385,37 @@ export interface operations {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ApiResponseInviteInfoResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: { [name: string]: unknown };
|
||||||
|
content: { "*/*": components["schemas"]["ApiError"] };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
acceptInvite: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AcceptInviteRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1339,6 +1426,16 @@ export interface operations {
|
|||||||
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
|
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Email already registered */
|
||||||
|
409: {
|
||||||
|
headers: { [name: string]: unknown };
|
||||||
|
content: { "*/*": components["schemas"]["ApiError"] };
|
||||||
|
};
|
||||||
|
/** @description Invite not found or invalid */
|
||||||
|
404: {
|
||||||
|
headers: { [name: string]: unknown };
|
||||||
|
content: { "*/*": components["schemas"]["ApiError"] };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
listCategories: {
|
listCategories: {
|
||||||
@@ -2010,6 +2107,97 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getActiveInvite: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ApiResponseInviteResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description No active invite */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
removeMember: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ApiError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
changeMemberRole: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeRoleRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ApiResponseMemberResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ApiError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listAuditLog: {
|
listAuditLog: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
value: string;
|
||||||
|
onchange: (value: string) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="group" class="segmented-control">
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={option.value === value ? 'true' : 'false'}
|
||||||
|
class="segment"
|
||||||
|
class:active={option.value === value}
|
||||||
|
onclick={() => onchange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment.active {
|
||||||
|
background: var(--color-page);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import SegmentedControl from './SegmentedControl.svelte';
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'planner', label: 'Planer' },
|
||||||
|
{ value: 'member', label: 'Mitglied' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SegmentedControl', () => {
|
||||||
|
it('renders all option labels', () => {
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active option with aria-pressed', () => {
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onchange with the new value when an option is clicked', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
|
||||||
|
expect(onchange).toHaveBeenCalledWith('member');
|
||||||
|
});
|
||||||
|
});
|
||||||
29
frontend/src/lib/components/SettingsCard.svelte
Normal file
29
frontend/src/lib/components/SettingsCard.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
cta: string;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, href, cta, meta }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[16px] font-medium text-[var(--color-text)]">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if meta}
|
||||||
|
<p data-testid="card-meta" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
|
||||||
|
{cta}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
32
frontend/src/lib/components/SettingsCard.test.ts
Normal file
32
frontend/src/lib/components/SettingsCard.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import SettingsCard from './SettingsCard.svelte';
|
||||||
|
|
||||||
|
describe('SettingsCard', () => {
|
||||||
|
it('renders the title', () => {
|
||||||
|
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
|
||||||
|
expect(screen.getByText('Vorräte')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as an anchor tag with the given href', () => {
|
||||||
|
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/household/staples');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the cta text', () => {
|
||||||
|
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
|
||||||
|
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders meta text when provided', () => {
|
||||||
|
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } });
|
||||||
|
expect(screen.getByText('3 Mitglieder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render meta element when meta is not provided', () => {
|
||||||
|
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } });
|
||||||
|
expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
31
frontend/src/lib/components/Toast.svelte
Normal file
31
frontend/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { message, visible, ondismiss }: {
|
||||||
|
message: string;
|
||||||
|
visible: boolean;
|
||||||
|
ondismiss?: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span>{message}</span>
|
||||||
|
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
23
frontend/src/lib/components/Toast.test.ts
Normal file
23
frontend/src/lib/components/Toast.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import Toast from './Toast.svelte';
|
||||||
|
|
||||||
|
describe('Toast', () => {
|
||||||
|
it('is not mounted when visible is false', () => {
|
||||||
|
render(Toast, { props: { message: 'Hallo', visible: false } });
|
||||||
|
expect(screen.queryByRole('status')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the message when visible is true', () => {
|
||||||
|
render(Toast, { props: { message: 'Gespeichert', visible: true } });
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ondismiss when close button is clicked', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
|
||||||
|
expect(ondismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,28 @@ const requiredTokens = [
|
|||||||
// Shadows
|
// Shadows
|
||||||
'--shadow-card',
|
'--shadow-card',
|
||||||
'--shadow-raised',
|
'--shadow-raised',
|
||||||
'--shadow-overlay'
|
'--shadow-overlay',
|
||||||
|
// Planner flip-tile semantic tokens
|
||||||
|
'--color-ring-today',
|
||||||
|
'--color-ring-selected',
|
||||||
|
'--opacity-dimmed',
|
||||||
|
// Protein gradient tokens
|
||||||
|
'--gradient-protein-haehnchen',
|
||||||
|
'--gradient-protein-rind',
|
||||||
|
'--gradient-protein-fisch',
|
||||||
|
'--gradient-protein-tofu',
|
||||||
|
'--gradient-protein-vegetarisch',
|
||||||
|
'--gradient-protein-schwein',
|
||||||
|
'--gradient-protein-lamm',
|
||||||
|
'--gradient-protein-eier',
|
||||||
|
'--gradient-protein-kaese',
|
||||||
|
'--gradient-protein-huelsenfruechte',
|
||||||
|
// Cuisine gradient tokens
|
||||||
|
'--gradient-cuisine-deutsch',
|
||||||
|
'--gradient-cuisine-asiatisch',
|
||||||
|
'--gradient-cuisine-indisch',
|
||||||
|
'--gradient-cuisine-mexikanisch',
|
||||||
|
'--gradient-cuisine-mediterran'
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('design token completeness', () => {
|
describe('design token completeness', () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('AppShell', () => {
|
|||||||
it('renders all navigation links from all nav variants', () => {
|
it('renders all navigation links from all nav variants', () => {
|
||||||
render(AppShell, { props: defaultProps });
|
render(AppShell, { props: defaultProps });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
|
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
|
||||||
expect(links).toHaveLength(13);
|
expect(links).toHaveLength(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{section.title}
|
{section.title}
|
||||||
</p>
|
</p>
|
||||||
{#each section.items as item (item.href)}
|
{#each section.items as item (item.href)}
|
||||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Household section with 2 items', () => {
|
it('renders Household section with Einstellungen', () => {
|
||||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
|
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has 5 navigation links total', () => {
|
it('has 4 navigation links total', () => {
|
||||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
expect(links).toHaveLength(5);
|
expect(links).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks active item with aria-current="page"', () => {
|
it('marks active item with aria-current="page"', () => {
|
||||||
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(widget).toBeInTheDocument();
|
expect(widget).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DesktopSidebar — extraPaths active state', () => {
|
||||||
|
it('marks Einstellungen active when on /household/staples', async () => {
|
||||||
|
const { readable } = await import('svelte/store');
|
||||||
|
vi.doMock('$app/stores', () => ({
|
||||||
|
page: readable({ url: new URL('http://localhost/household/staples') })
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
const { render: r, screen: s } = await import('@testing-library/svelte');
|
||||||
|
const { default: Sidebar } = await import('./DesktopSidebar.svelte');
|
||||||
|
r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } });
|
||||||
|
const link = s.getByRole('link', { name: /einstellungen/i });
|
||||||
|
expect(link).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
|
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
|
||||||
>
|
>
|
||||||
{#each mobileNavItems as item (item.href)}
|
{#each mobileNavItems as item (item.href)}
|
||||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
|
|||||||
expect(recipesLink).not.toHaveAttribute('aria-current');
|
expect(recipesLink).not.toHaveAttribute('aria-current');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MobileTabBar — extraPaths active state', () => {
|
||||||
|
it('marks Einstellungen active when on /household/staples', async () => {
|
||||||
|
const { readable } = await import('svelte/store');
|
||||||
|
vi.doMock('$app/stores', () => ({
|
||||||
|
page: readable({ url: new URL('http://localhost/household/staples') })
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
const { render: r, screen: s } = await import('@testing-library/svelte');
|
||||||
|
const { default: TabBar } = await import('./MobileTabBar.svelte');
|
||||||
|
r(TabBar);
|
||||||
|
const link = s.getByRole('link', { name: /einstellungen/i });
|
||||||
|
expect(link).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ describe('nav config', () => {
|
|||||||
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
|
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Household section has Members, Settings', () => {
|
it('Household section has Settings', () => {
|
||||||
const labels = desktopNavSections[1].items.map((item) => item.label);
|
const labels = desktopNavSections[1].items.map((item) => item.label);
|
||||||
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
|
expect(labels).toEqual(['Einstellungen']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,5 +56,35 @@ describe('nav config', () => {
|
|||||||
it('does not match unrelated route', () => {
|
it('does not match unrelated route', () => {
|
||||||
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches when pathname is in extraPaths', () => {
|
||||||
|
expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches sub-route of extraPath', () => {
|
||||||
|
expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match extraPath with similar prefix', () => {
|
||||||
|
expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when extraPaths provided but no match', () => {
|
||||||
|
expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NavItem extraPaths', () => {
|
||||||
|
it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => {
|
||||||
|
const einstellungen = desktopNavSections
|
||||||
|
.flatMap((s) => s.items)
|
||||||
|
.find((i) => i.href === '/settings');
|
||||||
|
expect(einstellungen?.extraPaths).toContain('/household/staples');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => {
|
||||||
|
const einstellungen = mobileNavItems.find((i) => i.href === '/settings');
|
||||||
|
expect(einstellungen?.extraPaths).toContain('/household/staples');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface NavItem {
|
|||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
extraPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavSection {
|
export interface NavSection {
|
||||||
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
|
|||||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||||
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
|
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isActiveRoute(href: string, pathname: string): boolean {
|
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
if (pathname === href || pathname.startsWith(href + '/')) return true;
|
||||||
|
if (extraPaths) {
|
||||||
|
return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const desktopNavSections: NavSection[] = [
|
export const desktopNavSections: NavSection[] = [
|
||||||
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'Haushalt',
|
title: 'Haushalt',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/members', label: 'Mitglieder', icon: '👥' },
|
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
412
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
412
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||||
|
import { formatDayAbbr } from '$lib/planner/week';
|
||||||
|
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
||||||
|
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
slot,
|
||||||
|
isToday,
|
||||||
|
activeSlotId,
|
||||||
|
isPlanner,
|
||||||
|
slotMap,
|
||||||
|
suggestions,
|
||||||
|
topSuggestion,
|
||||||
|
onflip,
|
||||||
|
onclose,
|
||||||
|
onswap,
|
||||||
|
onremove,
|
||||||
|
onaddrecipe
|
||||||
|
}: {
|
||||||
|
slot: Slot;
|
||||||
|
isToday: boolean;
|
||||||
|
activeSlotId: string | null;
|
||||||
|
isPlanner: boolean;
|
||||||
|
slotMap: Record<string, any>;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
topSuggestion?: Suggestion;
|
||||||
|
onflip?: (slotId: string) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
onswap?: () => void;
|
||||||
|
onremove?: () => void;
|
||||||
|
onaddrecipe?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const slotId = $derived(slot.id ?? '');
|
||||||
|
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
|
||||||
|
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
|
||||||
|
|
||||||
|
const dayAbbr = $derived(slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : '');
|
||||||
|
const dateNum = $derived(slot.slotDate ? slot.slotDate.slice(-2).replace(/^0/, '') : '');
|
||||||
|
|
||||||
|
const visibleTags = $derived(
|
||||||
|
(slot.recipe?.tags ?? []).filter((t) => t.tagType === 'protein' || t.tagType === 'cuisine')
|
||||||
|
);
|
||||||
|
|
||||||
|
const metaLine = $derived((() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (slot.recipe?.cookTimeMin) parts.push(`${slot.recipe.cookTimeMin} Min`);
|
||||||
|
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
|
||||||
|
return parts.join(' · ');
|
||||||
|
})());
|
||||||
|
|
||||||
|
function handleFlip() {
|
||||||
|
onflip?.(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onflip?.(slotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const umlautMap: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
|
||||||
|
function toCssKey(name: string): string {
|
||||||
|
return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientBackground = $derived((() => {
|
||||||
|
if (!slot.recipe) return 'var(--color-surface)';
|
||||||
|
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
|
||||||
|
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
|
||||||
|
if (proteinTag?.name) {
|
||||||
|
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
||||||
|
}
|
||||||
|
const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine');
|
||||||
|
if (cuisineTag?.name) {
|
||||||
|
return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`;
|
||||||
|
}
|
||||||
|
return 'var(--color-surface)';
|
||||||
|
})());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if slot.recipe}
|
||||||
|
<div
|
||||||
|
data-testid="day-meal-card-{slot.slotDate ?? ''}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={slot.recipe?.name ?? 'Gericht'}
|
||||||
|
aria-expanded={isFlipped}
|
||||||
|
data-today={isToday}
|
||||||
|
data-flipped={isFlipped}
|
||||||
|
data-dimmed={isDimmed}
|
||||||
|
class="scene"
|
||||||
|
class:scene-today={isToday && !isFlipped}
|
||||||
|
class:scene-selected={isFlipped}
|
||||||
|
class:scene-dimmed={isDimmed}
|
||||||
|
onclick={handleFlip}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
>
|
||||||
|
<!-- FRONT -->
|
||||||
|
<div class="card-front" class:flipped={isFlipped}>
|
||||||
|
<div
|
||||||
|
class="card-front-inner"
|
||||||
|
style="background: {gradientBackground}; background-size: cover; background-position: center;"
|
||||||
|
>
|
||||||
|
<!-- Full-tile dual gradient overlay -->
|
||||||
|
<div class="tile-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Day header -->
|
||||||
|
<div class="tile-head">
|
||||||
|
<span class="tile-day-abbr">{dayAbbr}</span>
|
||||||
|
<span class="tile-day-num" class:dn-today={isToday} class:dn-selected={isFlipped}>
|
||||||
|
{dateNum}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe info at bottom -->
|
||||||
|
<div class="tile-info">
|
||||||
|
<p class="tile-name">{slot.recipe.name}</p>
|
||||||
|
{#if metaLine}
|
||||||
|
<p class="tile-meta">{metaLine}</p>
|
||||||
|
{/if}
|
||||||
|
{#if visibleTags.length > 0}
|
||||||
|
<div class="tile-tags">
|
||||||
|
{#each visibleTags as tag (tag.id)}
|
||||||
|
<span class="tile-tag" class:tag-today={isToday} class:tag-selected={isFlipped}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div> <!-- /.card-front-inner -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BACK -->
|
||||||
|
<div class="card-back" class:flipped={isFlipped} aria-hidden={!isFlipped}>
|
||||||
|
<div class="card-back-inner">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
class="btn-close"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="back-name">{slot.recipe.name}</p>
|
||||||
|
{#if metaLine}
|
||||||
|
<p class="back-meta">{metaLine}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="back-actions">
|
||||||
|
<a class="btn-action btn-primary" href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
|
||||||
|
<a class="btn-action" href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
|
||||||
|
|
||||||
|
{#if isPlanner}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-action"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
|
||||||
|
>
|
||||||
|
Gericht tauschen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if slot.id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-action btn-danger"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div> <!-- /.card-back-inner -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyDayTile
|
||||||
|
slotDate={slot.slotDate ?? ''}
|
||||||
|
slotId={slot.id ?? ''}
|
||||||
|
{isPlanner}
|
||||||
|
{slotMap}
|
||||||
|
{topSuggestion}
|
||||||
|
{onaddrecipe}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Scene (outermost positioned element) ── */
|
||||||
|
.scene {
|
||||||
|
perspective: 900px;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||||
|
transition: box-shadow .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
.scene:hover {
|
||||||
|
box-shadow: 0 6px 18px rgba(28,28,24,.14), 0 2px 6px rgba(28,28,24,.08);
|
||||||
|
}
|
||||||
|
.scene-today {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-ring-today), 0 1px 3px rgba(28,28,24,.06);
|
||||||
|
}
|
||||||
|
.scene-today:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-ring-today), 0 6px 18px rgba(28,28,24,.14);
|
||||||
|
}
|
||||||
|
.scene-selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
|
||||||
|
}
|
||||||
|
/* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */
|
||||||
|
.scene-selected:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
|
||||||
|
}
|
||||||
|
.scene-dimmed {
|
||||||
|
opacity: var(--opacity-dimmed);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card flip — independent face transforms, no preserve-3d ──
|
||||||
|
preserve-3d + box-shadow/transition on parent causes Chrome to
|
||||||
|
fail backface-visibility:hidden. Rotating each face independently
|
||||||
|
avoids the 3D context entirely. */
|
||||||
|
.card-front,
|
||||||
|
.card-back {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.card-front { transform: rotateY(0deg); }
|
||||||
|
.card-front.flipped { transform: rotateY(-180deg); }
|
||||||
|
.card-back { transform: rotateY(180deg); pointer-events: none; }
|
||||||
|
.card-back.flipped { transform: rotateY(0deg); pointer-events: auto; }
|
||||||
|
.card-front.flipped { pointer-events: none; }
|
||||||
|
.card-front-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Front face ── */
|
||||||
|
.tile-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0,0,0,.32) 0%,
|
||||||
|
rgba(0,0,0,0) 30%,
|
||||||
|
rgba(0,0,0,0) 45%,
|
||||||
|
rgba(0,0,0,.55) 100%
|
||||||
|
);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.tile-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 8px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.tile-day-abbr {
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: rgba(255,255,255,.85);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tile-day-num {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255,255,255,.9);
|
||||||
|
background: rgba(255,255,255,.22);
|
||||||
|
}
|
||||||
|
.dn-today {
|
||||||
|
background: var(--color-ring-today) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.dn-selected {
|
||||||
|
background: var(--green-dark) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.tile-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 8px 9px 9px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.tile-name {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.tile-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255,255,255,.75);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
.tile-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.tile-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255,255,255,.2);
|
||||||
|
color: rgba(255,255,255,.92);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.tag-today { background: rgba(242,193,46,.35); }
|
||||||
|
.tag-selected { background: rgba(46,110,57,.45); }
|
||||||
|
|
||||||
|
/* ── Back face ── */
|
||||||
|
.card-back-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-page);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 2px 6px;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin: -4px -4px 2px 0;
|
||||||
|
}
|
||||||
|
.btn-close:hover { color: var(--color-text); }
|
||||||
|
.back-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.back-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.back-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.btn-action {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.btn-action:hover { background: var(--color-surface); }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--green-dark);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--green-dark); filter: brightness(1.1); }
|
||||||
|
.btn-danger {
|
||||||
|
color: #dc4c3e;
|
||||||
|
border-color: #dc4c3e;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(220,76,62,.08); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card-front, .card-back { transition: none; }
|
||||||
|
.scene { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
205
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
205
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import DesktopDayTile from './DesktopDayTile.svelte';
|
||||||
|
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
|
||||||
|
|
||||||
|
const filledSlot = {
|
||||||
|
id: 's1',
|
||||||
|
slotDate: '2026-04-14',
|
||||||
|
recipe: {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Pasta Bolognese',
|
||||||
|
cookTimeMin: 45,
|
||||||
|
effort: 'mittel',
|
||||||
|
heroImageUrl: null,
|
||||||
|
tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
|
||||||
|
|
||||||
|
describe('sanitizeForCssUrl', () => {
|
||||||
|
it('strips parentheses that could break out of url() context', () => {
|
||||||
|
expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips single quotes', () => {
|
||||||
|
expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips double quotes', () => {
|
||||||
|
expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips backslashes', () => {
|
||||||
|
expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a safe data URI unchanged', () => {
|
||||||
|
const safe = 'data:image/png;base64,abc123+/==';
|
||||||
|
expect(sanitizeForCssUrl(safe)).toBe(safe);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DesktopDayTile — filled slot', () => {
|
||||||
|
describe('front face', () => {
|
||||||
|
it('renders recipe name on front face', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getAllByText('Pasta Bolognese').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has data-testid="day-meal-card" on the scene element', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies today ring when isToday', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('data-today')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies selected ring when activeSlotId matches slot id', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('data-flipped')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims tile when another slot is active', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('data-dimmed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not dimmed when no slot is active', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('data-dimmed')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flip interaction', () => {
|
||||||
|
it('calls onflip with slot id when scene is clicked', async () => {
|
||||||
|
const onflip = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||||
|
await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
|
||||||
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onflip when Enter key is pressed on scene', async () => {
|
||||||
|
const onflip = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||||
|
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onflip when Space key is pressed on scene', async () => {
|
||||||
|
const onflip = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
|
||||||
|
screen.getByTestId("day-meal-card-2026-04-14").focus();
|
||||||
|
await user.keyboard(' ');
|
||||||
|
expect(onflip).toHaveBeenCalledWith('s1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('back face (flipped state)', () => {
|
||||||
|
it('shows recipe name on back face when flipped', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
// Back face should also show recipe name
|
||||||
|
const names = screen.getAllByText('Pasta Bolognese');
|
||||||
|
expect(names.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Koch-Modus link on back face when flipped', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Rezept ansehen link on back face when flipped', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows close button on back face', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when close button clicked', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Schließen/i }));
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Gericht tauschen button for planner on back face', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Gericht tauschen for non-planner', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onswap when Gericht tauschen clicked', async () => {
|
||||||
|
const onswap = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
|
||||||
|
expect(onswap).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Entfernen button for planner when slot has id', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Entfernen button when slot.id is null (optimistic slot)', () => {
|
||||||
|
const slotWithoutId = { ...filledSlot, id: null };
|
||||||
|
render(DesktopDayTile, { props: { slot: slotWithoutId, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
// flip the tile first so back face is visible
|
||||||
|
// activeSlotId must match slot.id to flip — but slot.id is null, so isFlipped = false
|
||||||
|
// The back face is still rendered in the DOM; check button is absent
|
||||||
|
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onremove when Entfernen clicked', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
|
||||||
|
expect(onremove).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-expanded is true when flipped', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-expanded is false when not flipped', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
const scene = screen.getByTestId("day-meal-card-2026-04-14");
|
||||||
|
expect(scene.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DesktopDayTile — empty slot', () => {
|
||||||
|
it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Koch-Modus for empty slot', () => {
|
||||||
|
render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
|
||||||
|
expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Strips characters that could break out of a CSS url() context or inject
|
||||||
|
* CSS into an inline style attribute. Safe data URIs (base64) are unaffected
|
||||||
|
* as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix.
|
||||||
|
*/
|
||||||
|
export function sanitizeForCssUrl(url: string): string {
|
||||||
|
return url.replace(/['"()\\]/g, '');
|
||||||
|
}
|
||||||
73
frontend/src/lib/planner/EmptyDayTile.svelte
Normal file
73
frontend/src/lib/planner/EmptyDayTile.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { computeReasoningTags } from './reasoningTags';
|
||||||
|
import { formatDayAbbr } from '$lib/planner/week';
|
||||||
|
import type { Suggestion, SlotMap } from '$lib/planner/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
slotDate,
|
||||||
|
slotId,
|
||||||
|
isPlanner,
|
||||||
|
slotMap,
|
||||||
|
topSuggestion,
|
||||||
|
onaddrecipe
|
||||||
|
}: {
|
||||||
|
slotDate: string;
|
||||||
|
slotId: string;
|
||||||
|
isPlanner: boolean;
|
||||||
|
slotMap: SlotMap;
|
||||||
|
topSuggestion?: Suggestion;
|
||||||
|
onaddrecipe?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let reasoningTags = $derived(
|
||||||
|
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayAbbr = $derived(slotDate ? formatDayAbbr(slotDate, 'short') : '');
|
||||||
|
const dateNum = $derived(slotDate ? slotDate.slice(-2).replace(/^0/, '') : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="empty-day-tile"
|
||||||
|
role="group"
|
||||||
|
class="h-full flex flex-col overflow-hidden"
|
||||||
|
style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: 0 1px 3px rgba(28,28,24,.06);"
|
||||||
|
>
|
||||||
|
<!-- Day header -->
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 7px 8px 0; flex-shrink: 0;">
|
||||||
|
<span style="font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); font-weight: 500;">{dayAbbr}</span>
|
||||||
|
<span style="font-size: 10px; font-weight: 500; color: var(--color-text-muted);">{dateNum}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
{#if isPlanner}
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 6px 6px; gap: 2px; flex-shrink: 0; border-bottom: 1px solid var(--color-border);">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Gericht wählen"
|
||||||
|
onclick={() => onaddrecipe?.()}
|
||||||
|
style="background: none; border: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;"
|
||||||
|
>
|
||||||
|
<span style="font-size: 18px; color: var(--color-border); line-height: 1;">+</span>
|
||||||
|
<span style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans);">Gericht wählen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if topSuggestion}
|
||||||
|
<div style="display: flex; flex-direction: column; padding: 5px 7px 6px; flex: 1; overflow: hidden;">
|
||||||
|
<div style="font-size: 8px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--color-text-muted); padding: 3px 0 5px; border-bottom: 1px solid var(--color-subtle, var(--color-border)); margin-bottom: 2px;">Vorschlag</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px; padding: 5px 0;">
|
||||||
|
<span style="font-family: var(--font-display); font-size: 11px; font-weight: 300; color: var(--color-text); flex: 1; line-height: 1.2;">{topSuggestion.recipe.name}</span>
|
||||||
|
{#each reasoningTags as tag (tag.id)}
|
||||||
|
<span
|
||||||
|
data-testid="reasoning-tag"
|
||||||
|
style="font-size: 8px; font-weight: 500; padding: 1px 4px; border-radius: 2px; white-space: nowrap; flex-shrink: 0; {tag.color === 'green' ? 'background: var(--green-tint); color: var(--green-dark);' : 'background: var(--yellow-tint); color: var(--yellow-text);'}"
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal file
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||||
|
|
||||||
|
const slotDate = '2026-04-14';
|
||||||
|
const slotId = 'slot-1';
|
||||||
|
|
||||||
|
const topSuggestionNewProtein = {
|
||||||
|
recipe: {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Lachs mit Gemüse',
|
||||||
|
cookTimeMin: 20,
|
||||||
|
effort: 'einfach',
|
||||||
|
tags: [{ id: 't1', name: 'Fisch', tagType: 'protein' }]
|
||||||
|
},
|
||||||
|
scoreDelta: 3.2,
|
||||||
|
hasConflict: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotMapEmpty = {};
|
||||||
|
|
||||||
|
describe('EmptyDayTile', () => {
|
||||||
|
describe('base render', () => {
|
||||||
|
it('shows + CTA for planner', () => {
|
||||||
|
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||||
|
expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides + CTA for non-planner', () => {
|
||||||
|
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: false, slotMap: slotMapEmpty } });
|
||||||
|
expect(screen.queryByRole('button', { name: /Gericht wählen/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onaddrecipe when + CTA clicked', async () => {
|
||||||
|
const onaddrecipe = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, onaddrecipe } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Gericht wählen/i }));
|
||||||
|
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has data-testid="empty-day-tile"', () => {
|
||||||
|
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||||
|
expect(screen.getByTestId('empty-day-tile')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reasoning tags', () => {
|
||||||
|
it('shows no tags when no topSuggestion', () => {
|
||||||
|
render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } });
|
||||||
|
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Neues Protein tag when topSuggestion has new protein', () => {
|
||||||
|
render(EmptyDayTile, {
|
||||||
|
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Neues Protein')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Aufwand tag for easy suggestion', () => {
|
||||||
|
render(EmptyDayTile, {
|
||||||
|
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Aufwand: leicht')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows suggestion recipe name when topSuggestion provided', () => {
|
||||||
|
render(EmptyDayTile, {
|
||||||
|
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein }
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Lachs mit Gemüse')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show tags when suggestion has no matching conditions', () => {
|
||||||
|
const heavySuggestion = {
|
||||||
|
recipe: { id: 'r2', name: 'Roulade', cookTimeMin: 120, effort: 'aufwändig', tags: [] },
|
||||||
|
scoreDelta: 1.0,
|
||||||
|
hasConflict: false
|
||||||
|
};
|
||||||
|
render(EmptyDayTile, {
|
||||||
|
props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: heavySuggestion }
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('reasoning-tag')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal file
79
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Recipe, Suggestion } from '$lib/planner/types';
|
||||||
|
import RecipePicker from './RecipePicker.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open,
|
||||||
|
slotDate,
|
||||||
|
planId,
|
||||||
|
suggestions,
|
||||||
|
allRecipes,
|
||||||
|
isLoading,
|
||||||
|
onpick,
|
||||||
|
onclose,
|
||||||
|
excludeRecipeId,
|
||||||
|
replacingRecipe
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
slotDate: string;
|
||||||
|
planId: string;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
allRecipes: Recipe[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onpick: (recipeId: string, recipeName: string) => void;
|
||||||
|
onclose: () => void;
|
||||||
|
excludeRecipeId?: string;
|
||||||
|
replacingRecipe?: { name: string; meta?: string };
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let drawerTransform = $derived(open ? 'translateX(0)' : 'translateX(100%)');
|
||||||
|
let backdropVisibility = $derived(open ? 'visible' : 'hidden');
|
||||||
|
let backdropOpacity = $derived(open ? '1' : '0');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
data-testid="drawer-backdrop"
|
||||||
|
aria-hidden="true"
|
||||||
|
onclick={onclose}
|
||||||
|
style="position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 40; visibility: {backdropVisibility}; opacity: {backdropOpacity}; transition: opacity 0.2s, visibility 0.2s;"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
data-testid="recipe-picker-drawer"
|
||||||
|
aria-hidden={!open}
|
||||||
|
style="position: fixed; right: 0; top: 0; height: 100%; width: min(480px, 90vw); background: var(--color-page); border-left: 1px solid var(--color-border); z-index: 50; transform: {drawerTransform}; transition: transform 0.25s ease; display: flex; flex-direction: column;"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;">
|
||||||
|
<p style="margin: 0; font-family: var(--font-display); font-size: 15px; font-weight: 500; color: var(--color-text);">
|
||||||
|
Rezept wählen
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
onclick={onclose}
|
||||||
|
style="background: none; border: none; cursor: pointer; font-size: 20px; line-height: 1; color: var(--color-text-muted); padding: 4px 8px;"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
|
||||||
|
<div style="overflow-y: auto; flex: 1;">
|
||||||
|
{#if open}
|
||||||
|
<RecipePicker
|
||||||
|
{planId}
|
||||||
|
date={slotDate}
|
||||||
|
dateLabel={slotDate}
|
||||||
|
{suggestions}
|
||||||
|
{allRecipes}
|
||||||
|
{isLoading}
|
||||||
|
{onpick}
|
||||||
|
{excludeRecipeId}
|
||||||
|
{replacingRecipe}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
81
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal file
81
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import RecipePickerDrawer from './RecipePickerDrawer.svelte';
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
open: true,
|
||||||
|
slotDate: '2026-04-14',
|
||||||
|
planId: 'plan-1',
|
||||||
|
suggestions: [],
|
||||||
|
allRecipes: [
|
||||||
|
{ id: 'r1', name: 'Pasta Bolognese', cookTimeMin: 45, effort: 'mittel' },
|
||||||
|
{ id: 'r2', name: 'Lachs', cookTimeMin: 20, effort: 'einfach' }
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
onpick: vi.fn(),
|
||||||
|
onclose: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecipePickerDrawer', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
describe('visibility', () => {
|
||||||
|
it('renders drawer content when open=true', () => {
|
||||||
|
render(RecipePickerDrawer, { props: baseProps });
|
||||||
|
expect(screen.getByTestId('recipe-picker-drawer')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drawer is not visible when open=false', () => {
|
||||||
|
render(RecipePickerDrawer, { props: { ...baseProps, open: false } });
|
||||||
|
const drawer = screen.getByTestId('recipe-picker-drawer');
|
||||||
|
// Drawer exists in DOM but should be off-screen / aria-hidden
|
||||||
|
expect(drawer.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recipe list inside drawer', () => {
|
||||||
|
render(RecipePickerDrawer, { props: baseProps });
|
||||||
|
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backdrop', () => {
|
||||||
|
it('renders backdrop when open', () => {
|
||||||
|
render(RecipePickerDrawer, { props: baseProps });
|
||||||
|
expect(screen.getByTestId('drawer-backdrop')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when backdrop is clicked', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
|
||||||
|
await user.click(screen.getByTestId('drawer-backdrop'));
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('close button', () => {
|
||||||
|
it('renders a close button inside the drawer', () => {
|
||||||
|
render(RecipePickerDrawer, { props: baseProps });
|
||||||
|
expect(screen.getByRole('button', { name: /schließen|close/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when close button clicked', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /schließen|close/i }));
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recipe picking', () => {
|
||||||
|
it('calls onpick when a recipe is selected', async () => {
|
||||||
|
const onpick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(RecipePickerDrawer, { props: { ...baseProps, onpick } });
|
||||||
|
const pickButtons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
await user.click(pickButtons[0]);
|
||||||
|
expect(onpick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal file
106
frontend/src/lib/planner/reasoningTags.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { computeReasoningTags, type ReasoningTag } from './reasoningTags';
|
||||||
|
|
||||||
|
// SlotMap fixture helpers
|
||||||
|
const emptySlotMap = {};
|
||||||
|
|
||||||
|
const slotMapWithChicken = {
|
||||||
|
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Chicken curry', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotMapWithBeefAndChicken = {
|
||||||
|
'2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Steak', tags: [{ id: 't2', name: 'Rind', tagType: 'protein' }] } },
|
||||||
|
'2026-04-08': { id: 's2', slotDate: '2026-04-08', recipe: { id: 'r2', name: 'Chicken', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fishRecipe = { id: 'r3', name: 'Lachs', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
|
||||||
|
const chickenRecipe = { id: 'r1', name: 'Chicken curry', cookTimeMin: 45, effort: 'mittel', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] };
|
||||||
|
const easyRecipe = { id: 'r4', name: 'Salat', cookTimeMin: 15, effort: 'einfach', tags: [] };
|
||||||
|
const heavyRecipe = { id: 'r5', name: 'Roulade', cookTimeMin: 90, effort: 'aufwändig', tags: [] };
|
||||||
|
|
||||||
|
describe('computeReasoningTags', () => {
|
||||||
|
describe('Neues Protein tag', () => {
|
||||||
|
it('returns Neues Protein tag when recipe protein is not in week', () => {
|
||||||
|
const tags = computeReasoningTags(slotMapWithChicken, fishRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).toContain('neues-protein');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return Neues Protein when recipe protein is already in week', () => {
|
||||||
|
const tags = computeReasoningTags(slotMapWithChicken, chickenRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).not.toContain('neues-protein');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Neues Protein when recipe has protein tag and slotMap is empty', () => {
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).toContain('neues-protein');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return Neues Protein when recipe has no protein tag', () => {
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).not.toContain('neues-protein');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aufwand: leicht tag', () => {
|
||||||
|
it('returns Aufwand tag when cookTimeMin is less than 30', () => {
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, easyRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Aufwand tag when effort is einfach regardless of cookTime', () => {
|
||||||
|
const recipe = { ...fishRecipe, cookTimeMin: 45 };
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return Aufwand tag for heavy recipe', () => {
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, heavyRecipe);
|
||||||
|
const tagTypes = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagTypes).not.toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Aufwand tag exactly at cookTimeMin 29', () => {
|
||||||
|
const recipe = { ...heavyRecipe, cookTimeMin: 29, effort: undefined };
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||||
|
expect(tags.map((t: ReasoningTag) => t.id)).toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return Aufwand tag at cookTimeMin 30 with non-easy effort', () => {
|
||||||
|
const recipe = { ...heavyRecipe, cookTimeMin: 30, effort: 'mittel' };
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, recipe);
|
||||||
|
expect(tags.map((t: ReasoningTag) => t.id)).not.toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReasoningTag shape', () => {
|
||||||
|
it('each tag has id, label, and color', () => {
|
||||||
|
const tags = computeReasoningTags(emptySlotMap, fishRecipe);
|
||||||
|
for (const tag of tags) {
|
||||||
|
expect(tag).toHaveProperty('id');
|
||||||
|
expect(tag).toHaveProperty('label');
|
||||||
|
expect(tag).toHaveProperty('color');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple tags', () => {
|
||||||
|
it('returns multiple tags when multiple conditions are true', () => {
|
||||||
|
const recipe = { id: 'r6', name: 'Easy fish', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] };
|
||||||
|
const tags = computeReasoningTags(slotMapWithBeefAndChicken, recipe);
|
||||||
|
const tagIds = tags.map((t: ReasoningTag) => t.id);
|
||||||
|
expect(tagIds).toContain('neues-protein');
|
||||||
|
expect(tagIds).toContain('aufwand-leicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no conditions are true', () => {
|
||||||
|
const tags = computeReasoningTags(slotMapWithChicken, { ...chickenRecipe, cookTimeMin: 60, effort: 'aufwändig' });
|
||||||
|
expect(tags).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
frontend/src/lib/planner/reasoningTags.ts
Normal file
38
frontend/src/lib/planner/reasoningTags.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Recipe, Slot, SlotMap } from '$lib/planner/types';
|
||||||
|
|
||||||
|
export interface ReasoningTag {
|
||||||
|
id: 'neues-protein' | 'aufwand-leicht';
|
||||||
|
label: string;
|
||||||
|
color: 'green' | 'yellow';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeReasoningTags(slotMap: SlotMap, recipe: Recipe): ReasoningTag[] {
|
||||||
|
const tags: ReasoningTag[] = [];
|
||||||
|
|
||||||
|
// Neues Protein: recipe has a protein tag not already present in any filled slot
|
||||||
|
const recipeProtein = recipe.tags?.find((t) => t.tagType === 'protein')?.name;
|
||||||
|
if (recipeProtein) {
|
||||||
|
const weekProteins = new Set<string>();
|
||||||
|
for (const slot of Object.values(slotMap)) {
|
||||||
|
if (slot.recipe) {
|
||||||
|
for (const tag of slot.recipe.tags ?? []) {
|
||||||
|
if (tag.tagType === 'protein' && tag.name) {
|
||||||
|
weekProteins.add(tag.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!weekProteins.has(recipeProtein)) {
|
||||||
|
tags.push({ id: 'neues-protein', label: 'Neues Protein', color: 'green' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufwand: leicht — cookTimeMin < 30 OR effort is 'einfach'/'leicht'
|
||||||
|
const isEasyEffort = recipe.effort === 'einfach' || recipe.effort === 'leicht';
|
||||||
|
const isQuick = recipe.cookTimeMin != null && recipe.cookTimeMin < 30;
|
||||||
|
if (isEasyEffort || isQuick) {
|
||||||
|
tags.push({ id: 'aufwand-leicht', label: 'Aufwand: leicht', color: 'yellow' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
|
export interface TagItem {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
tagType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recipe {
|
export interface Recipe {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
cookTimeMin?: number;
|
cookTimeMin?: number;
|
||||||
|
heroImageUrl?: string | null;
|
||||||
|
tags?: TagItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Slot {
|
||||||
|
id?: string | null;
|
||||||
|
slotDate?: string;
|
||||||
|
recipe?: Recipe | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlotMap = Record<string, Slot>;
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
scoreDelta: number;
|
scoreDelta: number;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if isOnboarding}
|
{#if isOnboarding}
|
||||||
<div class="flex min-h-screen bg-[var(--color-page)]">
|
<div class="fixed inset-0 z-50 flex bg-[var(--color-page)]">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
|
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
|
||||||
<ProgressSidebar currentStep={2} />
|
<ProgressSidebar currentStep={2} />
|
||||||
@@ -44,8 +44,10 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
|
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||||
|
<a href="/settings" class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] mb-4 inline-block">← Einstellungen</a>
|
||||||
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
|
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
|
||||||
<StaplesManager categories={data.categories} context="settings" />
|
<StaplesManager categories={data.categories} context="settings" />
|
||||||
|
<p class="mt-4 font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
|
|||||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
|
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
|
||||||
|
expect(backLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back-link points to /settings', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
|
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
|
||||||
|
expect(backLink).toHaveAttribute('href', '/settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hint text about autosave', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
|
expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hint text about next shopping list', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
|
expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render back-link in onboarding context', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||||
|
expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render hint text in onboarding context', () => {
|
||||||
|
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||||
|
expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
18
frontend/src/routes/(app)/members/+page.server.ts
Normal file
18
frontend/src/routes/(app)/members/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
|
||||||
|
const [membersRes, inviteRes] = await Promise.all([
|
||||||
|
api.GET('/v1/households/mine/members'),
|
||||||
|
api.GET('/v1/households/mine/invites')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: membersRes.data ?? [],
|
||||||
|
currentUserId: locals.benutzer!.id,
|
||||||
|
activeInvite: inviteRes.data?.data ?? null,
|
||||||
|
householdName: locals.haushalt?.name ?? ''
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1 +1,114 @@
|
|||||||
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import MemberGrid from './MemberGrid.svelte';
|
||||||
|
import InvitePanel from './InvitePanel.svelte';
|
||||||
|
import RemoveDialog from './RemoveDialog.svelte';
|
||||||
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
|
||||||
|
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
|
||||||
|
let showInvitePanel = $state(false);
|
||||||
|
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastVisible = $state(false);
|
||||||
|
|
||||||
|
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
|
||||||
|
const isPlanner = $derived(currentUserRole === 'planner');
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
toastMessage = message;
|
||||||
|
toastVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoleChange(
|
||||||
|
member: { userId: string; displayName: string; role: string; joinedAt: string },
|
||||||
|
newRole: string
|
||||||
|
) {
|
||||||
|
const original = members.slice();
|
||||||
|
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
|
||||||
|
|
||||||
|
const res = await fetch('/members/' + member.userId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role: newRole })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
members = original;
|
||||||
|
showToast('Fehler beim Ändern der Rolle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
|
||||||
|
removeTarget = member;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmRemove() {
|
||||||
|
if (!removeTarget) return;
|
||||||
|
const target = removeTarget;
|
||||||
|
const original = members.slice();
|
||||||
|
removeTarget = null;
|
||||||
|
members = members.filter((m) => m.userId !== target.userId);
|
||||||
|
|
||||||
|
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
members = original;
|
||||||
|
showToast('Fehler beim Entfernen des Mitglieds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInviteClick() {
|
||||||
|
if (!activeInvite) {
|
||||||
|
const res = await fetch('/members/invites', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
activeInvite = await res.json();
|
||||||
|
} else {
|
||||||
|
showToast('Einladung konnte nicht erstellt werden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showInvitePanel = !showInvitePanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
const res = await fetch('/members/invites', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
activeInvite = await res.json();
|
||||||
|
} else {
|
||||||
|
showToast('Link konnte nicht erneuert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||||
|
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
|
||||||
|
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
|
||||||
|
|
||||||
|
<MemberGrid
|
||||||
|
{members}
|
||||||
|
currentUserId={data.currentUserId}
|
||||||
|
{isPlanner}
|
||||||
|
showInviteCard={isPlanner}
|
||||||
|
onremove={handleRemove}
|
||||||
|
onrolechange={handleRoleChange}
|
||||||
|
oninviteclick={handleInviteClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showInvitePanel && isPlanner && activeInvite}
|
||||||
|
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<RemoveDialog
|
||||||
|
show={removeTarget !== null}
|
||||||
|
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
|
||||||
|
onconfirm={handleConfirmRemove}
|
||||||
|
oncancel={() => (removeTarget = null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
|
||||||
|
</div>
|
||||||
|
|||||||
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
onclick: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="invite-card"
|
||||||
|
{onclick}
|
||||||
|
class="invite-card"
|
||||||
|
>
|
||||||
|
<div class="invite-plus">+</div>
|
||||||
|
<div class="invite-label">Mitglied einladen</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invite-card {
|
||||||
|
background: white;
|
||||||
|
border: 1.5px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
background: var(--green-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-plus {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover .invite-plus {
|
||||||
|
background: var(--green-light);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover .invite-label {
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.invite-card {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-plus {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import InviteCard from './InviteCard.svelte';
|
||||||
|
|
||||||
|
describe('InviteCard', () => {
|
||||||
|
it('renders the invite tile', () => {
|
||||||
|
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a descriptive label', () => {
|
||||||
|
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||||
|
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclick when tile is clicked', async () => {
|
||||||
|
const onclick = vi.fn();
|
||||||
|
render(InviteCard, { props: { onclick } });
|
||||||
|
await userEvent.click(screen.getByTestId('invite-card'));
|
||||||
|
expect(onclick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
invite,
|
||||||
|
onregenerate
|
||||||
|
}: {
|
||||||
|
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
|
||||||
|
onregenerate: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(invite.shareUrl);
|
||||||
|
}
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiry(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpiringSoon = $derived(
|
||||||
|
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="invite-panel">
|
||||||
|
<div class="invite-panel-title">Einladelink teilen</div>
|
||||||
|
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
|
||||||
|
|
||||||
|
<div class="invite-link-row">
|
||||||
|
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
|
||||||
|
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
|
||||||
|
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invite-expiry">
|
||||||
|
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
|
||||||
|
Neuen Link generieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invite-panel {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-panel-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-panel-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-box {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry span.yellow {
|
||||||
|
background: var(--yellow-tint);
|
||||||
|
color: var(--yellow-text);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regen {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regen:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import InvitePanel from './InvitePanel.svelte';
|
||||||
|
|
||||||
|
const invite = {
|
||||||
|
inviteCode: 'ABC123XY',
|
||||||
|
shareUrl: 'https://example.com/join/ABC123XY',
|
||||||
|
expiresAt: '2026-12-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InvitePanel', () => {
|
||||||
|
it('shows the invite URL', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a copy button', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a regenerate button', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onregenerate when regenerate button is clicked', async () => {
|
||||||
|
const onregenerate = vi.fn();
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate } });
|
||||||
|
await userEvent.click(screen.getByTestId('regenerate-btn'));
|
||||||
|
expect(onregenerate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the panel title', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
member,
|
||||||
|
isCurrentUser,
|
||||||
|
isPlanner,
|
||||||
|
onremove,
|
||||||
|
onrolechange
|
||||||
|
}: {
|
||||||
|
member: Member;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isPlanner: boolean;
|
||||||
|
onremove: (member: Member) => void;
|
||||||
|
onrolechange: (member: Member, newRole: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let editingRole = $state(false);
|
||||||
|
|
||||||
|
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
||||||
|
|
||||||
|
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
|
||||||
|
|
||||||
|
const joinDateFormatted = $derived(
|
||||||
|
new Date(member.joinedAt).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let cardEl: HTMLElement | undefined = $state(undefined);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onClickAway(e: MouseEvent) {
|
||||||
|
if (cardEl && !cardEl.contains(e.target as Node)) {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', onClickAway);
|
||||||
|
return () => document.removeEventListener('click', onClickAway);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
bind:this={cardEl}
|
||||||
|
data-testid="member-card"
|
||||||
|
class="member-card"
|
||||||
|
class:own={isCurrentUser}
|
||||||
|
class:editing={editingRole}
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="avatar" style="background: {avatarBg};">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="member-name">{member.displayName}</div>
|
||||||
|
|
||||||
|
<!-- Role badge or inline role control -->
|
||||||
|
{#if editingRole}
|
||||||
|
<div role="group" class="role-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="role-control-btn"
|
||||||
|
class:active={member.role === 'planner'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'planner') {
|
||||||
|
onrolechange(member, 'planner');
|
||||||
|
editingRole = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Planer</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="role-control-btn"
|
||||||
|
class:active={member.role === 'member'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'member') {
|
||||||
|
onrolechange(member, 'member');
|
||||||
|
editingRole = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Mitglied</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
|
||||||
|
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Join date -->
|
||||||
|
<div class="join-date">seit {joinDateFormatted}</div>
|
||||||
|
|
||||||
|
<!-- Du badge (own card) or Abbrechen (when editing role) -->
|
||||||
|
{#if isCurrentUser}
|
||||||
|
<div class="self-badge-wrap">
|
||||||
|
<span class="self-badge">Du</span>
|
||||||
|
</div>
|
||||||
|
{:else if editingRole}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-btn"
|
||||||
|
onclick={() => { editingRole = false; }}
|
||||||
|
>Abbrechen</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kebab button -->
|
||||||
|
{#if isPlanner && !isCurrentUser && !editingRole}
|
||||||
|
<button
|
||||||
|
data-testid="kebab-btn"
|
||||||
|
type="button"
|
||||||
|
class="kebab-btn"
|
||||||
|
onclick={() => { menuOpen = true; }}
|
||||||
|
aria-label="Optionen"
|
||||||
|
>⋯</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => { menuOpen = false; editingRole = true; }}
|
||||||
|
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item danger"
|
||||||
|
onclick={() => { menuOpen = false; onremove(member); }}
|
||||||
|
><span class="dropdown-icon">✕</span>Entfernen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.own {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.editing {
|
||||||
|
border-color: #B5D4F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.planer {
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.mitglied {
|
||||||
|
background: var(--blue-tint);
|
||||||
|
color: var(--blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-btn:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card:hover .kebab-btn,
|
||||||
|
.member-card:focus-within .kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 10;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger:hover {
|
||||||
|
background: var(--error-tint, #FDECEA);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn.active {
|
||||||
|
background: var(--green-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-card {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
|
||||||
|
const plannerMember = {
|
||||||
|
userId: 'u1',
|
||||||
|
displayName: 'Sarah',
|
||||||
|
role: 'planner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const regularMember = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MemberCard', () => {
|
||||||
|
it('shows the member display name', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when viewer is not a planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kebab button for other members when viewer is planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens dropdown when kebab is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove,
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Entfernen'));
|
||||||
|
expect(onremove).toHaveBeenCalledWith(regularMember);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByRole('group')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown on Escape key', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByText('Entfernen')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows formatted join date', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Abbrechen button when editing role', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
import InviteCard from './InviteCard.svelte';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
members,
|
||||||
|
currentUserId,
|
||||||
|
isPlanner,
|
||||||
|
showInviteCard,
|
||||||
|
onremove,
|
||||||
|
onrolechange,
|
||||||
|
oninviteclick
|
||||||
|
}: {
|
||||||
|
members: Member[];
|
||||||
|
currentUserId: string;
|
||||||
|
isPlanner: boolean;
|
||||||
|
showInviteCard: boolean;
|
||||||
|
onremove: (member: any) => void;
|
||||||
|
onrolechange: (member: any, newRole: string) => void;
|
||||||
|
oninviteclick: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sortedMembers = $derived(
|
||||||
|
[...members].sort((a, b) => {
|
||||||
|
if (a.userId === currentUserId) return -1;
|
||||||
|
if (b.userId === currentUserId) return 1;
|
||||||
|
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="member-grid">
|
||||||
|
{#each sortedMembers as m (m.userId)}
|
||||||
|
<MemberCard
|
||||||
|
member={m}
|
||||||
|
isCurrentUser={m.userId === currentUserId}
|
||||||
|
{isPlanner}
|
||||||
|
{onremove}
|
||||||
|
onrolechange={(m, role) => onrolechange(m, role)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if isPlanner && showInviteCard}
|
||||||
|
<InviteCard onclick={oninviteclick} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import MemberGrid from './MemberGrid.svelte';
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
|
||||||
|
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('MemberGrid', () => {
|
||||||
|
it('renders all member cards', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides invite card when isPlanner is false', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u2',
|
||||||
|
isPlanner: false,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('invite-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge on the current user card', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
show,
|
||||||
|
member,
|
||||||
|
onconfirm,
|
||||||
|
oncancel
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
member: { userId: string; displayName: string; role: string; joinedAt: string };
|
||||||
|
onconfirm: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
{#if isMobile()}
|
||||||
|
<BottomSheet open={show} onclose={oncancel}>
|
||||||
|
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
|
||||||
|
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
|
||||||
|
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="remove-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="remove-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
|
||||||
|
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import RemoveDialog from './RemoveDialog.svelte';
|
||||||
|
|
||||||
|
const member = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RemoveDialog', () => {
|
||||||
|
it('is not rendered when show is false', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('remove-dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the member displayName in dialog', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Tom/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onconfirm when confirm button is clicked', async () => {
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm, oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
|
||||||
|
expect(onconfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls oncancel when cancel button is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call oncancel when backdrop is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
const backdrop = screen.getByTestId('dialog-backdrop');
|
||||||
|
await userEvent.click(backdrop);
|
||||||
|
expect(oncancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ fetch, params }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } }
|
||||||
|
});
|
||||||
|
return new Response(null, { status: response?.status ?? 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
return json(data, { status: response?.status ?? 200 });
|
||||||
|
};
|
||||||
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
const mockPatch = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const USER_UUID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
describe('members server routes', () => {
|
||||||
|
let DELETE: any;
|
||||||
|
let PATCH: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
DELETE = mod.DELETE;
|
||||||
|
PATCH = mod.PATCH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE proxies to backend and returns 204', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ response: { status: 204 } });
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: vi.fn() }
|
||||||
|
} as any;
|
||||||
|
const res = await DELETE(event);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH proxies to backend and returns member response', async () => {
|
||||||
|
mockPatch.mockResolvedValue({
|
||||||
|
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
|
||||||
|
response: { status: 200 }
|
||||||
|
});
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: async () => ({ role: 'planner' }) }
|
||||||
|
} as any;
|
||||||
|
const res = await PATCH(event);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.POST('/v1/households/mine/invites');
|
||||||
|
return json(data?.data, { status: response?.status ?? 201 });
|
||||||
|
};
|
||||||
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal file
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ POST: mockPost })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('invites server route', () => {
|
||||||
|
let POST: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockPost.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
POST = mod.POST;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST returns unwrapped InviteResponse', async () => {
|
||||||
|
const invite = { inviteCode: 'ABC123', shareUrl: 'https://x.com/join/ABC123', expiresAt: '2026-12-01T00:00:00Z' };
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { status: 'success', data: invite },
|
||||||
|
response: { status: 200 }
|
||||||
|
});
|
||||||
|
const event = { fetch: vi.fn() } as any;
|
||||||
|
const res = await POST(event);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.inviteCode).toBe('ABC123');
|
||||||
|
expect(body.shareUrl).toBe('https://x.com/join/ABC123');
|
||||||
|
expect(body.expiresAt).toBe('2026-12-01T00:00:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: vi.fn(() => ({
|
||||||
|
GET: vi.fn()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
describe('members page.server load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns members and currentUserId', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine/invites') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
inviteCode: 'ABC123',
|
||||||
|
shareUrl: 'https://x.com/join/ABC123',
|
||||||
|
expiresAt: '2024-12-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.members).toHaveLength(2);
|
||||||
|
expect(result.currentUserId).toBe('u1');
|
||||||
|
expect(result.activeInvite).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null activeInvite when no active invite exists', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') return { data: [] };
|
||||||
|
if (path === '/v1/households/mine/invites') return { data: null };
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.activeInvite).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
import { getWeekStart } from '$lib/planner/week';
|
import { getWeekStart } from '$lib/planner/week';
|
||||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||||
|
import type { TagItem } from '$lib/planner/types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
const weekParam = url.searchParams.get('week');
|
const weekParam = url.searchParams.get('week');
|
||||||
@@ -21,7 +22,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
|
|||||||
name: r.name!,
|
name: r.name!,
|
||||||
cookTimeMin: r.cookTimeMin,
|
cookTimeMin: r.cookTimeMin,
|
||||||
effort: r.effort,
|
effort: r.effort,
|
||||||
heroImageUrl: r.heroImageUrl
|
heroImageUrl: r.heroImageUrl,
|
||||||
|
tags: (r.tags ?? []).map((t: TagItem) => ({ id: t.id, name: t.name, tagType: t.tagType }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||||
|
import DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
|
||||||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||||
|
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
|
||||||
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
||||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||||
@@ -23,7 +25,20 @@
|
|||||||
|
|
||||||
let days = $derived(weekDays(weekStart));
|
let days = $derived(weekDays(weekStart));
|
||||||
let slots = $derived(weekPlan?.slots ?? []);
|
let slots = $derived(weekPlan?.slots ?? []);
|
||||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
// SlotRecipe from the API has no tags — merge from data.recipes by id
|
||||||
|
const recipeById = $derived(
|
||||||
|
Object.fromEntries((data.recipes ?? []).map((r: any) => [r.id, r]))
|
||||||
|
);
|
||||||
|
let slotMap = $derived(
|
||||||
|
Object.fromEntries(
|
||||||
|
slots.map((s: any) => [
|
||||||
|
s.slotDate,
|
||||||
|
s.recipe
|
||||||
|
? { ...s, recipe: { ...s.recipe, tags: recipeById[s.recipe.id]?.tags ?? [] } }
|
||||||
|
: s
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Default selected day: today if in this week, else first day
|
// Default selected day: today if in this week, else first day
|
||||||
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
||||||
@@ -49,35 +64,27 @@
|
|||||||
|
|
||||||
let weekRange = $derived(formatWeekRange(weekStart));
|
let weekRange = $derived(formatWeekRange(weekStart));
|
||||||
|
|
||||||
// Desktop right panel state machine
|
|
||||||
type PanelState =
|
|
||||||
| { kind: 'idle' }
|
|
||||||
| { kind: 'day-detail'; date: string }
|
|
||||||
| { kind: 'recipe-picker'; date: string };
|
|
||||||
|
|
||||||
let panelState = $state<PanelState>({ kind: 'idle' });
|
|
||||||
|
|
||||||
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
|
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
let actionSheetOpen = $state(false);
|
let actionSheetOpen = $state(false);
|
||||||
let swapSheetOpen = $state(false);
|
let swapSheetOpen = $state(false);
|
||||||
let swapLoading = $state(false);
|
let swapLoading = $state(false);
|
||||||
|
|
||||||
|
// Desktop flip tile + drawer state (page-owned per Kai's architecture decision)
|
||||||
|
let activeSlotId = $state<string | null>(null);
|
||||||
|
let drawerOpen = $state(false);
|
||||||
|
let drawerSlotId = $state<string | null>(null);
|
||||||
|
|
||||||
const activePickerDate = $derived(
|
const activePickerDate = $derived(
|
||||||
pickerOpen ? selectedDay
|
pickerOpen ? selectedDay
|
||||||
: swapSheetOpen ? selectedDay
|
: swapSheetOpen ? selectedDay
|
||||||
: panelState.kind === 'recipe-picker' ? panelState.date
|
: drawerOpen && drawerSlotId ? drawerSlotId
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
let suggestions: Suggestion[] = $state([]);
|
let suggestions: Suggestion[] = $state([]);
|
||||||
let isLoadingSuggestions = $state(false);
|
let isLoadingSuggestions = $state(false);
|
||||||
|
|
||||||
// Recipes already in any slot this week — used for ⚠ overlap warnings
|
|
||||||
let currentWeekRecipeIds = $derived(
|
|
||||||
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hidden form field bindings
|
// Hidden form field bindings
|
||||||
let addPlanId = $state('');
|
let addPlanId = $state('');
|
||||||
let addSlotDate = $state('');
|
let addSlotDate = $state('');
|
||||||
@@ -115,9 +122,23 @@
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single Escape key handler — priority: drawer > flip (Kai architecture decision)
|
||||||
|
$effect(() => {
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (drawerOpen) {
|
||||||
|
drawerOpen = false;
|
||||||
|
drawerSlotId = null;
|
||||||
|
} else if (activeSlotId) {
|
||||||
|
activeSlotId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
function handleSelectDay(day: string) {
|
function handleSelectDay(day: string) {
|
||||||
selectedDay = day;
|
selectedDay = day;
|
||||||
panelState = { kind: 'day-detail', date: day };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||||
@@ -130,14 +151,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRecipePick(recipeId: string, recipeName: string) {
|
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||||||
// Capture date before modifying panel state
|
// Drawer date takes priority (desktop), then mobile picker date
|
||||||
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
|
||||||
|
|
||||||
// Close pickers
|
// Close all pickers
|
||||||
pickerOpen = false;
|
pickerOpen = false;
|
||||||
if (panelState.kind === 'recipe-picker') {
|
drawerOpen = false;
|
||||||
panelState = { kind: 'idle' };
|
drawerSlotId = null;
|
||||||
}
|
|
||||||
|
|
||||||
const existingSlot = slotMap[date];
|
const existingSlot = slotMap[date];
|
||||||
|
|
||||||
@@ -196,17 +216,39 @@
|
|||||||
swapLoading = false;
|
swapLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePanelToIdle() {
|
// Desktop tile handlers
|
||||||
panelState = { kind: 'idle' };
|
function handleTileFlip(slotId: string) {
|
||||||
|
activeSlotId = slotId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePanelToDayDetail() {
|
function handleTileClose() {
|
||||||
if (panelState.kind === 'recipe-picker') {
|
activeSlotId = null;
|
||||||
panelState = { kind: 'day-detail', date: panelState.date };
|
|
||||||
} else {
|
|
||||||
panelState = { kind: 'idle' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTileSwap(slotDate: string) {
|
||||||
|
activeSlotId = null;
|
||||||
|
drawerSlotId = slotDate;
|
||||||
|
drawerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTileRemove(slot: any) {
|
||||||
|
activeSlotId = null;
|
||||||
|
await handleRemoveMeal(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmptyTileAdd(slotDate: string) {
|
||||||
|
drawerSlotId = slotDate;
|
||||||
|
drawerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawerSlot = $derived(drawerSlotId ? (slotMap[drawerSlotId] ?? null) : null);
|
||||||
|
const drawerReplacingMeta = $derived(
|
||||||
|
drawerSlot?.recipe
|
||||||
|
? [drawerSlot.recipe.cookTimeMin ? `${drawerSlot.recipe.cookTimeMin} Min` : null, drawerSlot.recipe.effort ?? null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: null
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mobile & Tablet: vertical stack -->
|
<!-- Mobile & Tablet: vertical stack -->
|
||||||
@@ -369,7 +411,7 @@
|
|||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: 3-panel layout -->
|
<!-- Desktop: 2-panel layout (sidebar + full-width flip-tile grid) -->
|
||||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||||
<!-- Topbar -->
|
<!-- Topbar -->
|
||||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||||
@@ -400,21 +442,11 @@
|
|||||||
Heute
|
Heute
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if isPlanner}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
|
|
||||||
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
|
||||||
>
|
|
||||||
+ Gericht hinzufügen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- Left sidebar -->
|
<!-- Left sidebar (unchanged) -->
|
||||||
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||||
<!-- Variety widget at bottom -->
|
|
||||||
{#if varietyScore}
|
{#if varietyScore}
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<VarietyScoreCard
|
<VarietyScoreCard
|
||||||
@@ -426,8 +458,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main calendar (only scrollable panel) -->
|
<!-- Main grid — full width, full height -->
|
||||||
<main class="flex-1 overflow-y-auto p-5">
|
<main class="flex-1 overflow-hidden p-5">
|
||||||
{#if !weekPlan}
|
{#if !weekPlan}
|
||||||
<div class="flex h-full flex-col items-center justify-center">
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||||
@@ -441,198 +473,47 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-7 gap-[8px]">
|
<div class="grid h-full grid-cols-7 gap-2">
|
||||||
{#each days as day (day)}
|
{#each days as day (day)}
|
||||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||||
{@const isTodayDay = day === today}
|
{@const isTodayDay = day === today}
|
||||||
{@const isSelectedDay = day === selectedDay}
|
{@const isThisTileActive = drawerSlotId === day}
|
||||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
|
||||||
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="h-full">
|
||||||
<!-- Column header: day name + date badge -->
|
<DesktopDayTile
|
||||||
<div class="mb-2 flex flex-col items-center gap-1">
|
{slot}
|
||||||
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
isToday={isTodayDay}
|
||||||
{dayAbbr}
|
{activeSlotId}
|
||||||
</p>
|
{isPlanner}
|
||||||
<div
|
{slotMap}
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
{suggestions}
|
||||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
|
||||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
onflip={handleTileFlip}
|
||||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
onclose={handleTileClose}
|
||||||
>
|
onswap={() => handleTileSwap(day)}
|
||||||
{dateNum}
|
onremove={() => handleTileRemove(slot)}
|
||||||
</div>
|
onaddrecipe={() => handleEmptyTileAdd(day)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<!-- Meal tile -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
handleSelectDay(day);
|
|
||||||
if (!slot.recipe && isPlanner) {
|
|
||||||
panelState = { kind: 'recipe-picker', date: day };
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
|
||||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
|
||||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
|
||||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
|
||||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
|
||||||
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
|
|
||||||
>
|
|
||||||
{#if slot.recipe}
|
|
||||||
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
|
|
||||||
{slot.recipe.name}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
|
|
||||||
<span class="text-[18px]" aria-hidden="true">+</span>
|
|
||||||
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Right detail panel -->
|
|
||||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
|
|
||||||
{#if panelState.kind === 'idle'}
|
|
||||||
<div class="flex flex-1 flex-col items-center justify-center">
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if panelState.kind === 'day-detail'}
|
|
||||||
{@const detailDate = panelState.date}
|
|
||||||
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
|
||||||
|
|
||||||
<!-- Panel header with close button -->
|
|
||||||
<div class="mb-3 flex items-start justify-between">
|
|
||||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
{formatDayLabel(detailDate)} · Abendessen
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={closePanelToIdle}
|
|
||||||
aria-label="Panel schließen"
|
|
||||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if detailSlot.recipe}
|
|
||||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
|
||||||
{detailSlot.recipe.name}
|
|
||||||
</h2>
|
|
||||||
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
|
|
||||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
|
||||||
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
<a
|
|
||||||
href="/recipes/{detailSlot.recipe.id}"
|
|
||||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
|
||||||
>
|
|
||||||
Rezept ansehen
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/recipes/{detailSlot.recipe.id}/cook"
|
|
||||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
|
||||||
>
|
|
||||||
Koch-Modus
|
|
||||||
</a>
|
|
||||||
{#if isPlanner}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
|
||||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
|
||||||
>
|
|
||||||
Gericht tauschen
|
|
||||||
</button>
|
|
||||||
{#if detailSlot.id}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
|
|
||||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
|
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
|
||||||
{#if isPlanner}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
|
||||||
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
|
||||||
>
|
|
||||||
+ Gericht wählen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{:else if panelState.kind === 'recipe-picker'}
|
|
||||||
{@const pickerDate = panelState.date}
|
|
||||||
{@const pickerSlot = slotMap[pickerDate] ?? null}
|
|
||||||
{@const isSwapContext = !!pickerSlot?.recipe}
|
|
||||||
|
|
||||||
<!-- Panel header with back/close button -->
|
|
||||||
<div class="mb-3 flex items-center justify-between">
|
|
||||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={closePanelToDayDetail}
|
|
||||||
aria-label="Zurück"
|
|
||||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isSwapContext}
|
|
||||||
{@const replacingMeta = [
|
|
||||||
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
|
|
||||||
pickerSlot.recipe.effort ?? null
|
|
||||||
].filter(Boolean).join(' · ')}
|
|
||||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
|
||||||
<RecipePicker
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
date={pickerDate}
|
|
||||||
dateLabel={formatDayLabel(pickerDate)}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allRecipes={data.recipes}
|
|
||||||
isLoading={isLoadingSuggestions}
|
|
||||||
excludeRecipeId={pickerSlot.recipe.id}
|
|
||||||
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
|
|
||||||
onpick={handleRecipePick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
|
||||||
<RecipePicker
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
date={pickerDate}
|
|
||||||
dateLabel={formatDayLabel(pickerDate)}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allRecipes={data.recipes}
|
|
||||||
isLoading={isLoadingSuggestions}
|
|
||||||
onpick={handleRecipePick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe picker drawer (slide-in from right) -->
|
||||||
|
<RecipePickerDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
slotDate={drawerSlotId ?? ''}
|
||||||
|
planId={weekPlan?.id ?? ''}
|
||||||
|
{suggestions}
|
||||||
|
allRecipes={data.recipes}
|
||||||
|
isLoading={isLoadingSuggestions}
|
||||||
|
onpick={handleRecipePick}
|
||||||
|
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
|
||||||
|
excludeRecipeId={drawerSlot?.recipe?.id}
|
||||||
|
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden forms for slot mutations -->
|
<!-- Hidden forms for slot mutations -->
|
||||||
|
|||||||
21
frontend/src/routes/(app)/settings/+page.server.ts
Normal file
21
frontend/src/routes/(app)/settings/+page.server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
|
||||||
|
const [ingredientsRes, householdRes] = await Promise.all([
|
||||||
|
api.GET('/v1/ingredients'),
|
||||||
|
api.GET('/v1/households/mine')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0;
|
||||||
|
const memberCount = householdRes.data?.data?.members?.length ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stapleCount,
|
||||||
|
memberCount,
|
||||||
|
// hooks.server.ts guarantees benutzer is set for all (app) routes
|
||||||
|
userName: locals.benutzer!.name
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1 +1,72 @@
|
|||||||
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>
|
<script lang="ts">
|
||||||
|
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
stapleCount: number;
|
||||||
|
memberCount: number;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||||
|
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Einstellungen</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]">
|
||||||
|
<!-- Card 1: Vorräte (inline, conditional content) -->
|
||||||
|
<a
|
||||||
|
href="/household/staples"
|
||||||
|
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[16px] font-medium">Vorräte</span>
|
||||||
|
|
||||||
|
{#if data.stapleCount > 0}
|
||||||
|
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
|
||||||
|
<span
|
||||||
|
data-testid="staple-count"
|
||||||
|
class="font-[var(--font-display)] text-[28px] font-light leading-[1] tracking-[-0.02em] text-[var(--green-dark)]"
|
||||||
|
>{data.stapleCount}</span>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
|
||||||
|
Noch keine Vorräte eingerichtet
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
|
||||||
|
{#if data.stapleCount > 0}
|
||||||
|
Vorräte bearbeiten →
|
||||||
|
{:else}
|
||||||
|
Jetzt einrichten →
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Card 2: Haushalt (inline, needs data-testid on member count) -->
|
||||||
|
<a
|
||||||
|
href="/members"
|
||||||
|
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[16px] font-medium">Haushalt</span>
|
||||||
|
|
||||||
|
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
|
||||||
|
<span data-testid="member-count">{data.memberCount}</span> Mitglieder
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
|
||||||
|
Mitglieder anzeigen →
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Card 3: Profil (uses SettingsCard) -->
|
||||||
|
<SettingsCard
|
||||||
|
title="Profil"
|
||||||
|
href="/profile"
|
||||||
|
cta="Profil bearbeiten →"
|
||||||
|
meta={data.userName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
105
frontend/src/routes/(app)/settings/page.server.test.ts
Normal file
105
frontend/src/routes/(app)/settings/page.server.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIngredients = [
|
||||||
|
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
|
||||||
|
{ id: 'ing-2', name: 'Butter', isStaple: false },
|
||||||
|
{ id: 'ing-3', name: 'Salz', isStaple: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockHousehold = {
|
||||||
|
status: 'OK',
|
||||||
|
data: {
|
||||||
|
id: 'hh-1',
|
||||||
|
name: 'Familie Raddatz',
|
||||||
|
members: [
|
||||||
|
{ userId: 'u-1', name: 'Marcel' },
|
||||||
|
{ userId: 'u-2', name: 'Anna' },
|
||||||
|
{ userId: 'u-3', name: 'Ben' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } };
|
||||||
|
|
||||||
|
describe('settings page — load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockApiResponses() {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/ingredients') {
|
||||||
|
return Promise.resolve({ data: mockIngredients, error: undefined });
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine') {
|
||||||
|
return Promise.resolve({ data: mockHousehold, error: undefined });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns stapleCount as number of ingredients where isStaple=true', async () => {
|
||||||
|
mockApiResponses();
|
||||||
|
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
expect(result.stapleCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns memberCount as number of household members', async () => {
|
||||||
|
mockApiResponses();
|
||||||
|
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
expect(result.memberCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns userName from locals.benutzer.name', async () => {
|
||||||
|
mockApiResponses();
|
||||||
|
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
expect(result.userName).toBe('Marcel Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches ingredients and household in parallel', async () => {
|
||||||
|
mockApiResponses();
|
||||||
|
await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
const calls = mockGet.mock.calls.map((c) => c[0]);
|
||||||
|
expect(calls).toContain('/v1/ingredients');
|
||||||
|
expect(calls).toContain('/v1/households/mine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults stapleCount to 0 when ingredients API fails', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/ingredients') {
|
||||||
|
return Promise.resolve({ data: undefined, error: { status: 500 } });
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine') {
|
||||||
|
return Promise.resolve({ data: mockHousehold, error: undefined });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
expect(result.stapleCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults memberCount to 0 when household API fails', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/ingredients') {
|
||||||
|
return Promise.resolve({ data: mockIngredients, error: undefined });
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine') {
|
||||||
|
return Promise.resolve({ data: undefined, error: { status: 500 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
|
||||||
|
expect(result.memberCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
frontend/src/routes/(app)/settings/page.test.ts
Normal file
71
frontend/src/routes/(app)/settings/page.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) {
|
||||||
|
return {
|
||||||
|
stapleCount: 14,
|
||||||
|
memberCount: 3,
|
||||||
|
userName: 'Marcel Raddatz',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('settings page — hub', () => {
|
||||||
|
it('renders the page heading Einstellungen', () => {
|
||||||
|
render(Page, { props: { data: makeData() } });
|
||||||
|
expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vorräte card linking to /household/staples', () => {
|
||||||
|
render(Page, { props: { data: makeData() } });
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples');
|
||||||
|
expect(vorrateLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Haushalt card linking to /members', () => {
|
||||||
|
render(Page, { props: { data: makeData() } });
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
const haushaltLink = links.find((l) => l.getAttribute('href') === '/members');
|
||||||
|
expect(haushaltLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Profil card linking to /profile', () => {
|
||||||
|
render(Page, { props: { data: makeData() } });
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
const profilLink = links.find((l) => l.getAttribute('href') === '/profile');
|
||||||
|
expect(profilLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stapleCount as a number in the Vorräte card', () => {
|
||||||
|
render(Page, { props: { data: makeData({ stapleCount: 14 }) } });
|
||||||
|
expect(screen.getByTestId('staple-count')).toHaveTextContent('14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows memberCount in the Haushalt card', () => {
|
||||||
|
render(Page, { props: { data: makeData({ memberCount: 3 }) } });
|
||||||
|
expect(screen.getByTestId('member-count')).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows userName in the Profil card meta', () => {
|
||||||
|
render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } });
|
||||||
|
expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state text when stapleCount is 0', () => {
|
||||||
|
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
|
||||||
|
expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => {
|
||||||
|
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
|
||||||
|
expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => {
|
||||||
|
render(Page, { props: { data: makeData({ stapleCount: 5 }) } });
|
||||||
|
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
frontend/src/routes/(public)/join/[token]/+page.server.ts
Normal file
84
frontend/src/routes/(public)/join/[token]/+page.server.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.GET('/v1/invites/{code}', {
|
||||||
|
params: { path: { code: params.token } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data?.data) {
|
||||||
|
return { invalid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
invalid: false,
|
||||||
|
householdName: data.data.householdName ?? '',
|
||||||
|
inviterName: data.data.inviterName ?? ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ params, request, fetch, cookies }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = (formData.get('name') ?? '').toString().trim();
|
||||||
|
const email = (formData.get('email') ?? '').toString().trim();
|
||||||
|
const password = (formData.get('password') ?? '').toString();
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errors.name = 'Name ist erforderlich';
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailPattern.test(email)) {
|
||||||
|
errors.email = 'Ungültige E-Mail-Adresse';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
errors.password = 'Mindestens 8 Zeichen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return fail(400, { errors, name, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { error, response } = await api.POST('/v1/invites/{code}/accept', {
|
||||||
|
params: { path: { code: params.token } },
|
||||||
|
body: { name, email, password }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.status === 409) {
|
||||||
|
return fail(409, {
|
||||||
|
errors: {
|
||||||
|
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fail(400, {
|
||||||
|
errors: { form: 'Einladung ungültig oder abgelaufen.' },
|
||||||
|
name,
|
||||||
|
email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
|
||||||
|
if (sessionId) {
|
||||||
|
cookies.set('JSESSIONID', sessionId, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: !dev
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
46
frontend/src/routes/(public)/join/[token]/+page.svelte
Normal file
46
frontend/src/routes/(public)/join/[token]/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
|
||||||
|
import JoinForm from './JoinForm.svelte';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Haushalt beitreten — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if data.invalid}
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
|
||||||
|
Einladung ungültig oder abgelaufen
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||||
|
Bitte bitte den Einladenden, einen neuen Link zu senden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Mobile layout (< 1024px): stacked banner + form -->
|
||||||
|
<!-- Desktop layout (≥ 1024px): two-column side by side -->
|
||||||
|
<div class="flex min-h-screen flex-col lg:flex-row">
|
||||||
|
<!-- Left / top: green-tint panel -->
|
||||||
|
<div class="bg-[var(--green-dark)] p-6 lg:flex lg:w-[400px] lg:flex-shrink-0 lg:items-center lg:justify-center lg:p-12">
|
||||||
|
<HouseholdIdentityPanel
|
||||||
|
householdName={data.householdName ?? ''}
|
||||||
|
inviterName={data.inviterName ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right / bottom: form -->
|
||||||
|
<div class="flex flex-1 items-center justify-center bg-[var(--color-page)] p-6 lg:p-12">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<h1 class="mb-6 font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
|
||||||
|
Konto erstellen & beitreten
|
||||||
|
</h1>
|
||||||
|
<JoinForm {form} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { householdName, inviterName }: { householdName: string; inviterName: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 rounded-[var(--radius-xl)] bg-[var(--green-dark)] p-6 text-center">
|
||||||
|
<!-- App logo -->
|
||||||
|
<span class="text-[48px]" aria-hidden="true">🥗</span>
|
||||||
|
|
||||||
|
<!-- Household name -->
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-white"
|
||||||
|
>
|
||||||
|
{householdName}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--green-light)]">
|
||||||
|
Eingeladen von {inviterName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permissions info box -->
|
||||||
|
<div class="w-full rounded-xl bg-white/10 px-4 py-3 text-left">
|
||||||
|
<p class="mb-2 font-[var(--font-sans)] text-[11px] font-medium uppercase tracking-wide text-[var(--green-light)]">
|
||||||
|
Als Mitglied kannst du
|
||||||
|
</p>
|
||||||
|
<ul aria-label="Als Mitglied kannst du" class="flex flex-col gap-1.5">
|
||||||
|
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
|
||||||
|
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true">✓</span>
|
||||||
|
Wochenplan einsehen
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
|
||||||
|
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true">✓</span>
|
||||||
|
Einkaufsliste abhaken
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
|
||||||
|
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true">✓</span>
|
||||||
|
Artikel zur Liste hinzufügen
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
|
||||||
|
|
||||||
|
describe('HouseholdIdentityPanel', () => {
|
||||||
|
it('renders household name', () => {
|
||||||
|
render(HouseholdIdentityPanel, {
|
||||||
|
props: { householdName: 'Smith family', inviterName: 'Sarah' }
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Smith family')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inviter name', () => {
|
||||||
|
render(HouseholdIdentityPanel, {
|
||||||
|
props: { householdName: 'Smith family', inviterName: 'Sarah' }
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Sarah/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all three member permissions', () => {
|
||||||
|
render(HouseholdIdentityPanel, {
|
||||||
|
props: { householdName: 'Smith family', inviterName: 'Sarah' }
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders app logo', () => {
|
||||||
|
render(HouseholdIdentityPanel, {
|
||||||
|
props: { householdName: 'Smith family', inviterName: 'Sarah' }
|
||||||
|
});
|
||||||
|
expect(screen.getByText('🥗')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permissions list has accessible name', () => {
|
||||||
|
render(HouseholdIdentityPanel, {
|
||||||
|
props: { householdName: 'Smith family', inviterName: 'Sarah' }
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('list', { name: /als mitglied kannst du/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
113
frontend/src/routes/(public)/join/[token]/JoinForm.svelte
Normal file
113
frontend/src/routes/(public)/join/[token]/JoinForm.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
let { form = null }: { form?: FormData } = $props();
|
||||||
|
|
||||||
|
let showPassword = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form method="POST" use:enhance>
|
||||||
|
<!-- Form-level error -->
|
||||||
|
{#if form?.errors?.form}
|
||||||
|
<p
|
||||||
|
class="mb-4 rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
|
||||||
|
>
|
||||||
|
{form.errors.form}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Name field -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="name"
|
||||||
|
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
autocomplete="given-name"
|
||||||
|
value={form?.name ?? ''}
|
||||||
|
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
|
||||||
|
{form?.errors?.name ? 'border-[var(--color-error)]' : ''}"
|
||||||
|
/>
|
||||||
|
{#if form?.errors?.name}
|
||||||
|
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||||
|
{form.errors.name}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email field -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
value={form?.email ?? ''}
|
||||||
|
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
|
||||||
|
{form?.errors?.email ? 'border-[var(--color-error)]' : ''}"
|
||||||
|
/>
|
||||||
|
{#if form?.errors?.email}
|
||||||
|
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||||
|
{form.errors.email}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password field -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label
|
||||||
|
for="password"
|
||||||
|
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] pr-[80px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
|
||||||
|
{form?.errors?.password ? 'border-[var(--color-error)]' : ''}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if form?.errors?.password}
|
||||||
|
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||||
|
{form.errors.password}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] font-[var(--font-sans)] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
|
||||||
|
>
|
||||||
|
Haushalt beitreten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
83
frontend/src/routes/(public)/join/[token]/JoinForm.test.ts
Normal file
83
frontend/src/routes/(public)/join/[token]/JoinForm.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import JoinForm from './JoinForm.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: () => ({ destroy: () => {} })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('JoinForm', () => {
|
||||||
|
it('renders name, email and password fields', () => {
|
||||||
|
render(JoinForm);
|
||||||
|
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Haushalt beitreten" submit button', () => {
|
||||||
|
render(JoinForm);
|
||||||
|
expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password field is initially of type password', () => {
|
||||||
|
render(JoinForm);
|
||||||
|
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password toggle switches type to text', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(JoinForm);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password toggle aria-label updates to "Passwort verbergen" when visible', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(JoinForm);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(screen.getByRole('button', { name: /passwort verbergen/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows form-level error from form prop', () => {
|
||||||
|
render(JoinForm, {
|
||||||
|
props: {
|
||||||
|
form: {
|
||||||
|
errors: { form: 'Einladung ungültig oder abgelaufen.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows email-taken error with login link', () => {
|
||||||
|
render(JoinForm, {
|
||||||
|
props: {
|
||||||
|
form: {
|
||||||
|
errors: {
|
||||||
|
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/bereits registriert/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills name and email from form prop', () => {
|
||||||
|
render(JoinForm, {
|
||||||
|
props: {
|
||||||
|
form: {
|
||||||
|
errors: {},
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText('Name')).toHaveValue('Tom');
|
||||||
|
expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
202
frontend/src/routes/(public)/join/[token]/page.server.test.ts
Normal file
202
frontend/src/routes/(public)/join/[token]/page.server.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$app/environment', () => ({ dev: false }));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('join page load function', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createLoadEvent(token: string) {
|
||||||
|
return {
|
||||||
|
params: { token },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns householdName and inviterName for valid token', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } },
|
||||||
|
error: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load(createLoadEvent('ABC12XYZ'));
|
||||||
|
|
||||||
|
expect(result.invalid).toBeFalsy();
|
||||||
|
expect(result.householdName).toBe('Smith family');
|
||||||
|
expect(result.inviterName).toBe('Sarah');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid:true on 404 (expired/used/unknown token)', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { status: 404 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load(createLoadEvent('BADTOKEN'));
|
||||||
|
|
||||||
|
expect(result.invalid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('join page form action', () => {
|
||||||
|
let actions: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockPost.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
actions = mod.actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createRequest(token: string, formData: Record<string, string>) {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
fd.append(key, value);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
params: { token },
|
||||||
|
request: { formData: () => Promise.resolve(fd) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
cookies: { get: vi.fn(), set: vi.fn() }
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('calls POST /v1/invites/{token}/accept with form data', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { data: { householdName: 'Smith family', role: 'member' } },
|
||||||
|
error: undefined,
|
||||||
|
response: { headers: { get: vi.fn().mockReturnValue(null) } }
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actions.default(createRequest('ABC12XYZ', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'secret123'
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// redirect throws
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', {
|
||||||
|
params: { path: { code: 'ABC12XYZ' } },
|
||||||
|
body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets JSESSIONID cookie and redirects to / on success', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { data: { householdName: 'Smith family', role: 'member' } },
|
||||||
|
error: undefined,
|
||||||
|
response: {
|
||||||
|
headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = createRequest('ABC12XYZ', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'secret123'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actions.default(event);
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.status).toBe(303);
|
||||||
|
expect(e.location).toBe('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(event.cookies.set).toHaveBeenCalledWith(
|
||||||
|
'JSESSIONID',
|
||||||
|
'abc123',
|
||||||
|
expect.objectContaining({ path: '/', secure: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 fail with email-taken message on conflict', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { status: 409 },
|
||||||
|
response: { headers: { get: vi.fn().mockReturnValue(null) } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await actions.default(createRequest('ABC12XYZ', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'secret123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.status).toBe(409);
|
||||||
|
expect(result.data.errors.email).toContain('registriert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 fail on invalid token (404 from backend)', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { status: 404 },
|
||||||
|
response: { headers: { get: vi.fn().mockReturnValue(null) } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await actions.default(createRequest('BADTOKEN', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'secret123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.data.errors.form).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty name with validation error', async () => {
|
||||||
|
const result = await actions.default(createRequest('ABC12XYZ', {
|
||||||
|
name: '',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'secret123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.data.errors.name).toBeTruthy();
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid email with validation error', async () => {
|
||||||
|
const result = await actions.default(createRequest('ABC12XYZ', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'notanemail',
|
||||||
|
password: 'secret123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.data.errors.email).toBeTruthy();
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects short password with validation error', async () => {
|
||||||
|
const result = await actions.default(createRequest('ABC12XYZ', {
|
||||||
|
name: 'Tom',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
password: 'short'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.data.errors.password).toBeTruthy();
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
625
specs/frontend/c3-variety-rework-v1-spec.html
Normal file
625
specs/frontend/c3-variety-rework-v1-spec.html
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Recipe App — C3 Abwechslungs-Analyse · Implementierungsspezifikation V1</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
|
||||||
|
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
|
||||||
|
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;
|
||||||
|
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;
|
||||||
|
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
|
||||||
|
--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;
|
||||||
|
--orange-tint:#FEF0E6;--orange:#E8862A;
|
||||||
|
--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;
|
||||||
|
--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;
|
||||||
|
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
|
||||||
|
--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);
|
||||||
|
}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#DDDBD5;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1100px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
|
||||||
|
.doc-header{background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:40px 40px 28px;margin:-48px -40px 48px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--color-border);}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:26px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-ready{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
.pill-warn{background:var(--yellow-tint);color:var(--yellow-text);}
|
||||||
|
|
||||||
|
.section{margin-bottom:56px;}
|
||||||
|
.section-label{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:20px;}
|
||||||
|
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.7;max-width:720px;margin-bottom:16px;}
|
||||||
|
.prose strong{color:var(--color-text);font-weight:500;}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.code{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:16px 20px;font-family:var(--font-mono);font-size:12px;line-height:1.7;overflow-x:auto;margin-bottom:16px;white-space:pre;}
|
||||||
|
.code .cm{color:var(--color-text-muted);}
|
||||||
|
.code .kw{color:var(--purple);}
|
||||||
|
.code .ty{color:var(--blue-dark);}
|
||||||
|
.code .st{color:var(--green-dark);}
|
||||||
|
.code .nu{color:var(--orange);}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.tbl{width:100%;border-collapse:collapse;font-size:12px;background:var(--color-surface);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--color-border);margin-bottom:16px;}
|
||||||
|
.tbl thead tr{background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
|
||||||
|
.tbl th{text-align:left;padding:10px 14px;font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
|
||||||
|
.tbl td{padding:9px 14px;border-bottom:1px solid var(--color-subtle);vertical-align:top;}
|
||||||
|
.tbl tr:last-child td{border-bottom:none;}
|
||||||
|
.tbl td:first-child{font-weight:500;color:var(--color-text-muted);white-space:nowrap;font-size:11px;}
|
||||||
|
.tbl td.mono{font-family:var(--font-mono);font-size:11px;}
|
||||||
|
|
||||||
|
/* Callout boxes */
|
||||||
|
.box{border-radius:var(--radius-lg);padding:16px 20px;margin-bottom:16px;}
|
||||||
|
.box-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;}
|
||||||
|
.box ul{list-style:none;display:flex;flex-direction:column;gap:5px;}
|
||||||
|
.box li{font-size:12px;line-height:1.5;display:flex;align-items:flex-start;gap:8px;}
|
||||||
|
.box li::before{font-weight:500;flex-shrink:0;}
|
||||||
|
.box-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
|
||||||
|
.box-y .box-lbl,.box-y li::before{color:var(--yellow-text);}
|
||||||
|
.box-y li{color:var(--yellow-text);}
|
||||||
|
.box-g{background:var(--green-tint);border:1px solid var(--green-light);}
|
||||||
|
.box-g .box-lbl,.box-g li::before{color:var(--green-dark);}
|
||||||
|
.box-g li{color:var(--green-dark);}
|
||||||
|
.box-b{background:var(--blue-tint);border:1px solid var(--blue-light);}
|
||||||
|
.box-b .box-lbl,.box-b li::before{color:var(--blue-dark);}
|
||||||
|
.box-b li{color:var(--blue-dark);}
|
||||||
|
.box ul.checks li::before{content:'✓';}
|
||||||
|
.box ul.arrows li::before{content:'→';}
|
||||||
|
|
||||||
|
/* State cards */
|
||||||
|
.state-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
|
||||||
|
.state-head{background:var(--color-subtle);padding:10px 16px;border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
|
||||||
|
.state-id{font-family:var(--font-mono);font-size:11px;font-weight:500;color:var(--color-text-muted);}
|
||||||
|
.state-title{font-size:13px;font-weight:500;}
|
||||||
|
.state-body{padding:14px 16px;font-size:12px;line-height:1.7;}
|
||||||
|
|
||||||
|
/* Device frames (compact preview) */
|
||||||
|
.prev-row{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap;margin-bottom:16px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:8px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
.phone{width:300px;flex-shrink:0;background:var(--color-page);border-radius:32px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.08);border:5px solid #1C1C18;}
|
||||||
|
.pst{padding:8px 16px 0;display:flex;justify-content:space-between;align-items:center;font-size:10px;background:var(--color-page);}
|
||||||
|
.pst b{font-weight:600;font-size:11px;}
|
||||||
|
|
||||||
|
/* Warning card preview */
|
||||||
|
.wcard{border-radius:8px;border:1px solid var(--yellow-light);background:var(--yellow-tint);overflow:hidden;margin-bottom:8px;}
|
||||||
|
.wcard:last-child{margin-bottom:0;}
|
||||||
|
.wcard-hd{padding:9px 14px;border-bottom:1px solid var(--yellow-light);}
|
||||||
|
.wcard-hd-t{font-size:13px;font-weight:500;color:var(--yellow-text);}
|
||||||
|
.wcard-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-bottom:1px solid rgba(249,224,138,.4);}
|
||||||
|
.wcard-row:last-child{border-bottom:none;}
|
||||||
|
.wcard-left{display:flex;align-items:center;gap:8px;min-width:0;}
|
||||||
|
.wcard-day{font-size:11px;font-weight:600;color:var(--yellow-text);width:20px;flex-shrink:0;}
|
||||||
|
.wcard-recipe{font-size:13px;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.wcard-swap{font-size:12px;font-weight:500;color:var(--yellow-text);white-space:nowrap;flex-shrink:0;}
|
||||||
|
|
||||||
|
.divider{border:none;border-top:1px solid var(--color-border);margin:40px 0;}
|
||||||
|
|
||||||
|
/* File diff style */
|
||||||
|
.diff{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);font-family:var(--font-mono);font-size:12px;line-height:1.6;overflow-x:auto;margin-bottom:16px;}
|
||||||
|
.diff-file{padding:8px 16px;background:var(--color-subtle);border-bottom:1px solid var(--color-border);font-size:11px;font-weight:500;color:var(--color-text-muted);}
|
||||||
|
.diff-body{padding:12px 16px;white-space:pre;}
|
||||||
|
.diff-add{color:var(--green-dark);background:rgba(61,140,74,.06);}
|
||||||
|
.diff-rem{color:var(--red-dark);background:rgba(220,76,62,.06);}
|
||||||
|
.diff-ctx{color:var(--color-text-muted);}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>C3 — Abwechslungs-Analyse · Implementierungsspezifikation</h1>
|
||||||
|
<p>Recipe App · Variation V1 "Erweiterte Karten" · Rezeptnamen + Tausch-Links in Warnkarten</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span class="pill pill-ready">Final</span><br>
|
||||||
|
Erstellt: 2026-04<br>
|
||||||
|
Screen: C3<br>
|
||||||
|
Bezug: c3-variety-rework.html
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 1. ÜBERBLICK ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">1 · Überblick</div>
|
||||||
|
<p class="prose">Die Seite <strong>/planner/variety</strong> zeigt derzeit Warnkarten mit technischen Tages-Codes (<code style="font-family:var(--font-mono);font-size:11px;">MON, WED — erwäge einen Tausch</code>). Der Planer muss manuell nachschlagen, welches Gericht an diesen Tagen eingeplant ist, und dann zurück zum Planer navigieren um es zu tauschen.</p>
|
||||||
|
<p class="prose"><strong>V1 "Erweiterte Karten"</strong> löst dies mit minimalem Umbauaufwand: Die Warnkarten erhalten eine strukturierte Zeile pro betroffenem Tag — mit Wochentag-Abkürzung, Rezeptname und direktem "Tauschen →" Link. Score-Hero, Bewertungsdetails und das Gesamt-Layout bleiben unverändert.</p>
|
||||||
|
|
||||||
|
<div class="box box-b">
|
||||||
|
<div class="box-lbl">Scope</div>
|
||||||
|
<ul class="arrows">
|
||||||
|
<li>Kein neues Backend-Endpoint — alle nötigen Daten sind bereits im weekPlan-Load vorhanden</li>
|
||||||
|
<li>Kein Layout-Umbau — nur VarietyWarningCards.svelte und die Datenvorbereitung in +page.svelte ändern sich</li>
|
||||||
|
<li>Protein-Grid und EffortBar bleiben wie bisher (Desktop)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 2. PROBLEM IM DETAIL ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">2 · Aktueller Ist-Zustand und Problem</div>
|
||||||
|
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Element</th><th>Aktuell</th><th>Soll (V1)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Warnkarte Inhalt</td>
|
||||||
|
<td><code style="font-family:var(--font-mono);font-size:11px;">title + explanation (String)</code></td>
|
||||||
|
<td>Strukturierte Zeilen: Wochentag · Rezeptname · Tauschen-Link</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tages-Angabe</td>
|
||||||
|
<td>API-Code <code style="font-family:var(--font-mono);font-size:11px;">MON, WED</code></td>
|
||||||
|
<td>Abkürzung <code style="font-family:var(--font-mono);font-size:11px;">Mo, Mi</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rezeptname</td>
|
||||||
|
<td>Fehlt</td>
|
||||||
|
<td>Aus <code style="font-family:var(--font-mono);font-size:11px;">weekPlan.slots[].recipe.name</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tausch-Navigation</td>
|
||||||
|
<td>Fehlt — Nutzer verlässt die Seite manuell</td>
|
||||||
|
<td><code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&swap={slotId}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Datenbasis</td>
|
||||||
|
<td><code style="font-family:var(--font-mono);font-size:11px;">computeWarnings()</code> aus variety.ts</td>
|
||||||
|
<td>Inline <code style="font-family:var(--font-mono);font-size:11px;">$derived.by()</code> in +page.svelte, direkt aus API-Daten</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 3. DATENFLUSS ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">3 · Datenfluss</div>
|
||||||
|
|
||||||
|
<p class="prose">Alle nötigen Daten werden bereits im Server-Load geladen. Kein neuer API-Call erforderlich.</p>
|
||||||
|
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Quelle</th><th>Feld</th><th>Verwendung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>weekPlan.slots[]</td>
|
||||||
|
<td class="mono">{ id, dayOfWeek, recipe: { id, name } }</td>
|
||||||
|
<td>Aufbau der <code style="font-family:var(--font-mono);font-size:11px;">slotsByDay</code>-Map: DayCode → { slotId, recipeName }</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>varietyScore.tagRepeats[]</td>
|
||||||
|
<td class="mono">{ tagType, tagName, days: string[] }</td>
|
||||||
|
<td>Warnkarten für wiederholte Tags (Protein, Cuisine). days[] enthält API-Codes: "MON", "TUE" …</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>varietyScore.ingredientOverlaps[]</td>
|
||||||
|
<td class="mono">{ ingredientName, days: string[] }</td>
|
||||||
|
<td>Warnkarten für Zutaten-Überschneidungen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>varietyScore.duplicatesInPlan[]</td>
|
||||||
|
<td class="mono">string[] (Rezeptnamen)</td>
|
||||||
|
<td>Warnkarte: "X doppelt geplant". Alle Slots mit diesem Rezeptnamen liefern die Items.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>data.weekStart</td>
|
||||||
|
<td class="mono">string (YYYY-MM-DD)</td>
|
||||||
|
<td>Swap-URL-Parameter</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="prose">Tag-Code → Abkürzung Mapping (konstant):</p>
|
||||||
|
<div class="code"><span class="cm">// Day code → German short label</span>
|
||||||
|
<span class="kw">const</span> DAY_SHORT: Record<<span class="ty">string</span>, <span class="ty">string</span>> = {
|
||||||
|
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
|
||||||
|
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
|
||||||
|
};</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 4. TYPEN ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">4 · Typen</div>
|
||||||
|
|
||||||
|
<p class="prose">Die bestehende <code style="font-family:var(--font-mono);font-size:11px;">VarietyWarningCards.svelte</code> definiert bereits die korrekten Interfaces. Diese bleiben unverändert:</p>
|
||||||
|
|
||||||
|
<div class="code"><span class="cm">// In VarietyWarningCards.svelte (bereits vorhanden, nicht ändern)</span>
|
||||||
|
<span class="kw">interface</span> <span class="ty">WarningItem</span> {
|
||||||
|
dayShort: <span class="ty">string</span>; <span class="cm">// 'Mo', 'Di', …</span>
|
||||||
|
recipeName: <span class="ty">string</span>; <span class="cm">// aus weekPlan.slots[].recipe.name</span>
|
||||||
|
slotId: <span class="ty">number</span>; <span class="cm">// für Swap-Link</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="kw">interface</span> <span class="ty">ActionWarning</span> {
|
||||||
|
title: <span class="ty">string</span>; <span class="cm">// z.B. "Tofu mehrfach diese Woche"</span>
|
||||||
|
items: <span class="ty">WarningItem</span>[]; <span class="cm">// eine Zeile pro betroffenem Tag</span>
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
<p class="prose">Die alte <code style="font-family:var(--font-mono);font-size:11px;">Warning</code>-Schnittstelle aus <code style="font-family:var(--font-mono);font-size:11px;">variety.ts</code> (<code style="font-family:var(--font-mono);font-size:11px;">{ title, explanation }</code>) wird nicht mehr verwendet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 5. IMPLEMENTIERUNG ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">5 · Implementierung</div>
|
||||||
|
|
||||||
|
<p class="prose">Es gibt drei Änderungen:</p>
|
||||||
|
|
||||||
|
<!-- 5.1 slotsByDay -->
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-head">
|
||||||
|
<div class="state-id">5.1</div>
|
||||||
|
<div class="state-title">+page.svelte — slotsByDay Map aufbauen</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<p style="margin-bottom:10px;">Füge direkt nach den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">$derived</code>-Deklarationen hinzu:</p>
|
||||||
|
<div class="code" style="margin-bottom:0"><span class="kw">const</span> DAY_SHORT: Record<<span class="ty">string</span>, <span class="ty">string</span>> = {
|
||||||
|
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
|
||||||
|
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
<span class="cm">// dayOfWeek (API code) → { slotId, recipeName }</span>
|
||||||
|
<span class="kw">let</span> slotsByDay = $derived.by(() => {
|
||||||
|
<span class="kw">const</span> map: Record<<span class="ty">string</span>, { slotId: <span class="ty">number</span>; recipeName: <span class="ty">string</span> }> = {};
|
||||||
|
<span class="kw">for</span> (<span class="kw">const</span> slot <span class="kw">of</span> weekPlan?.slots ?? []) {
|
||||||
|
<span class="kw">if</span> (slot.dayOfWeek && slot.recipe?.name && slot.id) {
|
||||||
|
map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<span class="kw">return</span> map;
|
||||||
|
});</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5.2 actionWarnings -->
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-head">
|
||||||
|
<div class="state-id">5.2</div>
|
||||||
|
<div class="state-title">+page.svelte — actionWarnings ersetzen computeWarnings()</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<p style="margin-bottom:10px;">Ersetze den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">let warnings = $derived.by(() => computeWarnings(…))</code>-Block vollständig:</p>
|
||||||
|
<div class="code" style="margin-bottom:0"><span class="kw">interface</span> <span class="ty">WarningItem</span> { dayShort: <span class="ty">string</span>; recipeName: <span class="ty">string</span>; slotId: <span class="ty">number</span>; }
|
||||||
|
<span class="kw">interface</span> <span class="ty">ActionWarning</span> { title: <span class="ty">string</span>; items: <span class="ty">WarningItem</span>[]; }
|
||||||
|
|
||||||
|
<span class="kw">let</span> actionWarnings = $derived.by((): <span class="ty">ActionWarning</span>[] => {
|
||||||
|
<span class="kw">const</span> result: <span class="ty">ActionWarning</span>[] = [];
|
||||||
|
<span class="kw">const</span> vs = varietyScore;
|
||||||
|
<span class="kw">if</span> (!vs) <span class="kw">return</span> result;
|
||||||
|
|
||||||
|
<span class="cm">// Tag repeats (protein, cuisine, …)</span>
|
||||||
|
<span class="kw">for</span> (<span class="kw">const</span> repeat <span class="kw">of</span> vs.tagRepeats ?? []) {
|
||||||
|
<span class="kw">if</span> ((repeat.days?.length ?? <span class="nu">0</span>) < <span class="nu">2</span>) <span class="kw">continue</span>;
|
||||||
|
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (repeat.days ?? [])
|
||||||
|
.map((day) => {
|
||||||
|
<span class="kw">const</span> slot = slotsByDay[day];
|
||||||
|
<span class="kw">return</span> slot
|
||||||
|
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
|
||||||
|
: <span class="kw">null</span>;
|
||||||
|
})
|
||||||
|
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
|
||||||
|
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
|
||||||
|
result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="cm">// Ingredient overlaps</span>
|
||||||
|
<span class="kw">for</span> (<span class="kw">const</span> overlap <span class="kw">of</span> vs.ingredientOverlaps ?? []) {
|
||||||
|
<span class="kw">if</span> ((overlap.days?.length ?? <span class="nu">0</span>) < <span class="nu">2</span>) <span class="kw">continue</span>;
|
||||||
|
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (overlap.days ?? [])
|
||||||
|
.map((day) => {
|
||||||
|
<span class="kw">const</span> slot = slotsByDay[day];
|
||||||
|
<span class="kw">return</span> slot
|
||||||
|
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
|
||||||
|
: <span class="kw">null</span>;
|
||||||
|
})
|
||||||
|
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
|
||||||
|
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
|
||||||
|
result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="cm">// Duplicate recipes — find all slots with that recipe name</span>
|
||||||
|
<span class="kw">for</span> (<span class="kw">const</span> name <span class="kw">of</span> vs.duplicatesInPlan ?? []) {
|
||||||
|
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = Object.entries(slotsByDay)
|
||||||
|
.filter(([, s]) => s.recipeName === name)
|
||||||
|
.map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId }));
|
||||||
|
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
|
||||||
|
result.push({ title: `${name} doppelt geplant`, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="kw">return</span> result;
|
||||||
|
});</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5.3 Template update -->
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-head">
|
||||||
|
<div class="state-id">5.3</div>
|
||||||
|
<div class="state-title">+page.svelte — Template: warnings → actionWarnings, weekStart übergeben</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<p style="margin-bottom:10px;">An beiden Stellen im Template (Mobile + Desktop) ersetzen:</p>
|
||||||
|
<div class="diff">
|
||||||
|
<div class="diff-file">+page.svelte (Mobile, ~Zeile 110 / Desktop, ~Zeile 222)</div>
|
||||||
|
<div class="diff-body"><span class="diff-rem">- {#if warnings.length > 0}</span>
|
||||||
|
<span class="diff-rem">- <VarietyWarningCards {warnings} /></span>
|
||||||
|
<span class="diff-add">+ {#if actionWarnings.length > 0}</span>
|
||||||
|
<span class="diff-add">+ <VarietyWarningCards warnings={actionWarnings} {weekStart} /></span></div>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--color-text-muted);">Achtung: <code style="font-family:var(--font-mono);font-size:11px;">weekStart</code> ist für die Swap-URL erforderlich und muss explizit übergeben werden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5.4 Import cleanup -->
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-head">
|
||||||
|
<div class="state-id">5.4</div>
|
||||||
|
<div class="state-title">+page.svelte — Import aufräumen</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<p style="margin-bottom:10px;">Entferne den nicht mehr genutzten Import:</p>
|
||||||
|
<div class="diff">
|
||||||
|
<div class="diff-file">+page.svelte (Script-Block, oben)</div>
|
||||||
|
<div class="diff-body"><span class="diff-rem">- import { computeSubScores, computeWarnings } from '$lib/planner/variety';</span>
|
||||||
|
<span class="diff-add">+ import { computeSubScores } from '$lib/planner/variety';</span></div>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--color-text-muted);">computeSubScores wird noch für die Score-Breakdown-Anzeige genutzt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 6. KOMPONENTE: VarietyWarningCards ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">6 · VarietyWarningCards.svelte — bereits korrekt</div>
|
||||||
|
|
||||||
|
<p class="prose">Die Komponente wurde bereits auf das neue <code style="font-family:var(--font-mono);font-size:11px;">ActionWarning</code>-Format aktualisiert. <strong>Keine Änderung erforderlich.</strong> Zur Referenz die erwartete Props-Schnittstelle:</p>
|
||||||
|
|
||||||
|
<div class="code"><span class="cm">// Props (bereits implementiert)</span>
|
||||||
|
<span class="kw">let</span> { warnings, weekStart }: {
|
||||||
|
warnings: <span class="ty">ActionWarning</span>[];
|
||||||
|
weekStart: <span class="ty">string</span>;
|
||||||
|
} = $props();</div>
|
||||||
|
|
||||||
|
<p class="prose">Die Komponente rendert für jede Warnung:</p>
|
||||||
|
<ul style="font-size:12px;color:var(--color-text-muted);margin-left:20px;margin-bottom:16px;line-height:1.9;">
|
||||||
|
<li>Gelbe Karte (<code style="font-family:var(--font-mono);font-size:11px;">border: yellow-light, bg: yellow-tint</code>) mit Header-Zeile (Titel)</li>
|
||||||
|
<li>Pro Item: Zeile mit Wochentag-Abkürzung (W=20px, fixed) · Rezeptname (truncate) · "Tauschen →" Link (rechts)</li>
|
||||||
|
<li>Swap-URL: <code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&swap={item.slotId}</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Visual preview -->
|
||||||
|
<div class="prev-row">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Warnkarte · Referenz-Darstellung</div>
|
||||||
|
<div style="width:340px;">
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 7. EDGE CASES ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">7 · Edge Cases</div>
|
||||||
|
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Fall</th><th>Verhalten</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Tag im tagRepeat hat keinen Slot</td>
|
||||||
|
<td>Filter-Schritt (.filter(x => x !== null)) entfernt das Item. Warnkarte erscheint nur wenn ≥1 Item vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>weekPlan hat keine Slots (leere Woche)</td>
|
||||||
|
<td>slotsByDay ist {}, actionWarnings ist []. Keine Warnkarten sichtbar.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>varietyScore ist null</td>
|
||||||
|
<td>Bestehende {#if !varietyScore}-Guard greift — actionWarnings wird nie gerendert.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Slot hat kein Rezept (slot.recipe === null)</td>
|
||||||
|
<td>slot.recipe?.name ist undefined → Slot wird nicht in slotsByDay aufgenommen.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>duplicatesInPlan: Rezeptname kommt in slotsByDay nicht vor</td>
|
||||||
|
<td>items ist leer → Warnkarte wird nicht gepusht.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Unbekannter Tag-Code (z.B. zukünftige API-Erweiterung)</td>
|
||||||
|
<td>DAY_SHORT[day] ?? day — Fallback auf den rohen Code.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sehr langer Rezeptname</td>
|
||||||
|
<td>CSS truncate auf .wcard-recipe — kein Überlauf, Swap-Link bleibt sichtbar.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 8. ABNAHMEKRITERIEN ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">8 · Abnahmekriterien</div>
|
||||||
|
|
||||||
|
<div class="box box-g">
|
||||||
|
<div class="box-lbl">Acceptance Criteria</div>
|
||||||
|
<ul class="checks">
|
||||||
|
<li>AC-1: Warnkarte zeigt pro betroffenem Tag eine eigene Zeile (nicht mehr einen langen Erklärungstext)</li>
|
||||||
|
<li>AC-2: Jede Zeile enthält die deutsche Wochentag-Abkürzung (Mo, Di, Mi, Do, Fr, Sa, So)</li>
|
||||||
|
<li>AC-3: Jede Zeile enthält den Namen des eingeplanten Rezepts</li>
|
||||||
|
<li>AC-4: Jede Zeile enthält einen "Tauschen →" Link, der zu /planner?week={weekStart}&swap={slotId} führt</li>
|
||||||
|
<li>AC-5: Tags mit nur einem betroffenen Tag (days.length < 2) erzeugen keine Warnkarte</li>
|
||||||
|
<li>AC-6: Score-Hero, Bewertungsdetails und Protein-Grid (Desktop) bleiben unverändert</li>
|
||||||
|
<li>AC-7: Wenn varietyScore null ist, werden keine Warnkarten gerendert (leere-Woche-State bleibt)</li>
|
||||||
|
<li>AC-8: Der Import von computeWarnings ist entfernt, TypeScript kompiliert fehlerfrei</li>
|
||||||
|
<li>AC-9: Auf Mobilgerät sind Tausch-Links touch-freundlich (mind. 44px Zeilenhöhe)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box box-y">
|
||||||
|
<div class="box-lbl">Nicht in Scope</div>
|
||||||
|
<ul class="arrows">
|
||||||
|
<li>Neues Backend-Endpoint — alle Daten kommen aus dem bestehenden Load</li>
|
||||||
|
<li>Layout-Umbau der Seite — Score bleibt oben, Warnungen unten wie bisher</li>
|
||||||
|
<li>Protein-Grid oder EffortBar Änderungen</li>
|
||||||
|
<li>computeSubScores aus variety.ts — bleibt unverändert</li>
|
||||||
|
<li>Entfernen von computeWarnings aus variety.ts (Funktion bleibt, wird nur nicht mehr aufgerufen)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── 9. DATEIEN ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">9 · Betroffene Dateien</div>
|
||||||
|
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Datei</th><th>Änderung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">frontend/src/routes/(app)/planner/variety/+page.svelte</td>
|
||||||
|
<td>DAY_SHORT-Konstante, slotsByDay-Derived, actionWarnings-Derived, Template-Update (2×), Import-Bereinigung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">frontend/src/lib/planner/VarietyWarningCards.svelte</td>
|
||||||
|
<td>Keine — bereits auf ActionWarning-Format aktualisiert</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">frontend/src/lib/planner/variety.ts</td>
|
||||||
|
<td>Keine — computeWarnings bleibt (ungenutzt, aber nicht entfernen um Regressions-Risiko zu vermeiden)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── LLM AGENT REGION ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">LLM-Agent-Lesbereich</div>
|
||||||
|
<p class="prose">Dieser Abschnitt enthält maschinenlesbare Regeln für einen KI-Agenten der die Implementierung durchführt.</p>
|
||||||
|
|
||||||
|
<div class="code"><span class="cm">SCREEN: C3 /planner/variety
|
||||||
|
VARIATION: V1 "Erweiterte Karten"
|
||||||
|
STATUS: Final spec — ready for implementation
|
||||||
|
|
||||||
|
FILES TO MODIFY:
|
||||||
|
frontend/src/routes/(app)/planner/variety/+page.svelte
|
||||||
|
|
||||||
|
FILES NOT TO MODIFY:
|
||||||
|
frontend/src/lib/planner/VarietyWarningCards.svelte (already correct)
|
||||||
|
frontend/src/lib/planner/variety.ts (keep computeWarnings, remove only import)
|
||||||
|
|
||||||
|
STEP 1 — Add DAY_SHORT constant (in <script> block, after imports):
|
||||||
|
const DAY_SHORT: Record<string, string> = {
|
||||||
|
MON: 'Mo', TUE: 'Di', WED: 'Mi',
|
||||||
|
THU: 'Do', FRI: 'Fr', SAT: 'Sa', SUN: 'So'
|
||||||
|
};
|
||||||
|
|
||||||
|
STEP 2 — Add slotsByDay derived (after $derived declarations for weekPlan, etc.):
|
||||||
|
let slotsByDay = $derived.by(() => {
|
||||||
|
const map: Record<string, { slotId: number; recipeName: string }> = {};
|
||||||
|
for (const slot of weekPlan?.slots ?? []) {
|
||||||
|
if (slot.dayOfWeek && slot.recipe?.name && slot.id) {
|
||||||
|
map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
STEP 3 — Define inline interfaces + actionWarnings derived:
|
||||||
|
interface WarningItem { dayShort: string; recipeName: string; slotId: number; }
|
||||||
|
interface ActionWarning { title: string; items: WarningItem[]; }
|
||||||
|
|
||||||
|
let actionWarnings = $derived.by((): ActionWarning[] => {
|
||||||
|
const result: ActionWarning[] = [];
|
||||||
|
const vs = varietyScore;
|
||||||
|
if (!vs) return result;
|
||||||
|
|
||||||
|
for (const repeat of vs.tagRepeats ?? []) {
|
||||||
|
if ((repeat.days?.length ?? 0) < 2) continue;
|
||||||
|
const items: WarningItem[] = (repeat.days ?? [])
|
||||||
|
.map((day) => {
|
||||||
|
const slot = slotsByDay[day];
|
||||||
|
return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is WarningItem => x !== null);
|
||||||
|
if (items.length > 0) result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const overlap of vs.ingredientOverlaps ?? []) {
|
||||||
|
if ((overlap.days?.length ?? 0) < 2) continue;
|
||||||
|
const items: WarningItem[] = (overlap.days ?? [])
|
||||||
|
.map((day) => {
|
||||||
|
const slot = slotsByDay[day];
|
||||||
|
return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is WarningItem => x !== null);
|
||||||
|
if (items.length > 0) result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of vs.duplicatesInPlan ?? []) {
|
||||||
|
const items: WarningItem[] = Object.entries(slotsByDay)
|
||||||
|
.filter(([, s]) => s.recipeName === name)
|
||||||
|
.map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId }));
|
||||||
|
if (items.length > 0) result.push({ title: `${name} doppelt geplant`, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
STEP 4 — Replace template occurrences (both mobile and desktop sections):
|
||||||
|
OLD: {#if warnings.length > 0} / <VarietyWarningCards {warnings} />
|
||||||
|
NEW: {#if actionWarnings.length > 0} / <VarietyWarningCards warnings={actionWarnings} {weekStart} />
|
||||||
|
|
||||||
|
STEP 5 — Fix import:
|
||||||
|
OLD: import { computeSubScores, computeWarnings } from '$lib/planner/variety';
|
||||||
|
NEW: import { computeSubScores } from '$lib/planner/variety';
|
||||||
|
|
||||||
|
INVARIANTS (do not change):
|
||||||
|
- VarietyScoreHero, ScoreBreakdownList, EffortBar remain untouched
|
||||||
|
- Desktop protein grid (proteinByDay) remains untouched
|
||||||
|
- Layout structure (score top, warnings bottom) stays identical
|
||||||
|
- No new server load or API calls</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
790
specs/frontend/c3-variety-rework.html
Normal file
790
specs/frontend/c3-variety-rework.html
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Recipe App — C3 Abwechslungs-Analyse · 3 Mockup-Variationen</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
|
||||||
|
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
|
||||||
|
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;
|
||||||
|
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;
|
||||||
|
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
|
||||||
|
--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;
|
||||||
|
--orange-tint:#FEF0E6;--orange:#E8862A;
|
||||||
|
--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;
|
||||||
|
--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;
|
||||||
|
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
|
||||||
|
--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);
|
||||||
|
--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);
|
||||||
|
}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#DDDBD5;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.doc-header{background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:40px 40px 28px;margin:-48px -40px 48px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--color-border);}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:26px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-draft{background:var(--yellow-tint);color:var(--yellow-text);}
|
||||||
|
.pill-rec{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.section{margin-bottom:80px;}
|
||||||
|
.section-label{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.7;max-width:720px;margin-bottom:24px;}
|
||||||
|
|
||||||
|
/* Variation header */
|
||||||
|
.var-head{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:32px;display:flex;align-items:flex-start;gap:16px;}
|
||||||
|
.var-num{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;flex-shrink:0;}
|
||||||
|
.var-id{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;margin-bottom:4px;}
|
||||||
|
.var-title{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;}
|
||||||
|
.var-desc{font-size:13px;line-height:1.6;max-width:600px;}
|
||||||
|
.var-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
|
||||||
|
.var-y .var-num,.var-y .var-id{color:var(--yellow-dark);}
|
||||||
|
.var-y .var-desc{color:var(--yellow-text);}
|
||||||
|
.var-g{background:var(--green-tint);border:1px solid var(--green-light);}
|
||||||
|
.var-g .var-num,.var-g .var-id{color:var(--green);}
|
||||||
|
.var-g .var-desc{color:var(--green-dark);}
|
||||||
|
.var-p{background:var(--purple-tint);border:1px solid var(--purple-light);}
|
||||||
|
.var-p .var-num,.var-p .var-id{color:var(--purple);}
|
||||||
|
.var-p .var-desc{color:var(--purple-dark);}
|
||||||
|
|
||||||
|
/* Device frames */
|
||||||
|
.previews{display:flex;gap:40px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:28px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.08);border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:11px;background:var(--color-page);}
|
||||||
|
.pst b{font-weight:600;font-size:12px;}
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:500px;}
|
||||||
|
|
||||||
|
/* Nav chrome */
|
||||||
|
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
|
||||||
|
.mtb-back{font-size:20px;color:var(--color-text-muted);line-height:1;}
|
||||||
|
.mtb-t{font-family:var(--font-display);font-size:18px;font-weight:300;letter-spacing:-.02em;flex:1;}
|
||||||
|
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;flex-shrink:0;}
|
||||||
|
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}
|
||||||
|
.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);font-size:11px;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.mt-i.a .mt-ic{background:var(--yellow-tint);}
|
||||||
|
.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}
|
||||||
|
.mt-i.a .mt-l{color:var(--yellow-text);}
|
||||||
|
/* Desktop sidebar */
|
||||||
|
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
|
||||||
|
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}
|
||||||
|
.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);font-size:12px;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
|
||||||
|
.dsb-nav{padding:12px 10px;flex:1;}
|
||||||
|
.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}
|
||||||
|
.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;}
|
||||||
|
.dsb-ni.a{background:var(--yellow-tint);color:var(--yellow-text);font-weight:500;}
|
||||||
|
.dsb-nc{font-size:13px;width:18px;text-align:center;}
|
||||||
|
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
|
||||||
|
.dtb{padding:14px 24px;border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||||||
|
.dtb-bc{font-size:12px;color:var(--color-text-muted);}
|
||||||
|
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:300;letter-spacing:-.02em;}
|
||||||
|
.dmc{padding:24px;flex:1;overflow-y:auto;}
|
||||||
|
|
||||||
|
/* Shared components */
|
||||||
|
.score-num{font-family:var(--font-display);font-weight:300;letter-spacing:-.02em;line-height:1;}
|
||||||
|
.prog{height:6px;border-radius:3px;background:var(--color-border);overflow:hidden;margin-top:8px;}
|
||||||
|
.prog-fill{height:100%;border-radius:3px;background:var(--yellow);}
|
||||||
|
|
||||||
|
/* Warning card styles */
|
||||||
|
.wcard{border-radius:var(--radius-lg);border:1px solid var(--yellow-light);background:var(--yellow-tint);overflow:hidden;margin-bottom:8px;}
|
||||||
|
.wcard:last-child{margin-bottom:0;}
|
||||||
|
.wcard-hd{padding:10px 14px;border-bottom:1px solid var(--yellow-light);}
|
||||||
|
.wcard-hd-t{font-size:13px;font-weight:500;color:var(--yellow-text);}
|
||||||
|
.wcard-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-bottom:1px solid rgba(249,224,138,.4);}
|
||||||
|
.wcard-row:last-child{border-bottom:none;}
|
||||||
|
.wcard-left{display:flex;align-items:baseline;gap:8px;min-width:0;}
|
||||||
|
.wcard-day{font-size:11px;font-weight:600;color:var(--yellow-text);width:20px;flex-shrink:0;}
|
||||||
|
.wcard-recipe{font-size:13px;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.wcard-swap{font-size:12px;font-weight:500;color:var(--yellow-text);white-space:nowrap;flex-shrink:0;}
|
||||||
|
.wcard-swap:hover{text-decoration:underline;}
|
||||||
|
|
||||||
|
/* Score breakdown rows */
|
||||||
|
.sb-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}
|
||||||
|
.sb-row:last-child{border-bottom:none;}
|
||||||
|
.sb-label{font-size:12px;color:var(--color-text-muted);}
|
||||||
|
.sb-val{font-family:var(--font-mono);font-size:12px;font-weight:500;}
|
||||||
|
|
||||||
|
/* Collapsible details */
|
||||||
|
.det summary{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);cursor:default;list-style:none;display:flex;align-items:center;justify-content:space-between;}
|
||||||
|
.det summary::after{content:'▾';font-size:11px;}
|
||||||
|
.det[open] summary::after{content:'▴';}
|
||||||
|
.det-body{padding-top:8px;}
|
||||||
|
|
||||||
|
/* Notes block */
|
||||||
|
.notes{border-radius:var(--radius-lg);padding:16px 20px;margin-top:20px;}
|
||||||
|
.notes-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;}
|
||||||
|
.notes ul{list-style:none;display:flex;flex-direction:column;gap:5px;}
|
||||||
|
.notes li{font-size:12px;line-height:1.5;display:flex;align-items:flex-start;gap:8px;}
|
||||||
|
.notes li::before{content:'→';font-weight:500;flex-shrink:0;}
|
||||||
|
.notes-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
|
||||||
|
.notes-y .notes-lbl,.notes-y li::before{color:var(--yellow-text);}
|
||||||
|
.notes-y li{color:var(--yellow-text);}
|
||||||
|
.notes-g{background:var(--green-tint);border:1px solid var(--green-light);}
|
||||||
|
.notes-g .notes-lbl,.notes-g li::before{color:var(--green-dark);}
|
||||||
|
.notes-g li{color:var(--green-dark);}
|
||||||
|
.notes-p{background:var(--purple-tint);border:1px solid var(--purple-light);}
|
||||||
|
.notes-p .notes-lbl,.notes-p li::before{color:var(--purple-dark);}
|
||||||
|
.notes-p li{color:var(--purple-dark);}
|
||||||
|
|
||||||
|
.divider{border:none;border-top:1px solid var(--color-border);margin:48px 0;}
|
||||||
|
|
||||||
|
/* Comparison table */
|
||||||
|
.ct{width:100%;border-collapse:collapse;font-size:13px;background:var(--color-surface);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--color-border);}
|
||||||
|
.ct thead tr{background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
|
||||||
|
.ct th{text-align:left;padding:10px 16px;font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
|
||||||
|
.ct td{padding:10px 16px;border-bottom:1px solid var(--color-subtle);font-size:12px;vertical-align:top;}
|
||||||
|
.ct tr:last-child td{border-bottom:none;}
|
||||||
|
.ct td:first-child{font-weight:500;font-size:11px;color:var(--color-text-muted);white-space:nowrap;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>C3 — Abwechslungs-Analyse · Rework</h1>
|
||||||
|
<p>Recipe App · 3 Mockup-Variationen · Aktuell: technische Tages-Codes, keine Rezeptnamen, kein direkter Tausch</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span class="pill pill-draft">Entwurf</span><br>
|
||||||
|
Erstellt: 2026-04<br>
|
||||||
|
Variationen: 3<br>
|
||||||
|
Screen: C3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Problem</div>
|
||||||
|
<p class="prose">Die aktuelle Seite zeigt Warnungen wie <strong style="font-family:var(--font-mono);font-size:12px;">"MON, WED — erwäge einen Tausch"</strong>. Der Planer muss selbst nachschlagen, welches Gericht an Montag und Mittwoch geplant ist, und dann manuell zum Planer navigieren um zu tauschen. Zwei Probleme:</p>
|
||||||
|
<p class="prose"><strong>1. Keine Rezeptnamen</strong> — Tag-Codes statt echter Gerichte. <strong>2. Kein direkter Tausch</strong> — der Planer muss die Seite verlassen, zurück zum Planer, das richtige Gericht suchen und dann tauschen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════
|
||||||
|
V1 — ERWEITERTE KARTEN
|
||||||
|
════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="var-head var-y">
|
||||||
|
<div class="var-num">V1</div>
|
||||||
|
<div>
|
||||||
|
<div class="var-id">Variation 1</div>
|
||||||
|
<div class="var-title">Erweiterte Karten</div>
|
||||||
|
<div class="var-desc">Minimale Änderung: bestehende gelbe Karten bleiben, aber der Text wird durch strukturierte Zeilen ersetzt — eine pro betroffenem Gericht, mit Wochentag, Rezeptname und "Tauschen →" Link. Score-Bereich und Layout bleiben unverändert.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
|
||||||
|
<!-- Mobile V1 -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<!-- topbar -->
|
||||||
|
<div class="mtb">
|
||||||
|
<div class="mtb-back">‹</div>
|
||||||
|
<div class="mtb-t">Abwechslungs-Analyse</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:20px 16px 16px;">
|
||||||
|
|
||||||
|
<!-- Score hero -->
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:8px;">
|
||||||
|
<span class="score-num" style="font-size:56px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:16px;color:var(--color-text-muted);">/ 10</span>
|
||||||
|
<span style="font-size:14px;font-weight:500;color:var(--yellow-text);margin-left:4px;">Verbesserbar</span>
|
||||||
|
</div>
|
||||||
|
<div class="prog" style="width:120px;"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-scores -->
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Bewertung im Detail</div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warnings — V1 style: same card structure, but rows inside -->
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
|
||||||
|
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop V1 -->
|
||||||
|
<div class="prev-col" style="flex:1;min-width:580px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb">
|
||||||
|
<span class="dtb-bc">Planer /</span>
|
||||||
|
<span class="dtb-t">Abwechslungs-Analyse</span>
|
||||||
|
</div>
|
||||||
|
<div class="dmc">
|
||||||
|
<!-- 2-col: left score + breakdown, right warnings -->
|
||||||
|
<div style="display:flex;gap:32px;">
|
||||||
|
<!-- Left -->
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:12px;">
|
||||||
|
<span class="score-num" style="font-size:72px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:18px;color:var(--color-text-muted);">/ 10</span>
|
||||||
|
<span style="font-size:14px;font-weight:500;color:var(--yellow-text);margin-left:4px;">Verbesserbar</span>
|
||||||
|
</div>
|
||||||
|
<div class="prog" style="width:200px;"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Bewertung im Detail</div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right: warnings -->
|
||||||
|
<div style="width:340px;flex-shrink:0;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes notes-y">
|
||||||
|
<div class="notes-lbl">Design-Notizen V1</div>
|
||||||
|
<ul>
|
||||||
|
<li>Geringster Umbauaufwand — nur VarietyWarningCards.svelte ändert sich, keine Layout-Umstrukturierung.</li>
|
||||||
|
<li>Behält die bekannte Score-Hierarchie bei: Zahl oben, dann Detail, dann Hinweise.</li>
|
||||||
|
<li>Schwachstelle: Hinweise sind trotzdem am Ende der Seite versteckt — auf kurzen Telefon-Bildschirmen muss gescrollt werden, bevor der Planer die Tausch-Links sieht.</li>
|
||||||
|
<li>Die Sub-Scores bleiben immer sichtbar, auch wenn der Planer nur die Tausch-Aktionen braucht.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider"/>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════
|
||||||
|
V2 — AKTIONS-LISTE (EMPFOHLEN)
|
||||||
|
════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="var-head var-g">
|
||||||
|
<div class="var-num">V2</div>
|
||||||
|
<div>
|
||||||
|
<div class="var-id">Variation 2 · Empfohlen</div>
|
||||||
|
<div class="var-title">Aktions-Liste</div>
|
||||||
|
<div class="var-desc">Hinweise rücken nach oben — direkt unter dem Score. Der Planer sieht sofort, was zu tun ist. Sub-Scores wandern in ein ausklappbares "Bewertung im Detail" (native <details>, kein JS). Kompakterer Score-Hero gibt Hinweisen mehr Raum.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
|
||||||
|
<!-- Mobile V2 -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="mtb">
|
||||||
|
<div class="mtb-back">‹</div>
|
||||||
|
<div class="mtb-t">Abwechslungs-Analyse</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:16px;">
|
||||||
|
|
||||||
|
<!-- Compact score strip -->
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--color-surface);border-radius:var(--radius-lg);border:1px solid var(--color-border);margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<span class="score-num" style="font-size:40px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:13px;color:var(--color-text-muted);margin-left:4px;">/ 10</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:12px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar</div>
|
||||||
|
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warnings — primary content -->
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">2 Hinweise</div>
|
||||||
|
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wcard" style="margin-bottom:16px;">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-scores — collapsed -->
|
||||||
|
<details class="det" style="border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 14px;background:var(--color-surface);">
|
||||||
|
<summary>Bewertung im Detail</summary>
|
||||||
|
<div class="det-body">
|
||||||
|
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop V2 -->
|
||||||
|
<div class="prev-col" style="flex:1;min-width:580px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb">
|
||||||
|
<span class="dtb-bc">Planer /</span>
|
||||||
|
<span class="dtb-t">Abwechslungs-Analyse</span>
|
||||||
|
</div>
|
||||||
|
<div class="dmc">
|
||||||
|
<!-- Top: compact score strip + effort -->
|
||||||
|
<div style="display:flex;gap:16px;align-items:center;padding:16px;background:var(--color-surface);border-radius:var(--radius-lg);border:1px solid var(--color-border);margin-bottom:24px;">
|
||||||
|
<div>
|
||||||
|
<span class="score-num" style="font-size:52px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:14px;color:var(--color-text-muted);margin-left:6px;">/ 10</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar — 2 Hinweise</div>
|
||||||
|
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Sub-scores inline on desktop -->
|
||||||
|
<div style="border-left:1px solid var(--color-border);padding-left:16px;display:flex;gap:16px;">
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--red-dark);">4</div>
|
||||||
|
<div style="font-size:10px;color:var(--color-text-muted);">Quellen</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--yellow-text);">7</div>
|
||||||
|
<div style="font-size:10px;color:var(--color-text-muted);">Zutaten</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--green-dark);">8</div>
|
||||||
|
<div style="font-size:10px;color:var(--color-text-muted);">Aufwand</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Warnings full-width -->
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Hinweise</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wcard">
|
||||||
|
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="wcard-row">
|
||||||
|
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
|
||||||
|
<span class="wcard-swap">Tauschen →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes notes-g">
|
||||||
|
<div class="notes-lbl">Design-Notizen V2</div>
|
||||||
|
<ul>
|
||||||
|
<li>Hinweise erscheinen direkt unter dem Score — kein Scrollen nötig auf typischen Telefon-Bildschirmen.</li>
|
||||||
|
<li>Kompakter Score-Strip auf Mobile spart ~80px gegenüber dem aktuellen großen Hero — mehr Raum für die eigentlichen Tausch-Aktionen.</li>
|
||||||
|
<li>Desktop: Sub-Scores werden als kompakte Zahlen-Spalte in die Score-Leiste integriert — kein separater Abschnitt mehr nötig.</li>
|
||||||
|
<li>Native <details> auf Mobile braucht kein JavaScript; funktioniert auch ohne hydration.</li>
|
||||||
|
<li>"2 Hinweise" im Score-Strip auf Desktop gibt dem Planer sofort Kontext, ohne zu scrollen.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider"/>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════
|
||||||
|
V3 — HINWEISE ZUERST
|
||||||
|
════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="var-head var-p">
|
||||||
|
<div class="var-num">V3</div>
|
||||||
|
<div>
|
||||||
|
<div class="var-id">Variation 3</div>
|
||||||
|
<div class="var-title">Hinweise zuerst</div>
|
||||||
|
<div class="var-desc">Invertiertes Layout: die Seite öffnet mit den konkreten Problem-Karten — groß und klar. Score und Breakdown erscheinen darunter als unterstützende Information. Jede Warnung ist eine eigenständige "Aufgaben-Karte" mit prominentem Tausch-Button statt Link.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
|
||||||
|
<!-- Mobile V3 -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="mtb">
|
||||||
|
<div class="mtb-back">‹</div>
|
||||||
|
<div class="mtb-t">Abwechslungs-Analyse</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:16px;">
|
||||||
|
|
||||||
|
<!-- Problem cards — full width, prominent -->
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Was zu tun ist</div>
|
||||||
|
|
||||||
|
<!-- Problem card 1 -->
|
||||||
|
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:10px;background:var(--yellow-tint);">
|
||||||
|
<div style="padding:12px 14px;border-bottom:1px solid var(--yellow-light);">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:2px;">Quellen-Wiederholung</div>
|
||||||
|
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Tofu an 2 Tagen</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 1 -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-bottom:1px solid rgba(249,224,138,.5);">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1px;">
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Montag</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Gemüse-Pfanne</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1px;">
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Mittwoch</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Problem card 2 -->
|
||||||
|
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:16px;background:var(--yellow-tint);">
|
||||||
|
<div style="padding:12px 14px;border-bottom:1px solid var(--yellow-light);">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:2px;">Zutaten-Überschneidung</div>
|
||||||
|
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Paprika an 2 aufeinanderfolgenden Tagen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-bottom:1px solid rgba(249,224,138,.5);">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1px;">
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Dienstag</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Paprika-Linsen-Eintopf</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1px;">
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--yellow-text);">Mittwoch</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 12px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score — secondary, at bottom -->
|
||||||
|
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px 16px;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">Gesamt-Score</div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
|
||||||
|
<span class="score-num" style="font-size:36px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:13px;color:var(--color-text-muted);">/ 10 · Verbesserbar</span>
|
||||||
|
</div>
|
||||||
|
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
<div class="det" style="margin-top:10px;">
|
||||||
|
<details>
|
||||||
|
<summary style="font-size:11px;color:var(--color-text-muted);cursor:default;">Aufschlüsselung anzeigen</summary>
|
||||||
|
<div style="padding-top:8px;">
|
||||||
|
<div class="sb-row"><span class="sb-label">Quellen-Vielfalt</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Zutaten-Überschneidung</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Aufwandsbalance</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">📊</div><div class="mt-l">Analyse</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop V3 -->
|
||||||
|
<div class="prev-col" style="flex:1;min-width:580px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb">
|
||||||
|
<span class="dtb-bc">Planer /</span>
|
||||||
|
<span class="dtb-t">Abwechslungs-Analyse</span>
|
||||||
|
</div>
|
||||||
|
<div class="dmc">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 240px;gap:24px;">
|
||||||
|
<!-- Left: problem cards -->
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Was zu tun ist</div>
|
||||||
|
|
||||||
|
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;margin-bottom:10px;background:var(--yellow-tint);">
|
||||||
|
<div style="padding:10px 16px;border-bottom:1px solid var(--yellow-light);">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:1px;">Quellen-Wiederholung</div>
|
||||||
|
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Tofu an 2 Tagen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(249,224,138,.5);">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Montag</span>
|
||||||
|
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Gemüse-Pfanne</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Mittwoch</span>
|
||||||
|
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border:1px solid var(--yellow-light);border-radius:var(--radius-xl);overflow:hidden;background:var(--yellow-tint);">
|
||||||
|
<div style="padding:10px 16px;border-bottom:1px solid var(--yellow-light);">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--yellow-text);margin-bottom:1px;">Zutaten-Überschneidung</div>
|
||||||
|
<div style="font-size:14px;font-weight:500;color:var(--color-text);">Paprika an 2 aufeinanderfolgenden Tagen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(249,224,138,.5);">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Dienstag</span>
|
||||||
|
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Paprika-Linsen-Eintopf</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--yellow-text);width:60px;">Mittwoch</span>
|
||||||
|
<span style="font-size:13px;font-weight:500;color:var(--color-text);">Tofu-Curry mit Reis</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" style="font-size:12px;font-weight:600;padding:6px 14px;background:var(--yellow-text);color:#fff;border-radius:var(--radius-md);white-space:nowrap;text-decoration:none;">Tauschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: score panel -->
|
||||||
|
<div>
|
||||||
|
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;">Score</div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px;">
|
||||||
|
<span class="score-num" style="font-size:40px;color:var(--color-text);">5.8</span>
|
||||||
|
<span style="font-size:13px;color:var(--color-text-muted);">/ 10</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;font-weight:500;color:var(--yellow-text);margin-bottom:6px;">Verbesserbar</div>
|
||||||
|
<div class="prog"><div class="prog-fill" style="width:58%;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||||
|
<div style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:10px;">Aufschlüsselung</div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Quellen</span><span class="sb-val" style="color:var(--red-dark);">4 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Zutaten</span><span class="sb-val" style="color:var(--yellow-text);">7 / 10</span></div>
|
||||||
|
<div class="sb-row"><span class="sb-label">Aufwand</span><span class="sb-val" style="color:var(--green-dark);">8 / 10</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes notes-p">
|
||||||
|
<div class="notes-lbl">Design-Notizen V3</div>
|
||||||
|
<ul>
|
||||||
|
<li>Klarer Fokus: Das erste, was der Planer sieht, ist "Was zu tun ist" — keine Score-Hierarchie die von der Aktion ablenkt.</li>
|
||||||
|
<li>Prominente "Tauschen"-Buttons (gefüllt, dunkelgelb) statt Links — erhöht die Tipp-Fläche auf Mobile und macht die Aktion offensichtlicher.</li>
|
||||||
|
<li>Voller Wochentag ("Montag" statt "Mo") — lesbarer, besonders auf Desktop.</li>
|
||||||
|
<li>Schwachstelle: Wenn es keine Hinweise gibt (Score ≥ 9), wirkt die Seite leer — der Score müsste dann nach oben rücken. Erfordert einen separaten Empty-State.</li>
|
||||||
|
<li>Höherer Umbauaufwand gegenüber V1 und V2 — die Page-Struktur ändert sich grundlegend.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider"/>
|
||||||
|
|
||||||
|
<!-- Comparison -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Vergleich</div>
|
||||||
|
<table class="ct">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kriterium</th>
|
||||||
|
<th style="color:var(--yellow-text);">V1 Erweiterte Karten</th>
|
||||||
|
<th style="color:var(--green-dark);">V2 Aktions-Liste ★</th>
|
||||||
|
<th style="color:var(--purple-dark);">V3 Hinweise zuerst</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Rezeptnamen sichtbar</td>
|
||||||
|
<td>✓ Ja</td>
|
||||||
|
<td>✓ Ja</td>
|
||||||
|
<td>✓ Ja, prominent</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Direkter Tausch</td>
|
||||||
|
<td>Link</td>
|
||||||
|
<td>Link</td>
|
||||||
|
<td>Button (größere Tap-Fläche)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hinweise sichtbar ohne Scrollen</td>
|
||||||
|
<td>Nein (Score + Breakdown zuerst)</td>
|
||||||
|
<td>Ja (direkt unter kompaktem Score)</td>
|
||||||
|
<td>Ja (ganz oben)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Umbauaufwand</td>
|
||||||
|
<td>Niedrig</td>
|
||||||
|
<td>Mittel</td>
|
||||||
|
<td>Hoch</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Layout-Änderung</td>
|
||||||
|
<td>Keine</td>
|
||||||
|
<td>Score kompakter, Details kollabierbar</td>
|
||||||
|
<td>Grundlegende Umstrukturierung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Empfehlung</td>
|
||||||
|
<td>Wenn schnelle Lieferung Prio</td>
|
||||||
|
<td><strong>Empfohlen ★</strong></td>
|
||||||
|
<td>Wenn Aktions-Fokus Prio</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
700
specs/frontend/e1-settings-kachel.html
Normal file
700
specs/frontend/e1-settings-kachel.html
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>E1 — Einstellungen · Kachel-Ansicht · Finale Spezifikation</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<!--
|
||||||
|
spec:agent
|
||||||
|
document: E1 Einstellungen – Kachel-Ansicht, Finale Spezifikation
|
||||||
|
version: 1.0
|
||||||
|
journey: J8 Edit pantry staples
|
||||||
|
routes: /settings (E1 hub) → /household/staples?ctx=settings (D3)
|
||||||
|
screens: E1, D3
|
||||||
|
chosen-variation: V2 Kachel-Ansicht (Card sections)
|
||||||
|
last-updated: 2026-04-09
|
||||||
|
|
||||||
|
NAVIGATION STRUCTURE:
|
||||||
|
E1 (/settings) → Hub with 3 cards:
|
||||||
|
Card 1 "Vorräte" → navigates to D3 (/household/staples?ctx=settings)
|
||||||
|
Card 2 "Mitglieder" → navigates to E2 (/members)
|
||||||
|
Card 3 "Profil" → navigates to /profile (not yet implemented)
|
||||||
|
|
||||||
|
DATA:
|
||||||
|
Vorräte count: derived from GET /v1/ingredient-categories response
|
||||||
|
(count ingredients where isStaple === true)
|
||||||
|
Mitglieder count: from layout data (locals.haushalt via GET /v1/households/mine/members)
|
||||||
|
Profil name/email: from locals.benutzer
|
||||||
|
|
||||||
|
NOTE: D3 = A3. StaplesManager component is reused with context="settings".
|
||||||
|
StaplesManager renders categories as StapleChip pill grids, NOT checkboxes.
|
||||||
|
Auto-save on toggle (debounced PATCH 300ms). No save button.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-page: #FAFAF7;
|
||||||
|
--color-surface: #F5F4EE;
|
||||||
|
--color-subtle: #EDECEA;
|
||||||
|
--color-border: #D8D7D0;
|
||||||
|
--color-text-muted: #6B6A63;
|
||||||
|
--color-text: #1C1C18;
|
||||||
|
--green-tint: #E8F5EA;
|
||||||
|
--green-light: #AEDCB0;
|
||||||
|
--green: #3D8C4A;
|
||||||
|
--green-dark: #2E6E39;
|
||||||
|
--green-deeper: #1E4A26;
|
||||||
|
--yellow-tint: #FDF6D8;
|
||||||
|
--yellow-light: #F9E08A;
|
||||||
|
--yellow-text: #8A6800;
|
||||||
|
--color-error: #DC4C3E;
|
||||||
|
--blue-tint: #E6F1FB;
|
||||||
|
--blue: #185FA5;
|
||||||
|
--blue-dark: #0C447C;
|
||||||
|
--font-display: 'Fraunces', Georgia, serif;
|
||||||
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
|
||||||
|
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||||
|
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── Doc layout ── */
|
||||||
|
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
|
||||||
|
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
|
||||||
|
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||||
|
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
|
||||||
|
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
|
||||||
|
.intro { font-size: 14px; line-height: 1.75; max-width: 640px; margin-bottom: 40px; }
|
||||||
|
|
||||||
|
/* ── State sections ── */
|
||||||
|
.state { margin-bottom: 64px; }
|
||||||
|
.state-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.state-id { font-family: var(--font-mono); font-size: 10px; font-weight: 500; background: var(--color-subtle); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-sm); white-space: nowrap; margin-top: 3px; }
|
||||||
|
.state-title { font-size: 16px; font-weight: 500; letter-spacing: -0.01em; }
|
||||||
|
.state-desc { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; max-width: 540px; }
|
||||||
|
|
||||||
|
/* ── Preview containers ── */
|
||||||
|
.preview-wrap { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
|
||||||
|
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||||
|
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
|
||||||
|
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
|
||||||
|
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
|
||||||
|
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
|
||||||
|
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── AppShell chrome ── */
|
||||||
|
.shell { display: flex; min-height: 100vh; background: var(--color-page); font-family: var(--font-sans); }
|
||||||
|
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
|
||||||
|
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
|
||||||
|
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
|
||||||
|
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
|
||||||
|
.sidebar-nav { flex: 1; padding: 4px 8px; }
|
||||||
|
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
|
||||||
|
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
|
||||||
|
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
|
||||||
|
.sidebar-item:not(.active):hover { background: var(--color-subtle); }
|
||||||
|
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── Page content ── */
|
||||||
|
.page-content { flex: 1; padding: 32px 40px; }
|
||||||
|
.page-title { font-family: var(--font-display); font-size: 24px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--color-text-muted); margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* ── Settings card grid ── */
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
|
||||||
|
.settings-grid-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
|
||||||
|
|
||||||
|
/* ── Setting card ── */
|
||||||
|
.setting-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px; box-shadow: var(--shadow-card); cursor: pointer; text-decoration: none; color: inherit; display: flex; flex-direction: column; }
|
||||||
|
.setting-card:hover { box-shadow: var(--shadow-raised); border-color: #C0BFB8; }
|
||||||
|
.setting-card.primary { border-left: 3px solid var(--green-dark); }
|
||||||
|
.setting-card.primary:hover { border-left-color: var(--green-dark); }
|
||||||
|
|
||||||
|
.card-icon { font-size: 22px; margin-bottom: 12px; }
|
||||||
|
.card-stat { font-family: var(--font-display); font-size: 36px; font-weight: 500; letter-spacing: -0.02em; color: var(--green-dark); line-height: 1; margin-bottom: 2px; }
|
||||||
|
.card-stat-label { font-size: 11px; color: var(--color-text-muted); margin-bottom: 12px; }
|
||||||
|
.card-title { font-size: 15px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.card-desc { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; flex: 1; }
|
||||||
|
.card-cta { margin-top: 16px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: var(--green-dark); }
|
||||||
|
.card-cta-secondary { margin-top: 16px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: var(--color-text-muted); }
|
||||||
|
.card-meta { font-size: 12px; color: var(--color-text-muted); margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* ── D3 Staples page chrome ── */
|
||||||
|
.breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); margin-bottom: 20px; }
|
||||||
|
.breadcrumb a { color: var(--color-text-muted); text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { color: var(--color-text); }
|
||||||
|
.breadcrumb-sep { font-size: 10px; }
|
||||||
|
|
||||||
|
/* ── Staple chips ── */
|
||||||
|
.category-block { margin-bottom: 24px; }
|
||||||
|
.category-name { font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 10px; }
|
||||||
|
.chip-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.chip { padding: 5px 12px; border-radius: var(--radius-full); border: 1px solid var(--color-border); font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
||||||
|
.chip.on { background: var(--green-dark); color: white; border-color: var(--green-dark); }
|
||||||
|
.chip.off { background: transparent; color: var(--color-text-muted); }
|
||||||
|
.chip.off:hover { border-color: var(--green-light); color: var(--green-dark); }
|
||||||
|
.save-note { font-size: 11px; color: var(--color-text-muted); margin-top: 16px; font-style: italic; }
|
||||||
|
|
||||||
|
/* ── Mobile shell ── */
|
||||||
|
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
|
||||||
|
.m-header { padding: 16px; background: white; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.m-header-title { font-size: 16px; font-weight: 500; }
|
||||||
|
.m-content { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.m-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 16px; box-shadow: var(--shadow-card); }
|
||||||
|
.m-card.primary { border-left: 3px solid var(--green-dark); }
|
||||||
|
.m-card-stat { font-family: var(--font-display); font-size: 28px; font-weight: 500; color: var(--green-dark); line-height: 1; margin-bottom: 2px; }
|
||||||
|
.m-card-stat-label { font-size: 10px; color: var(--color-text-muted); margin-bottom: 8px; }
|
||||||
|
.m-card-title { font-size: 14px; font-weight: 500; margin-bottom: 3px; }
|
||||||
|
.m-card-desc { font-size: 11px; color: var(--color-text-muted); }
|
||||||
|
.m-card-cta { margin-top: 12px; font-size: 11px; font-weight: 500; color: var(--green-dark); }
|
||||||
|
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
|
||||||
|
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
|
||||||
|
.m-tab.active { color: var(--green-dark); }
|
||||||
|
.m-tab-icon { font-size: 20px; }
|
||||||
|
|
||||||
|
/* ── Agent section ── */
|
||||||
|
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
|
||||||
|
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
|
||||||
|
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
|
||||||
|
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
|
||||||
|
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
|
||||||
|
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
|
||||||
|
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
|
||||||
|
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
|
||||||
|
.agent-table tr:last-child td { border-bottom: none; }
|
||||||
|
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
|
||||||
|
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
|
||||||
|
.agent-table td:nth-child(3) { color: #5A5A55; }
|
||||||
|
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>E1 — Einstellungen</h1>
|
||||||
|
<p>Kachel-Ansicht · Finale Spezifikation · Route: <code>/settings</code> → <code>/household/staples?ctx=settings</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
screens: E1, D3<br/>
|
||||||
|
journey: J8<br/>
|
||||||
|
variation: Kachel (V2)<br/>
|
||||||
|
version: 1.0<br/>
|
||||||
|
date: 2026-04-09
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
Die Einstellungsseite dient als Hub mit drei Kacheln: Vorräte (primäre Aktion, navigiert zu D3),
|
||||||
|
Mitglieder (navigiert zu E2) und Profil. Die Vorräte-Kachel zeigt die aktive Zutatenanzahl als
|
||||||
|
Display-Font-Zahl. D3 verwendet die bestehende StaplesManager-Komponente mit <code>context="settings"</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S1 — Hub-Ansicht (E1 /settings)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S1</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Einstellungs-Hub — drei Kacheln</div>
|
||||||
|
<div class="state-desc">Vorräte-Kachel (2fr, primär mit grünem Akzentstreifen), Mitglieder-Kachel (1fr), Profil-Kachel (1fr). Desktop 2-spaltig oben, dann 2-spaltig unten.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div>
|
||||||
|
<div class="sidebar-household">Familie Raddatz</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Einstellungen</div>
|
||||||
|
<div class="page-subtitle">Familie Raddatz</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<!-- Vorräte card (2fr, primary) -->
|
||||||
|
<a class="setting-card primary" href="#">
|
||||||
|
<div class="card-icon">🥫</div>
|
||||||
|
<div class="card-stat">14</div>
|
||||||
|
<div class="card-stat-label">von 32 Zutaten als Vorrat markiert</div>
|
||||||
|
<div class="card-title">Vorräte</div>
|
||||||
|
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind. Sie werden beim Einkaufen automatisch herausgefiltert.</div>
|
||||||
|
<div class="card-cta">Vorräte bearbeiten →</div>
|
||||||
|
</a>
|
||||||
|
<!-- Mitglieder card (1fr) -->
|
||||||
|
<a class="setting-card" href="#">
|
||||||
|
<div class="card-icon">👥</div>
|
||||||
|
<div class="card-title">Mitglieder</div>
|
||||||
|
<div class="card-meta" style="margin-top:4px;">3 Mitglieder</div>
|
||||||
|
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
|
||||||
|
<div class="card-cta-secondary">Mitglieder verwalten →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid-bottom">
|
||||||
|
<!-- Profil card -->
|
||||||
|
<a class="setting-card" href="#">
|
||||||
|
<div class="card-icon">👤</div>
|
||||||
|
<div class="card-title">Profil</div>
|
||||||
|
<div class="card-meta" style="margin-top:4px;">Marcel R.</div>
|
||||||
|
<div class="card-desc" style="margin-top:8px;">Name und E-Mail-Adresse anpassen.</div>
|
||||||
|
<div class="card-cta-secondary">Profil bearbeiten →</div>
|
||||||
|
</a>
|
||||||
|
<!-- Placeholder / future -->
|
||||||
|
<div style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 24px; display:flex; align-items:center; justify-content:center; color: var(--color-text-muted); font-size: 12px;">Weitere Einstellungen folgen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-header"><div class="m-header-title">Einstellungen</div></div>
|
||||||
|
<div class="m-content">
|
||||||
|
<div class="m-card primary">
|
||||||
|
<div class="m-card-stat">14</div>
|
||||||
|
<div class="m-card-stat-label">von 32 Vorräten aktiv</div>
|
||||||
|
<div class="m-card-title">Vorräte</div>
|
||||||
|
<div class="m-card-desc">Welche Zutaten hast du immer zu Hause?</div>
|
||||||
|
<div class="m-card-cta">Vorräte bearbeiten →</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-card">
|
||||||
|
<div class="m-card-title">👥 Mitglieder</div>
|
||||||
|
<div class="m-card-desc" style="margin-top:4px;">3 Mitglieder · Einladen & Rollen</div>
|
||||||
|
<div class="m-card-cta">Verwalten →</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-card">
|
||||||
|
<div class="m-card-title">👤 Profil</div>
|
||||||
|
<div class="m-card-desc" style="margin-top:4px;">Marcel R.</div>
|
||||||
|
<div class="m-card-cta">Bearbeiten →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Vorräte-Kachel: <code>grid-column: span 1</code> aber <code>2fr</code> Spaltenbreite im 2-Spalten-Grid. Grüner Linksstreifen (<code>border-left: 3px solid --green-dark</code>).</li>
|
||||||
|
<li>Stat-Zahl: Anzahl Zutaten mit <code>isStaple === true</code>, aus dem gleichen Load-Call der D3-Seite</li>
|
||||||
|
<li>Mitglieder-Karte: Anzahl aus <code>locals.haushalt</code> oder separatem API-Call; navigiert zu <code>/members</code></li>
|
||||||
|
<li>Profil-Karte: Name aus <code>locals.benutzer.name</code>; Zielseite <code>/profile</code> (noch nicht implementiert — Link disabled oder Placeholder)</li>
|
||||||
|
<li>Hover: <code>box-shadow: --shadow-raised</code>, leicht dunklerer Border</li>
|
||||||
|
<li>Alle Kacheln sind <code><a></code>-Tags für korrekte Navigation und Accessibility</li>
|
||||||
|
<li>Mobile: Kacheln stapeln sich vertikal in voller Breite, kein Grid</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S2 — Vorräte-Seite (D3 /household/staples?ctx=settings)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S2</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">D3 — Vorräte bearbeiten (StaplesManager, context="settings")</div>
|
||||||
|
<div class="state-desc">Navigiert man von der Vorräte-Kachel aus, erscheint die bestehende StaplesManager-Komponente mit Breadcrumb zurück zu Einstellungen.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="#">← Einstellungen</a>
|
||||||
|
<span class="breadcrumb-sep">/</span>
|
||||||
|
<span>Vorräte</span>
|
||||||
|
</div>
|
||||||
|
<div class="page-title">Vorräte</div>
|
||||||
|
<div class="page-subtitle">Markierte Zutaten werden beim Einkaufen herausgefiltert.</div>
|
||||||
|
|
||||||
|
<!-- StaplesManager content (context="settings") -->
|
||||||
|
<div class="category-block">
|
||||||
|
<div class="category-name">Gewürze & Öle</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="chip on">Salz</span>
|
||||||
|
<span class="chip on">Pfeffer</span>
|
||||||
|
<span class="chip on">Olivenöl</span>
|
||||||
|
<span class="chip off">Paprika</span>
|
||||||
|
<span class="chip off">Kreuzkümmel</span>
|
||||||
|
<span class="chip on">Knoblauch</span>
|
||||||
|
<span class="chip off">Chili</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="category-block">
|
||||||
|
<div class="category-name">Grundnahrung</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="chip on">Reis</span>
|
||||||
|
<span class="chip off">Nudeln</span>
|
||||||
|
<span class="chip on">Mehl</span>
|
||||||
|
<span class="chip on">Zucker</span>
|
||||||
|
<span class="chip off">Linsen</span>
|
||||||
|
<span class="chip off">Hülsenfrüchte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="category-block">
|
||||||
|
<div class="category-name">Kühlschrank</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="chip on">Butter</span>
|
||||||
|
<span class="chip on">Eier</span>
|
||||||
|
<span class="chip off">Milch</span>
|
||||||
|
<span class="chip off">Käse</span>
|
||||||
|
<span class="chip off">Joghurt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="save-note">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-header">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:2px;">← Einstellungen</div>
|
||||||
|
<div class="m-header-title">Vorräte</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-content" style="gap:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="category-name">Gewürze & Öle</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="chip on" style="font-size:11px;">Salz</span>
|
||||||
|
<span class="chip on" style="font-size:11px;">Pfeffer</span>
|
||||||
|
<span class="chip on" style="font-size:11px;">Olivenöl</span>
|
||||||
|
<span class="chip off" style="font-size:11px;">Paprika</span>
|
||||||
|
<span class="chip on" style="font-size:11px;">Knoblauch</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="category-name">Grundnahrung</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="chip on" style="font-size:11px;">Reis</span>
|
||||||
|
<span class="chip off" style="font-size:11px;">Nudeln</span>
|
||||||
|
<span class="chip on" style="font-size:11px;">Mehl</span>
|
||||||
|
<span class="chip on" style="font-size:11px;">Zucker</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Breadcrumb "← Einstellungen" navigiert zurück zu <code>/settings</code></li>
|
||||||
|
<li>"Einstellungen" bleibt in der Sidebar aktiv (kein eigener Nav-Eintrag für Vorräte)</li>
|
||||||
|
<li>StaplesManager-Komponente unverändert mit <code>context="settings"</code> (3-spaltig auf md+)</li>
|
||||||
|
<li>Kein Speichern-Button. Hinweistext "Änderungen werden automatisch gespeichert." unter den Chips</li>
|
||||||
|
<li>Mobile: Chips statt 3-spaltig 1-spaltig (volle Breite), Flex-Wrap bleibt bestehen</li>
|
||||||
|
<li>D3 hat eigene <code>+page.server.ts</code> die <code>+page.svelte</code> bei <code>/household/staples</code> gibt es bereits</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S3 — Hover-Zustand der Kacheln</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S3</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Kachel-Hover — visuelles Feedback</div>
|
||||||
|
<div class="state-desc">Alle Kacheln sind anklickbare Links. Hover hebt die Kachel visuell an.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — Vorräte-Kachel im Hover</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Einstellungen</div>
|
||||||
|
<div class="page-subtitle">Familie Raddatz</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<!-- Hovered Vorräte card -->
|
||||||
|
<a class="setting-card primary" href="#" style="box-shadow:var(--shadow-raised);border-color:#C0BFB8;cursor:pointer;">
|
||||||
|
<div class="card-icon">🥫</div>
|
||||||
|
<div class="card-stat">14</div>
|
||||||
|
<div class="card-stat-label">von 32 Zutaten als Vorrat markiert</div>
|
||||||
|
<div class="card-title">Vorräte</div>
|
||||||
|
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind.</div>
|
||||||
|
<div class="card-cta">Vorräte bearbeiten →</div>
|
||||||
|
</a>
|
||||||
|
<a class="setting-card" href="#">
|
||||||
|
<div class="card-icon">👥</div>
|
||||||
|
<div class="card-title">Mitglieder</div>
|
||||||
|
<div class="card-meta" style="margin-top:4px;">3 Mitglieder</div>
|
||||||
|
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
|
||||||
|
<div class="card-cta-secondary">Mitglieder verwalten →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid-bottom">
|
||||||
|
<a class="setting-card" href="#"><div class="card-icon">👤</div><div class="card-title">Profil</div><div class="card-meta" style="margin-top:4px;">Marcel R.</div><div class="card-desc" style="margin-top:8px;">Name und E-Mail anpassen.</div><div class="card-cta-secondary">Profil bearbeiten →</div></a>
|
||||||
|
<div style="border:1.5px dashed var(--color-border);border-radius:var(--radius-xl);padding:24px;display:flex;align-items:center;justify-content:center;color:var(--color-text-muted);font-size:12px;">Weitere folgen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Hover: <code>box-shadow: --shadow-raised</code> + <code>border-color: #C0BFB8</code></li>
|
||||||
|
<li>Vorräte-Kachel behält den grünen Linksstreifen auch im Hover</li>
|
||||||
|
<li>Transition: <code>box-shadow 150ms ease, border-color 150ms ease</code></li>
|
||||||
|
<li>Cursor: <code>pointer</code> auf allen Kacheln</li>
|
||||||
|
<li>Focus-visible: <code>outline: 2px solid --green-dark; outline-offset: 2px</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S4 — Leerer Zustand (kein Vorrat gesetzt)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S4</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Vorräte-Kachel bei 0 aktiven Vorräten</div>
|
||||||
|
<div class="state-desc">Wenn noch kein Vorrat gesetzt wurde, zeigt die Kachel eine Einladung zur Aktion statt der Zahl.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — 0 Vorräte</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Einstellungen</div>
|
||||||
|
<div class="page-subtitle">Familie Raddatz</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<a class="setting-card primary" href="#">
|
||||||
|
<div class="card-icon">🥫</div>
|
||||||
|
<!-- Empty state: no big number, instead prompt -->
|
||||||
|
<div style="font-size:13px;color:var(--color-text-muted);margin-bottom:8px;">Noch keine Vorräte eingerichtet</div>
|
||||||
|
<div class="card-title">Vorräte</div>
|
||||||
|
<div class="card-desc">Lege fest, welche Zutaten immer zu Hause sind. Sie werden beim Einkaufen automatisch herausgefiltert.</div>
|
||||||
|
<div class="card-cta">Jetzt einrichten →</div>
|
||||||
|
</a>
|
||||||
|
<a class="setting-card" href="#">
|
||||||
|
<div class="card-icon">👥</div>
|
||||||
|
<div class="card-title">Mitglieder</div>
|
||||||
|
<div class="card-meta" style="margin-top:4px;">1 Mitglied</div>
|
||||||
|
<div class="card-desc" style="margin-top:8px;">Haushaltsmitglieder einladen, Rollen verwalten.</div>
|
||||||
|
<div class="card-cta-secondary">Mitglieder verwalten →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid-bottom">
|
||||||
|
<a class="setting-card" href="#"><div class="card-icon">👤</div><div class="card-title">Profil</div><div class="card-meta" style="margin-top:4px;">Marcel R.</div><div class="card-cta-secondary" style="margin-top:8px;">Bearbeiten →</div></a>
|
||||||
|
<div style="border:1.5px dashed var(--color-border);border-radius:var(--radius-xl);padding:24px;display:flex;align-items:center;justify-content:center;color:var(--color-text-muted);font-size:12px;">Weitere folgen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Wenn <code>stapleCount === 0</code>: Stat-Zahl weglassen, stattdessen "Noch keine Vorräte eingerichtet" in muted</li>
|
||||||
|
<li>CTA-Text ändert sich: "Jetzt einrichten →" statt "Vorräte bearbeiten →"</li>
|
||||||
|
<li>Kachel navigiert weiterhin zu D3 — StaplesManager lädt immer, unabhängig vom Count</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Machine-readable agent section ─── -->
|
||||||
|
<div class="agent-section">
|
||||||
|
<h2>Maschinen-lesbare Spezifikation</h2>
|
||||||
|
<p>Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.</p>
|
||||||
|
|
||||||
|
<pre class="spec-comment">
|
||||||
|
/* spec:rules — E1 Einstellungen Kachel
|
||||||
|
*
|
||||||
|
* ROUTE: /settings (E1 hub)
|
||||||
|
* DATA LOAD (page.server.ts):
|
||||||
|
* - GET /v1/ingredient-categories to count stapleCount
|
||||||
|
* stapleCount = sum of ingredients where isStaple === true
|
||||||
|
* - member count available from layout data (locals.haushalt)
|
||||||
|
* or fetch GET /v1/households/mine/members and count length
|
||||||
|
* - profile name from locals.benutzer.name
|
||||||
|
*
|
||||||
|
* LAYOUT: E1 HUB
|
||||||
|
* grid: 2 columns (2fr 1fr) top row + 2 columns (1fr 1fr) bottom row; gap 16px
|
||||||
|
* mobile: single column, full-width cards, gap 12px
|
||||||
|
*
|
||||||
|
* CARD: all cards are <a> tags (href to target route)
|
||||||
|
* border-radius: --radius-xl
|
||||||
|
* border: 1px solid --color-border
|
||||||
|
* bg: white
|
||||||
|
* padding: 24px desktop / 16px mobile
|
||||||
|
* hover: box-shadow --shadow-raised, border-color #C0BFB8
|
||||||
|
* transition: box-shadow 150ms ease, border-color 150ms ease
|
||||||
|
* cursor: pointer
|
||||||
|
* focus-visible: outline 2px solid --green-dark, offset 2px
|
||||||
|
*
|
||||||
|
* VORRÄTE CARD (primary)
|
||||||
|
* border-left: 3px solid --green-dark
|
||||||
|
* stat number: font-family --font-display, font-size 36px, color --green-dark
|
||||||
|
* stat label: "von {total} Zutaten als Vorrat markiert", 11px, --color-text-muted
|
||||||
|
* empty state (stapleCount === 0): hide stat, show "Noch keine Vorräte eingerichtet"
|
||||||
|
* cta: "Vorräte bearbeiten →" (empty: "Jetzt einrichten →")
|
||||||
|
* href: /household/staples?ctx=settings
|
||||||
|
*
|
||||||
|
* MITGLIEDER CARD
|
||||||
|
* meta: "{memberCount} Mitglieder"
|
||||||
|
* href: /members
|
||||||
|
*
|
||||||
|
* PROFIL CARD
|
||||||
|
* meta: locals.benutzer.name
|
||||||
|
* href: /profile (not yet implemented — render as disabled or placeholder)
|
||||||
|
*
|
||||||
|
* ROUTE: /household/staples?ctx=settings (D3)
|
||||||
|
* component: StaplesManager with context="settings" (already exists)
|
||||||
|
* breadcrumb: "← Einstellungen" linking back to /settings
|
||||||
|
* sidebar: "Einstellungen" stays active (no separate nav item for staples)
|
||||||
|
* no save button — StaplesManager auto-saves via debounced PATCH 300ms
|
||||||
|
* hint text below grid: "Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste."
|
||||||
|
* grid: 3-col on md+ (context="settings" already sets this in StaplesManager)
|
||||||
|
*
|
||||||
|
* CHIP STYLES (for reference — rendered by StapleChip, do NOT reimplement)
|
||||||
|
* selected: bg --green-dark, color white, border-color --green-dark
|
||||||
|
* unselected: bg transparent, color --color-text-muted, border 1px solid --color-border
|
||||||
|
* hover unselected: border-color --green-light, color --green-dark
|
||||||
|
*
|
||||||
|
* CATEGORY LABEL TYPOGRAPHY
|
||||||
|
* font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase
|
||||||
|
* color: --color-text-muted; margin-bottom: 10px
|
||||||
|
*/
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<table class="agent-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="group-row"><td colspan="3">E1 Hub Layout</td></tr>
|
||||||
|
<tr><td>grid-desktop</td><td>2fr 1fr / 1fr 1fr</td><td>top row / bottom row</td></tr>
|
||||||
|
<tr><td>grid-mobile</td><td>1fr</td><td>full-width stack</td></tr>
|
||||||
|
<tr><td>gap</td><td>16px desktop / 12px mobile</td><td>—</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">Vorräte Card</td></tr>
|
||||||
|
<tr><td>stat-font</td><td>--font-display, 36px, --green-dark</td><td>Fraunces</td></tr>
|
||||||
|
<tr><td>accent-border</td><td>border-left: 3px solid --green-dark</td><td>primary indicator</td></tr>
|
||||||
|
<tr><td>stat-source</td><td>count isStaple=true from /v1/ingredient-categories</td><td>load in page.server.ts</td></tr>
|
||||||
|
<tr><td>empty-state</td><td>hide stat; show muted text</td><td>when stapleCount === 0</td></tr>
|
||||||
|
<tr><td>href</td><td>/household/staples?ctx=settings</td><td>D3 route</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">D3 Staples Page</td></tr>
|
||||||
|
<tr><td>component</td><td>StaplesManager context="settings"</td><td>existing, do not modify</td></tr>
|
||||||
|
<tr><td>breadcrumb</td><td>← Einstellungen → /settings</td><td>above page title</td></tr>
|
||||||
|
<tr><td>active-nav</td><td>Einstellungen in sidebar</td><td>not a separate nav entry</td></tr>
|
||||||
|
<tr><td>save-hint</td><td>"Änderungen werden automatisch gespeichert."</td><td>below chip grid</td></tr>
|
||||||
|
<tr><td>debounce</td><td>300ms (in StaplesManager)</td><td>do not add extra debounce</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
905
specs/frontend/e2-members-kachel.html
Normal file
905
specs/frontend/e2-members-kachel.html
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>E2 — Mitglieder · Kachel-Ansicht · Finale Spezifikation</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<!--
|
||||||
|
spec:agent
|
||||||
|
document: E2 Mitglieder – Kachel-Ansicht, Finale Spezifikation
|
||||||
|
version: 1.0
|
||||||
|
journey: J7 Manage household members
|
||||||
|
route: /members
|
||||||
|
screen: E2
|
||||||
|
chosen-variation: V2 Kachel-Ansicht (Card grid)
|
||||||
|
last-updated: 2026-04-09
|
||||||
|
|
||||||
|
BACKEND GAPS (must be implemented before this page can ship):
|
||||||
|
- DELETE /v1/households/mine/members/{userId} → remove member
|
||||||
|
- PATCH /v1/households/mine/members/{userId} → body: { role: "planer"|"mitglied" }
|
||||||
|
- GET /v1/households/mine/invites → list active invites with expiry
|
||||||
|
These endpoints do not exist in the current API schema (schema.d.ts).
|
||||||
|
Existing: GET /v1/households/mine/members, POST /v1/households/mine/invites
|
||||||
|
|
||||||
|
ROLE ACCESS:
|
||||||
|
- rolle === 'planer': sees kebab menu on all cards except own
|
||||||
|
- rolle === 'mitglied': sees all cards read-only, no kebab, no invite card CTA
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-page: #FAFAF7;
|
||||||
|
--color-surface: #F5F4EE;
|
||||||
|
--color-subtle: #EDECEA;
|
||||||
|
--color-border: #D8D7D0;
|
||||||
|
--color-text-muted: #6B6A63;
|
||||||
|
--color-text: #1C1C18;
|
||||||
|
--green-tint: #E8F5EA;
|
||||||
|
--green-light: #AEDCB0;
|
||||||
|
--green: #3D8C4A;
|
||||||
|
--green-dark: #2E6E39;
|
||||||
|
--yellow-tint: #FDF6D8;
|
||||||
|
--yellow-light: #F9E08A;
|
||||||
|
--yellow-text: #8A6800;
|
||||||
|
--color-error: #DC4C3E;
|
||||||
|
--error-tint: #FDECEA;
|
||||||
|
--blue-tint: #E6F1FB;
|
||||||
|
--blue: #185FA5;
|
||||||
|
--blue-dark: #0C447C;
|
||||||
|
--font-display: 'Fraunces', Georgia, serif;
|
||||||
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
|
||||||
|
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||||
|
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── Doc layout ── */
|
||||||
|
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
|
||||||
|
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
|
||||||
|
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||||
|
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
|
||||||
|
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
|
||||||
|
.intro { font-size: 14px; line-height: 1.75; max-width: 640px; margin-bottom: 40px; }
|
||||||
|
|
||||||
|
/* ── State sections ── */
|
||||||
|
.state { margin-bottom: 64px; }
|
||||||
|
.state-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.state-id { font-family: var(--font-mono); font-size: 10px; font-weight: 500; background: var(--color-subtle); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-sm); white-space: nowrap; margin-top: 3px; }
|
||||||
|
.state-title { font-size: 16px; font-weight: 500; letter-spacing: -0.01em; }
|
||||||
|
.state-desc { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; max-width: 540px; }
|
||||||
|
|
||||||
|
/* ── Preview ── */
|
||||||
|
.preview-wrap { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
|
||||||
|
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||||
|
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
|
||||||
|
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
|
||||||
|
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
|
||||||
|
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
|
||||||
|
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
|
||||||
|
.notes li.warn::before { content: '⚠'; color: var(--yellow-text); }
|
||||||
|
.notes li.gap::before { content: '✗'; color: var(--color-error); }
|
||||||
|
|
||||||
|
/* ── Warning banner ── */
|
||||||
|
.backend-warning { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 18px; margin-bottom: 40px; }
|
||||||
|
.backend-warning h3 { font-size: 12px; font-weight: 600; color: var(--yellow-text); margin-bottom: 6px; }
|
||||||
|
.backend-warning ul { list-style: none; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.backend-warning li { font-family: var(--font-mono); font-size: 11px; color: var(--yellow-text); display: flex; gap: 8px; }
|
||||||
|
.backend-warning li::before { content: '○'; }
|
||||||
|
|
||||||
|
/* ── AppShell chrome ── */
|
||||||
|
.shell { display: flex; min-height: 100vh; background: var(--color-page); font-family: var(--font-sans); }
|
||||||
|
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
|
||||||
|
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
|
||||||
|
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
|
||||||
|
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
|
||||||
|
.sidebar-nav { flex: 1; padding: 4px 8px; }
|
||||||
|
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
|
||||||
|
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
|
||||||
|
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
|
||||||
|
.sidebar-item:not(.active):hover { background: var(--color-subtle); }
|
||||||
|
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── Page content ── */
|
||||||
|
.page-content { flex: 1; padding: 32px 40px; }
|
||||||
|
.page-title { font-family: var(--font-display); font-size: 24px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--color-text-muted); margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* ── Member card grid ── */
|
||||||
|
.member-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||||
|
.member-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px 20px 20px; box-shadow: var(--shadow-card); position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||||
|
.member-card.hovered { box-shadow: var(--shadow-raised); border-color: #C0BFB8; }
|
||||||
|
.member-card.own { border-color: var(--green-light); }
|
||||||
|
|
||||||
|
.avatar { width: 56px; height: 56px; border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-size: 20px; font-weight: 500; color: white; margin-bottom: 12px; flex-shrink: 0; }
|
||||||
|
.avatar-planer { background: var(--green-dark); }
|
||||||
|
.avatar-mitglied { background: var(--blue); }
|
||||||
|
|
||||||
|
.member-name { font-size: 14px; font-weight: 500; margin-bottom: 6px; }
|
||||||
|
.role-badge { font-size: 10px; font-weight: 500; letter-spacing: 0.04em; padding: 2px 8px; border-radius: var(--radius-full); white-space: nowrap; }
|
||||||
|
.role-badge.planer { background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
.role-badge.mitglied { background: var(--blue-tint); color: var(--blue-dark); }
|
||||||
|
.join-date { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
|
||||||
|
.self-badge { font-size: 10px; font-weight: 500; letter-spacing: 0.04em; padding: 2px 8px; border-radius: var(--radius-full); background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
|
||||||
|
/* ── Kebab button ── */
|
||||||
|
.kebab-btn { position: absolute; top: 12px; right: 12px; width: 28px; height: 28px; border-radius: var(--radius-md); background: transparent; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; color: var(--color-text-muted); }
|
||||||
|
.kebab-btn:hover, .kebab-btn.open { background: var(--color-subtle); color: var(--color-text); }
|
||||||
|
|
||||||
|
/* ── Dropdown menu ── */
|
||||||
|
.dropdown { position: absolute; top: 44px; right: 12px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); min-width: 160px; z-index: 10; overflow: hidden; }
|
||||||
|
.dropdown-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; font-size: 13px; color: var(--color-text); cursor: pointer; white-space: nowrap; }
|
||||||
|
.dropdown-item:hover { background: var(--color-subtle); }
|
||||||
|
.dropdown-item.danger { color: var(--color-error); }
|
||||||
|
.dropdown-item.danger:hover { background: var(--error-tint); }
|
||||||
|
.dropdown-icon { font-size: 14px; width: 16px; text-align: center; }
|
||||||
|
.dropdown-divider { height: 1px; background: var(--color-border); margin: 2px 0; }
|
||||||
|
|
||||||
|
/* ── Role segmented control (inline on card) ── */
|
||||||
|
.role-control { display: flex; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; margin-top: 8px; width: 100%; }
|
||||||
|
.role-control-btn { flex: 1; padding: 6px 8px; font-size: 11px; font-weight: 500; background: white; border: none; cursor: pointer; color: var(--color-text-muted); }
|
||||||
|
.role-control-btn.active { background: var(--green-dark); color: white; }
|
||||||
|
.role-control-btn:first-child { border-right: 1px solid var(--color-border); }
|
||||||
|
|
||||||
|
/* ── Invite card ── */
|
||||||
|
.invite-card { background: white; border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 24px 20px 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; cursor: pointer; min-height: 180px; gap: 10px; }
|
||||||
|
.invite-card:hover { border-color: var(--green-light); background: var(--green-tint); }
|
||||||
|
.invite-plus { width: 44px; height: 44px; border-radius: var(--radius-full); background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 22px; color: var(--color-text-muted); }
|
||||||
|
.invite-card:hover .invite-plus { background: var(--green-light); color: var(--green-dark); }
|
||||||
|
.invite-label { font-size: 13px; font-weight: 500; color: var(--color-text-muted); }
|
||||||
|
.invite-card:hover .invite-label { color: var(--green-dark); }
|
||||||
|
|
||||||
|
/* ── Invite panel (expanded inline) ── */
|
||||||
|
.invite-panel { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 24px; margin-top: 8px; }
|
||||||
|
.invite-panel-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.invite-panel-desc { font-size: 12px; color: var(--color-text-muted); margin-bottom: 16px; }
|
||||||
|
.invite-link-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.invite-link-box { flex: 1; background: var(--color-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 8px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.btn-copy { padding: 8px 14px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
||||||
|
.btn-copy:hover { background: var(--color-subtle); }
|
||||||
|
.invite-expiry { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
|
||||||
|
.invite-expiry span { background: var(--yellow-tint); color: var(--yellow-text); padding: 1px 6px; border-radius: var(--radius-sm); font-weight: 500; }
|
||||||
|
.btn-regen { margin-top: 12px; font-size: 12px; color: var(--color-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; }
|
||||||
|
.btn-regen:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
/* ── Dialog overlay ── */
|
||||||
|
.overlay { position: absolute; inset: 0; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||||
|
.dialog { background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised); }
|
||||||
|
.dialog-title { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
|
||||||
|
.dialog-body { font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px; }
|
||||||
|
.dialog-body strong { color: var(--color-text); font-weight: 500; }
|
||||||
|
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||||
|
.btn-cancel { padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||||
|
.btn-cancel:hover { background: var(--color-subtle); }
|
||||||
|
.btn-remove { padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||||
|
.btn-remove:hover { background: #C43A2E; }
|
||||||
|
|
||||||
|
/* ── Mobile shell ── */
|
||||||
|
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
|
||||||
|
.m-header { padding: 16px; background: white; border-bottom: 1px solid var(--color-border); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.m-header-title { font-size: 16px; font-weight: 500; }
|
||||||
|
.m-header-btn { width: 36px; height: 36px; border-radius: var(--radius-full); background: var(--green-dark); display: flex; align-items: center; justify-content: center; font-size: 18px; color: white; border: none; cursor: pointer; }
|
||||||
|
.m-content { flex: 1; padding: 16px; }
|
||||||
|
.m-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.m-card { background: white; border: 1px solid var(--color-border); border-radius: var(--radius-xl); padding: 16px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; box-shadow: var(--shadow-card); }
|
||||||
|
.m-avatar { width: 44px; height: 44px; border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-size: 16px; font-weight: 500; color: white; margin-bottom: 8px; }
|
||||||
|
.m-avatar.planer { background: var(--green-dark); }
|
||||||
|
.m-avatar.mitglied { background: var(--blue); }
|
||||||
|
.m-name { font-size: 12px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.m-role { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: var(--radius-full); }
|
||||||
|
.m-role.planer { background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
.m-role.mitglied { background: var(--blue-tint); color: var(--blue-dark); }
|
||||||
|
.m-kebab { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--color-text-muted); background: none; border: none; }
|
||||||
|
.m-invite-card { background: white; border: 1.5px dashed var(--color-border); border-radius: var(--radius-xl); padding: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 120px; gap: 6px; }
|
||||||
|
.m-invite-plus { width: 36px; height: 36px; border-radius: var(--radius-full); background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 18px; color: var(--color-text-muted); }
|
||||||
|
.m-invite-label { font-size: 11px; color: var(--color-text-muted); font-weight: 500; }
|
||||||
|
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
|
||||||
|
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
|
||||||
|
.m-tab.active { color: var(--green-dark); }
|
||||||
|
.m-tab-icon { font-size: 20px; }
|
||||||
|
|
||||||
|
/* ── Agent section ── */
|
||||||
|
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
|
||||||
|
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
|
||||||
|
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
|
||||||
|
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
|
||||||
|
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
|
||||||
|
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
|
||||||
|
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
|
||||||
|
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
|
||||||
|
.agent-table tr:last-child td { border-bottom: none; }
|
||||||
|
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
|
||||||
|
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
|
||||||
|
.agent-table td:nth-child(3) { color: #5A5A55; }
|
||||||
|
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>E2 — Mitglieder</h1>
|
||||||
|
<p>Kachel-Ansicht · Finale Spezifikation · Route: <code>/members</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
screen: E2<br/>
|
||||||
|
journey: J7<br/>
|
||||||
|
variation: Kachel (V2)<br/>
|
||||||
|
version: 1.0<br/>
|
||||||
|
date: 2026-04-09
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
Die Mitgliederseite zeigt alle Haushaltsmitglieder als Kacheln. Der Planer kann Rollen ändern und Mitglieder
|
||||||
|
entfernen über ein Kebab-Menü auf jeder Kachel. Eine Einladekachel ermöglicht das Generieren und Kopieren des
|
||||||
|
Einlade-Links. Mitglieder sehen alle Kacheln nur lesend.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="backend-warning">
|
||||||
|
<h3>Backend-Lücken — vor Implementierung schließen</h3>
|
||||||
|
<ul>
|
||||||
|
<li>DELETE /v1/households/mine/members/{userId} — Mitglied entfernen</li>
|
||||||
|
<li>PATCH /v1/households/mine/members/{userId} — Rolle ändern (body: { role })</li>
|
||||||
|
<li>GET /v1/households/mine/invites — aktive Einladungen auflisten (inkl. expiresAt)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S1 — Standardansicht (Planer)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S1</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Standardansicht — Planer sieht vollständige Kacheln</div>
|
||||||
|
<div class="state-desc">Alle Mitglieder als Kacheln, dahinter die Einladekachel. Kebab-Button erscheint on hover.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="sidebar-brand-row">
|
||||||
|
<div class="sidebar-logo"></div>
|
||||||
|
<span class="sidebar-app">Mealplan</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-household">Familie Raddatz</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
|
||||||
|
<div class="member-grid">
|
||||||
|
<!-- Own card -->
|
||||||
|
<div class="member-card own">
|
||||||
|
<div class="avatar avatar-planer">MR</div>
|
||||||
|
<div class="member-name">Marcel R.</div>
|
||||||
|
<span class="role-badge planer">Planer</span>
|
||||||
|
<div class="join-date">seit 02.04.2026</div>
|
||||||
|
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Member 2 -->
|
||||||
|
<div class="member-card">
|
||||||
|
<button class="kebab-btn">⋯</button>
|
||||||
|
<div class="avatar avatar-mitglied">SR</div>
|
||||||
|
<div class="member-name">Sandra R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 03.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<!-- Member 3 -->
|
||||||
|
<div class="member-card">
|
||||||
|
<button class="kebab-btn">⋯</button>
|
||||||
|
<div class="avatar avatar-mitglied">LR</div>
|
||||||
|
<div class="member-name">Lena R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 05.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<!-- Invite card -->
|
||||||
|
<div class="invite-card">
|
||||||
|
<div class="invite-plus">+</div>
|
||||||
|
<div class="invite-label">Mitglied einladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-header">
|
||||||
|
<span class="m-header-title">Mitglieder</span>
|
||||||
|
<button class="m-header-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="m-content">
|
||||||
|
<div class="m-grid">
|
||||||
|
<div class="m-card" style="border-color:var(--green-light);">
|
||||||
|
<div class="m-avatar planer">MR</div>
|
||||||
|
<div class="m-name">Marcel R.</div>
|
||||||
|
<span class="m-role planer">Planer</span>
|
||||||
|
<div style="margin-top:6px;font-size:10px;color:var(--color-text-muted);">Du</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-card">
|
||||||
|
<button class="m-kebab">⋯</button>
|
||||||
|
<div class="m-avatar mitglied">SR</div>
|
||||||
|
<div class="m-name">Sandra R.</div>
|
||||||
|
<span class="m-role mitglied">Mitglied</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-card">
|
||||||
|
<button class="m-kebab">⋯</button>
|
||||||
|
<div class="m-avatar mitglied">LR</div>
|
||||||
|
<div class="m-name">Lena R.</div>
|
||||||
|
<span class="m-role mitglied">Mitglied</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-invite-card">
|
||||||
|
<div class="m-invite-plus">+</div>
|
||||||
|
<div class="m-invite-label">Einladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Eigene Kachel (Du): grüner Kartenrahmen (<code>border: var(--green-light)</code>), "Du"-Badge statt Kebab</li>
|
||||||
|
<li>Kebab-Button (<code>⋯</code>): immer im DOM, <code>opacity:0</code> bis hover/focus, dann <code>opacity:1</code>. Auf Touch-Geräten immer sichtbar.</li>
|
||||||
|
<li>Avatar-Initialen: erste zwei Buchstaben des displayName. Planer = green-dark, Mitglied = blue</li>
|
||||||
|
<li>Kachel-Reihenfolge: eigene Kachel immer zuerst, dann joinedAt aufsteigend, Einladekachel immer zuletzt</li>
|
||||||
|
<li>Mobile: "+" Button in der Header-Zeile öffnet Einlade-Panel. Einladekachel bleibt zusätzlich im Grid.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S2 — Kebab-Menü offen</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S2</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Kebab-Menü geöffnet</div>
|
||||||
|
<div class="state-desc">Klick auf ⋯ öffnet Dropdown mit zwei Aktionen. Klick außerhalb schließt das Menü.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — Menü offen auf "Sandra R."</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar" style="width:224px;min-width:224px;">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
|
||||||
|
<div class="member-grid">
|
||||||
|
<div class="member-card own">
|
||||||
|
<div class="avatar avatar-planer">MR</div>
|
||||||
|
<div class="member-name">Marcel R.</div>
|
||||||
|
<span class="role-badge planer">Planer</span>
|
||||||
|
<div class="join-date">seit 02.04.2026</div>
|
||||||
|
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Card with open menu -->
|
||||||
|
<div class="member-card hovered" style="z-index:20;">
|
||||||
|
<button class="kebab-btn open">⋯</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<div class="dropdown-item"><span class="dropdown-icon">🔄</span>Rolle ändern</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item danger"><span class="dropdown-icon">✕</span>Entfernen</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar avatar-mitglied">SR</div>
|
||||||
|
<div class="member-name">Sandra R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 03.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-card">
|
||||||
|
<button class="kebab-btn">⋯</button>
|
||||||
|
<div class="avatar avatar-mitglied">LR</div>
|
||||||
|
<div class="member-name">Lena R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 05.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="invite-card">
|
||||||
|
<div class="invite-plus">+</div>
|
||||||
|
<div class="invite-label">Mitglied einladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- No mobile preview needed for this state; same as desktop but full-screen -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Dropdown: <code>position: absolute; top: 44px; right: 12px</code> relativ zur Kachel</li>
|
||||||
|
<li>Zwei Einträge: "Rolle ändern" (neutrales Icon 🔄) und "Entfernen" (rot, Icon ✕)</li>
|
||||||
|
<li>Klick außerhalb des Dropdowns schließt diesen (click-away listener)</li>
|
||||||
|
<li>Nur ein Menü gleichzeitig offen. ESC schließt ebenfalls.</li>
|
||||||
|
<li>Mobile: Tap auf ⋯ öffnet Bottom Sheet mit denselben zwei Einträgen (44px min-height pro Eintrag)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S3 — Rolle ändern (inline auf der Kachel)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S3</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Rolle ändern — Segmented Control erscheint</div>
|
||||||
|
<div class="state-desc">Wahl von "Rolle ändern" ersetzt das Rolle-Badge durch einen 2-Button-Schalter [Planer | Mitglied]. Aktive Rolle vorausgewählt. Bestätigung sofort mit PATCH-Request.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — Rolle-Control auf "Sandra R." aktiv</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar" style="width:224px;min-width:224px;">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
|
||||||
|
<div class="member-grid">
|
||||||
|
<div class="member-card own">
|
||||||
|
<div class="avatar avatar-planer">MR</div>
|
||||||
|
<div class="member-name">Marcel R.</div>
|
||||||
|
<span class="role-badge planer">Planer</span>
|
||||||
|
<div class="join-date">seit 02.04.2026</div>
|
||||||
|
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Card in role-edit mode -->
|
||||||
|
<div class="member-card" style="border-color:#B5D4F4;">
|
||||||
|
<div class="avatar avatar-mitglied">SR</div>
|
||||||
|
<div class="member-name">Sandra R.</div>
|
||||||
|
<!-- Role control replaces badge -->
|
||||||
|
<div class="role-control" style="width:100%;">
|
||||||
|
<button class="role-control-btn">Planer</button>
|
||||||
|
<button class="role-control-btn active">Mitglied</button>
|
||||||
|
</div>
|
||||||
|
<div class="join-date">seit 03.04.2026</div>
|
||||||
|
<button style="margin-top:8px;font-size:11px;color:var(--color-text-muted);background:none;border:none;cursor:pointer;text-decoration:underline;">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
<div class="member-card">
|
||||||
|
<button class="kebab-btn">⋯</button>
|
||||||
|
<div class="avatar avatar-mitglied">LR</div>
|
||||||
|
<div class="member-name">Lena R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 05.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="invite-card">
|
||||||
|
<div class="invite-plus">+</div>
|
||||||
|
<div class="invite-label">Mitglied einladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Role-Control ersetzt das Badge in-place auf der Kachel. Kein Dialog, kein Page-Change.</li>
|
||||||
|
<li>Klick auf die inaktive Rolle → optimistisches Update → PATCH /v1/households/mine/members/{userId} { role }</li>
|
||||||
|
<li>Bei Erfolg: Role-Control durch neues Badge ersetzen</li>
|
||||||
|
<li>Bei Fehler: Rollback + Toast "Rolle konnte nicht geändert werden."</li>
|
||||||
|
<li>"Abbrechen" bringt ohne PATCH-Call das Badge zurück</li>
|
||||||
|
<li>Der Planer kann seinen eigenen Planer-Status nicht abgeben, solange er der einzige Planer ist</li>
|
||||||
|
<li>Kachel bekommt blauen Rahmen (<code>border-color: #B5D4F4</code>) als Editier-Indikator</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S4 — Entfernen-Bestätigung (Dialog)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S4</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Bestätigungsdialog "Mitglied entfernen"</div>
|
||||||
|
<div class="state-desc">Klick auf "Entfernen" im Dropdown öffnet einen modalen Dialog. Kein direktes Löschen ohne Bestätigung.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — Dialog über der Seite</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale" style="position:relative;">
|
||||||
|
<div class="shell" style="position:relative;">
|
||||||
|
<div class="sidebar" style="width:224px;min-width:224px;">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content" style="opacity:0.4;pointer-events:none;">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="member-grid">
|
||||||
|
<div class="member-card own"><div class="avatar avatar-planer">MR</div><div class="member-name">Marcel R.</div><span class="role-badge planer">Planer</span></div>
|
||||||
|
<div class="member-card"><div class="avatar avatar-mitglied">SR</div><div class="member-name">Sandra R.</div><span class="role-badge mitglied">Mitglied</span></div>
|
||||||
|
<div class="member-card"><div class="avatar avatar-mitglied">LR</div><div class="member-name">Lena R.</div><span class="role-badge mitglied">Mitglied</span></div>
|
||||||
|
<div class="invite-card"><div class="invite-plus">+</div><div class="invite-label">Mitglied einladen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Dialog -->
|
||||||
|
<div class="overlay" style="position:absolute;">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-title">Mitglied entfernen?</div>
|
||||||
|
<div class="dialog-body"><strong>Sandra R.</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn-cancel">Abbrechen</button>
|
||||||
|
<button class="btn-remove">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;position:relative;">
|
||||||
|
<div class="m-header"><span class="m-header-title">Mitglieder</span><button class="m-header-btn">+</button></div>
|
||||||
|
<div class="m-content" style="opacity:0.35;pointer-events:none;">
|
||||||
|
<div class="m-grid">
|
||||||
|
<div class="m-card"><div class="m-avatar planer">MR</div><div class="m-name">Marcel R.</div><span class="m-role planer">Planer</span></div>
|
||||||
|
<div class="m-card"><div class="m-avatar mitglied">SR</div><div class="m-name">Sandra R.</div><span class="m-role mitglied">Mitglied</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile dialog -->
|
||||||
|
<div class="overlay" style="position:absolute;align-items:flex-end;padding-bottom:0;">
|
||||||
|
<div class="dialog" style="border-radius:var(--radius-xl) var(--radius-xl) 0 0;max-width:100%;padding:24px 24px 32px;">
|
||||||
|
<div class="dialog-title" style="font-size:15px;">Mitglied entfernen?</div>
|
||||||
|
<div class="dialog-body" style="font-size:12px;"><strong>Sandra R.</strong> wird aus dem Haushalt entfernt.</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn-cancel" style="font-size:12px;">Abbrechen</button>
|
||||||
|
<button class="btn-remove" style="font-size:12px;">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Dialog zeigt den <strong>displayName</strong> des Mitglieds explizit</li>
|
||||||
|
<li>Bestätigung → DELETE /v1/households/mine/members/{userId} → Kachel aus Grid entfernen</li>
|
||||||
|
<li>Planer kann sich nicht selbst entfernen (eigene Kachel hat kein Kebab-Menü)</li>
|
||||||
|
<li>Letzter verbleibender Planer kann nicht entfernt werden → Fehlermeldung im Dialog</li>
|
||||||
|
<li>Mobile: Dialog als Bottom Sheet (<code>border-radius</code> nur oben, kein max-width)</li>
|
||||||
|
<li>Hintergrund leicht gedimmt: <code>rgba(28,28,24,.45)</code>, Klick außerhalb schließt nicht (explizite Bestätigung erforderlich)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S5 — Einladekachel: Einlade-Panel</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S5</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Einlade-Panel — nach Klick auf die Einladekachel</div>
|
||||||
|
<div class="state-desc">Kachel expandiert zum Panel unterhalb der Grid-Reihe. Zeigt generierten Link + Ablaufdatum + Regenerieren-Option.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar" style="width:224px;min-width:224px;">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
|
||||||
|
<div class="member-grid">
|
||||||
|
<div class="member-card own"><div class="avatar avatar-planer">MR</div><div class="member-name">Marcel R.</div><span class="role-badge planer">Planer</span><div class="join-date">seit 02.04.2026</div><div style="margin-top:8px;"><span class="self-badge">Du</span></div></div>
|
||||||
|
<div class="member-card"><button class="kebab-btn">⋯</button><div class="avatar avatar-mitglied">SR</div><div class="member-name">Sandra R.</div><span class="role-badge mitglied">Mitglied</span><div class="join-date">seit 03.04.2026</div></div>
|
||||||
|
<div class="member-card"><button class="kebab-btn">⋯</button><div class="avatar avatar-mitglied">LR</div><div class="member-name">Lena R.</div><span class="role-badge mitglied">Mitglied</span><div class="join-date">seit 05.04.2026</div></div>
|
||||||
|
<div class="invite-card" style="border-color:var(--green-light);background:var(--green-tint);">
|
||||||
|
<div class="invite-plus" style="background:var(--green-light);color:var(--green-dark);">+</div>
|
||||||
|
<div class="invite-label" style="color:var(--green-dark);">Mitglied einladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Invite panel below grid -->
|
||||||
|
<div class="invite-panel" style="margin-top:16px;">
|
||||||
|
<div class="invite-panel-title">Einladelink teilen</div>
|
||||||
|
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
|
||||||
|
<div class="invite-link-row">
|
||||||
|
<div class="invite-link-box">https://mealplan.app/join/X4K9-RZMQ</div>
|
||||||
|
<button class="btn-copy">Kopieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="invite-expiry">Läuft ab: <span>12.04.2026</span></div>
|
||||||
|
<button class="btn-regen">Neuen Link generieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Klick auf Einladekachel → POST /v1/households/mine/invites (falls kein aktiver Code vorhanden) oder GET /v1/households/mine/invites</li>
|
||||||
|
<li>Invite-Panel erscheint unterhalb der Grid-Reihe (kein Modal, kein Page-Change)</li>
|
||||||
|
<li>"Kopieren" → navigator.clipboard.writeText(shareUrl) → Button zeigt kurz "Kopiert ✓"</li>
|
||||||
|
<li>"Neuen Link generieren" → POST /v1/households/mine/invites → alten Code invalidieren → neuen Code anzeigen</li>
|
||||||
|
<li>Ablaufdatum <code>expiresAt</code> in gelbem Badge wenn ≤ 24h verbleibend</li>
|
||||||
|
<li>Nur Planer sehen den Einlade-CTA. Mitglied sieht keine Einladekachel.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">S6 — Mitglied-Perspektive (read-only)</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-header">
|
||||||
|
<div class="state-id">S6</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Ansicht als Haushaltsmitglied (rolle = mitglied)</div>
|
||||||
|
<div class="state-desc">Mitglieder sehen die Kacheln ohne Kebab-Menü und ohne Einladekachel.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop — Mitglied-Perspektive</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="sidebar" style="width:224px;min-width:224px;">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
<div class="sidebar-group-label">Haushalt</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">👥</span>Mitglieder</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">⚙️</span>Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="page-title">Mitglieder</div>
|
||||||
|
<div class="page-subtitle">3 Mitglieder · Familie Raddatz</div>
|
||||||
|
<div class="member-grid" style="grid-template-columns:repeat(3,1fr);">
|
||||||
|
<div class="member-card own">
|
||||||
|
<div class="avatar avatar-mitglied">SR</div>
|
||||||
|
<div class="member-name">Sandra R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 03.04.2026</div>
|
||||||
|
<div style="margin-top:8px;"><span class="self-badge">Du</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="member-card">
|
||||||
|
<div class="avatar avatar-planer">MR</div>
|
||||||
|
<div class="member-name">Marcel R.</div>
|
||||||
|
<span class="role-badge planer">Planer</span>
|
||||||
|
<div class="join-date">seit 02.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-card">
|
||||||
|
<div class="avatar avatar-mitglied">LR</div>
|
||||||
|
<div class="member-name">Lena R.</div>
|
||||||
|
<span class="role-badge mitglied">Mitglied</span>
|
||||||
|
<div class="join-date">seit 05.04.2026</div>
|
||||||
|
</div>
|
||||||
|
<!-- No invite card for members -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Mitglied sieht keine Einladekachel und keine Kebab-Buttons auf anderen Kacheln</li>
|
||||||
|
<li>Eigene Kachel zeigt "Du"-Badge (grüner Rahmen), aber kein Kebab</li>
|
||||||
|
<li>Grid passt sich an: bei 3 Kacheln → <code>grid-template-columns: repeat(3, 1fr)</code> (kein leerer Slot für Einladen)</li>
|
||||||
|
<li>Server-seitige Prüfung: Aktionen (DELETE, PATCH) geben 403 für nicht-Planer zurück</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<!-- ─── Machine-readable agent section ─── -->
|
||||||
|
<div class="agent-section">
|
||||||
|
<h2>Maschinen-lesbare Spezifikation</h2>
|
||||||
|
<p>Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.</p>
|
||||||
|
|
||||||
|
<pre class="spec-comment">
|
||||||
|
/* spec:rules — E2 Mitglieder Kachel
|
||||||
|
*
|
||||||
|
* LAYOUT
|
||||||
|
* grid: repeat(4, 1fr) gap 16px desktop; repeat(2, 1fr) gap 12px mobile
|
||||||
|
* card: bg white, border 1px solid --color-border, border-radius --radius-xl
|
||||||
|
* card padding: 24px 20px 20px desktop; 16px mobile
|
||||||
|
*
|
||||||
|
* AVATAR
|
||||||
|
* size: 56px desktop / 44px mobile; border-radius 50%
|
||||||
|
* initials: first two chars of displayName, uppercase
|
||||||
|
* planer color: --green-dark (#2E6E39)
|
||||||
|
* mitglied color: --blue (#185FA5)
|
||||||
|
*
|
||||||
|
* ROLE BADGE
|
||||||
|
* planer: bg --green-tint, color --green-dark
|
||||||
|
* mitglied: bg --blue-tint, color --blue-dark
|
||||||
|
* font-size 10px, font-weight 500, padding 2px 8px, border-radius --radius-full
|
||||||
|
*
|
||||||
|
* OWN CARD (benutzer.id === member.userId)
|
||||||
|
* border-color: --green-light
|
||||||
|
* show "Du" badge below join-date
|
||||||
|
* hide kebab button entirely
|
||||||
|
*
|
||||||
|
* KEBAB BUTTON
|
||||||
|
* position absolute, top 12px, right 12px
|
||||||
|
* opacity 0 by default; 1 on card:hover, card:focus-within, touch devices always 1
|
||||||
|
* opens dropdown: [Rolle ändern, divider, Entfernen(danger)]
|
||||||
|
* click-away closes; ESC closes
|
||||||
|
*
|
||||||
|
* ROLE CHANGE (S3)
|
||||||
|
* replaces badge in-place with segmented control [Planer | Mitglied]
|
||||||
|
* active button: bg --green-dark, color white
|
||||||
|
* inactive button: bg white, color --color-text-muted
|
||||||
|
* on select: PATCH /v1/households/mine/members/{userId} body { role }
|
||||||
|
* optimistic update; on error: rollback + toast
|
||||||
|
* Abbrechen link below control: reverts to badge without API call
|
||||||
|
* guard: planer cannot demote self if sole planer
|
||||||
|
*
|
||||||
|
* REMOVE CONFIRM (S4)
|
||||||
|
* modal dialog, backdrop rgba(28,28,24,.45), backdrop does NOT close on click
|
||||||
|
* shows member displayName in body text
|
||||||
|
* confirm → DELETE /v1/households/mine/members/{userId}
|
||||||
|
* on success: remove card from grid with fade-out
|
||||||
|
* mobile: bottom-sheet (border-radius top only)
|
||||||
|
*
|
||||||
|
* INVITE (S5)
|
||||||
|
* invite card always last in grid, only visible to planer
|
||||||
|
* click → POST /v1/households/mine/invites OR GET /v1/households/mine/invites
|
||||||
|
* panel below grid (not modal)
|
||||||
|
* copy: navigator.clipboard.writeText(shareUrl) → button text "Kopiert ✓" for 2s
|
||||||
|
* regenerate: POST new invite → invalidate old
|
||||||
|
* expiresAt badge yellow if ≤ 24h remaining
|
||||||
|
*
|
||||||
|
* MEMBER VIEW (S6)
|
||||||
|
* rolle === 'mitglied': hide all kebab buttons, hide invite card
|
||||||
|
* grid auto-adjusts columns (no empty slot)
|
||||||
|
*
|
||||||
|
* CARD ORDER
|
||||||
|
* 1. own card (benutzer.id === userId)
|
||||||
|
* 2. other members sorted by joinedAt ASC
|
||||||
|
* 3. invite card (planer only)
|
||||||
|
*
|
||||||
|
* BACKEND GAPS (must exist before page ships)
|
||||||
|
* DELETE /v1/households/mine/members/{userId}
|
||||||
|
* PATCH /v1/households/mine/members/{userId} body: { role: "planer"|"mitglied" }
|
||||||
|
* GET /v1/households/mine/invites
|
||||||
|
*/
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<table class="agent-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="group-row"><td colspan="3">Component: MemberCard</td></tr>
|
||||||
|
<tr><td>card-width</td><td>1fr (grid)</td><td>4-col desktop, 2-col mobile</td></tr>
|
||||||
|
<tr><td>card-min-height</td><td>180px</td><td>desktop; auto mobile</td></tr>
|
||||||
|
<tr><td>avatar-size</td><td>56px / 44px</td><td>desktop / mobile</td></tr>
|
||||||
|
<tr><td>avatar-radius</td><td>50%</td><td>full circle</td></tr>
|
||||||
|
<tr><td>kebab-target</td><td>44×44px</td><td>WCAG 2.2 minimum touch target</td></tr>
|
||||||
|
<tr><td>dropdown-min-width</td><td>160px</td><td>right-aligned to kebab</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">Role Control</td></tr>
|
||||||
|
<tr><td>control-height</td><td>32px</td><td>segmented, full card width</td></tr>
|
||||||
|
<tr><td>active-bg</td><td>--green-dark</td><td>selected role button</td></tr>
|
||||||
|
<tr><td>api-endpoint</td><td>PATCH /v1/households/mine/members/{userId}</td><td>body: { role }</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">Remove Dialog</td></tr>
|
||||||
|
<tr><td>confirm-btn-bg</td><td>--color-error (#DC4C3E)</td><td>danger action</td></tr>
|
||||||
|
<tr><td>api-endpoint</td><td>DELETE /v1/households/mine/members/{userId}</td><td>—</td></tr>
|
||||||
|
<tr><td>backdrop</td><td>rgba(28,28,24,.45)</td><td>click-outside does NOT close</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">Invite</td></tr>
|
||||||
|
<tr><td>api-create</td><td>POST /v1/households/mine/invites</td><td>returns InviteResponse</td></tr>
|
||||||
|
<tr><td>api-list</td><td>GET /v1/households/mine/invites</td><td>backend gap</td></tr>
|
||||||
|
<tr><td>copy-feedback</td><td>"Kopiert ✓" for 2000ms</td><td>then revert to "Kopieren"</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
981
specs/frontend/e4-variety-settings-kachel.html
Normal file
981
specs/frontend/e4-variety-settings-kachel.html
Normal file
@@ -0,0 +1,981 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Recipe App — E4 Vielfalt-Einstellungen · Implementierungsspezifikation</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
/* Journey header */
|
||||||
|
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||||
|
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||||
|
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||||
|
.jh-p{background:var(--purple-tint);border:1px solid var(--purple-light);}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
|
||||||
|
/* Screen block */
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
/* Device frames */
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
|
||||||
|
/* Agent spec block */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
/* LLM section */
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--green);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--green-dark);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
/* Shared nav chrome */
|
||||||
|
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
|
||||||
|
.mtb-back{font-size:12px;color:var(--color-text-muted);display:flex;align-items:center;gap:4px;flex-shrink:0;}
|
||||||
|
.mtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;flex:1;}
|
||||||
|
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;flex-shrink:0;}
|
||||||
|
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}
|
||||||
|
.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||||
|
.mt-i.a .mt-ic{background:var(--green-tint);}
|
||||||
|
.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}
|
||||||
|
.mt-i.a .mt-l{color:var(--green-dark);}
|
||||||
|
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
|
||||||
|
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}
|
||||||
|
.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||||
|
.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
|
||||||
|
.dsb-nav{padding:12px 10px;flex:1;}
|
||||||
|
.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}
|
||||||
|
.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;}
|
||||||
|
.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}
|
||||||
|
.dsb-nc{font-size:13px;width:18px;text-align:center;}
|
||||||
|
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
|
||||||
|
.dtb{padding:14px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
|
||||||
|
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.dtb-bc{font-size:12px;color:var(--color-text-muted);display:flex;align-items:center;gap:6px;margin-bottom:2px;}
|
||||||
|
.dtb-bc span{color:var(--color-border);}
|
||||||
|
.dmc{padding:24px;flex:1;overflow-y:auto;}
|
||||||
|
/* UI components */
|
||||||
|
.tog{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--color-subtle);}
|
||||||
|
.tog:last-child{border-bottom:none;}
|
||||||
|
.tog-l{display:flex;flex-direction:column;gap:2px;}
|
||||||
|
.tog-name{font-size:13px;font-weight:500;}
|
||||||
|
.tog-hint{font-size:11px;color:var(--color-text-muted);}
|
||||||
|
.tog-sw{width:36px;height:20px;border-radius:10px;background:var(--green);flex-shrink:0;position:relative;}
|
||||||
|
.tog-sw::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;right:2px;box-shadow:0 1px 3px rgba(0,0,0,.2);}
|
||||||
|
.tog-sw.off{background:var(--color-border);}
|
||||||
|
.tog-sw.off::after{right:auto;left:2px;}
|
||||||
|
.seg{display:flex;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;background:var(--color-surface);}
|
||||||
|
.seg-o{flex:1;text-align:center;font-size:11px;font-weight:500;padding:6px 0;color:var(--color-text-muted);}
|
||||||
|
.seg-o.a{background:var(--color-page);color:var(--color-text);box-shadow:var(--shadow-card);}
|
||||||
|
.seg-o.a-r{background:var(--red-tint);color:var(--red-dark);}
|
||||||
|
.seg-o.a-g{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
.grp{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
|
||||||
|
.grp-hd{padding:10px 14px;border-bottom:1px solid var(--color-border);background:var(--color-subtle);}
|
||||||
|
.grp-hd-t{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
|
||||||
|
.grp-b{padding:0 14px;}
|
||||||
|
.wr{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--color-subtle);}
|
||||||
|
.wr:last-child{border-bottom:none;}
|
||||||
|
.wr-l{display:flex;flex-direction:column;gap:1px;min-width:0;}
|
||||||
|
.wr-name{font-size:12px;font-weight:500;}
|
||||||
|
.wr-sub{font-size:10px;color:var(--color-text-muted);}
|
||||||
|
/* Context chips */
|
||||||
|
.ctx-chips{display:flex;gap:8px;margin-bottom:16px;}
|
||||||
|
.ctx-chip{flex:1;padding:14px 12px;border-radius:var(--radius-xl);border:1.5px solid var(--color-border);background:var(--color-surface);display:flex;flex-direction:column;gap:3px;cursor:default;}
|
||||||
|
.ctx-chip.sel{border-color:var(--green-light);background:var(--green-tint);}
|
||||||
|
.ctx-chip.ind{border-color:var(--purple-light);background:var(--purple-tint);}
|
||||||
|
.ctx-em{font-size:18px;}
|
||||||
|
.ctx-name{font-size:12px;font-weight:600;color:var(--color-text);}
|
||||||
|
.ctx-chip.sel .ctx-name{color:var(--green-dark);}
|
||||||
|
.ctx-chip.ind .ctx-name{color:var(--purple-dark);}
|
||||||
|
.ctx-sub{font-size:10px;color:var(--color-text-muted);line-height:1.3;}
|
||||||
|
.ctx-chip.sel .ctx-sub{color:var(--green-dark);opacity:.7;}
|
||||||
|
.ctx-chip.ind .ctx-sub{color:var(--purple-dark);opacity:.7;}
|
||||||
|
/* Summary pills */
|
||||||
|
.s-pills{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px;}
|
||||||
|
.s-pill{font-size:10px;font-weight:500;padding:3px 8px;border-radius:20px;display:flex;align-items:center;gap:3px;}
|
||||||
|
.s-pill.on{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
.s-pill.off{background:var(--color-subtle);color:var(--color-text-muted);}
|
||||||
|
.s-pill.warn{background:var(--yellow-tint);color:var(--yellow-text);}
|
||||||
|
/* Accordion */
|
||||||
|
.acc{border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
|
||||||
|
.acc-hd{padding:12px 14px;display:flex;justify-content:space-between;align-items:center;background:var(--color-surface);}
|
||||||
|
.acc-hd-t{font-size:13px;font-weight:500;}
|
||||||
|
.acc-hd-r{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--color-text-muted);}
|
||||||
|
.acc-b{padding:14px;border-top:1px solid var(--color-border);background:var(--color-page);}
|
||||||
|
/* Score preview */
|
||||||
|
.score-banner{background:var(--color-text);border-radius:var(--radius-lg);padding:14px 16px;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between;gap:12px;}
|
||||||
|
.score-banner-l{}
|
||||||
|
.score-banner-label{font-size:10px;color:#6B6A63;margin-bottom:2px;}
|
||||||
|
.score-banner-val{font-family:var(--font-display);font-size:30px;font-weight:300;letter-spacing:-.02em;color:#E8E8E2;line-height:1;}
|
||||||
|
.score-banner-sub{font-size:10px;margin-top:3px;}
|
||||||
|
.score-banner-up{color:#6FCF97;}
|
||||||
|
.score-banner-neutral{color:#6B6A63;}
|
||||||
|
.score-banner-r{font-size:28px;opacity:.7;}
|
||||||
|
/* Summary detail rows (desktop right column) */
|
||||||
|
.sum-rows{display:flex;flex-direction:column;gap:5px;}
|
||||||
|
.sum-row{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-radius:var(--radius-md);font-size:12px;}
|
||||||
|
.sum-row.on{background:var(--green-tint);}
|
||||||
|
.sum-row.off{background:var(--color-subtle);}
|
||||||
|
.sum-row.warn{background:var(--yellow-tint);}
|
||||||
|
.sum-row-name{font-weight:500;}
|
||||||
|
.sum-row.on .sum-row-name{color:var(--green-dark);}
|
||||||
|
.sum-row.off .sum-row-name{color:var(--color-text-muted);}
|
||||||
|
.sum-row.warn .sum-row-name{color:var(--yellow-text);}
|
||||||
|
.sum-row-val{font-size:10px;font-weight:500;}
|
||||||
|
.sum-row.on .sum-row-val{color:var(--green-dark);}
|
||||||
|
.sum-row.off .sum-row-val{color:var(--color-text-muted);}
|
||||||
|
.sum-row.warn .sum-row-val{color:var(--yellow-text);}
|
||||||
|
/* Reset link */
|
||||||
|
.reset-link{font-size:12px;color:var(--red-dark);padding:10px 0;display:block;text-align:center;}
|
||||||
|
/* Divider */
|
||||||
|
.sec-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
|
||||||
|
/* Modal overlay */
|
||||||
|
.overlay{position:relative;border-radius:var(--radius-xl);overflow:hidden;}
|
||||||
|
.modal-backdrop{position:absolute;inset:0;background:rgba(28,28,24,.4);display:flex;align-items:center;justify-content:center;padding:24px;}
|
||||||
|
.modal{background:var(--color-page);border-radius:var(--radius-xl);padding:24px;width:100%;max-width:280px;box-shadow:var(--shadow-overlay);}
|
||||||
|
.modal-title{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;}
|
||||||
|
.modal-body{font-size:13px;color:var(--color-text-muted);line-height:1.6;margin-bottom:20px;}
|
||||||
|
.modal-acts{display:flex;flex-direction:column;gap:8px;}
|
||||||
|
.btn-dest{padding:11px 16px;border-radius:var(--radius-md);background:var(--red);color:#fff;font-weight:500;font-size:13px;text-align:center;}
|
||||||
|
.btn-ghost{padding:11px 16px;border-radius:var(--radius-md);background:var(--color-surface);border:1px solid var(--color-border);color:var(--color-text-muted);font-weight:500;font-size:13px;text-align:center;}
|
||||||
|
/* E1 hub card */
|
||||||
|
.hub-grid{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:12px;}
|
||||||
|
.hub-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-xl);padding:16px;}
|
||||||
|
.hub-card.primary{border-left:3px solid var(--green-dark);}
|
||||||
|
.hub-card.variety{border-left:3px solid var(--purple);}
|
||||||
|
.hub-stat{font-family:var(--font-display);font-size:36px;font-weight:300;letter-spacing:-.02em;line-height:1;margin-bottom:4px;}
|
||||||
|
.hub-stat.green{color:var(--green-dark);}
|
||||||
|
.hub-stat.purple{color:var(--purple);}
|
||||||
|
.hub-name{font-size:12px;font-weight:500;margin-bottom:2px;}
|
||||||
|
.hub-sub{font-size:11px;color:var(--color-text-muted);}
|
||||||
|
.hub-arr{font-size:12px;color:var(--color-text-muted);margin-top:10px;}
|
||||||
|
/* Settings hub bottom row */
|
||||||
|
.hub-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>E4 — Vielfalt-Einstellungen</h1>
|
||||||
|
<p>Implementierungsspezifikation · V2 Kontext-Preset · Journey J9</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span class="pill">v1.0</span><br>
|
||||||
|
Screens: E1 (Update) + E4<br>
|
||||||
|
States: 5<br>
|
||||||
|
Rolle: Planer only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Journey context ── -->
|
||||||
|
<div class="jh jh-p">
|
||||||
|
<div class="jn">J9</div>
|
||||||
|
<div>
|
||||||
|
<h2>Vielfalt-Algorithmus konfigurieren</h2>
|
||||||
|
<p>Planer passt Bewertungsregeln an den Haushaltskontext an — primär das Deaktivieren der Protein-Prüfung für vegetarische Haushalte.</p>
|
||||||
|
<div class="fl">E1 → E4 → C3 · Planer only · Auto-Save · Reset benötigt Bestätigung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════
|
||||||
|
E1 — SETTINGS HUB UPDATE
|
||||||
|
═════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">E1 — Settings-Hub (Update)</div>
|
||||||
|
<p class="prose">Der bestehende Settings-Hub (E1) erhält eine dritte Kachel: "Vielfalt-Einstellungen". Die Kachel zeigt den aktuellen Vielfalt-Score als Kennzahl. Das Grid-Layout wird von 2-spaltig zu einem Mix aus Hauptkachel oben und zwei gleichbreiten Kacheln unten angepasst.</p>
|
||||||
|
|
||||||
|
<!-- S0: E1 Hub -->
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head"><h3>S0 · Settings-Hub mit Vielfalt-Kachel</h3><span class="scr-id">E1</span></div>
|
||||||
|
<div class="scr-desc">Die neue Vielfalt-Kachel erscheint in der unteren Reihe neben der Haushalt-Kachel. Zeigt den aktuellen Score als lila Kennzahl. Bei Score < 6.0 färbt sich die Kennzahl orange als Aufmerksamkeitshinweis.</div>
|
||||||
|
<div class="scr-var"><strong>Änderung gegenüber E1 v1:</strong> dritte Kachel + Grid-Anpassung. Vorräte-Kachel bleibt primär (2fr oben).</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="mtb"><div class="mtb-t">Einstellungen</div></div>
|
||||||
|
<div style="padding:16px;flex:1;">
|
||||||
|
<!-- Vorräte (primary, full width) -->
|
||||||
|
<div class="hub-card primary" style="margin-bottom:12px;">
|
||||||
|
<div class="hub-stat green">12</div>
|
||||||
|
<div class="hub-name">Vorräte</div>
|
||||||
|
<div class="hub-sub">Zutaten immer vorrätig</div>
|
||||||
|
<div class="hub-arr">Bearbeiten →</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom row: 2 cards -->
|
||||||
|
<div class="hub-row">
|
||||||
|
<div class="hub-card">
|
||||||
|
<div class="hub-stat" style="font-size:28px;color:var(--blue-dark);">3</div>
|
||||||
|
<div class="hub-name">Haushalt</div>
|
||||||
|
<div class="hub-sub">Mitglieder</div>
|
||||||
|
<div class="hub-arr">Verwalten →</div>
|
||||||
|
</div>
|
||||||
|
<div class="hub-card variety">
|
||||||
|
<div class="hub-stat purple" style="font-size:28px;">7.4</div>
|
||||||
|
<div class="hub-name">Vielfalt</div>
|
||||||
|
<div class="hub-sub">Diese Woche</div>
|
||||||
|
<div class="hub-arr">Einstellungen →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-col" style="flex:1;min-width:600px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb"><div class="dtb-t">Einstellungen</div></div>
|
||||||
|
<div class="dmc">
|
||||||
|
<div style="max-width:640px;">
|
||||||
|
<!-- Vorräte primary -->
|
||||||
|
<div class="hub-card primary" style="margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="hub-stat green">12</div>
|
||||||
|
<div class="hub-name">Vorräte</div>
|
||||||
|
<div class="hub-sub">Zutaten, die immer vorrätig sind und nicht auf die Einkaufsliste kommen</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--green-dark);">Bearbeiten →</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom row: 2 cards -->
|
||||||
|
<div class="hub-row">
|
||||||
|
<div class="hub-card" style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="hub-stat" style="font-size:28px;color:var(--blue-dark);">3</div>
|
||||||
|
<div class="hub-name">Haushalt</div>
|
||||||
|
<div class="hub-sub">Mitglieder & Rollen</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--blue-dark);">Verwalten →</div>
|
||||||
|
</div>
|
||||||
|
<div class="hub-card variety" style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="hub-stat purple" style="font-size:28px;">7.4</div>
|
||||||
|
<div class="hub-name">Vielfalt-Einstellungen</div>
|
||||||
|
<div class="hub-sub">Algorithmus anpassen</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--purple-dark);">Einstellungen →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>E1 Hub Update · S0</h4>
|
||||||
|
<pre>/* E1 grid: Vorräte (full width, 2fr, border-left: 3px solid --green-dark) on top row.
|
||||||
|
* Bottom row: 2 equal columns — Haushalt + Vielfalt-Einstellungen.
|
||||||
|
* Vielfalt card: border-left: 3px solid --purple. Stat color: --purple (7.4).
|
||||||
|
* If score < 6.0: stat color switches to --orange (Aufmerksamkeit) with no other change.
|
||||||
|
* Score value: load from GET /v1/week-plans?weekStart=current → GET /v1/week-plans/{id}/variety-score.
|
||||||
|
* If no current plan: show "–" as stat value, sub: "Kein Plan".
|
||||||
|
* Tap/click Vielfalt card → navigate to E4. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Vielfalt-Kachel</td></tr>
|
||||||
|
<tr><td>Kennzahl</td><td>varietyScore.score, 1 Dezimalstelle</td><td>Farbe: --purple normal, --orange wenn < 6.0</td></tr>
|
||||||
|
<tr><td>Label</td><td>Vielfalt-Einstellungen</td><td>Desktop; Mobile: "Vielfalt"</td></tr>
|
||||||
|
<tr><td>Sub-Label</td><td>"Diese Woche" / "Kein Plan" / "–"</td><td>Kein Plan = kein weekPlan für aktuelle Woche</td></tr>
|
||||||
|
<tr><td>Rand</td><td>border-left: 3px solid --purple</td><td>Analog zu Vorräte → --green-dark</td></tr>
|
||||||
|
<tr><td>Aktion</td><td>Tap → navigate /settings/variety</td><td>Route: +page.svelte unter (app)/settings/variety/</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Grid-Layout</td></tr>
|
||||||
|
<tr><td>Mobile</td><td>Vorräte fullwidth + grid-template-columns: 1fr 1fr unten</td><td>Gap: 12px</td></tr>
|
||||||
|
<tr><td>Desktop</td><td>Vorräte fullwidth + grid-template-columns: 1fr 1fr unten</td><td>Max-width: 640px, gap: 16px</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════
|
||||||
|
S1 — DEFAULT (KEIN CUSTOM-CONFIG)
|
||||||
|
═════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">E4 — Vielfalt-Einstellungen · States</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head"><h3>S1 · Standard (kein Custom-Config)</h3><span class="scr-id">E4</span></div>
|
||||||
|
<div class="scr-desc">Erster Aufruf, kein haushaltsindividueller Config-Eintrag. Omnivor-Chip ist ausgewählt (Default-Zustand). Score-Preview zeigt den aktuellen tatsächlichen Score — keine Simulation nötig, da noch nichts geändert wurde. Hinweis-Text erklärt kurz den Zweck der Seite.</div>
|
||||||
|
<div class="scr-var"><strong>S1</strong> · Omnivor selected · Score-Preview = aktueller Score · Erweiterte Einstellungen eingeklappt</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
|
||||||
|
<div style="padding:16px;flex:1;overflow-y:auto;">
|
||||||
|
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:14px;">Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.</div>
|
||||||
|
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips">
|
||||||
|
<div class="ctx-chip sel">
|
||||||
|
<div class="ctx-em">🥩</div>
|
||||||
|
<div class="ctx-name">Omnivor</div>
|
||||||
|
<div class="ctx-sub">Alle Regeln aktiv</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-chip">
|
||||||
|
<div class="ctx-em">🥦</div>
|
||||||
|
<div class="ctx-name">Vegetarisch</div>
|
||||||
|
<div class="ctx-sub">Protein deaktiviert</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-chip">
|
||||||
|
<div class="ctx-em">🌱</div>
|
||||||
|
<div class="ctx-name">Vegan</div>
|
||||||
|
<div class="ctx-sub">Protein deaktiviert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec-lbl">Aktive Regeln</div>
|
||||||
|
<div class="s-pills">
|
||||||
|
<div class="s-pill on">✓ Protein</div>
|
||||||
|
<div class="s-pill on">✓ Küche</div>
|
||||||
|
<div class="s-pill on">✓ Zutaten · Mittel</div>
|
||||||
|
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
|
||||||
|
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd">
|
||||||
|
<div class="acc-hd-t">Erweiterte Einstellungen</div>
|
||||||
|
<div class="acc-hd-r">▸</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-banner">
|
||||||
|
<div class="score-banner-l">
|
||||||
|
<div class="score-banner-label">Aktueller Score</div>
|
||||||
|
<div class="score-banner-val">7.4</div>
|
||||||
|
<div class="score-banner-sub score-banner-neutral">Keine Änderungen</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📊</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reset-link" style="color:var(--color-text-muted);">Bereits Standard-Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-col" style="flex:1;min-width:600px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span>›</span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
|
||||||
|
<div class="dmc">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.</div>
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips" style="margin-bottom:20px;">
|
||||||
|
<div class="ctx-chip sel"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r">▸</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="reset-link" style="text-align:left;color:var(--color-text-muted);">Bereits Standard-Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="score-banner">
|
||||||
|
<div>
|
||||||
|
<div class="score-banner-label">Aktueller Score</div>
|
||||||
|
<div class="score-banner-val">7.4 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
|
||||||
|
<div class="score-banner-sub score-banner-neutral">Keine Änderungen aktiv</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📊</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-lbl">Aktive Regeln</div>
|
||||||
|
<div class="sum-rows">
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Protein</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Niedrig</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>E4 · S1 Default</h4>
|
||||||
|
<pre>/* Load: GET /v1/households/mine/variety-config → 404 if no custom config.
|
||||||
|
* On 404: use defaults (Omnivor preset), show Omnivor chip as selected.
|
||||||
|
* Score banner: show actual GET /v1/week-plans/{id}/variety-score (no simulation).
|
||||||
|
* "Bereits Standard-Einstellungen" replaces reset link if no custom config exists.
|
||||||
|
* Accordion: closed. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Laden</td></tr>
|
||||||
|
<tr><td>Config-Load</td><td>GET /v1/households/mine/variety-config</td><td>404 → Defaults verwenden, Omnivor selected</td></tr>
|
||||||
|
<tr><td>Score-Load</td><td>GET /v1/week-plans/{id}/variety-score</td><td>Nur wenn weekPlan existiert; sonst Score-Banner ausblenden</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Kontext-Chips</td></tr>
|
||||||
|
<tr><td>Omnivor</td><td>repeatTagTypes: ["protein","cuisine"], alle Gewichte Standard</td><td>Default-Preset = backend defaults</td></tr>
|
||||||
|
<tr><td>Vegetarisch</td><td>repeatTagTypes: ["cuisine"], wTagRepeat Standard</td><td>Protein deaktiviert</td></tr>
|
||||||
|
<tr><td>Vegan</td><td>repeatTagTypes: ["cuisine"], wTagRepeat Standard</td><td>Identisch zu Vegetarisch in v1</td></tr>
|
||||||
|
<tr><td>Individuell</td><td>Erscheint automatisch wenn Advanced abweicht vom Preset</td><td>Kein manuell wählbarer Chip — nur automatisch</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Score-Banner (S1)</td></tr>
|
||||||
|
<tr><td>Wert</td><td>Aktueller Score (keine Simulation)</td><td>Label: "Aktueller Score"</td></tr>
|
||||||
|
<tr><td>Sub-Label</td><td>"Keine Änderungen"</td><td>Neutral-Farbe (#6B6A63)</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- S2: Vegetarisch selected -->
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head"><h3>S2 · Vegetarisch ausgewählt — Score-Simulation</h3><span class="scr-id">E4</span></div>
|
||||||
|
<div class="scr-desc">Planer tippt auf "Vegetarisch". Config wird sofort per PATCH gespeichert. Score-Banner lädt die simulierte Punktzahl: wie würde der aktuelle Plan mit der neuen Config abschneiden. Delta wird grün hervorgehoben. Protein-Pill wechselt zu "off". Erweiterte Einstellungen zeigt Protein-Toggle als deaktiviert.</div>
|
||||||
|
<div class="scr-var"><strong>S2</strong> · Vegetarisch selected · Score-Preview = simuliert · Protein-Pill = off</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
|
||||||
|
<div style="padding:16px;flex:1;overflow-y:auto;">
|
||||||
|
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:14px;">Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.</div>
|
||||||
|
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips">
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
|
||||||
|
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec-lbl">Aktive Regeln</div>
|
||||||
|
<div class="s-pills">
|
||||||
|
<div class="s-pill off">– Protein</div>
|
||||||
|
<div class="s-pill on">✓ Küche</div>
|
||||||
|
<div class="s-pill on">✓ Zutaten · Mittel</div>
|
||||||
|
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
|
||||||
|
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r">▸</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-banner">
|
||||||
|
<div class="score-banner-l">
|
||||||
|
<div class="score-banner-label">Mit diesen Einstellungen</div>
|
||||||
|
<div class="score-banner-val">8.9</div>
|
||||||
|
<div class="score-banner-sub score-banner-up">↑ +1.5 gegenüber vorher</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📈</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reset-link">Auf Standard zurücksetzen</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-col" style="flex:1;min-width:600px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span>›</span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
|
||||||
|
<div class="dmc">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.</div>
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips" style="margin-bottom:20px;">
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln aktiv</div></div>
|
||||||
|
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein deaktiviert</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r">▸</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="reset-link" style="text-align:left;">Auf Standard zurücksetzen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="score-banner">
|
||||||
|
<div>
|
||||||
|
<div class="score-banner-label">Mit diesen Einstellungen</div>
|
||||||
|
<div class="score-banner-val">8.9 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
|
||||||
|
<div class="score-banner-sub score-banner-up">↑ +1.5 gegenüber vorher</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📈</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-lbl">Aktive Regeln</div>
|
||||||
|
<div class="sum-rows">
|
||||||
|
<div class="sum-row off"><span class="sum-row-name">Protein</span><span class="sum-row-val">Deaktiviert</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Niedrig</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>E4 · S2 Vegetarisch</h4>
|
||||||
|
<pre>/* On chip tap (Vegetarisch):
|
||||||
|
* 1. Optimistic UI: swap selected chip, update pills, update sum-rows immediately.
|
||||||
|
* 2. PATCH /v1/households/mine/variety-config { repeatTagTypes: ["cuisine"],
|
||||||
|
* wTagRepeat: 1.5, wIngredientOverlap: 0.3, wRecentRepeat: 1.0, wPlanDuplicate: 2.0 }
|
||||||
|
* 3. On PATCH success: fire GET /v1/week-plans/{id}/variety-score?simulate=true
|
||||||
|
* with same config body → update score-banner with simulated score + delta.
|
||||||
|
* 4. On PATCH error: rollback to previous chip selection + show toast "Fehler beim Speichern".
|
||||||
|
* Score-Banner during load: show spinner in place of val. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Score-Banner (S2)</td></tr>
|
||||||
|
<tr><td>Label</td><td>"Mit diesen Einstellungen"</td><td>Statt "Aktueller Score"</td></tr>
|
||||||
|
<tr><td>Delta</td><td>"↑ +X.X gegenüber vorher"</td><td>Grün (#6FCF97) wenn positiv; rot wenn negativ; neutral wenn = 0</td></tr>
|
||||||
|
<tr><td>Simulation-Endpoint</td><td>POST /v1/week-plans/{id}/variety-score/simulate</td><td>Body: VarietyScoreConfig-Felder. Neuer Endpoint nötig (Backend-Task).</td></tr>
|
||||||
|
<tr><td>Kein Plan</td><td>Score-Banner ausblenden</td><td>Kein simulierter Score ohne Plan möglich</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Chip-Preset Vegetarisch</td></tr>
|
||||||
|
<tr><td>repeatTagTypes</td><td>["cuisine"]</td><td>Protein entfernt</td></tr>
|
||||||
|
<tr><td>wTagRepeat</td><td>1.5 (Standard)</td><td>Unverändert</td></tr>
|
||||||
|
<tr><td>wIngredientOverlap</td><td>0.3 (Standard)</td><td>Unverändert</td></tr>
|
||||||
|
<tr><td>wRecentRepeat</td><td>1.0 (Standard)</td><td>Unverändert</td></tr>
|
||||||
|
<tr><td>wPlanDuplicate</td><td>2.0 (Standard)</td><td>Unverändert</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- S3: Erweiterte Einstellungen -->
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head"><h3>S3 · Erweiterte Einstellungen geöffnet</h3><span class="scr-id">E4</span></div>
|
||||||
|
<div class="scr-desc">Planer öffnet das Accordion "Erweiterte Einstellungen". Er sieht Segmented Controls (Niedrig / Mittel / Hoch) für jeden Gewichts-Parameter. Ändert er einen Wert, der nicht mehr dem aktuellen Preset entspricht, erscheint automatisch ein vierter Chip "Individuell" (lila) und ersetzt den aktiven Preset-Chip. Score-Banner aktualisiert sich nach jeder Änderung.</div>
|
||||||
|
<div class="scr-var"><strong>S3</strong> · Erweiterte Einstellungen offen · "Individuell"-Chip erschienen (Planer hat Zutaten-Gewicht angepasst)</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
|
||||||
|
<div style="padding:16px;flex:1;overflow-y:auto;">
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips" style="flex-wrap:wrap;">
|
||||||
|
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div></div>
|
||||||
|
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div>
|
||||||
|
<div class="ctx-chip" style="flex:1;min-width:60px;"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div></div>
|
||||||
|
<div class="ctx-chip ind" style="flex:1;min-width:60px;"><div class="ctx-em">✦</div><div class="ctx-name">Individuell</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-pills" style="margin-top:10px;">
|
||||||
|
<div class="s-pill off">– Protein</div>
|
||||||
|
<div class="s-pill on">✓ Küche</div>
|
||||||
|
<div class="s-pill on">✓ Zutaten · Hoch</div>
|
||||||
|
<div class="s-pill on">✓ Letzte Wochen · Mittel</div>
|
||||||
|
<div class="s-pill warn">⚠ Duplikate · Hoch</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r">▾</div></div>
|
||||||
|
<div class="acc-b">
|
||||||
|
<div style="font-size:10px;color:var(--color-text-muted);margin-bottom:10px;">Protein ist über den Kontext deaktiviert. Die übrigen Gewichte kannst du hier anpassen.</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Küche</div><div class="wr-sub">Tag-Wiederholung</div></div>
|
||||||
|
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Zutaten</div><div class="wr-sub">Überschneidung</div></div>
|
||||||
|
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Letzte Wochen</div><div class="wr-sub">Kochverlauf</div></div>
|
||||||
|
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Duplikate</div><div class="wr-sub">Im Plan</div></div>
|
||||||
|
<div class="seg" style="width:108px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-banner">
|
||||||
|
<div class="score-banner-l">
|
||||||
|
<div class="score-banner-label">Mit diesen Einstellungen</div>
|
||||||
|
<div class="score-banner-val">8.1</div>
|
||||||
|
<div class="score-banner-sub score-banner-up">↑ +0.7 gegenüber vorher</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📈</div>
|
||||||
|
</div>
|
||||||
|
<div class="reset-link">Auf Standard zurücksetzen</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-col" style="flex:1;min-width:600px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="dsb">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div><div class="dsb-sub">Familie Raddatz</div></div>
|
||||||
|
<div class="dsb-nav">
|
||||||
|
<div class="dsb-nl">Planung</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">📅</span> Wochenplan</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🛒</span> Einkaufsliste</div>
|
||||||
|
<div class="dsb-ni"><span class="dsb-nc">🍳</span> Rezepte</div>
|
||||||
|
<div class="dsb-nl" style="margin-top:12px;">Haushalt</div>
|
||||||
|
<div class="dsb-ni a"><span class="dsb-nc">⚙️</span> Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm">
|
||||||
|
<div class="dtb"><div><div class="dtb-bc">Einstellungen <span>›</span> Vielfalt-Einstellungen</div><div class="dtb-t">Vielfalt-Einstellungen</div></div></div>
|
||||||
|
<div class="dmc">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 280px;gap:24px;max-width:800px;">
|
||||||
|
<div>
|
||||||
|
<div class="sec-lbl">Haushaltskontext</div>
|
||||||
|
<div class="ctx-chips" style="margin-bottom:20px;">
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🥩</div><div class="ctx-name">Omnivor</div><div class="ctx-sub">Alle Regeln</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div><div class="ctx-sub">Protein aus</div></div>
|
||||||
|
<div class="ctx-chip"><div class="ctx-em">🌱</div><div class="ctx-name">Vegan</div><div class="ctx-sub">Protein aus</div></div>
|
||||||
|
<div class="ctx-chip ind"><div class="ctx-em">✦</div><div class="ctx-name">Individuell</div><div class="ctx-sub">Benutzerdefiniert</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="acc">
|
||||||
|
<div class="acc-hd"><div class="acc-hd-t">Erweiterte Einstellungen</div><div class="acc-hd-r">▾</div></div>
|
||||||
|
<div class="acc-b">
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:12px;">Protein ist über den Haushaltskontext deaktiviert. Passe die Stärke der übrigen Regeln an.</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Küchen-Wiederholung</div><div class="wr-sub">Gleiche Küche an aufeinanderfolgenden Tagen</div></div>
|
||||||
|
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Zutaten-Überschneidung</div><div class="wr-sub">Gleiche Zutaten an aufeinanderfolgenden Tagen</div></div>
|
||||||
|
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Letzte Wochen</div><div class="wr-sub">Kochverlauf (14 Tage)</div></div>
|
||||||
|
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o a">Mittel</div><div class="seg-o">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="wr">
|
||||||
|
<div class="wr-l"><div class="wr-name">Doppelte Rezepte</div><div class="wr-sub">Gleiches Rezept mehrfach im Plan</div></div>
|
||||||
|
<div class="seg" style="width:160px"><div class="seg-o">Niedrig</div><div class="seg-o">Mittel</div><div class="seg-o a-r">Hoch</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reset-link" style="text-align:left;">Auf Standard zurücksetzen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="score-banner">
|
||||||
|
<div>
|
||||||
|
<div class="score-banner-label">Mit diesen Einstellungen</div>
|
||||||
|
<div class="score-banner-val">8.1 <span style="font-size:13px;opacity:.5;">/ 10</span></div>
|
||||||
|
<div class="score-banner-sub score-banner-up">↑ +0.7 gegenüber vorher</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-banner-r">📈</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-lbl">Aktive Regeln</div>
|
||||||
|
<div class="sum-rows">
|
||||||
|
<div class="sum-row off"><span class="sum-row-name">Protein</span><span class="sum-row-val">Deaktiviert</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Küche</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row warn"><span class="sum-row-name">Zutaten</span><span class="sum-row-val">Hoch ↑</span></div>
|
||||||
|
<div class="sum-row on"><span class="sum-row-name">Letzte Wochen</span><span class="sum-row-val">Mittel</span></div>
|
||||||
|
<div class="sum-row warn"><span class="sum-row-name">Duplikate</span><span class="sum-row-val">Hoch</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>E4 · S3 Erweiterte Einstellungen</h4>
|
||||||
|
<pre>/* Accordion öffnet sich per Click/Tap auf acc-hd. Keine Animation nötig — display toggle reicht.
|
||||||
|
* Erweiterte Einstellungen zeigt NUR aktive Tag-Typen als Gewichts-Rows.
|
||||||
|
* Wenn Protein deaktiviert (über Preset): Protein-Row wird in acc-b NICHT angezeigt.
|
||||||
|
* "Individuell"-Chip: erscheint automatisch wenn die Kombination repeatTagTypes+weights
|
||||||
|
* nicht exakt einem der drei Presets entspricht. Kein manueller Auslöser.
|
||||||
|
* Gewichts-Änderung → PATCH → Score-Simulation → Banner-Update.
|
||||||
|
* Debounce der Simulation: 300ms nach letzter Interaktion. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Gewicht-Mapping</td></tr>
|
||||||
|
<tr><td>Niedrig</td><td>Faktor 0.5 × Standard-Gewicht</td><td>wTagRepeat: 0.75, wIngredient: 0.15, wRecent: 0.5, wDuplicate: 1.0</td></tr>
|
||||||
|
<tr><td>Mittel</td><td>Faktor 1.0 (Standard)</td><td>wTagRepeat: 1.5, wIngredient: 0.3, wRecent: 1.0, wDuplicate: 2.0</td></tr>
|
||||||
|
<tr><td>Hoch</td><td>Faktor 1.5 × Standard-Gewicht</td><td>wTagRepeat: 2.25, wIngredient: 0.45, wRecent: 1.5, wDuplicate: 3.0</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Individuell-Chip</td></tr>
|
||||||
|
<tr><td>Trigger</td><td>Wenn gespeicherter Config ≠ Omnivor, Vegetarisch, oder Vegan Preset</td><td>Lila Border + Hintergrund</td></tr>
|
||||||
|
<tr><td>Symbol</td><td>✦ (U+2726)</td><td>Statt Emoji</td></tr>
|
||||||
|
<tr><td>Label</td><td>Individuell</td><td>Nicht anklickbar — nur Status-Indikator</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Simulation-Debounce</td></tr>
|
||||||
|
<tr><td>Delay</td><td>300ms</td><td>Nach letzter Segmented-Control-Interaktion</td></tr>
|
||||||
|
<tr><td>Während Laden</td><td>Score-Wert zeigt Spinner (CSS animation)</td><td>Kein Skeleton — nur val-Bereich</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- S4: Reset confirmation -->
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head"><h3>S4 · Reset-Bestätigung</h3><span class="scr-id">E4</span></div>
|
||||||
|
<div class="scr-desc">Planer tippt "Auf Standard zurücksetzen". Ein Dialog erscheint und benennt explizit, was zurückgesetzt wird. Bestätigung löscht den Custom-Config-Eintrag (DELETE) und stellt die Omnivor-Defaults wieder her. Kein Backdrop-Dismiss — der Planer muss explizit wählen.</div>
|
||||||
|
<div class="scr-var"><strong>S4</strong> · Modal über S2-Zustand · Backdrop nicht anklickbar · Mobile: Bottom Sheet</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px (Bottom Sheet)</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<!-- Blurred background state -->
|
||||||
|
<div class="mtb"><div class="mtb-back">← Einstellungen</div><div class="mtb-t">Vielfalt</div></div>
|
||||||
|
<div style="padding:16px;flex:1;overflow-y:auto;opacity:.35;pointer-events:none;">
|
||||||
|
<div class="ctx-chips">
|
||||||
|
<div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom sheet -->
|
||||||
|
<div style="background:var(--color-page);border-radius:20px 20px 0 0;padding:20px;border-top:1px solid var(--color-border);box-shadow:0 -4px 24px rgba(0,0,0,.12);">
|
||||||
|
<div style="width:36px;height:4px;background:var(--color-border);border-radius:2px;margin:0 auto 16px;"></div>
|
||||||
|
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;margin-bottom:8px;">Auf Standard zurücksetzen?</div>
|
||||||
|
<div style="font-size:12px;color:var(--color-text-muted);line-height:1.6;margin-bottom:16px;">Alle individuellen Einstellungen werden gelöscht. Der Algorithmus verwendet dann wieder:<br><br>• Protein: Aktiv<br>• Küche: Aktiv<br>• Alle Gewichte: Mittel</div>
|
||||||
|
<div class="btn-dest" style="margin-bottom:8px;">Zurücksetzen</div>
|
||||||
|
<div class="btn-ghost">Abbrechen</div>
|
||||||
|
</div>
|
||||||
|
<div class="mbt">
|
||||||
|
<div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Plan</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Einkauf</div></div>
|
||||||
|
<div class="mt-i"><div class="mt-ic">🍳</div><div class="mt-l">Rezepte</div></div>
|
||||||
|
<div class="mt-i a"><div class="mt-ic">⚙️</div><div class="mt-l">Einstellungen</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-col" style="flex:1;min-width:600px;">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px (Centered Modal)</div>
|
||||||
|
<div class="desk overlay">
|
||||||
|
<div class="dsb" style="opacity:.35;">
|
||||||
|
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥦</div><div class="dsb-nm">Mealprep</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="dm" style="opacity:.35;">
|
||||||
|
<div class="dtb"><div class="dtb-t">Vielfalt-Einstellungen</div></div>
|
||||||
|
<div class="dmc"><div class="ctx-chips"><div class="ctx-chip sel"><div class="ctx-em">🥦</div><div class="ctx-name">Vegetarisch</div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Auf Standard zurücksetzen?</div>
|
||||||
|
<div class="modal-body">Alle individuellen Einstellungen werden gelöscht. Der Algorithmus verwendet dann wieder die Standard-Werte:<br><br>
|
||||||
|
<strong>Protein-Prüfung:</strong> Aktiv<br>
|
||||||
|
<strong>Küchen-Vielfalt:</strong> Aktiv<br>
|
||||||
|
<strong>Alle Gewichte:</strong> Mittel</div>
|
||||||
|
<div class="modal-acts">
|
||||||
|
<div class="btn-dest">Zurücksetzen</div>
|
||||||
|
<div class="btn-ghost">Abbrechen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>E4 · S4 Reset-Bestätigung</h4>
|
||||||
|
<pre>/* Reset-Link Tap → Dialog öffnet (kein Backdrop-Dismiss, kein Escape-Dismiss).
|
||||||
|
* "Zurücksetzen" → DELETE /v1/households/mine/variety-config
|
||||||
|
* On success: optimistic reset von UI zu S1 (Omnivor), Score-Banner zeigt echten Score.
|
||||||
|
* On error: Toast "Fehler beim Zurücksetzen".
|
||||||
|
* Mobile: Bottom Sheet (position:fixed, bottom 0, border-radius 20px 20px 0 0).
|
||||||
|
* Desktop: centered modal, backdrop rgba(28,28,24,0.4), max-width 380px. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Wert</th><th>Notizen</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Dialog-Inhalt</td></tr>
|
||||||
|
<tr><td>Titel</td><td>"Auf Standard zurücksetzen?"</td><td>Fraunces 18px (Mobile), 20px (Desktop)</td></tr>
|
||||||
|
<tr><td>Body</td><td>Auflistung der zurückgesetzten Werte</td><td>Muss konkret benennen: Protein aktiv, Küche aktiv, alle Gewichte Mittel</td></tr>
|
||||||
|
<tr><td>Primär-Aktion</td><td>"Zurücksetzen" → DELETE</td><td>Hintergrund: --red, Text: weiß</td></tr>
|
||||||
|
<tr><td>Sekundär-Aktion</td><td>"Abbrechen"</td><td>Ghost-Button, schließt Dialog</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">API</td></tr>
|
||||||
|
<tr><td>Endpoint</td><td>DELETE /v1/households/mine/variety-config</td><td>Löscht Custom-Config-Row; Backend fällt auf Defaults zurück</td></tr>
|
||||||
|
<tr><td>On Success</td><td>UI reset zu S1</td><td>Omnivor chip selected, Score-Banner: echter Score</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════
|
||||||
|
LLM / AGENT REGION
|
||||||
|
═════════════════════════════════════════ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Maschinenlesbare Spezifikation — E4 Vielfalt-Einstellungen</h2>
|
||||||
|
|
||||||
|
<h3>Screens</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Screen</th><th>Route</th><th>Zugriff</th><th>Zweck</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>E1 (Update)</td><td>/settings</td><td>Planer</td><td>Settings-Hub: dritte Kachel "Vielfalt-Einstellungen" mit aktuellem Score</td></tr>
|
||||||
|
<tr><td>E4</td><td>/settings/variety</td><td>Planer only</td><td>Vielfalt-Algorithmus per Kontext-Preset und Feineinstellungen konfigurieren</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>States</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>State</th><th>Trigger</th><th>Beschreibung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>S0</td><td>E1 load</td><td>Settings-Hub zeigt Score-Kachel (lila Kennzahl)</td></tr>
|
||||||
|
<tr><td>S1</td><td>E4 load, kein Custom-Config</td><td>Omnivor chip selected, Score = aktueller echter Score, Reset-Link = deaktiviert/neutral</td></tr>
|
||||||
|
<tr><td>S2</td><td>Preset-Chip tap</td><td>Chip wechselt, PATCH, Score-Simulation lädt und zeigt Delta</td></tr>
|
||||||
|
<tr><td>S3</td><td>Accordion öffnen + Gewicht ändern</td><td>Individuell-Chip erscheint, Score-Simulation mit Debounce 300ms</td></tr>
|
||||||
|
<tr><td>S4</td><td>Reset-Link tap</td><td>Modal/Bottom Sheet — Bestätigung vor DELETE</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>API-Endpoints (neu + bestehend)</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Method</th><th>Endpoint</th><th>Neu?</th><th>Zweck</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>GET</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Aktuellen Config laden; 404 = Defaults verwenden</td></tr>
|
||||||
|
<tr><td>PATCH</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Config speichern (auto-save bei jedem Preset/Gewicht-Wechsel)</td></tr>
|
||||||
|
<tr><td>DELETE</td><td>/v1/households/mine/variety-config</td><td>Neu</td><td>Custom-Config löschen, Backend fällt auf Defaults zurück</td></tr>
|
||||||
|
<tr><td>POST</td><td>/v1/week-plans/{id}/variety-score/simulate</td><td>Neu</td><td>Score simulieren mit temporärem Config-Body (nicht persistiert)</td></tr>
|
||||||
|
<tr><td>GET</td><td>/v1/week-plans/{id}/variety-score</td><td>Bestehend</td><td>Aktuellen Score laden (für S1 Banner + E1 Kachel)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Kontext-Preset Mapping</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Preset</th><th>repeatTagTypes</th><th>wTagRepeat</th><th>wIngredientOverlap</th><th>wRecentRepeat</th><th>wPlanDuplicate</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Omnivor (Default)</td><td>["protein","cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
|
||||||
|
<tr><td>Vegetarisch</td><td>["cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
|
||||||
|
<tr><td>Vegan</td><td>["cuisine"]</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
|
||||||
|
<tr><td>Individuell</td><td>Beliebig (≠ obige Presets)</td><td>Beliebig</td><td>Beliebig</td><td>Beliebig</td><td>Beliebig</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Gewicht-Preset Mapping</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Stufe</th><th>Faktor</th><th>wTagRepeat</th><th>wIngredientOverlap</th><th>wRecentRepeat</th><th>wPlanDuplicate</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Niedrig</td><td>×0.5</td><td>0.75</td><td>0.15</td><td>0.5</td><td>1.0</td></tr>
|
||||||
|
<tr><td>Mittel</td><td>×1.0</td><td>1.5</td><td>0.3</td><td>1.0</td><td>2.0</td></tr>
|
||||||
|
<tr><td>Hoch</td><td>×1.5</td><td>2.25</td><td>0.45</td><td>1.5</td><td>3.0</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Implementierungsregeln (für Agenten)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>E4 ist nur für <code>rolle === 'planer'</code> zugänglich. Mitglieder werden auf E1 redirected.</li>
|
||||||
|
<li>Auto-Save auf jede Preset- oder Gewicht-Änderung. Kein expliziter Speichern-Button.</li>
|
||||||
|
<li>Optimistic Update: UI wechselt sofort; Rollback mit Toast bei API-Fehler.</li>
|
||||||
|
<li>Score-Simulation: Debounce 300ms. Während Laden: Spinner im Score-Wert-Bereich (nicht Skeleton).</li>
|
||||||
|
<li>"Individuell"-Chip ist nicht anklickbar — er ist ein reiner Status-Indikator.</li>
|
||||||
|
<li>Reset-Bestätigung: Backdrop-Dismiss deaktiviert (nicht schließbar durch Klick/Tap auf Overlay).</li>
|
||||||
|
<li>Mobile Reset: Bottom Sheet mit Handle-Bar (36×4px, --color-border, border-radius 2px). Kein Backdrop-Dismiss.</li>
|
||||||
|
<li>Desktop Reset: Zentriertes Modal, max-width 380px. Backdrop rgba(28,28,24,0.4).</li>
|
||||||
|
<li>E1 Vielfalt-Kachel: Score < 6.0 → Kennzahl in --orange; Score ≥ 6.0 → Kennzahl in --purple.</li>
|
||||||
|
<li>E4-Route: <code>(app)/settings/variety/+page.svelte</code>. Load-Funktion: <code>+page.server.ts</code> → Promise.all([GET variety-config, GET variety-score]).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1138
specs/frontend/e4-variety-settings.html
Normal file
1138
specs/frontend/e4-variety-settings.html
Normal file
File diff suppressed because it is too large
Load Diff
841
specs/frontend/variety-page-rework.html
Normal file
841
specs/frontend/variety-page-rework.html
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Variety Page Rework · 3 Variationen</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<!--
|
||||||
|
spec:agent
|
||||||
|
document: /planner/variety — Variety Page Rework, 3 Variationen
|
||||||
|
version: 1.0
|
||||||
|
journey: J4 Swap (adjacent)
|
||||||
|
route: /planner/variety
|
||||||
|
screen: C2
|
||||||
|
variations: V1 Recipe Pills | V2 Action Rows | V3 Week Grid
|
||||||
|
last-updated: 2026-04-09
|
||||||
|
|
||||||
|
PROBLEMS ADDRESSED:
|
||||||
|
1. Warnings show day abbreviations ("MON, WED") — replace with recipe names
|
||||||
|
2. No swap action reachable from warnings — add inline swap CTA per recipe
|
||||||
|
3. Protein score is meat-centric for vegetarian households (backend concern, noted below)
|
||||||
|
|
||||||
|
FRONTEND-ONLY CHANGE (no backend schema changes required for items 1+2):
|
||||||
|
weekPlan.slots has { dayOfWeek: "MON", recipe: { id, name } }
|
||||||
|
tagRepeats.days[] contains day keys matching dayOfWeek
|
||||||
|
→ build slotsByDay map frontend-side, look up recipeName + slotId per day
|
||||||
|
→ swap CTA links to /planner?week={weekStart}&swap={slotId}
|
||||||
|
|
||||||
|
PROTEIN SCORE — VEGETARIAN HOUSEHOLDS (backend concern, TBD):
|
||||||
|
Current: proteinDiversity = 10 - proteinRepeats * 2
|
||||||
|
Problem: vegetarian protein sources (Tofu, Linsen, Ei) may repeat more than
|
||||||
|
omnivore households; penalty of -2 per repeat is calibrated for meat variety.
|
||||||
|
Backend discussed: tag filtering or weight adjustment needed.
|
||||||
|
Frontend impact: if backend changes tagRepeats to exclude non-meat or adjusts score,
|
||||||
|
the frontend ScoreBreakdownList label "Protein-Vielfalt" may need renaming.
|
||||||
|
Until resolved: the rework does NOT change protein score display — only warnings.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-page: #FAFAF7;
|
||||||
|
--color-surface: #F5F4EE;
|
||||||
|
--color-subtle: #EDECEA;
|
||||||
|
--color-border: #D8D7D0;
|
||||||
|
--color-text-muted: #6B6A63;
|
||||||
|
--color-text: #1C1C18;
|
||||||
|
--green-tint: #E8F5EA;
|
||||||
|
--green-light: #AEDCB0;
|
||||||
|
--green: #3D8C4A;
|
||||||
|
--green-dark: #2E6E39;
|
||||||
|
--yellow-tint: #FDF6D8;
|
||||||
|
--yellow-light: #F9E08A;
|
||||||
|
--yellow: #E8B400;
|
||||||
|
--yellow-text: #8A6800;
|
||||||
|
--color-error: #DC4C3E;
|
||||||
|
--blue-tint: #E6F1FB;
|
||||||
|
--blue: #185FA5;
|
||||||
|
--font-display: 'Fraunces', Georgia, serif;
|
||||||
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-xl: 16px; --radius-full: 9999px;
|
||||||
|
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||||
|
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.05);
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.doc { max-width: 1040px; margin: 0 auto; padding: 48px 40px 96px; }
|
||||||
|
.doc-header { padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; }
|
||||||
|
.doc-header p { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||||
|
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
|
||||||
|
.intro { font-size: 14px; line-height: 1.75; max-width: 680px; margin-bottom: 16px; }
|
||||||
|
.section-label { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 32px; margin-top: 56px; }
|
||||||
|
|
||||||
|
/* Notice box */
|
||||||
|
.notice { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 18px; margin-bottom: 40px; }
|
||||||
|
.notice h3 { font-size: 12px; font-weight: 600; color: var(--yellow-text); margin-bottom: 4px; }
|
||||||
|
.notice p { font-size: 12px; color: var(--yellow-text); line-height: 1.6; }
|
||||||
|
.notice code { font-family: var(--font-mono); background: rgba(0,0,0,.07); padding: 1px 4px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* Variation sections */
|
||||||
|
.variation { margin-bottom: 72px; }
|
||||||
|
.var-header { display: flex; align-items: flex-start; gap: 20px; margin-bottom: 24px; }
|
||||||
|
.var-num { font-family: var(--font-display); font-size: 44px; font-weight: 300; color: var(--yellow-light); line-height: 1; flex-shrink: 0; width: 56px; letter-spacing: -0.03em; }
|
||||||
|
.var-meta { flex: 1; padding-top: 4px; }
|
||||||
|
.var-title { font-size: 18px; font-weight: 500; letter-spacing: -0.01em; margin-bottom: 4px; }
|
||||||
|
.var-desc { font-size: 13px; color: var(--color-text-muted); line-height: 1.6; max-width: 540px; }
|
||||||
|
.var-tag { font-size: 10px; font-weight: 500; letter-spacing: 0.07em; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); background: var(--color-subtle); color: var(--color-text-muted); margin-top: 6px; display: inline-block; }
|
||||||
|
.var-tag.rec { background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
.var-tag.amb { background: var(--blue-tint); color: var(--blue); }
|
||||||
|
|
||||||
|
/* Preview containers */
|
||||||
|
.preview-pair { display: flex; gap: 24px; align-items: flex-start; margin-bottom: 20px; }
|
||||||
|
.preview-d-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-m-wrap { flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.preview-label { font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||||
|
.preview-d-clip { height: 340px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-page); }
|
||||||
|
.preview-d-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
.preview-m-clip { width: 196px; height: 340px; overflow: hidden; border: 1.5px solid var(--color-border); border-radius: 24px; background: var(--color-page); }
|
||||||
|
.preview-m-scale { transform: scale(0.5); transform-origin: top left; width: 200%; }
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.notes { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 18px; }
|
||||||
|
.notes-label { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
|
||||||
|
.notes ul { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.notes li { font-size: 12px; color: var(--color-text-muted); line-height: 1.5; display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.notes li::before { content: '→'; color: var(--green); font-weight: 500; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── AppShell chrome ── */
|
||||||
|
.shell { display: flex; background: var(--color-page); font-family: var(--font-sans); overflow: hidden; }
|
||||||
|
.sidebar { width: 224px; min-width: 224px; background: white; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; }
|
||||||
|
.sidebar-brand { padding: 14px 18px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.sidebar-brand-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.sidebar-logo { width: 22px; height: 22px; background: var(--green); border-radius: var(--radius-sm); }
|
||||||
|
.sidebar-app { font-family: var(--font-display); font-size: 15px; font-weight: 500; }
|
||||||
|
.sidebar-household { font-size: 10px; color: var(--color-text-muted); margin-top: 1px; }
|
||||||
|
.sidebar-nav { flex: 1; padding: 4px 8px; }
|
||||||
|
.sidebar-group-label { font-size: 8px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); padding: 16px 12px 4px; }
|
||||||
|
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: var(--radius-md); font-size: 13px; color: var(--color-text); text-decoration: none; }
|
||||||
|
.sidebar-item.active { background: var(--green-tint); color: var(--green-dark); font-weight: 500; }
|
||||||
|
.sidebar-icon { width: 20px; text-align: center; font-size: 16px; }
|
||||||
|
|
||||||
|
/* Page chrome */
|
||||||
|
.topbar { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--color-border); background: var(--color-page); padding: 14px 24px; }
|
||||||
|
.topbar-back { font-size: 13px; color: var(--color-text-muted); text-decoration: none; }
|
||||||
|
.topbar-sep { font-size: 13px; color: var(--color-text-muted); }
|
||||||
|
.topbar-title { font-family: var(--font-display); font-size: 20px; font-weight: 300; }
|
||||||
|
.main { flex: 1; padding: 24px; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Score hero (shared across variations) */
|
||||||
|
.score-hero { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-bottom: 20px; }
|
||||||
|
.score-num { font-family: var(--font-display); font-size: 64px; font-weight: 300; line-height: 1; letter-spacing: -0.03em; }
|
||||||
|
.score-denom { font-family: var(--font-display); font-size: 24px; font-weight: 300; color: var(--color-text-muted); }
|
||||||
|
.score-label { font-size: 13px; font-weight: 500; color: var(--yellow-text); }
|
||||||
|
.score-bar { height: 6px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; width: 160px; }
|
||||||
|
.score-fill { height: 100%; border-radius: var(--radius-full); }
|
||||||
|
.score-fill.good { background: var(--green-dark); }
|
||||||
|
.score-fill.warn { background: var(--yellow); }
|
||||||
|
|
||||||
|
/* Sub-scores */
|
||||||
|
.sub-scores { border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; background: white; margin-bottom: 20px; }
|
||||||
|
.sub-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.sub-row:last-child { border-bottom: none; }
|
||||||
|
.sub-label { font-size: 13px; }
|
||||||
|
.sub-val { font-size: 13px; font-weight: 500; color: var(--yellow-text); }
|
||||||
|
.sub-val.ok { color: var(--green-dark); }
|
||||||
|
|
||||||
|
/* Section heading */
|
||||||
|
.section-hd { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
V1: Recipe Pill Warning Cards
|
||||||
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.warn-card { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 10px; }
|
||||||
|
.warn-title { font-size: 13px; font-weight: 500; color: var(--yellow-text); margin-bottom: 8px; }
|
||||||
|
.pill-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.recipe-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 12px; border-radius: var(--radius-full); background: white; border: 1px solid var(--yellow-light); font-size: 12px; font-weight: 500; color: var(--color-text); }
|
||||||
|
.recipe-pill-day { font-size: 10px; color: var(--color-text-muted); font-weight: 400; }
|
||||||
|
.pill-swap-btn { width: 22px; height: 22px; border-radius: var(--radius-full); background: var(--color-subtle); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
|
.pill-swap-btn:hover { background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
V2: Action Rows
|
||||||
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Compact score header for V2 */
|
||||||
|
.score-compact { display: flex; align-items: center; gap: 14px; padding: 14px 20px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); margin-bottom: 20px; }
|
||||||
|
.score-compact-num { font-family: var(--font-display); font-size: 36px; font-weight: 300; line-height: 1; }
|
||||||
|
.score-compact-denom { font-family: var(--font-display); font-size: 16px; font-weight: 300; color: var(--color-text-muted); }
|
||||||
|
.score-compact-right { flex: 1; }
|
||||||
|
.score-compact-label { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 4px; }
|
||||||
|
.score-compact-bar { height: 5px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.score-compact-fill { height: 100%; border-radius: var(--radius-full); background: var(--yellow); }
|
||||||
|
|
||||||
|
.action-row { display: flex; align-items: flex-start; gap: 14px; padding: 14px 16px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-lg); margin-bottom: 8px; }
|
||||||
|
.action-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--yellow-tint); display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.action-body { flex: 1; }
|
||||||
|
.action-title { font-size: 13px; font-weight: 500; margin-bottom: 6px; }
|
||||||
|
.action-recipe-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--color-subtle); border-radius: var(--radius-md); margin-bottom: 4px; }
|
||||||
|
.action-recipe-name { font-size: 12px; font-weight: 500; }
|
||||||
|
.action-recipe-day { font-size: 10px; color: var(--color-text-muted); margin-left: 4px; }
|
||||||
|
.btn-swap { padding: 4px 10px; background: white; border: 1px solid var(--color-border); border-radius: var(--radius-md); font-size: 11px; font-weight: 500; color: var(--color-text-muted); cursor: pointer; white-space: nowrap; }
|
||||||
|
.btn-swap:hover { border-color: var(--green-light); color: var(--green-dark); }
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
V3: Week Grid + Side Panel
|
||||||
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.v3-layout { display: flex; gap: 0; height: 680px; }
|
||||||
|
.v3-main { flex: 1; padding: 24px; overflow-y: auto; }
|
||||||
|
.v3-panel { width: 280px; min-width: 280px; border-left: 1px solid var(--color-border); background: white; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.week-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; margin-bottom: 20px; }
|
||||||
|
.day-col { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.day-header { font-size: 10px; font-weight: 500; color: var(--color-text-muted); text-align: center; padding-bottom: 4px; }
|
||||||
|
.recipe-slot { border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; padding: 6px 5px; min-height: 52px; font-size: 10px; font-weight: 500; text-align: center; display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1.3; }
|
||||||
|
.recipe-slot.warn { border-color: var(--yellow); background: var(--yellow-tint); color: var(--yellow-text); box-shadow: 0 0 0 2px rgba(232,180,0,.25); }
|
||||||
|
.recipe-slot.warn:hover { box-shadow: 0 0 0 2px var(--yellow); }
|
||||||
|
.recipe-slot.selected { border-color: var(--green-dark); box-shadow: 0 0 0 2px var(--green-light); }
|
||||||
|
.recipe-slot.empty { background: var(--color-subtle); color: var(--color-text-muted); font-weight: 400; font-size: 9px; }
|
||||||
|
.warn-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--yellow); display: inline-block; margin-left: 3px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.panel-score { display: flex; align-items: baseline; gap: 4px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.panel-score-num { font-family: var(--font-display); font-size: 48px; font-weight: 300; line-height: 1; }
|
||||||
|
.panel-score-denom { font-family: var(--font-display); font-size: 18px; font-weight: 300; color: var(--color-text-muted); }
|
||||||
|
.panel-warn-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.panel-warn-desc { font-size: 12px; color: var(--color-text-muted); margin-bottom: 14px; line-height: 1.5; }
|
||||||
|
.panel-recipe-entry { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; background: var(--color-subtle); border-radius: var(--radius-md); margin-bottom: 6px; }
|
||||||
|
.panel-recipe-name { font-size: 12px; font-weight: 500; }
|
||||||
|
.panel-recipe-day { font-size: 10px; color: var(--color-text-muted); }
|
||||||
|
.btn-swap-primary { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; background: var(--green-dark); color: white; border-radius: var(--radius-md); font-size: 12px; font-weight: 500; border: none; cursor: pointer; width: 100%; margin-top: 12px; }
|
||||||
|
.btn-swap-primary:hover { background: var(--green); }
|
||||||
|
.panel-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 8px; }
|
||||||
|
.panel-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 8px; }
|
||||||
|
.panel-empty-icon { font-size: 24px; opacity: .4; }
|
||||||
|
.panel-empty-text { font-size: 12px; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
.m-shell { display: flex; flex-direction: column; background: var(--color-page); }
|
||||||
|
.m-topbar { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--color-border); background: var(--color-page); padding: 12px 16px; position: sticky; top: 0; z-index: 10; }
|
||||||
|
.m-back { font-size: 20px; color: var(--color-text-muted); }
|
||||||
|
.m-title { font-family: var(--font-display); font-size: 16px; font-weight: 300; }
|
||||||
|
.m-content { flex: 1; padding: 16px; overflow-y: auto; }
|
||||||
|
.m-score-hero { display: flex; align-items: baseline; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.m-score-num { font-family: var(--font-display); font-size: 52px; font-weight: 300; line-height: 1; }
|
||||||
|
.m-score-denom { font-family: var(--font-display); font-size: 20px; font-weight: 300; color: var(--color-text-muted); }
|
||||||
|
.m-score-bar { height: 5px; background: var(--color-subtle); border-radius: var(--radius-full); overflow: hidden; margin-bottom: 4px; }
|
||||||
|
.m-score-fill { height: 100%; border-radius: var(--radius-full); background: var(--yellow); }
|
||||||
|
.m-score-label { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 16px; }
|
||||||
|
.m-section-hd { font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 8px; }
|
||||||
|
.m-warn-card { background: var(--yellow-tint); border: 1px solid var(--yellow-light); border-radius: var(--radius-lg); padding: 12px 14px; margin-bottom: 8px; }
|
||||||
|
.m-warn-title { font-size: 12px; font-weight: 500; color: var(--yellow-text); margin-bottom: 6px; }
|
||||||
|
.m-pill-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.m-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px 4px 10px; background: white; border: 1px solid var(--yellow-light); border-radius: var(--radius-full); font-size: 11px; font-weight: 500; }
|
||||||
|
.m-pill-swap { width: 18px; height: 18px; border-radius: 50%; background: var(--color-subtle); display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; }
|
||||||
|
.m-tabbar { display: flex; border-top: 1px solid var(--color-border); background: white; }
|
||||||
|
.m-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 4px 4px; font-size: 10px; color: var(--color-text-muted); gap: 2px; }
|
||||||
|
.m-tab.active { color: var(--green-dark); }
|
||||||
|
.m-tab-icon { font-size: 20px; }
|
||||||
|
|
||||||
|
/* Agent section */
|
||||||
|
.agent-section { background: var(--color-text); color: #E8E8E2; padding: 40px 48px; margin-top: 64px; }
|
||||||
|
.agent-section h2 { font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: #6B6A63; margin-bottom: 4px; }
|
||||||
|
.agent-section > p { font-size: 13px; color: #9A9990; margin-bottom: 28px; line-height: 1.6; max-width: 640px; }
|
||||||
|
.spec-comment { font-family: var(--font-mono); font-size: 11px; color: #3A3A36; margin-bottom: 32px; line-height: 1.9; white-space: pre-wrap; }
|
||||||
|
.agent-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; margin-bottom: 40px; }
|
||||||
|
.agent-table thead tr { border-bottom: 1px solid #2A2A26; }
|
||||||
|
.agent-table th { text-align: left; padding: 8px 14px; font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #5A5A55; font-family: var(--font-sans); }
|
||||||
|
.agent-table td { padding: 9px 14px; border-bottom: 1px solid #1E1E1A; vertical-align: top; line-height: 1.5; }
|
||||||
|
.agent-table tr:last-child td { border-bottom: none; }
|
||||||
|
.agent-table td:first-child { color: #7A7A72; white-space: nowrap; }
|
||||||
|
.agent-table td:nth-child(2) { color: #E8E8E2; font-weight: 500; }
|
||||||
|
.agent-table td:nth-child(3) { color: #5A5A55; }
|
||||||
|
.group-row td { padding-top: 20px; font-family: var(--font-sans); font-size: 9px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase; color: #3A3A36; border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Variety Page — Rework</h1>
|
||||||
|
<p>3 Design-Variationen · Route: <code>/planner/variety</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
screen: C2<br/>
|
||||||
|
journey: J4<br/>
|
||||||
|
version: 1.0<br/>
|
||||||
|
date: 2026-04-09
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
Zwei Kernprobleme werden adressiert: (1) Warnungen zeigen aktuell Wochentag-Kürzel ("MON, WED")
|
||||||
|
statt Rezeptnamen — rein frontend-seitig lösbar über <code>weekPlan.slots</code>-Mapping.
|
||||||
|
(2) Es gibt keine Swap-Aktion direkt aus den Warnungen heraus. Das Protein-Score-Problem
|
||||||
|
für vegetarische Haushalte ist ein Backend-Thema und separat zu behandeln.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
<h3>Protein-Score: Vegetarische Haushalte — Backend TBD</h3>
|
||||||
|
<p>
|
||||||
|
Die aktuelle Formel <code>proteinDiversity = 10 − repeats × 2</code> bestraft vegetarische
|
||||||
|
Proteinquellen (Tofu, Linsen, Ei) stärker als in omnivoren Haushalten üblich.
|
||||||
|
Frontend-seitig ändert sich das Label "Protein-Vielfalt" ggf. zu "Quellen-Vielfalt" sobald
|
||||||
|
das Backend die Score-Gewichtung anpasst. Bis dahin: keine Änderung an <code>ScoreBreakdownList</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">V1 — Rezept-Pills in Warnkarten</div>
|
||||||
|
|
||||||
|
<div class="variation">
|
||||||
|
<div class="var-header">
|
||||||
|
<div class="var-num">1</div>
|
||||||
|
<div class="var-meta">
|
||||||
|
<div class="var-title">Rezept-Pills in Warnkarten</div>
|
||||||
|
<div class="var-desc">Minimale Änderung an der bestehenden Seitenstruktur. Warnkarten zeigen statt "MON, WED" konkrete Rezept-Pills mit Tauschen-Button. Seitenaufbau und Score-Hero bleiben identisch.</div>
|
||||||
|
<span class="var-tag">Vertraut · Geringer Aufwand</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-pair">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell" style="min-height:680px;">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="topbar-back" href="#">Planer</a>
|
||||||
|
<span class="topbar-sep">/</span>
|
||||||
|
<span class="topbar-title">Abwechslungs-Analyse</span>
|
||||||
|
</div>
|
||||||
|
<div class="main" style="display:flex;gap:32px;align-items:flex-start;overflow-y:auto;">
|
||||||
|
<!-- Left -->
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="score-hero">
|
||||||
|
<div><span class="score-num" style="color:var(--yellow-text);">6.5</span><span class="score-denom">/10</span></div>
|
||||||
|
<div class="score-bar" style="width:200px;"><div class="score-fill warn" style="width:65%;"></div></div>
|
||||||
|
<div class="score-label">Verbesserbar</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-hd">Bewertung im Detail</div>
|
||||||
|
<div class="sub-scores">
|
||||||
|
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Warnings with recipe pills -->
|
||||||
|
<div class="section-hd" style="margin-top:20px;">Hinweise</div>
|
||||||
|
|
||||||
|
<div class="warn-card">
|
||||||
|
<div class="warn-title">Tofu mehrfach diese Woche</div>
|
||||||
|
<div class="pill-row">
|
||||||
|
<span class="recipe-pill"><span class="recipe-pill-day">Mo</span>Tofu-Curry<button class="pill-swap-btn">↔</button></span>
|
||||||
|
<span class="recipe-pill"><span class="recipe-pill-day">Mi</span>Tofu-Bowl<button class="pill-swap-btn">↔</button></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warn-card">
|
||||||
|
<div class="warn-title">Linsen in mehreren Gerichten</div>
|
||||||
|
<div class="pill-row">
|
||||||
|
<span class="recipe-pill"><span class="recipe-pill-day">Di</span>Linsen-Suppe<button class="pill-swap-btn">↔</button></span>
|
||||||
|
<span class="recipe-pill"><span class="recipe-pill-day">Fr</span>Linsen-Dal<button class="pill-swap-btn">↔</button></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right -->
|
||||||
|
<div style="width:280px;flex-shrink:0;">
|
||||||
|
<div class="section-hd">Quellen-Verteilung</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:5px;margin-bottom:16px;">
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Di</span><div style="width:100%;height:40px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--green-dark);">LIN</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Do</span><div style="width:100%;height:40px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--green-dark);">GEM</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span><div style="width:100%;height:40px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;color:var(--yellow-text);">LIN</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">Sa</span><div style="width:100%;height:40px;background:var(--color-subtle);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);">—</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;"><span style="font-size:9px;color:var(--color-text-muted);">So</span><div style="width:100%;height:40px;background:var(--color-subtle);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-hd">Aufwandsverteilung</div>
|
||||||
|
<div style="display:flex;height:16px;border-radius:var(--radius-full);overflow:hidden;gap:2px;">
|
||||||
|
<div style="flex:3;background:var(--green-dark);"></div>
|
||||||
|
<div style="flex:2;background:var(--yellow);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:6px;font-size:10px;color:var(--color-text-muted);">
|
||||||
|
<span>Einfach ×3</span><span>Mittel ×2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-topbar"><span class="m-back">‹</span><span class="m-title">Abwechslungs-Analyse</span></div>
|
||||||
|
<div class="m-content">
|
||||||
|
<div class="m-score-hero"><span class="m-score-num" style="color:var(--yellow-text);">6.5</span><span class="m-score-denom">/10</span></div>
|
||||||
|
<div class="m-score-bar"><div class="m-score-fill" style="width:65%;"></div></div>
|
||||||
|
<div class="m-score-label">Verbesserbar</div>
|
||||||
|
<div class="m-section-hd">Hinweise</div>
|
||||||
|
<div class="m-warn-card">
|
||||||
|
<div class="m-warn-title">Tofu mehrfach diese Woche</div>
|
||||||
|
<div class="m-pill-row">
|
||||||
|
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span>Tofu-Curry<span class="m-pill-swap">↔</span></span>
|
||||||
|
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span>Tofu-Bowl<span class="m-pill-swap">↔</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-warn-card">
|
||||||
|
<div class="m-warn-title">Linsen in mehreren Gerichten</div>
|
||||||
|
<div class="m-pill-row">
|
||||||
|
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Di</span>Linsen-Suppe<span class="m-pill-swap">↔</span></span>
|
||||||
|
<span class="m-pill"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span>Linsen-Dal<span class="m-pill-swap">↔</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Kein Backend-Change nötig. Frontend mappt <code>tagRepeat.days[]</code> → <code>weekPlan.slots.find(s => s.dayOfWeek === day)</code> → <code>recipe.name</code></li>
|
||||||
|
<li>Pill-Swap-Button (↔): navigiert zu <code>/planner?week={weekStart}&swap={slotId}</code> — öffnet RecipePicker für den betreffenden Slot</li>
|
||||||
|
<li>Pill-Label links: Wochentag-Kürzel (Mo, Di, …) aus <code>dayOfWeek</code>-Mapping</li>
|
||||||
|
<li>Wenn ein Slot leer ist (Rezept wurde bereits entfernt): Pill zeigt nur den Wochentag, kein Swap-Button</li>
|
||||||
|
<li>Geringe Änderung: nur <code>VarietyWarningCards.svelte</code> + <code>variety.ts</code> anpassen; Rest der Seite bleibt</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">V2 — Aktions-Zeilen (Action-first)</div>
|
||||||
|
|
||||||
|
<div class="variation">
|
||||||
|
<div class="var-header">
|
||||||
|
<div class="var-num">2</div>
|
||||||
|
<div class="var-meta">
|
||||||
|
<div class="var-title">Aktions-Zeilen</div>
|
||||||
|
<div class="var-desc">Warnungen stehen oben, Score-Hero wird kompakt. Pro Warnung gibt es eine vollständige Rezept-Zeile mit Wochentag und dediziertem "Tauschen"-Button. Fokus auf sofortige Handlung statt auf Metrik-Verständnis.</div>
|
||||||
|
<span class="var-tag rec">Empfohlen · Aktionsfokus</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-pair">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell" style="min-height:680px;">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="topbar-back" href="#">Planer</a>
|
||||||
|
<span class="topbar-sep">/</span>
|
||||||
|
<span class="topbar-title">Abwechslungs-Analyse</span>
|
||||||
|
</div>
|
||||||
|
<div class="main" style="overflow-y:auto;">
|
||||||
|
<!-- Compact score -->
|
||||||
|
<div class="score-compact">
|
||||||
|
<div><span class="score-compact-num" style="color:var(--yellow-text);">6.5</span><span class="score-compact-denom">/10</span></div>
|
||||||
|
<div class="score-compact-right">
|
||||||
|
<div class="score-compact-label">Verbesserbar — 2 Hinweise</div>
|
||||||
|
<div class="score-compact-bar"><div class="score-compact-fill" style="width:65%;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:24px;align-items:flex-start;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="section-hd">Empfehlenswerte Tausche</div>
|
||||||
|
|
||||||
|
<!-- Action row 1 -->
|
||||||
|
<div class="action-row">
|
||||||
|
<div class="action-icon">🔄</div>
|
||||||
|
<div class="action-body">
|
||||||
|
<div class="action-title">Tofu mehrfach diese Woche</div>
|
||||||
|
<div class="action-recipe-row">
|
||||||
|
<span><span class="action-recipe-name">Tofu-Curry</span><span class="action-recipe-day">· Montag</span></span>
|
||||||
|
<button class="btn-swap">Tauschen →</button>
|
||||||
|
</div>
|
||||||
|
<div class="action-recipe-row">
|
||||||
|
<span><span class="action-recipe-name">Tofu-Bowl</span><span class="action-recipe-day">· Mittwoch</span></span>
|
||||||
|
<button class="btn-swap">Tauschen →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action row 2 -->
|
||||||
|
<div class="action-row">
|
||||||
|
<div class="action-icon">🔄</div>
|
||||||
|
<div class="action-body">
|
||||||
|
<div class="action-title">Linsen in mehreren Gerichten</div>
|
||||||
|
<div class="action-recipe-row">
|
||||||
|
<span><span class="action-recipe-name">Linsen-Suppe</span><span class="action-recipe-day">· Dienstag</span></span>
|
||||||
|
<button class="btn-swap">Tauschen →</button>
|
||||||
|
</div>
|
||||||
|
<div class="action-recipe-row">
|
||||||
|
<span><span class="action-recipe-name">Linsen-Dal</span><span class="action-recipe-day">· Freitag</span></span>
|
||||||
|
<button class="btn-swap">Tauschen →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible detail scores -->
|
||||||
|
<details style="margin-top:16px;">
|
||||||
|
<summary style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);cursor:pointer;list-style:none;padding:8px 0;">Bewertung im Detail ▾</summary>
|
||||||
|
<div class="sub-scores" style="margin-top:10px;">
|
||||||
|
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:240px;flex-shrink:0;">
|
||||||
|
<div class="section-hd">Quellen-Verteilung</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Mo</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Di</span><div style="width:100%;height:36px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--green-dark);">LIN</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Mi</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">TOF</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Do</span><div style="width:100%;height:36px;background:var(--green-tint);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--green-dark);">GEM</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Fr</span><div style="width:100%;height:36px;background:var(--yellow-tint);border:2px solid var(--yellow);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;color:var(--yellow-text);">LIN</div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">Sa</span><div style="width:100%;height:36px;background:var(--color-subtle);border-radius:3px;"></div></div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;"><span style="font-size:9px;color:var(--color-text-muted);">So</span><div style="width:100%;height:36px;background:var(--color-subtle);border-radius:3px;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-topbar"><span class="m-back">‹</span><span class="m-title">Abwechslungs-Analyse</span></div>
|
||||||
|
<div class="m-content">
|
||||||
|
<!-- Compact score mobile -->
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);margin-bottom:16px;">
|
||||||
|
<div><span style="font-family:var(--font-display);font-size:32px;font-weight:300;color:var(--yellow-text);">6.5</span><span style="font-family:var(--font-display);font-size:14px;font-weight:300;color:var(--color-text-muted);">/10</span></div>
|
||||||
|
<div style="flex:1;"><div style="font-size:11px;font-weight:500;color:var(--yellow-text);margin-bottom:4px;">Verbesserbar</div><div style="height:4px;background:var(--color-subtle);border-radius:99px;overflow:hidden;"><div style="width:65%;height:100%;background:var(--yellow);border-radius:99px;"></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="m-section-hd">Empfehlenswerte Tausche</div>
|
||||||
|
<!-- Action row mobile -->
|
||||||
|
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:8px;">🔄 Tofu mehrfach diese Woche</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);margin-bottom:4px;"><span style="font-size:11px;font-weight:500;">Tofu-Curry <span style="color:var(--color-text-muted);font-weight:400;">Mo</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Tofu-Bowl <span style="color:var(--color-text-muted);font-weight:400;">Mi</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:8px;">🔄 Linsen in mehreren Gerichten</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);margin-bottom:4px;"><span style="font-size:11px;font-weight:500;">Linsen-Suppe <span style="color:var(--color-text-muted);font-weight:400;">Di</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--color-subtle);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Linsen-Dal <span style="color:var(--color-text-muted);font-weight:400;">Fr</span></span><button style="font-size:10px;font-weight:500;padding:3px 8px;background:white;border:1px solid var(--color-border);border-radius:var(--radius-md);color:var(--color-text-muted);">Tauschen →</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Score-Hero wird kompakt: Zahl + Label + Balken in einer horizontal komprimierten Leiste oben</li>
|
||||||
|
<li>Sub-Scores in aufklappbarem <code><details></code>-Element — zugänglich, kein JavaScript nötig</li>
|
||||||
|
<li>Jeder "Tauschen"-Button navigiert zum Planer mit dem spezifischen Slot vorselektiert</li>
|
||||||
|
<li>Wochentag als ausgeschriebenes Wort ("Montag") — nicht Kürzel — für bessere Lesbarkeit</li>
|
||||||
|
<li>Mobile: Score-Hero bleibt kompakt oben, Action-Rows nehmen den Hauptraum ein</li>
|
||||||
|
<li>Größerer Aufwand als V1: <code>VarietyWarningCards</code> grundlegend neu strukturieren</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section-label">V3 — Wochenraster mit Kontext-Panel</div>
|
||||||
|
|
||||||
|
<div class="variation">
|
||||||
|
<div class="var-header">
|
||||||
|
<div class="var-num">3</div>
|
||||||
|
<div class="var-meta">
|
||||||
|
<div class="var-title">Wochenraster mit Kontext-Panel</div>
|
||||||
|
<div class="var-desc">Das bestehende Protein-Raster wird zum Haupt-Interface. Alle 7 Tage zeigen das vollständige Rezept. Problematische Slots sind gelb markiert — Klick öffnet das rechte Panel mit Erklärung und Swap-CTA.</div>
|
||||||
|
<span class="var-tag amb">Ambitiös · Meiste Übersicht</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-pair">
|
||||||
|
<div class="preview-d-wrap">
|
||||||
|
<div class="preview-label">Desktop</div>
|
||||||
|
<div class="preview-d-clip">
|
||||||
|
<div class="preview-d-scale">
|
||||||
|
<div class="shell" style="min-height:680px;">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="sidebar-brand-row"><div class="sidebar-logo"></div><span class="sidebar-app">Mealplan</span></div><div class="sidebar-household">Familie Raddatz</div></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="sidebar-group-label">Plan</div>
|
||||||
|
<a class="sidebar-item active" href="#"><span class="sidebar-icon">📅</span>Planer</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🍽</span>Rezepte</a>
|
||||||
|
<a class="sidebar-item" href="#"><span class="sidebar-icon">🛒</span>Einkauf</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="topbar-back" href="#">Planer</a>
|
||||||
|
<span class="topbar-sep">/</span>
|
||||||
|
<span class="topbar-title">Abwechslungs-Analyse</span>
|
||||||
|
<!-- Score badge in topbar -->
|
||||||
|
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">Abwechslung</span>
|
||||||
|
<span style="font-family:var(--font-display);font-size:20px;font-weight:300;color:var(--yellow-text);">6.5</span>
|
||||||
|
<span style="font-family:var(--font-display);font-size:12px;color:var(--color-text-muted);">/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex:1;overflow:hidden;">
|
||||||
|
<!-- Main: week grid -->
|
||||||
|
<div class="v3-main">
|
||||||
|
<div class="section-hd">Wochenübersicht — gelb markierte Gerichte haben Hinweise</div>
|
||||||
|
<div class="week-grid">
|
||||||
|
<!-- Mon - Tofu-Curry WARN -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Mo</div>
|
||||||
|
<div class="recipe-slot warn selected">Tofu-Curry</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tue - Linsen-Suppe WARN -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Di</div>
|
||||||
|
<div class="recipe-slot warn">Linsen-Suppe</div>
|
||||||
|
</div>
|
||||||
|
<!-- Wed - Tofu-Bowl WARN -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Mi</div>
|
||||||
|
<div class="recipe-slot warn">Tofu-Bowl</div>
|
||||||
|
</div>
|
||||||
|
<!-- Thu - Gemüse OK -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Do</div>
|
||||||
|
<div class="recipe-slot">Gemüse-Stir-Fry</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fri - Linsen-Dal WARN -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Fr</div>
|
||||||
|
<div class="recipe-slot warn">Linsen-Dal</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sat - empty -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">Sa</div>
|
||||||
|
<div class="recipe-slot empty">—</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sun - empty -->
|
||||||
|
<div class="day-col">
|
||||||
|
<div class="day-header">So</div>
|
||||||
|
<div class="recipe-slot empty">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-hd" style="margin-top:16px;">Aufwandsverteilung</div>
|
||||||
|
<div style="display:flex;height:18px;border-radius:var(--radius-full);overflow:hidden;gap:2px;max-width:280px;">
|
||||||
|
<div style="flex:3;background:var(--green-dark);"></div>
|
||||||
|
<div style="flex:2;background:var(--yellow);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;margin-top:6px;font-size:11px;color:var(--color-text-muted);">
|
||||||
|
<span>Einfach ×3</span><span>Mittel ×2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sub-scores" style="margin-top:20px;max-width:360px;">
|
||||||
|
<div class="sub-row"><span class="sub-label">Quellen-Vielfalt</span><span class="sub-val">6/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Zutaten-Überlappung</span><span class="sub-val ok">8/10</span></div>
|
||||||
|
<div class="sub-row"><span class="sub-label">Aufwandsbalance</span><span class="sub-val ok">9/10</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: context for selected slot -->
|
||||||
|
<div class="v3-panel">
|
||||||
|
<div class="panel-score">
|
||||||
|
<span class="panel-score-num" style="color:var(--yellow-text);">6.5</span>
|
||||||
|
<span class="panel-score-denom">/10</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-warn-title">Tofu-Curry — Montag</div>
|
||||||
|
<div class="panel-warn-desc">Tofu taucht diese Woche auch am Mittwoch auf (Tofu-Bowl). Ein Tausch würde die Quellen-Vielfalt verbessern.</div>
|
||||||
|
<div class="section-hd">Andere betroffene Gerichte</div>
|
||||||
|
<div class="panel-recipe-entry">
|
||||||
|
<div><div class="panel-recipe-name">Tofu-Bowl</div><div class="panel-recipe-day">Mittwoch</div></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-swap-primary">↔ Tofu-Curry tauschen</button>
|
||||||
|
<div class="panel-hint">Öffnet den Rezept-Picker für Montag.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-m-wrap">
|
||||||
|
<div class="preview-label">Mobile (Tab-Navigation)</div>
|
||||||
|
<div class="preview-m-clip">
|
||||||
|
<div class="preview-m-scale">
|
||||||
|
<div class="m-shell" style="min-height:680px;">
|
||||||
|
<div class="m-topbar"><span class="m-back">‹</span><span class="m-title">Abwechslungs-Analyse</span><span style="margin-left:auto;font-family:var(--font-display);font-size:18px;font-weight:300;color:var(--yellow-text);">6.5<span style="font-size:12px;color:var(--color-text-muted);">/10</span></span></div>
|
||||||
|
<div class="m-content">
|
||||||
|
<!-- Tab switcher for mobile (Übersicht | Hinweise) -->
|
||||||
|
<div style="display:flex;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;margin-bottom:14px;">
|
||||||
|
<button style="flex:1;padding:7px;font-size:11px;font-weight:500;background:var(--color-subtle);color:var(--color-text-muted);border:none;">Übersicht</button>
|
||||||
|
<button style="flex:1;padding:7px;font-size:11px;font-weight:500;background:var(--green-dark);color:white;border:none;">Hinweise (2)</button>
|
||||||
|
</div>
|
||||||
|
<!-- Hinweise tab active -->
|
||||||
|
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:8px;color:var(--yellow-text);">Tofu mehrfach diese Woche</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);margin-bottom:5px;"><span style="font-size:11px;font-weight:500;">Tofu-Curry <span style="color:var(--color-text-muted);font-weight:400;">Mo</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Tofu-Bowl <span style="color:var(--color-text-muted);font-weight:400;">Mi</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:white;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:14px;">
|
||||||
|
<div style="font-size:12px;font-weight:500;margin-bottom:8px;color:var(--yellow-text);">Linsen in mehreren Gerichten</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);margin-bottom:5px;"><span style="font-size:11px;font-weight:500;">Linsen-Suppe <span style="color:var(--color-text-muted);font-weight:400;">Di</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--yellow-tint);border:1px solid var(--yellow-light);border-radius:var(--radius-md);"><span style="font-size:11px;font-weight:500;">Linsen-Dal <span style="color:var(--color-text-muted);font-weight:400;">Fr</span></span><button style="font-size:10px;font-weight:500;padding:4px 8px;background:var(--green-dark);color:white;border:none;border-radius:var(--radius-md);">Tauschen</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-tabbar">
|
||||||
|
<div class="m-tab active"><div class="m-tab-icon">📅</div>Planer</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🍽</div>Rezepte</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">🛒</div>Einkauf</div>
|
||||||
|
<div class="m-tab"><div class="m-tab-icon">⚙️</div>Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="notes-label">Notizen</div>
|
||||||
|
<ul>
|
||||||
|
<li>Wochenraster ersetzt das bisherige Protein-Grid (7 Spalten, Rezeptname statt Kürzel, größere Zellen)</li>
|
||||||
|
<li>Gelber Slot = mindestens ein Hinweis vorhanden. Klick selektiert den Slot, Panel rechts aktualisiert sich.</li>
|
||||||
|
<li>Panel zeigt: betroffenes Rezept + Wochentag + Erklärung + andere betroffene Slots + primären "Tauschen"-Button</li>
|
||||||
|
<li>Score-Zahl wandert in die Topbar-Leiste (kompakt, immer sichtbar)</li>
|
||||||
|
<li>Mobile: kein Panel — stattdessen Tab-Switcher "Übersicht | Hinweise (N)" mit aufklappbaren Einträgen</li>
|
||||||
|
<li>Größter Umbau: <code>+page.svelte</code> Struktur und alle beteiligten Komponenten müssen neu aufgebaut werden</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Agent section ─── -->
|
||||||
|
<div class="agent-section">
|
||||||
|
<h2>Maschinen-lesbare Spezifikation</h2>
|
||||||
|
<p>Gilt für alle drei Variationen. Implementierungs-Details werden nach Variantenwahl konkretisiert.</p>
|
||||||
|
|
||||||
|
<pre class="spec-comment">
|
||||||
|
/* spec:rules — Variety Page Rework (alle Variationen)
|
||||||
|
*
|
||||||
|
* RECIPE NAME MAPPING (frontend, no backend change)
|
||||||
|
* Source: weekPlan.slots[] → { dayOfWeek: "MON"|"TUE"|..., recipe: { id, name } }
|
||||||
|
* tagRepeats[].days[] contains dayOfWeek keys (e.g. "MON")
|
||||||
|
* slotsByDay = Object.fromEntries(weekPlan.slots.map(s => [s.dayOfWeek, s]))
|
||||||
|
* recipeName = slotsByDay[day]?.recipe?.name ?? day
|
||||||
|
* slotId = slotsByDay[day]?.id
|
||||||
|
*
|
||||||
|
* SWAP NAVIGATION
|
||||||
|
* "Tauschen" button href: /planner?week={weekStart}&swap={slotId}
|
||||||
|
* weekStart available in page data
|
||||||
|
* slotId from weekPlan.slots mapping above
|
||||||
|
* Opens RecipePicker for that slot (existing functionality in planner page)
|
||||||
|
*
|
||||||
|
* DAY LABEL MAPPING (for display)
|
||||||
|
* MON → "Montag" TUE → "Dienstag" WED → "Mittwoch" THU → "Donnerstag"
|
||||||
|
* FRI → "Freitag" SAT → "Samstag" SUN → "Sonntag"
|
||||||
|
* Short: Mo, Di, Mi, Do, Fr, Sa, So
|
||||||
|
*
|
||||||
|
* EMPTY SLOT HANDLING
|
||||||
|
* If slotsByDay[day] is undefined: show day key only, no swap button
|
||||||
|
* This can happen if slot was deleted since varietyScore was computed
|
||||||
|
*
|
||||||
|
* PROTEIN SCORE — VEGETARIAN NOTE
|
||||||
|
* Label "Protein-Vielfalt" in ScoreBreakdownList may change to "Quellen-Vielfalt"
|
||||||
|
* pending backend decision on scoring weight adjustment.
|
||||||
|
* No frontend change required until backend ships the updated score.
|
||||||
|
*
|
||||||
|
* VARIATION-SPECIFIC
|
||||||
|
* V1: Modify VarietyWarningCards + Warning type (add slots: { day, recipeName, slotId }[])
|
||||||
|
* computeWarnings() now returns slots[] instead of string days[]
|
||||||
|
* V2: Restructure VarietyWarningCards to ActionRows; VarietyScoreHero → compact variant
|
||||||
|
* <details> for sub-scores (no JS needed)
|
||||||
|
* V3: Replace protein grid with full week grid (recipe names); add side panel component
|
||||||
|
* Mobile: tab switcher (Übersicht | Hinweise) using $state activeTab
|
||||||
|
*/
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<table class="agent-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Property</th><th>Value</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="group-row"><td colspan="3">Shared: Recipe Mapping</td></tr>
|
||||||
|
<tr><td>data-source</td><td>weekPlan.slots[].dayOfWeek + recipe</td><td>already in page data</td></tr>
|
||||||
|
<tr><td>swap-url</td><td>/planner?week={weekStart}&swap={slotId}</td><td>RecipePicker pre-selects slot</td></tr>
|
||||||
|
<tr><td>day-long</td><td>MON→Montag, TUE→Dienstag…</td><td>for V2 display</td></tr>
|
||||||
|
<tr><td>day-short</td><td>MON→Mo, TUE→Di…</td><td>for V1 pills + V3 grid</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">V1 Recipe Pills</td></tr>
|
||||||
|
<tr><td>pill-padding</td><td>5px 10px 5px 12px</td><td>left more for text</td></tr>
|
||||||
|
<tr><td>swap-btn-size</td><td>22×22px, border-radius 50%</td><td>within pill</td></tr>
|
||||||
|
<tr><td>pill-bg</td><td>white, border --yellow-light</td><td>on yellow-tint card</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">V2 Action Rows</td></tr>
|
||||||
|
<tr><td>score-compact-height</td><td>~64px</td><td>replaces 180px hero</td></tr>
|
||||||
|
<tr><td>details-summary</td><td>native <details>, no JS</td><td>sub-scores hidden by default</td></tr>
|
||||||
|
<tr><td>recipe-row-bg</td><td>--color-subtle</td><td>within white action card</td></tr>
|
||||||
|
<tr class="group-row"><td colspan="3">V3 Week Grid</td></tr>
|
||||||
|
<tr><td>slot-height</td><td>52px min</td><td>enough for 2-line recipe name</td></tr>
|
||||||
|
<tr><td>warn-slot-ring</td><td>2px solid --yellow + yellow-tint bg</td><td>problem indicator</td></tr>
|
||||||
|
<tr><td>selected-slot-ring</td><td>2px solid --green-dark</td><td>active selection</td></tr>
|
||||||
|
<tr><td>panel-width</td><td>280px</td><td>fixed, right side</td></tr>
|
||||||
|
<tr><td>mobile-tab-active-bg</td><td>--green-dark</td><td>selected tab button</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
762
specs/planner-flip-tiles.html
Normal file
762
specs/planner-flip-tiles.html
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Planner — Flip Tiles</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@300;400&family=DM+Sans:wght@400;500;600&family=DM+Mono&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--page: #fafaf7;
|
||||||
|
--surface: #f5f4ee;
|
||||||
|
--subtle: #edecea;
|
||||||
|
--border: #d8d7d0;
|
||||||
|
--text: #1c1c18;
|
||||||
|
--muted: #6b6a63;
|
||||||
|
--gt: #e8f5ea; --gl: #aedcb0; --g: #3d8c4a; --gd: #2e6e39;
|
||||||
|
--yt: #fdf6d8; --yl: #f9e08a; --y: #f2c12e; --yx: #8a6800;
|
||||||
|
--pt: #eeedfe; --p: #534ab7;
|
||||||
|
--ot: #fef0e6; --od: #b46820;
|
||||||
|
--err: #dc4c3e;
|
||||||
|
--r-sm: 4px; --r-md: 6px; --r-lg: 10px; --r-full: 9999px;
|
||||||
|
--sh-card: 0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);
|
||||||
|
--sh-raised: 0 6px 18px rgba(28,28,24,.14),0 2px 6px rgba(28,28,24,.08);
|
||||||
|
--fd: 'Fraunces', Georgia, serif;
|
||||||
|
--fs: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--fm: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ingredient / cuisine colour palette ──────────────────────── */
|
||||||
|
/* Protein-based */
|
||||||
|
--col-haehnchen: linear-gradient(160deg,#d4923a 0%,#a85e1a 50%,#7a3d0c 100%);
|
||||||
|
--col-rind: linear-gradient(160deg,#c04545 0%,#8b2020 50%,#5a1010 100%);
|
||||||
|
--col-fisch: linear-gradient(160deg,#5b9fd4 0%,#2868a0 50%,#10406e 100%);
|
||||||
|
--col-tofu: linear-gradient(160deg,#5fa85e 0%,#2e7031 50%,#1a4a1e 100%);
|
||||||
|
--col-veg: linear-gradient(160deg,#7bc47b 0%,#3d8c3d 50%,#1e5a1e 100%);
|
||||||
|
--col-schwein: linear-gradient(160deg,#d4785a 0%,#a04535 50%,#6e2418 100%);
|
||||||
|
--col-lamm: linear-gradient(160deg,#9e6b3a 0%,#6b3f1a 50%,#3e2208 100%);
|
||||||
|
--col-ei: linear-gradient(160deg,#d4b832 0%,#a07010 50%,#6e4800 100%);
|
||||||
|
--col-linsen: linear-gradient(160deg,#8b6b3a 0%,#5e421a 50%,#3a2408 100%);
|
||||||
|
/* Cuisine-based */
|
||||||
|
--col-italienisch: linear-gradient(160deg,#c04545 0%,#7a1e1e 50%,#4a0f0f 100%);
|
||||||
|
--col-asiatisch: linear-gradient(160deg,#3a6e3a 0%,#1e4a1e 50%,#0e2e0e 100%);
|
||||||
|
--col-mexikanisch: linear-gradient(160deg,#d4923a 0%,#8b4e10 50%,#5a2e00 100%);
|
||||||
|
--col-indisch: linear-gradient(160deg,#c49010 0%,#8b5e00 50%,#5a3800 100%);
|
||||||
|
--col-mediterran: linear-gradient(160deg,#5b9fd4 0%,#1e5a8b 50%,#0a3456 100%);
|
||||||
|
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{
|
||||||
|
font-family:var(--fs);background:#dddcd7;color:var(--text);
|
||||||
|
padding:40px 24px 80px;line-height:1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow{font-family:var(--fs);font-size:11px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
|
||||||
|
.pg-title{font-family:var(--fd);font-size:34px;font-weight:300;margin-bottom:6px;}
|
||||||
|
.pg-sub{font-family:var(--fs);font-size:14px;color:var(--muted);max-width:700px;line-height:1.65;margin-bottom:44px;}
|
||||||
|
|
||||||
|
.block{margin-bottom:60px;}
|
||||||
|
.bl-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;}
|
||||||
|
.bl-num{font-family:var(--fm);font-size:11px;background:var(--subtle);color:var(--muted);padding:3px 8px;border-radius:var(--r-sm);}
|
||||||
|
.bl-name{font-family:var(--fd);font-size:22px;font-weight:300;}
|
||||||
|
.bl-sub{font-family:var(--fs);font-size:12px;color:var(--muted);margin-left:auto;}
|
||||||
|
.note{font-family:var(--fs);font-size:12px;color:var(--muted);border-left:3px solid var(--border);padding:10px 14px;margin-top:16px;line-height:1.6;}
|
||||||
|
.note strong{color:var(--text);font-weight:500;}
|
||||||
|
|
||||||
|
/* ── Colour palette swatches ─────────────────────────────────── */
|
||||||
|
.swatch-grid{display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.swatch{width:88px;border-radius:var(--r-md);overflow:hidden;box-shadow:var(--sh-card);}
|
||||||
|
.swatch-color{height:52px;}
|
||||||
|
.swatch-label{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--text);padding:5px 7px;background:var(--page);border-top:1px solid var(--border);}
|
||||||
|
.swatch-sub{font-family:var(--fs);font-size:9px;color:var(--muted);padding:0 7px 5px;}
|
||||||
|
|
||||||
|
/* ── Frame ───────────────────────────────────────────────────── */
|
||||||
|
.frame{display:flex;flex-direction:column;background:var(--page);border:1px solid var(--border);border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sh-raised);}
|
||||||
|
.tb{display:flex;align-items:center;gap:7px;padding:11px 18px;border-bottom:1px solid var(--border);background:var(--page);flex-shrink:0;}
|
||||||
|
.tb-h1{font-family:var(--fd);font-size:17px;font-weight:300;}
|
||||||
|
.tb-range{font-family:var(--fs);font-size:11px;color:var(--muted);}
|
||||||
|
.tb-arr{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:var(--r-md);font-size:13px;color:var(--muted);}
|
||||||
|
.tb-btn{height:28px;padding:0 10px;border:1px solid var(--border);border-radius:var(--r-md);font-family:var(--fs);font-size:11px;font-weight:500;letter-spacing:.04em;color:var(--text);background:var(--page);}
|
||||||
|
.tb-ml{margin-left:auto;}
|
||||||
|
.tb-pri{background:var(--gd);color:#fff;border:none;}
|
||||||
|
.body{display:flex;flex:1;overflow:hidden;}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sb{width:184px;flex-shrink:0;border-right:1px solid var(--border);background:var(--surface);padding:13px;display:flex;flex-direction:column;gap:13px;}
|
||||||
|
.sb-lbl{font-family:var(--fs);font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
|
||||||
|
.score-box{background:var(--yt);border:1px solid var(--yl);border-radius:var(--r-md);padding:10px;}
|
||||||
|
.sc-big{font-family:var(--fd);font-size:27px;font-weight:300;line-height:1;}
|
||||||
|
.sc-den{font-family:var(--fs);font-size:11px;color:var(--muted);}
|
||||||
|
.pbar{height:4px;border-radius:var(--r-full);overflow:hidden;margin-top:6px;}
|
||||||
|
.pb-y{background:var(--yl);} .pb-t{background:var(--border);}
|
||||||
|
.pb-fill{height:100%;border-radius:var(--r-full);}
|
||||||
|
.pb-fg-y{background:var(--y);} .pb-fg-g{background:var(--g);}
|
||||||
|
.sr{display:flex;align-items:center;gap:6px;margin-top:6px;}
|
||||||
|
.sr-l{font-family:var(--fs);font-size:10px;color:var(--muted);width:68px;flex-shrink:0;}
|
||||||
|
.sr-b{flex:1;height:3px;border-radius:var(--r-full);background:var(--border);overflow:hidden;}
|
||||||
|
.sr-f{height:100%;border-radius:var(--r-full);}
|
||||||
|
.sr-v{font-family:var(--fm);font-size:9px;color:var(--muted);width:18px;text-align:right;}
|
||||||
|
.w-item{font-family:var(--fs);font-size:10px;color:var(--yx);margin-top:4px;line-height:1.4;}
|
||||||
|
.dp{display:flex;gap:2px;margin-top:5px;}
|
||||||
|
.dp-s{flex:1;height:4px;border-radius:var(--r-full);}
|
||||||
|
.sb-link{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--yx);display:block;margin-top:8px;}
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.main{flex:1;overflow-y:auto;padding:12px;}
|
||||||
|
.grid7{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
CARD FLIP SYSTEM
|
||||||
|
Each tile is a .scene > .card > .front + .back
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
.scene{
|
||||||
|
border-radius:var(--r-lg);
|
||||||
|
/* Perspective for 3D depth */
|
||||||
|
perspective:900px;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
position:relative;
|
||||||
|
width:100%;height:100%;
|
||||||
|
transform-style:preserve-3d;
|
||||||
|
transition:transform .45s cubic-bezier(.4,0,.2,1);
|
||||||
|
border-radius:var(--r-lg);
|
||||||
|
}
|
||||||
|
.card.flipped{transform:rotateY(180deg);}
|
||||||
|
|
||||||
|
/* Both faces */
|
||||||
|
.card-front,
|
||||||
|
.card-back{
|
||||||
|
position:absolute;inset:0;
|
||||||
|
border-radius:var(--r-lg);
|
||||||
|
overflow:hidden;
|
||||||
|
backface-visibility:hidden;
|
||||||
|
-webkit-backface-visibility:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FRONT face: full-bleed image ─── */
|
||||||
|
.card-front{
|
||||||
|
background-size:cover;
|
||||||
|
background-position:center;
|
||||||
|
}
|
||||||
|
/* Gradient: dark top (header), clear middle, dark bottom (text) */
|
||||||
|
.front-overlay{
|
||||||
|
position:absolute;inset:0;
|
||||||
|
background:
|
||||||
|
linear-gradient(to bottom,
|
||||||
|
rgba(0,0,0,.38) 0%,
|
||||||
|
rgba(0,0,0,0) 28%,
|
||||||
|
rgba(0,0,0,0) 48%,
|
||||||
|
rgba(0,0,0,.62) 100%
|
||||||
|
);
|
||||||
|
border-radius:inherit;
|
||||||
|
}
|
||||||
|
.front-head{
|
||||||
|
position:absolute;top:0;left:0;right:0;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:8px 9px;z-index:2;
|
||||||
|
}
|
||||||
|
.front-abbr{font-family:var(--fs);font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgba(255,255,255,.85);font-weight:500;}
|
||||||
|
.front-badge{
|
||||||
|
width:20px;height:20px;border-radius:var(--r-full);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-family:var(--fs);font-size:10px;font-weight:500;
|
||||||
|
color:rgba(255,255,255,.9);background:rgba(255,255,255,.22);
|
||||||
|
}
|
||||||
|
.fb-today{background:var(--y) !important;color:#fff !important;}
|
||||||
|
.front-info{
|
||||||
|
position:absolute;bottom:0;left:0;right:0;
|
||||||
|
padding:8px 9px 10px;z-index:2;
|
||||||
|
}
|
||||||
|
.front-name{
|
||||||
|
font-family:var(--fd);font-size:13px;font-weight:300;
|
||||||
|
color:#fff;line-height:1.3;
|
||||||
|
text-shadow:0 1px 4px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
.front-meta{font-family:var(--fs);font-size:10px;color:rgba(255,255,255,.78);margin-top:2px;}
|
||||||
|
.front-tags{display:flex;gap:3px;flex-wrap:wrap;margin-top:5px;}
|
||||||
|
.ftag{
|
||||||
|
font-family:var(--fs);font-size:8px;font-weight:500;
|
||||||
|
padding:2px 5px;border-radius:2px;
|
||||||
|
background:rgba(255,255,255,.2);color:rgba(255,255,255,.92);
|
||||||
|
backdrop-filter:blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State rings via box-shadow (no layout shift) */
|
||||||
|
.card-front.st-default{box-shadow:var(--sh-card);}
|
||||||
|
.card-front.st-today{box-shadow:0 0 0 2px var(--y), var(--sh-card);}
|
||||||
|
.card-front.st-sel{box-shadow:0 0 0 2px var(--g), var(--sh-raised);}
|
||||||
|
.card-back.st-today{box-shadow:0 0 0 2px var(--y), var(--sh-raised);}
|
||||||
|
.card-back.st-sel{box-shadow:0 0 0 2px var(--g), var(--sh-raised);}
|
||||||
|
|
||||||
|
/* ── BACK face: recipe detail ─── */
|
||||||
|
.card-back{
|
||||||
|
transform:rotateY(180deg);
|
||||||
|
background:var(--page);
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin colour strip at top of back = recipe's colour accent */
|
||||||
|
.back-strip{height:5px;flex-shrink:0;border-radius:var(--r-lg) var(--r-lg) 0 0;}
|
||||||
|
|
||||||
|
.back-inner{
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
flex:1;padding:8px 9px 9px;overflow:hidden;
|
||||||
|
}
|
||||||
|
.back-head{
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
margin-bottom:6px;flex-shrink:0;
|
||||||
|
}
|
||||||
|
.back-day{font-family:var(--fs);font-size:9px;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);}
|
||||||
|
.back-close{
|
||||||
|
width:18px;height:18px;border-radius:var(--r-full);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:var(--subtle);font-size:11px;line-height:1;
|
||||||
|
color:var(--muted);cursor:pointer;flex-shrink:0;
|
||||||
|
border:none;font-family:var(--fs);
|
||||||
|
}
|
||||||
|
.back-close:hover{background:var(--border);}
|
||||||
|
.back-name{
|
||||||
|
font-family:var(--fd);font-size:15px;font-weight:300;
|
||||||
|
line-height:1.25;color:var(--text);
|
||||||
|
margin-bottom:3px;flex-shrink:0;
|
||||||
|
}
|
||||||
|
.back-meta{font-family:var(--fs);font-size:10px;color:var(--muted);margin-bottom:8px;flex-shrink:0;}
|
||||||
|
.back-ings{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:8px;flex-shrink:0;}
|
||||||
|
.bing{
|
||||||
|
font-family:var(--fs);font-size:9px;
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:var(--r-full);padding:2px 6px;color:var(--text);
|
||||||
|
}
|
||||||
|
.bing-s{background:var(--subtle);border-color:var(--subtle);color:var(--muted);}
|
||||||
|
.back-actions{display:flex;flex-direction:column;gap:4px;margin-top:auto;}
|
||||||
|
.bact{
|
||||||
|
display:block;width:100%;padding:6px 8px;
|
||||||
|
border-radius:var(--r-md);border:1px solid var(--border);
|
||||||
|
background:var(--page);font-family:var(--fs);
|
||||||
|
font-size:10px;font-weight:500;letter-spacing:.04em;
|
||||||
|
text-align:center;color:var(--text);cursor:pointer;
|
||||||
|
}
|
||||||
|
.bact-pri{background:var(--gd);color:#fff;border:none;}
|
||||||
|
.bact-err{color:var(--err);border-color:var(--err);background:transparent;margin-top:2px;}
|
||||||
|
|
||||||
|
/* Tile faded (non-selected state) */
|
||||||
|
.scene-faded{opacity:.38;pointer-events:none;}
|
||||||
|
|
||||||
|
/* ── EMPTY TILE (no flip needed) ─── */
|
||||||
|
.tile-empty{
|
||||||
|
border-radius:var(--r-lg);
|
||||||
|
border:1.5px dashed var(--border);
|
||||||
|
background:var(--surface);
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
overflow:hidden;box-shadow:var(--sh-card);
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.te-sel{border:2px dashed var(--g);background:rgba(232,245,234,.5);}
|
||||||
|
.te-faded{opacity:.22;pointer-events:none;}
|
||||||
|
.te-head{display:flex;align-items:center;justify-content:space-between;padding:7px 8px 0;flex-shrink:0;}
|
||||||
|
.te-abbr{font-family:var(--fs);font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);}
|
||||||
|
.te-num{font-family:var(--fs);font-size:10px;font-weight:500;color:var(--muted);}
|
||||||
|
.te-cta{display:flex;flex-direction:column;align-items:center;padding:7px 6px 5px;gap:2px;flex-shrink:0;border-bottom:1px solid var(--border);}
|
||||||
|
.te-plus{font-size:17px;color:var(--border);}
|
||||||
|
.te-label{font-family:var(--fs);font-size:9px;color:var(--muted);}
|
||||||
|
.sug-list{display:flex;flex-direction:column;padding:5px 7px 5px;flex:1;overflow:hidden;}
|
||||||
|
.sug-hd{font-family:var(--fs);font-size:8px;font-weight:500;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);padding:3px 0 4px;border-bottom:1px solid var(--subtle);margin-bottom:2px;}
|
||||||
|
.sug-row{display:flex;align-items:center;gap:4px;padding:5px 0;border-bottom:1px solid var(--subtle);cursor:pointer;}
|
||||||
|
.sug-row:last-of-type{border-bottom:none;}
|
||||||
|
.sug-name{font-family:var(--fd);font-size:11px;font-weight:300;color:var(--text);flex:1;line-height:1.2;}
|
||||||
|
.stag{font-family:var(--fs);font-size:8px;font-weight:500;padding:1px 4px;border-radius:2px;white-space:nowrap;flex-shrink:0;}
|
||||||
|
.st-g{background:var(--gt);color:var(--gd);}
|
||||||
|
.st-y{background:var(--yt);color:var(--yx);}
|
||||||
|
.sug-more{font-family:var(--fs);font-size:9px;font-weight:500;color:var(--yx);text-align:center;padding-top:4px;margin-top:auto;}
|
||||||
|
|
||||||
|
/* ── Image backgrounds ───────────────────────── */
|
||||||
|
.img-haehnchen{background:linear-gradient(160deg,#d4923a 0%,#a85e1a 50%,#7a3d0c 100%);}
|
||||||
|
.img-rind {background:linear-gradient(160deg,#c04545 0%,#8b2020 50%,#5a1010 100%);}
|
||||||
|
.img-stirfry {background:linear-gradient(160deg,#5fa85e 0%,#2e7031 50%,#1a4a1e 100%);}
|
||||||
|
.img-fisch {background:linear-gradient(160deg,#5b9fd4 0%,#2868a0 50%,#10406e 100%);}
|
||||||
|
.img-pizza {background:linear-gradient(160deg,#d4a832 0%,#a07010 50%,#6e4a00 100%);}
|
||||||
|
|
||||||
|
/* Accent strip matches image colours */
|
||||||
|
.strip-haehnchen{background:linear-gradient(90deg,#d4923a,#a85e1a);}
|
||||||
|
.strip-rind {background:linear-gradient(90deg,#c04545,#8b2020);}
|
||||||
|
.strip-stirfry {background:linear-gradient(90deg,#5fa85e,#2e7031);}
|
||||||
|
.strip-fisch {background:linear-gradient(90deg,#5b9fd4,#2868a0);}
|
||||||
|
.strip-pizza {background:linear-gradient(90deg,#d4a832,#a07010);}
|
||||||
|
|
||||||
|
/* ── Demo controls ───────────────────────────── */
|
||||||
|
.demo-hint{
|
||||||
|
font-family:var(--fs);font-size:11px;color:var(--muted);
|
||||||
|
text-align:center;margin-bottom:10px;
|
||||||
|
}
|
||||||
|
.demo-hint span{
|
||||||
|
background:var(--subtle);border-radius:var(--r-sm);
|
||||||
|
padding:2px 8px;font-weight:500;color:var(--text);
|
||||||
|
}
|
||||||
|
.specimen-row{display:flex;gap:14px;margin-bottom:8px;flex-wrap:wrap;align-items:flex-start;}
|
||||||
|
.specimen-wrap{display:flex;flex-direction:column;align-items:center;gap:6px;}
|
||||||
|
.specimen-label{font-family:var(--fs);font-size:10px;font-weight:500;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);text-align:center;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<p class="eyebrow">Mealplan · Planer · Flip Tiles</p>
|
||||||
|
<h1 class="pg-title">Kachel-Flip + Zutaten-Farben</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Klick auf eine gefüllte Kachel → sie dreht sich um. Auf der Rückseite: Rezeptname, Hauptzutaten, Aktionen.
|
||||||
|
Kein Expansion-Panel mehr. Leere Kacheln bleiben unverändert mit Inline-Vorschlägen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- SEKTION 1: FARB-PALETTE -->
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="block">
|
||||||
|
<div class="bl-hd">
|
||||||
|
<span class="bl-num">Palette</span>
|
||||||
|
<span class="bl-name">Farben nach Hauptzutat / Küchenstil</span>
|
||||||
|
<span class="bl-sub">Fallback wenn heroImageUrl fehlt</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<!-- Proteins -->
|
||||||
|
<div class="swatch"><div class="swatch-color img-haehnchen"></div><div class="swatch-label">Hähnchen</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color img-rind"></div><div class="swatch-label">Rind</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color img-fisch"></div><div class="swatch-label">Fisch</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color img-stirfry"></div><div class="swatch-label">Tofu</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#7bc47b 0%,#3d8c3d 50%,#1e5a1e 100%);"></div><div class="swatch-label">vegetarisch</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4785a 0%,#a04535 50%,#6e2418 100%);"></div><div class="swatch-label">Schwein</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#9e6b3a 0%,#6b3f1a 50%,#3e2208 100%);"></div><div class="swatch-label">Lamm</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4b832 0%,#a07010 50%,#6e4800 100%);"></div><div class="swatch-label">Ei</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#8b6b3a 0%,#5e421a 50%,#3a2408 100%);"></div><div class="swatch-label">Hülsenfrüchte</div><div class="swatch-sub">Protein</div></div>
|
||||||
|
<!-- Cuisine overrides -->
|
||||||
|
<div class="swatch"><div class="swatch-color img-pizza"></div><div class="swatch-label">Italienisch</div><div class="swatch-sub">Küche</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#3a6e3a 0%,#1e4a1e 50%,#0e2e0e 100%);"></div><div class="swatch-label">Asiatisch</div><div class="swatch-sub">Küche</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c49010 0%,#8b5e00 50%,#5a3800 100%);"></div><div class="swatch-label">Indisch</div><div class="swatch-sub">Küche</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545 0%,#7a1e1e 50%,#4a0f0f 100%);"></div><div class="swatch-label">Mexikanisch</div><div class="swatch-sub">Küche</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#4a90b8 0%,#1e5a8b 50%,#0a3456 100%);"></div><div class="swatch-label">Mediterran</div><div class="swatch-sub">Küche</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Priorität:</strong> Wenn <code>heroImageUrl</code> vorhanden → echtes Foto.
|
||||||
|
Sonst: Farbe nach erstem Protein-Tag (z.B. <code>tagType=protein</code>, <code>tagName=Hähnchen</code>).
|
||||||
|
Wenn kein Protein-Tag → Farbe nach Küchenstil-Tag (<code>tagType=cuisine</code>).
|
||||||
|
Fallback auf <code>--color-surface</code> neutral.
|
||||||
|
Die Farbwerte werden als CSS-Klassen gemappt: <code>protein-haehnchen</code>, <code>cuisine-asiatisch</code> etc.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- SEKTION 2: INTERACTIVE FLIP DEMO -->
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="block">
|
||||||
|
<div class="bl-hd">
|
||||||
|
<span class="bl-num">Demo</span>
|
||||||
|
<span class="bl-name">Flip-Interaktion — zum Klicken</span>
|
||||||
|
<span class="bl-sub">Echte CSS-3D-Transition</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="demo-hint">Klicke auf eine Kachel um sie umzudrehen. <span>×</span> auf der Rückseite klappt zurück.</p>
|
||||||
|
|
||||||
|
<div class="specimen-row">
|
||||||
|
|
||||||
|
<!-- Tile 1: Hähnchen-Curry (normal) -->
|
||||||
|
<div class="specimen-wrap">
|
||||||
|
<div class="specimen-label">Standard</div>
|
||||||
|
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-haehnchen st-default">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head">
|
||||||
|
<span class="front-abbr">Mo</span>
|
||||||
|
<span class="front-badge">7</span>
|
||||||
|
</div>
|
||||||
|
<div class="front-info">
|
||||||
|
<div class="front-name">Hähnchen-Curry</div>
|
||||||
|
<div class="front-meta">35 Min · mittel</div>
|
||||||
|
<div class="front-tags">
|
||||||
|
<span class="ftag">Hähnchen</span>
|
||||||
|
<span class="ftag">4 Port.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-back st-default">
|
||||||
|
<div class="back-strip strip-haehnchen"></div>
|
||||||
|
<div class="back-inner">
|
||||||
|
<div class="back-head">
|
||||||
|
<span class="back-day">Mo · 7. Apr</span>
|
||||||
|
<button class="back-close" onclick="unflip(event,this)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="back-name">Hähnchen-Curry</div>
|
||||||
|
<div class="back-meta">35 Min · mittel · 4 Port.</div>
|
||||||
|
<div class="back-ings">
|
||||||
|
<span class="bing">Hähnchen</span>
|
||||||
|
<span class="bing">Kokosmilch</span>
|
||||||
|
<span class="bing">Paprika</span>
|
||||||
|
<span class="bing">Spinat</span>
|
||||||
|
<span class="bing-s">Curry</span>
|
||||||
|
<span class="bing-s">Knoblauch</span>
|
||||||
|
</div>
|
||||||
|
<div class="back-actions">
|
||||||
|
<button class="bact bact-pri">Koch-Modus</button>
|
||||||
|
<button class="bact">Rezept ansehen</button>
|
||||||
|
<button class="bact">Gericht tauschen</button>
|
||||||
|
<button class="bact bact-err">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tile 2: Pasta Bolognese (today) -->
|
||||||
|
<div class="specimen-wrap">
|
||||||
|
<div class="specimen-label">Heute</div>
|
||||||
|
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-rind st-today">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head">
|
||||||
|
<span class="front-abbr">Di</span>
|
||||||
|
<span class="front-badge fb-today">8</span>
|
||||||
|
</div>
|
||||||
|
<div class="front-info">
|
||||||
|
<div class="front-name">Pasta Bolognese</div>
|
||||||
|
<div class="front-meta">45 Min · mittel</div>
|
||||||
|
<div class="front-tags">
|
||||||
|
<span class="ftag" style="background:rgba(242,193,46,.35);">Rind</span>
|
||||||
|
<span class="ftag" style="background:rgba(242,193,46,.35);">Heute</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-back st-today">
|
||||||
|
<div class="back-strip strip-rind"></div>
|
||||||
|
<div class="back-inner">
|
||||||
|
<div class="back-head">
|
||||||
|
<span class="back-day" style="color:var(--yx);">Di · Heute</span>
|
||||||
|
<button class="back-close" onclick="unflip(event,this)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="back-name">Pasta Bolognese</div>
|
||||||
|
<div class="back-meta">45 Min · mittel · 4 Port.</div>
|
||||||
|
<div class="back-ings">
|
||||||
|
<span class="bing">Rinderhack</span>
|
||||||
|
<span class="bing">Pasta</span>
|
||||||
|
<span class="bing">Tomaten</span>
|
||||||
|
<span class="bing">Zwiebeln</span>
|
||||||
|
<span class="bing-s">Olivenöl</span>
|
||||||
|
<span class="bing-s">Knoblauch</span>
|
||||||
|
</div>
|
||||||
|
<div class="back-actions">
|
||||||
|
<button class="bact bact-pri">Koch-Modus</button>
|
||||||
|
<button class="bact">Rezept ansehen</button>
|
||||||
|
<button class="bact">Gericht tauschen</button>
|
||||||
|
<button class="bact bact-err">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tile 3: Gemüse-Stir-fry (selected + flipped by default) -->
|
||||||
|
<div class="specimen-wrap">
|
||||||
|
<div class="specimen-label">Ausgewählt (bereits umgedreht)</div>
|
||||||
|
<div class="scene" style="width:150px;height:240px;" onclick="flip(this)">
|
||||||
|
<div class="card flipped">
|
||||||
|
<div class="card-front img-stirfry st-sel">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head">
|
||||||
|
<span class="front-abbr">Mi</span>
|
||||||
|
<span class="front-badge" style="background:var(--g);color:#fff;">9</span>
|
||||||
|
</div>
|
||||||
|
<div class="front-info">
|
||||||
|
<div class="front-name">Gemüse-Stir-fry</div>
|
||||||
|
<div class="front-meta">20 Min · einfach</div>
|
||||||
|
<div class="front-tags"><span class="ftag" style="background:rgba(61,140,74,.4);">Tofu</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-back st-sel">
|
||||||
|
<div class="back-strip strip-stirfry"></div>
|
||||||
|
<div class="back-inner">
|
||||||
|
<div class="back-head">
|
||||||
|
<span class="back-day" style="color:var(--gd);">Mi · 9. Apr</span>
|
||||||
|
<button class="back-close" onclick="unflip(event,this)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="back-name">Gemüse-Stir-fry</div>
|
||||||
|
<div class="back-meta">20 Min · einfach · 2 Port.</div>
|
||||||
|
<div class="back-ings">
|
||||||
|
<span class="bing">Tofu</span>
|
||||||
|
<span class="bing">Paprika</span>
|
||||||
|
<span class="bing">Brokkoli</span>
|
||||||
|
<span class="bing">Karotten</span>
|
||||||
|
<span class="bing-s">Sesamöl</span>
|
||||||
|
<span class="bing-s">Sojasauce</span>
|
||||||
|
</div>
|
||||||
|
<div class="back-actions">
|
||||||
|
<button class="bact bact-pri">Koch-Modus</button>
|
||||||
|
<button class="bact">Rezept ansehen</button>
|
||||||
|
<button class="bact">Gericht tauschen</button>
|
||||||
|
<button class="bact bact-err">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty tile with suggestions -->
|
||||||
|
<div class="specimen-wrap">
|
||||||
|
<div class="specimen-label">Leer — kein Flip</div>
|
||||||
|
<div class="tile-empty" style="width:150px;height:240px;">
|
||||||
|
<div class="te-head">
|
||||||
|
<span class="te-abbr">Sa</span>
|
||||||
|
<span class="te-num">12</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-cta">
|
||||||
|
<div class="te-plus">+</div>
|
||||||
|
<div class="te-label">Gericht wählen</div>
|
||||||
|
</div>
|
||||||
|
<div class="sug-list">
|
||||||
|
<div class="sug-hd">Vorschläge</div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Ramen mit Ei</span><span class="stag st-g">Neues Protein</span></div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Shakshuka</span><span class="stag st-g">Kein Overlap</span></div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Tacos</span><span class="stag st-y">Aufwand: leicht</span></div>
|
||||||
|
<div class="sug-more">Alle Rezepte →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Flip-Mechanik:</strong> CSS <code>transform:rotateY(180deg)</code> auf dem <code>.card</code> wrapper,
|
||||||
|
<code>backface-visibility:hidden</code> auf beiden Faces, <code>perspective:900px</code> auf der Scene.
|
||||||
|
Transition: <code>.45s cubic-bezier(.4,0,.2,1)</code> (Material-Easing — schnell herein, weich heraus).
|
||||||
|
Der <code>×</code> Button auf der Rückseite stoppt den Klick-Event mit <code>stopPropagation()</code>
|
||||||
|
und dreht die Karte zurück. Kein zusätzlicher State nötig — die Karte ist selbst das State-Element.
|
||||||
|
<br><br>
|
||||||
|
<strong>Farbstreifen</strong> oben auf der Rückseite = 5px Gradient, identisch mit der Front-Farbe.
|
||||||
|
Gibt visuelle Kontinuität zwischen Vorder- und Rückseite.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- SEKTION 3: VOLLSTÄNDIGE SEITENANSICHT — Mi UMGEDREHT -->
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="block">
|
||||||
|
<div class="bl-hd">
|
||||||
|
<span class="bl-num">Seite</span>
|
||||||
|
<span class="bl-name">Vollansicht — Mittwoch umgedreht</span>
|
||||||
|
<span class="bl-sub">Kein rechtes Panel. Kacheln bis zum Rand.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frame" style="height:560px;">
|
||||||
|
<div class="tb">
|
||||||
|
<span class="tb-h1">Wochenplaner</span>
|
||||||
|
<span class="tb-range">7.–13. Apr</span>
|
||||||
|
<div class="tb-arr">‹</div><div class="tb-arr">›</div>
|
||||||
|
<button class="tb-btn tb-ml">Heute</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="score-box">
|
||||||
|
<div class="sb-lbl">Abwechslungs-Score</div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:4px;"><span class="sc-big">7.8</span><span class="sc-den">/10</span></div>
|
||||||
|
<div class="pbar pb-y"><div class="pb-fill pb-fg-y" style="width:78%;"></div></div>
|
||||||
|
<div class="sr"><span class="sr-l">Protein</span><div class="sr-b"><div class="sr-f" style="width:80%;background:var(--g);"></div></div><span class="sr-v">8.0</span></div>
|
||||||
|
<div class="sr"><span class="sr-l">Zutaten</span><div class="sr-b"><div class="sr-f" style="width:72%;background:var(--y);"></div></div><span class="sr-v">7.2</span></div>
|
||||||
|
<div class="sr"><span class="sr-l">Aufwand</span><div class="sr-b"><div class="sr-f" style="width:82%;background:var(--g);"></div></div><span class="sr-v">8.2</span></div>
|
||||||
|
<a class="sb-link">Variety-Analyse →</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sb-lbl">Überschneidungen</div>
|
||||||
|
<div class="w-item">⚠ Hähnchen an Mo + Do</div>
|
||||||
|
<div class="w-item">⚠ Tomaten an Di + Do</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sb-lbl">Geplant</div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:3px;"><span style="font-family:var(--fd);font-size:20px;font-weight:300;">5</span><span style="font-family:var(--fs);font-size:10px;color:var(--muted);">/ 7 Tage</span></div>
|
||||||
|
<div class="dp" style="margin-top:5px;">
|
||||||
|
<div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--g);"></div><div class="dp-s" style="background:var(--border);"></div><div class="dp-s" style="background:var(--border);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN: grid fills full height, Mi is flipped -->
|
||||||
|
<div class="main">
|
||||||
|
<div class="grid7" style="height:100%;">
|
||||||
|
|
||||||
|
<!-- Mo: faded -->
|
||||||
|
<div class="scene scene-faded" style="height:100%;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-haehnchen st-default">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head"><span class="front-abbr">Mo</span><span class="front-badge">7</span></div>
|
||||||
|
<div class="front-info"><div class="front-name">Hähnchen-Curry</div><div class="front-meta">35 Min · mittel</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Di: today, faded -->
|
||||||
|
<div class="scene scene-faded" style="height:100%;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-rind st-today">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head"><span class="front-abbr">Di</span><span class="front-badge fb-today">8</span></div>
|
||||||
|
<div class="front-info"><div class="front-name">Pasta Bolognese</div><div class="front-meta">45 Min · mittel</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mi: SELECTED + FLIPPED -->
|
||||||
|
<div class="scene" style="height:100%;" onclick="flip(this)">
|
||||||
|
<div class="card flipped">
|
||||||
|
<div class="card-front img-stirfry st-sel">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head">
|
||||||
|
<span class="front-abbr">Mi</span>
|
||||||
|
<span class="front-badge" style="background:var(--g);color:#fff;">9</span>
|
||||||
|
</div>
|
||||||
|
<div class="front-info">
|
||||||
|
<div class="front-name">Gemüse-Stir-fry</div>
|
||||||
|
<div class="front-meta">20 Min · einfach</div>
|
||||||
|
<div class="front-tags"><span class="ftag" style="background:rgba(61,140,74,.4);">Tofu</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-back st-sel">
|
||||||
|
<div class="back-strip strip-stirfry"></div>
|
||||||
|
<div class="back-inner">
|
||||||
|
<div class="back-head">
|
||||||
|
<span class="back-day" style="color:var(--gd);">Mi · 9. Apr</span>
|
||||||
|
<button class="back-close" onclick="unflip(event,this)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="back-name">Gemüse-Stir-fry</div>
|
||||||
|
<div class="back-meta">20 Min · einfach · 2 Port.</div>
|
||||||
|
<div class="back-ings">
|
||||||
|
<span class="bing">Tofu</span>
|
||||||
|
<span class="bing">Paprika</span>
|
||||||
|
<span class="bing">Brokkoli</span>
|
||||||
|
<span class="bing">Karotten</span>
|
||||||
|
<span class="bing">Ingwer</span>
|
||||||
|
<span class="bing-s">Sesamöl</span>
|
||||||
|
<span class="bing-s">Sojasauce</span>
|
||||||
|
</div>
|
||||||
|
<div class="back-actions">
|
||||||
|
<button class="bact bact-pri">Koch-Modus</button>
|
||||||
|
<button class="bact">Rezept ansehen</button>
|
||||||
|
<button class="bact">Gericht tauschen</button>
|
||||||
|
<button class="bact bact-err">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Do: faded -->
|
||||||
|
<div class="scene scene-faded" style="height:100%;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-fisch st-default">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head"><span class="front-abbr">Do</span><span class="front-badge">10</span></div>
|
||||||
|
<div class="front-info"><div class="front-name">Lachs mit Kartoffeln</div><div class="front-meta">30 Min · einfach</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fr: faded -->
|
||||||
|
<div class="scene scene-faded" style="height:100%;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-front img-pizza st-default">
|
||||||
|
<div class="front-overlay"></div>
|
||||||
|
<div class="front-head"><span class="front-abbr">Fr</span><span class="front-badge">11</span></div>
|
||||||
|
<div class="front-info"><div class="front-name">Pizza Margherita</div><div class="front-meta">50 Min · aufwändig</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sa: empty with suggestions -->
|
||||||
|
<div class="tile-empty te-faded" style="height:100%;">
|
||||||
|
<div class="te-head"><span class="te-abbr">Sa</span><span class="te-num">12</span></div>
|
||||||
|
<div class="te-cta"><div class="te-plus">+</div><div class="te-label">Gericht wählen</div></div>
|
||||||
|
<div class="sug-list">
|
||||||
|
<div class="sug-hd">Vorschläge</div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Ramen mit Ei</span><span class="stag st-g">Neues Protein</span></div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Shakshuka</span><span class="stag st-g">Kein Overlap</span></div>
|
||||||
|
<div class="sug-more">Alle Rezepte →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- So: empty with suggestions -->
|
||||||
|
<div class="tile-empty te-faded" style="height:100%;">
|
||||||
|
<div class="te-head"><span class="te-abbr">So</span><span class="te-num">13</span></div>
|
||||||
|
<div class="te-cta"><div class="te-plus">+</div><div class="te-label">Gericht wählen</div></div>
|
||||||
|
<div class="sug-list">
|
||||||
|
<div class="sug-hd">Vorschläge</div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Grünes Thai-Curry</span><span class="stag st-g">Neues Protein</span></div>
|
||||||
|
<div class="sug-row"><span class="sug-name">Tacos</span><span class="stag st-y">Aufwand: leicht</span></div>
|
||||||
|
<div class="sug-more">Alle Rezepte →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Layout:</strong> Linke Sidebar (Variety-Score) bleibt. Kein rechtes Panel mehr.
|
||||||
|
Die Kacheln füllen den gesamten verbleibenden Platz (<code>flex:1</code>) — 7 gleich breite Spalten,
|
||||||
|
volle Höhe (<code>height:100%</code> auf Grid und Kacheln). Kein Layout-Shift, kein After-Scroll.
|
||||||
|
<br><br>
|
||||||
|
<strong>Dimm-Effekt:</strong> Beim Flip werden alle anderen Kacheln auf 38% gedimmt.
|
||||||
|
Kein neuer API-Aufruf nötig — reine CSS-Klasse per JS.
|
||||||
|
<br><br>
|
||||||
|
<strong>„Gericht tauschen":</strong> Öffnet den Rezept-Picker als Slide-in-Drawer von rechts
|
||||||
|
(kein persistentes Panel). Drawer schließt sich nach Auswahl oder Abbruch.
|
||||||
|
<br><br>
|
||||||
|
<strong>Leere Kacheln:</strong> Zeigen Inline-Vorschläge auch im gedimmten Zustand (wenn
|
||||||
|
eine andere Kachel geflippt ist). Kein Flip auf leeren Kacheln.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function flip(scene) {
|
||||||
|
const card = scene.querySelector('.card');
|
||||||
|
const isFlipped = card.classList.toggle('flipped');
|
||||||
|
// Dim all other scenes in the same grid
|
||||||
|
const grid = scene.closest('.grid7');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.querySelectorAll('.scene, .tile-empty').forEach(el => {
|
||||||
|
if (el === scene) return;
|
||||||
|
if (isFlipped) {
|
||||||
|
el.style.opacity = '0.38';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
} else {
|
||||||
|
el.style.opacity = '';
|
||||||
|
el.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unflip(event, btn) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const scene = btn.closest('.scene');
|
||||||
|
const card = scene.querySelector('.card');
|
||||||
|
card.classList.remove('flipped');
|
||||||
|
// Un-dim everything
|
||||||
|
const grid = scene.closest('.grid7');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.querySelectorAll('.scene, .tile-empty').forEach(el => {
|
||||||
|
el.style.opacity = '';
|
||||||
|
el.style.pointerEvents = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
459
specs/planner-redesign-flip-tiles.html
Normal file
459
specs/planner-redesign-flip-tiles.html
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Planner Redesign — Flip Tiles · Final Spec</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<!--
|
||||||
|
spec:agent
|
||||||
|
document: Planner Desktop Redesign — Flip Tiles
|
||||||
|
version: 1.0
|
||||||
|
route: /planner (desktop)
|
||||||
|
screens: Planner main area — tile grid, sidebar, recipe picker drawer
|
||||||
|
key-decisions:
|
||||||
|
- Full-bleed color/image tiles (no blank body space)
|
||||||
|
- CSS 3D card flip replaces expansion panel
|
||||||
|
- No persistent right panel — tiles fill full remaining width
|
||||||
|
- Ingredient/cuisine color palette as heroImageUrl fallback
|
||||||
|
- Inline suggestions on empty tiles (reasoning tags, no delta numbers)
|
||||||
|
- No "Gericht hinzufügen" toolbar button (empty tile CTA handles it)
|
||||||
|
- Recipe picker opens as slide-in drawer (on demand only)
|
||||||
|
last-updated: 2026-04
|
||||||
|
reference-mockups:
|
||||||
|
- specs/planner-flip-tiles.html (interactive demo, color palette)
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-page: #FAFAF7;
|
||||||
|
--color-surface: #F5F4EE;
|
||||||
|
--color-subtle: #EDECEA;
|
||||||
|
--color-border: #D8D7D0;
|
||||||
|
--color-text-muted: #6B6A63;
|
||||||
|
--color-text: #1C1C18;
|
||||||
|
--green-tint: #E8F5EA;
|
||||||
|
--green-light: #AEDCB0;
|
||||||
|
--green: #3D8C4A;
|
||||||
|
--green-dark: #2E6E39;
|
||||||
|
--yellow-tint: #FDF6D8;
|
||||||
|
--yellow-light: #F9E08A;
|
||||||
|
--yellow: #F2C12E;
|
||||||
|
--yellow-text: #8A6800;
|
||||||
|
--color-error: #DC4C3E;
|
||||||
|
--font-display: 'Fraunces', Georgia, serif;
|
||||||
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-full: 9999px;
|
||||||
|
--shadow-card: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
|
||||||
|
--shadow-raised: 0 4px 12px rgba(28,28,24,.10), 0 2px 4px rgba(28,28,24,.06);
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-sans); background: var(--color-page); color: var(--color-text); font-size: 14px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.doc { max-width: 900px; margin: 0 auto; padding: 48px 40px 96px; }
|
||||||
|
.doc-header { display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); margin-bottom: 48px; }
|
||||||
|
.doc-header h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||||
|
.doc-header p { font-size: 13px; color: var(--color-text-muted); }
|
||||||
|
.doc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); text-align: right; line-height: 1.9; }
|
||||||
|
|
||||||
|
.intro { font-size: 14px; line-height: 1.75; color: var(--color-text); max-width: 700px; margin-bottom: 48px; }
|
||||||
|
.intro p + p { margin-top: 12px; }
|
||||||
|
|
||||||
|
.section { margin-bottom: 56px; }
|
||||||
|
.section-label { font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-text-muted); padding-bottom: 10px; border-bottom: 1px solid var(--color-border); margin-bottom: 28px; }
|
||||||
|
h2 { font-family: var(--font-display); font-size: 20px; font-weight: 400; margin-bottom: 14px; }
|
||||||
|
h3 { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--color-text); }
|
||||||
|
|
||||||
|
p { margin-bottom: 10px; font-size: 14px; line-height: 1.7; }
|
||||||
|
ul { padding-left: 20px; margin-bottom: 12px; }
|
||||||
|
li { font-size: 14px; line-height: 1.65; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
code { font-family: var(--font-mono); font-size: 12px; background: var(--color-subtle); border-radius: 3px; padding: 1px 5px; }
|
||||||
|
pre { font-family: var(--font-mono); font-size: 12px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 14px 16px; margin: 12px 0; overflow-x: auto; line-height: 1.6; }
|
||||||
|
|
||||||
|
.callout { background: var(--color-surface); border-left: 3px solid var(--color-border); border-radius: 0 var(--radius-md) var(--radius-md) 0; padding: 12px 16px; margin: 16px 0; font-size: 13px; line-height: 1.65; }
|
||||||
|
.callout.green { border-color: var(--green); background: var(--green-tint); }
|
||||||
|
.callout.yellow { border-color: var(--yellow); background: var(--yellow-tint); }
|
||||||
|
.callout strong { font-weight: 600; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 16px 0; }
|
||||||
|
th { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--color-border); }
|
||||||
|
td { padding: 9px 12px; border-bottom: 1px solid var(--color-subtle); vertical-align: top; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.swatch-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
|
||||||
|
.swatch { width: 80px; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-card); }
|
||||||
|
.swatch-color { height: 44px; }
|
||||||
|
.swatch-name { font-size: 10px; font-weight: 500; padding: 4px 6px; background: var(--color-page); border-top: 1px solid var(--color-border); }
|
||||||
|
.swatch-sub { font-size: 9px; color: var(--color-text-muted); padding: 0 6px 4px; }
|
||||||
|
|
||||||
|
.state-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; }
|
||||||
|
.state-card { border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 14px 16px; }
|
||||||
|
.state-name { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 6px; }
|
||||||
|
.state-desc { font-size: 13px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.component-row { display: flex; gap: 8px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--color-subtle); }
|
||||||
|
.component-row:last-child { border-bottom: none; }
|
||||||
|
.comp-file { font-family: var(--font-mono); font-size: 12px; color: var(--color-text); flex: 0 0 auto; min-width: 280px; }
|
||||||
|
.comp-action { font-size: 13px; color: var(--color-text-muted); }
|
||||||
|
.badge { display: inline-block; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: var(--radius-full); }
|
||||||
|
.badge-new { background: var(--green-tint); color: var(--green-dark); }
|
||||||
|
.badge-mod { background: var(--yellow-tint); color: var(--yellow-text); }
|
||||||
|
.badge-del { background: #fde8e8; color: var(--color-error); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Planner Desktop Redesign</h1>
|
||||||
|
<p>Flip Tiles · Final Spec · Route: <code>/planner</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Version 1.0<br>
|
||||||
|
2026-04<br>
|
||||||
|
Mockup: <code>specs/planner-flip-tiles.html</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="intro">
|
||||||
|
<p>
|
||||||
|
Der Wochenplaner hat auf Desktop aktuell ~80 % vertikalen Leerraum unterhalb des 7-Spalten-Kalenders.
|
||||||
|
Zusätzlich ist das rechte Panel im Leerlauf nicht genutzt. Dieses Spec beschreibt ein vollständiges
|
||||||
|
Redesign der Desktop-Hauptfläche: Die Kacheln füllen die volle Höhe und Breite, Rezeptdetails werden
|
||||||
|
über einen CSS-3D-Flip direkt in der Kachel angezeigt, und leere Tage zeigen Inline-Vorschläge.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Das rechte Panel entfällt dauerhaft. Der Rezept-Picker öffnet sich als Slide-in-Drawer ausschließlich
|
||||||
|
auf Anfrage (Aktion „Gericht tauschen" auf der Kachel-Rückseite). Der Toolbar-Button
|
||||||
|
„Gericht hinzufügen" entfällt, da jede leere Kachel eine eigene CTA hat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">01 · Layout</div>
|
||||||
|
<h2>Seitenstruktur</h2>
|
||||||
|
|
||||||
|
<p>Desktop-Layout: 2 Spalten. Kein persistentes rechtes Panel mehr.</p>
|
||||||
|
|
||||||
|
<pre>┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Toolbar (Wochenplaner · 7.–13. Apr ‹ › Heute) │
|
||||||
|
├──────────┬──────────────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ 7-Spalten-Kachelgrid (flex: 1, height: 100%) │
|
||||||
|
│ 184 px │ │
|
||||||
|
│ Variety │ Mo Di Mi Do Fr Sa So │
|
||||||
|
│ Score │ ████ ████ ████ ████ ████ ░░░░ ░░░░ │
|
||||||
|
│ │ ████ ████ ████ ████ ████ ░+░░ ░+░░ │
|
||||||
|
│ │ ████ ████ ████ ████ ████ ░Vor░ ░Vor░ │
|
||||||
|
└──────────┴──────────────────────────────────────────────────┘</pre>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Sidebar (184 px, flex-shrink: 0):</strong> Variety-Score-Card, Sub-Scores, Überschneidungs-Warnungen, Link zur Variety-Analyse. Unverändert.</li>
|
||||||
|
<li><strong>Main (flex: 1):</strong> <code>display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; height: 100%</code>. Kacheln füllen die gesamte verbleibende Breite und Höhe.</li>
|
||||||
|
<li><strong>Toolbar:</strong> Nur Navigation — Wochenbezeichnung, Zurück/Vor-Pfeile, Heute-Button. Kein „+ Gericht hinzufügen" mehr.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout yellow">
|
||||||
|
<strong>Entfernt:</strong> Das rechte Panel (<code>width: 228px</code>) mit der „Heute Abend"-Karte und dem Leerlauf-Hinweis entfällt vollständig. Koch-Modus ist auf der Kachel-Rückseite zugänglich.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">02 · Kachel-Zustände</div>
|
||||||
|
<h2>Tile States</h2>
|
||||||
|
|
||||||
|
<div class="state-grid">
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-name">Standard (gefüllt)</div>
|
||||||
|
<div class="state-desc">
|
||||||
|
Vollbild-Farbhintergrund (Gradient nach Zutat/Küche) oder <code>heroImageUrl</code>.
|
||||||
|
Dual-Gradient-Overlay (oben + unten dunkel, Mitte klar).
|
||||||
|
Oben: Tageskürzel + Datumsziffer. Unten: Rezeptname, Kochzeit, Tags.
|
||||||
|
<br><br>
|
||||||
|
<code>box-shadow: var(--sh-card)</code> — kein sichtbarer Ring.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-name">Heute (gefüllt)</div>
|
||||||
|
<div class="state-desc">
|
||||||
|
Identisch wie Standard, aber mit gelbem Ring via
|
||||||
|
<code>box-shadow: 0 0 0 2px var(--yellow), var(--sh-card)</code>.
|
||||||
|
Datumsziffer-Badge in <code>--yellow</code>. Tag-Label „Heute" zusätzlich als frosted Tag.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-name">Ausgewählt / Geflippt</div>
|
||||||
|
<div class="state-desc">
|
||||||
|
Grüner Ring: <code>box-shadow: 0 0 0 2px var(--green), var(--sh-raised)</code>.
|
||||||
|
Karte dreht sich 180° (CSS 3D, siehe §04). Alle anderen Kacheln werden auf 38 % Deckkraft
|
||||||
|
gedimmt und sind nicht klickbar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-card">
|
||||||
|
<div class="state-name">Leer</div>
|
||||||
|
<div class="state-desc">
|
||||||
|
Kein Flip. Gestrichelter Rahmen (<code>border: 1.5px dashed var(--color-border)</code>),
|
||||||
|
<code>background: var(--color-surface)</code>. Oben: Tageskürzel + Datum.
|
||||||
|
Darunter: <code>+</code> Icon + „Gericht wählen". Rest der Kachel: Inline-Vorschläge (§05).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>box-shadow statt border:</strong> Statusringe werden via <code>box-shadow</code> gesetzt, nicht via <code>border</code>,
|
||||||
|
um Layout-Shift zu vermeiden. Die Kacheln behalten identische Außenmaße in allen Zuständen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">03 · Farb-Palette</div>
|
||||||
|
<h2>Ingredient & Cuisine Colors</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Wenn <code>heroImageUrl</code> vorhanden ist, wird das echte Foto als <code>background-image</code> gesetzt.
|
||||||
|
Fehlt es, greift die folgende Prioritätskette:
|
||||||
|
</p>
|
||||||
|
<ol style="padding-left:20px;margin-bottom:16px;">
|
||||||
|
<li>Ersten Tag mit <code>tagType = "protein"</code> finden → Protein-Farbe</li>
|
||||||
|
<li>Ersten Tag mit <code>tagType = "cuisine"</code> finden → Küchenstil-Farbe</li>
|
||||||
|
<li>Fallback: <code>background: var(--color-surface)</code> (neutral)</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Protein-Farben</h3>
|
||||||
|
<div class="swatch-row">
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4923a,#a85e1a,#7a3d0c)"></div><div class="swatch-name">Hähnchen</div><div class="swatch-sub">protein-haehnchen</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545,#8b2020,#5a1010)"></div><div class="swatch-name">Rind</div><div class="swatch-sub">protein-rind</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#5b9fd4,#2868a0,#10406e)"></div><div class="swatch-name">Fisch</div><div class="swatch-sub">protein-fisch</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#5fa85e,#2e7031,#1a4a1e)"></div><div class="swatch-name">Tofu</div><div class="swatch-sub">protein-tofu</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#7bc47b,#3d8c3d,#1e5a1e)"></div><div class="swatch-name">Vegetarisch</div><div class="swatch-sub">protein-veg</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4785a,#a04535,#6e2418)"></div><div class="swatch-name">Schwein</div><div class="swatch-sub">protein-schwein</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#9e6b3a,#6b3f1a,#3e2208)"></div><div class="swatch-name">Lamm</div><div class="swatch-sub">protein-lamm</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4b832,#a07010,#6e4800)"></div><div class="swatch-name">Ei</div><div class="swatch-sub">protein-ei</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#8b6b3a,#5e421a,#3a2408)"></div><div class="swatch-name">Hülsenfrüchte</div><div class="swatch-sub">protein-huelsenfruechte</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Küchenstil-Farben</h3>
|
||||||
|
<div class="swatch-row">
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c04545,#7a1e1e,#4a0f0f)"></div><div class="swatch-name">Italienisch</div><div class="swatch-sub">cuisine-italienisch</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#3a6e3a,#1e4a1e,#0e2e0e)"></div><div class="swatch-name">Asiatisch</div><div class="swatch-sub">cuisine-asiatisch</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#c49010,#8b5e00,#5a3800)"></div><div class="swatch-name">Indisch</div><div class="swatch-sub">cuisine-indisch</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#d4923a,#8b4e10,#5a2e00)"></div><div class="swatch-name">Mexikanisch</div><div class="swatch-sub">cuisine-mexikanisch</div></div>
|
||||||
|
<div class="swatch"><div class="swatch-color" style="background:linear-gradient(160deg,#4a90b8,#1e5a8b,#0a3456)"></div><div class="swatch-name">Mediterran</div><div class="swatch-sub">cuisine-mediterran</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die CSS-Klassen (<code>protein-haehnchen</code>, <code>cuisine-asiatisch</code>, …) werden
|
||||||
|
serverseitig aus den Rezept-Tags abgeleitet und als Svelte-Prop übergeben, z.B.
|
||||||
|
<code>colorClass="protein-haehnchen"</code>. Das Component setzt die Klasse auf dem Kachel-Wrapper.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">04 · Flip-Mechanik</div>
|
||||||
|
<h2>CSS 3D Card Flip</h2>
|
||||||
|
|
||||||
|
<p>Jede gefüllte Kachel besteht aus drei verschachtelten Elementen:</p>
|
||||||
|
<pre>.scene → perspective: 900px; border-radius: var(--radius-lg); cursor: pointer
|
||||||
|
.card → position: relative; transform-style: preserve-3d
|
||||||
|
transition: transform .45s cubic-bezier(.4,0,.2,1)
|
||||||
|
.card.flipped → transform: rotateY(180deg)
|
||||||
|
.card-front → backface-visibility: hidden; position: absolute; inset: 0
|
||||||
|
.card-back → backface-visibility: hidden; transform: rotateY(180deg)
|
||||||
|
position: absolute; inset: 0; background: var(--color-page)</pre>
|
||||||
|
|
||||||
|
<h3>Vorderseite</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Vollbild-Farbe oder <code>background-image: url(heroImageUrl)</code> mit <code>background-size: cover</code></li>
|
||||||
|
<li>Dual-Gradient-Overlay als absolutes <code>::after</code>-Pseudo-Element:<br>
|
||||||
|
<code>linear-gradient(to bottom, rgba(0,0,0,.38) 0%, transparent 28%, transparent 48%, rgba(0,0,0,.62) 100%)</code></li>
|
||||||
|
<li>Oben links: Tageskürzel (9px uppercase). Oben rechts: Datums-Badge (Kreis)</li>
|
||||||
|
<li>Unten: Rezeptname (Fraunces 13px), Meta-Zeile (Kochzeit · Aufwand), Tag-Chips</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Rückseite</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Farbstreifen (5 px)</strong> oben — identischer Gradient wie die Vorderseite. Gibt visuelle Kontinuität.</li>
|
||||||
|
<li>Tageskürzel + Datum (links) · × Schließen-Button (rechts)</li>
|
||||||
|
<li>Rezeptname (Fraunces 15px)</li>
|
||||||
|
<li>Meta: Kochzeit · Aufwand · Portionen</li>
|
||||||
|
<li>Zutaten-Pills: normale Zutaten als <code>.ingredient</code>, Vorrats-Zutaten (Staples) gedimmt als <code>.ingredient--staple</code></li>
|
||||||
|
<li>Aktionen (gestapelt, volle Breite):</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Aktion</th><th>Stil</th><th>Verhalten</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Koch-Modus starten</td><td>Primary (grün ausgefüllt)</td><td>Navigiert zu <code>/planner/cook/[slotId]</code></td></tr>
|
||||||
|
<tr><td>Rezept ansehen</td><td>Secondary (Rahmen)</td><td>Navigiert zu <code>/recipes/[recipeId]</code></td></tr>
|
||||||
|
<tr><td>Gericht tauschen</td><td>Secondary (Rahmen)</td><td>Öffnet Rezept-Picker-Drawer (§06)</td></tr>
|
||||||
|
<tr><td>Entfernen</td><td>Danger (roter Text, transparenter BG)</td><td>Löscht den Slot, Kachel wird leer</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Interaction Flow</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Klick auf <code>.scene</code> → <code>.card.classList.toggle('flipped')</code></li>
|
||||||
|
<li>Alle Geschwister-Kacheln im Grid → <code>opacity: 0.38; pointer-events: none</code></li>
|
||||||
|
<li>× Button auf Rückseite → <code>event.stopPropagation()</code>, <code>classList.remove('flipped')</code>, Geschwister-Opacity zurücksetzen</li>
|
||||||
|
<li>Escape-Taste → aktive Kachel zurückdrehen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout green">
|
||||||
|
<strong>Kein API-Aufruf beim Flip.</strong> Alle dargestellten Daten (Name, Zutaten, Aktionen) sind bereits
|
||||||
|
im vorhandenen <code>slotMap</code>-State vorhanden. Der Flip ist eine rein visuelle Operation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">05 · Leere Kacheln</div>
|
||||||
|
<h2>Empty Tile — Inline Suggestions</h2>
|
||||||
|
|
||||||
|
<p>Leere Kacheln haben denselben <code>height: 100%</code> wie gefüllte Kacheln. Kein Flip.</p>
|
||||||
|
|
||||||
|
<pre>┌─────────────────┐
|
||||||
|
│ Sa 12 │ ← Tageskürzel + Datum
|
||||||
|
│─────────────────│
|
||||||
|
│ + │
|
||||||
|
│ Gericht wählen │ ← Klick öffnet Rezept-Picker-Drawer
|
||||||
|
│─────────────────│
|
||||||
|
│ VORSCHLÄGE │
|
||||||
|
│ Ramen mit Ei [Neues Protein] │
|
||||||
|
│ Shakshuka [Kein Overlap] │
|
||||||
|
│ Tacos [Aufwand: leicht]│
|
||||||
|
│ │
|
||||||
|
│ Alle Rezepte → │
|
||||||
|
└────────────────────────────────┘</pre>
|
||||||
|
|
||||||
|
<h3>Vorschlag-Tags (Reasoning)</h3>
|
||||||
|
<p>Anstelle numerischer Score-Deltas (die für leere Slots immer positiv sind und daher keine Information tragen)
|
||||||
|
werden Begründungs-Tags angezeigt:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Tag</th><th>Farbe</th><th>Bedeutung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Neues Protein</td><td>Grün</td><td>Proteinquelle kommt diese Woche noch nicht vor</td></tr>
|
||||||
|
<tr><td>Kein Overlap</td><td>Grün</td><td>Keine Zutaten-Überschneidung mit anderen Tagen</td></tr>
|
||||||
|
<tr><td>Aufwand: leicht</td><td>Gelb</td><td>Kochzeit < 30 Min oder Aufwand = einfach</td></tr>
|
||||||
|
<tr><td>Aufwand: mittel</td><td>Neutral</td><td>Mittlerer Aufwand</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>Datenquelle:</strong> Die vorhandene <code>GET /api/suggestions?weekId=&dayOfWeek=</code> API liefert
|
||||||
|
<code>SuggestionItem { recipe, scoreDelta, hasConflict }</code>. Die Reasoning-Tags werden frontend-seitig
|
||||||
|
aus den Rezept-Tags und dem vorhandenen <code>slotMap</code> abgeleitet, kein Backend-Änderungsbedarf.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">06 · Rezept-Picker</div>
|
||||||
|
<h2>Recipe Picker Drawer</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Der Rezept-Picker öffnet sich als Slide-in-Drawer von rechts — ausschließlich auf explizite Anfrage.
|
||||||
|
Er hat keinen persistenten Platz im Layout mehr.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Trigger</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Klick auf <strong>„Gericht tauschen"</strong> auf der Kachel-Rückseite</li>
|
||||||
|
<li>Klick auf <strong>„Gericht wählen"</strong> CTA oder Vorschlag-Zeile auf einer leeren Kachel</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Drawer-Verhalten</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Slide-in von rechts, überlagert den Inhalt (kein Layout-Shift)</li>
|
||||||
|
<li>Breite: <code>min(480px, 90vw)</code></li>
|
||||||
|
<li>Backdrop (halbtransparent) schließt den Drawer bei Klick</li>
|
||||||
|
<li>Nach Auswahl: Drawer schließt sich, Slot wird aktualisiert, Kachel zeigt neues Rezept</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
Der bestehende <code>RecipePicker</code>-Komponente (aktuell im rechten Panel) wird in einen
|
||||||
|
generischen Drawer gewrappt. Der Drawer-Wrapper ist neu; der Picker selbst bleibt unverändert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">07 · Mobile</div>
|
||||||
|
<h2>Mobile — Out of Scope</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Dieses Spec betrifft ausschließlich die Desktop-Ansicht (<code>≥ 768px</code>).
|
||||||
|
Das mobile Layout (vertikaler Stack, DayMealCard, ActionSheet) bleibt unverändert.
|
||||||
|
CSS-3D-Flips auf Touch-Geräten haben bekannte Rendering-Unterschiede auf älteren Android-Browsern —
|
||||||
|
ein separates Issue sollte die mobile Interaktion (ggf. Slide-up Sheet statt Flip) spezifizieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">08 · Komponenten</div>
|
||||||
|
<h2>Komponenten-Übersicht</h2>
|
||||||
|
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/routes/(app)/planner/+page.svelte</span>
|
||||||
|
<span class="badge badge-mod">Ändern</span>
|
||||||
|
<span class="comp-action">Rechtes Panel entfernen. Layout auf 2-spaltig (sidebar + main) umstellen. Toolbar-Button entfernen. Grid-Höhe auf 100% setzen.</span>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/lib/planner/DayMealCard.svelte</span>
|
||||||
|
<span class="badge badge-mod">Ersetzen / umbenennen</span>
|
||||||
|
<span class="comp-action">Zur Flip-Kachel umbauen: .scene → .card → .card-front + .card-back. Farb-Klassen-Prop, Gradient-Overlay, Back-Face mit Aktionen.</span>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/lib/planner/EmptyDayTile.svelte</span>
|
||||||
|
<span class="badge badge-new">Neu</span>
|
||||||
|
<span class="comp-action">Leere Kachel: + CTA + Inline-Suggestion-Liste mit Reasoning-Tags. Ersetzt den bisherigen leeren Slot-Platzhalter.</span>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/lib/planner/RecipePickerDrawer.svelte</span>
|
||||||
|
<span class="badge badge-new">Neu</span>
|
||||||
|
<span class="comp-action">Drawer-Wrapper um den bestehenden RecipePicker. Slide-in von rechts, Backdrop, Schließ-Logik.</span>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/lib/planner/RecipePicker.svelte</span>
|
||||||
|
<span class="badge badge-mod">Ändern</span>
|
||||||
|
<span class="comp-action">Aus dem rechten Panel lösen. Bekommt slotId als Prop. Keine Änderung an der Such-/Auswahl-Logik nötig.</span>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="comp-file">src/app.css</span>
|
||||||
|
<span class="badge badge-mod">Ergänzen</span>
|
||||||
|
<span class="comp-action">14 Farb-Klassen für Protein- und Küchenstil-Gradients hinzufügen (<code>.protein-haehnchen</code>, <code>.cuisine-asiatisch</code>, …).</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">09 · Accessibility</div>
|
||||||
|
<h2>A11y-Anforderungen</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>.scene</code>: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-expanded="false|true"</code>, <code>aria-label="[Rezeptname] — Details anzeigen"</code></li>
|
||||||
|
<li><code>.card-back</code>: <code>aria-hidden="true"</code> solange nicht geflippt</li>
|
||||||
|
<li>× Schließen-Button: <code>aria-label="Schließen"</code>, <code>type="button"</code></li>
|
||||||
|
<li>Keyboard: <code>Enter</code> / <code>Space</code> flippt, <code>Escape</code> dreht zurück</li>
|
||||||
|
<li>Dimming: gedimmte Kacheln bekommen <code>aria-hidden="true"</code> wenn eine andere geflippt ist</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user