feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54
@@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,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%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user