feat(planner): J4 swap flow — action sheet + easiest-first suggestions #45
@@ -17,15 +17,19 @@
|
|||||||
isToday = false,
|
isToday = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
onaddrecipe
|
onaddrecipe,
|
||||||
|
onactionsheet
|
||||||
}: {
|
}: {
|
||||||
slot: Slot;
|
slot: Slot;
|
||||||
isToday?: boolean;
|
isToday?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
onaddrecipe?: () => void;
|
onaddrecipe?: () => void;
|
||||||
|
onactionsheet?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let actionSheetMode = $derived(!!onactionsheet && !!slot.recipe);
|
||||||
|
|
||||||
let metadata = $derived(
|
let metadata = $derived(
|
||||||
[
|
[
|
||||||
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
|
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
|
||||||
@@ -44,49 +48,66 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
{#snippet recipeInfo()}
|
||||||
data-testid="day-meal-card"
|
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
||||||
data-today={isToday}
|
{slot.recipe?.name ?? ''}
|
||||||
data-selected={isSelected}
|
</h3>
|
||||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
{#if metadata}
|
||||||
>
|
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||||
{#if slot.recipe}
|
|
||||||
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
|
||||||
{slot.recipe.name}
|
|
||||||
</h3>
|
|
||||||
{#if metadata}
|
|
||||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !readonly}
|
|
||||||
<div class="mt-3 flex gap-2">
|
|
||||||
<a
|
|
||||||
href="/recipes/{slot.recipe.id}/cook"
|
|
||||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
|
||||||
>
|
|
||||||
Jetzt kochen
|
|
||||||
</a>
|
|
||||||
{#if onaddrecipe}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onaddrecipe}
|
|
||||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
Tauschen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
|
||||||
{#if !readonly && onaddrecipe}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onaddrecipe}
|
|
||||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
|
||||||
>
|
|
||||||
+ Gericht hinzufügen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if actionSheetMode}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="day-meal-card"
|
||||||
|
data-today={isToday}
|
||||||
|
data-selected={isSelected}
|
||||||
|
onclick={onactionsheet}
|
||||||
|
class="w-full text-left rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||||
|
>
|
||||||
|
{@render recipeInfo()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="day-meal-card"
|
||||||
|
data-today={isToday}
|
||||||
|
data-selected={isSelected}
|
||||||
|
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||||
|
>
|
||||||
|
{#if slot.recipe}
|
||||||
|
{@render recipeInfo()}
|
||||||
|
|
||||||
|
{#if !readonly}
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<a
|
||||||
|
href="/recipes/{slot.recipe.id}/cook"
|
||||||
|
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||||
|
>
|
||||||
|
Jetzt kochen
|
||||||
|
</a>
|
||||||
|
{#if onaddrecipe}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onaddrecipe}
|
||||||
|
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
Tauschen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||||
|
{#if !readonly && onaddrecipe}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onaddrecipe}
|
||||||
|
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
+ Gericht hinzufügen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -81,4 +81,38 @@ describe('DayMealCard', () => {
|
|||||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||||
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onactionsheet prop (mobile full-card tap target)', () => {
|
||||||
|
it('card renders as a button when onactionsheet provided and recipe exists', () => {
|
||||||
|
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||||
|
const card = screen.getByRole('button', { name: /Pasta Bolognese/i });
|
||||||
|
expect(card).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking card calls onactionsheet', async () => {
|
||||||
|
const onactionsheet = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DayMealCard, { props: { slot, onactionsheet } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Pasta Bolognese/i }));
|
||||||
|
expect(onactionsheet).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inline Jetzt kochen and Tauschen buttons are hidden when onactionsheet provided', () => {
|
||||||
|
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||||
|
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to normal rendering when onactionsheet not provided', () => {
|
||||||
|
render(DayMealCard, { props: { slot, readonly: false, onaddrecipe: vi.fn() } });
|
||||||
|
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||||
|
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty slot does not render card as button even when onactionsheet provided', () => {
|
||||||
|
const emptySlot = { id: 's2', slotDate: '2026-03-31', recipe: null };
|
||||||
|
render(DayMealCard, { props: { slot: emptySlot, onactionsheet: vi.fn(), onaddrecipe: vi.fn() } });
|
||||||
|
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
111
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
111
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface SlotRecipe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
effort?: string;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
id?: string;
|
||||||
|
slotDate?: string;
|
||||||
|
recipe: SlotRecipe | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
slot: Slot;
|
||||||
|
onswap: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, slot, onswap, oncancel }: Props = $props();
|
||||||
|
|
||||||
|
const meta = $derived.by(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (slot.recipe?.cookTimeMin != null) parts.push(`${slot.recipe.cookTimeMin} min`);
|
||||||
|
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') oncancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
data-testid="sheet-backdrop"
|
||||||
|
style="position:fixed;inset:0;z-index:50;background:rgba(28,28,24,0.4)"
|
||||||
|
onclick={oncancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
style="position:absolute;bottom:0;left:0;right:0;background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:var(--shadow-overlay)"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div style="display:flex;justify-content:center;margin-top:12px">
|
||||||
|
<div style="width:32px;height:4px;background:var(--color-border);border-radius:9999px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meal title -->
|
||||||
|
<p style="font-family:var(--font-display);font-size:15px;font-weight:500;color:var(--color-text);padding:0 16px;margin:12px 0 4px">
|
||||||
|
{slot.recipe?.name ?? ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
{#if meta}
|
||||||
|
<p style="font-family:var(--font-sans);font-size:11px;color:var(--color-text-muted);padding:0 16px 12px;margin:0">
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="padding:0 16px 16px;display:flex;flex-direction:column;gap:6px">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="width:100%;background:var(--orange-tint);border:1px solid #FBCDA4;color:var(--orange-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
|
||||||
|
onclick={onswap}
|
||||||
|
>
|
||||||
|
↻ Gericht tauschen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if slot.recipe}
|
||||||
|
<a
|
||||||
|
href="/recipes/{slot.recipe.id}/cook"
|
||||||
|
style="display:block;width:100%;background:var(--green-tint);border:1px solid var(--green-light);color:var(--green-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||||
|
>
|
||||||
|
🍳 Jetzt kochen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/recipes/{slot.recipe.id}"
|
||||||
|
style="display:block;width:100%;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||||
|
>
|
||||||
|
👁 Rezept ansehen
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="width:100%;background:none;border:none;color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;text-align:center;cursor:pointer;padding:12px"
|
||||||
|
onclick={oncancel}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
79
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
79
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import MealActionSheet from './MealActionSheet.svelte';
|
||||||
|
|
||||||
|
const slot = {
|
||||||
|
id: 's1',
|
||||||
|
slotDate: '2026-04-08',
|
||||||
|
recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
open: true,
|
||||||
|
slot,
|
||||||
|
onswap: vi.fn(),
|
||||||
|
oncancel: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MealActionSheet', () => {
|
||||||
|
it('renders meal title', () => {
|
||||||
|
render(MealActionSheet, { props: baseProps });
|
||||||
|
expect(screen.getByText('Tomato pasta')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders meal metadata', () => {
|
||||||
|
render(MealActionSheet, { props: baseProps });
|
||||||
|
expect(screen.getByText(/45 min/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/easy/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all 4 action buttons', () => {
|
||||||
|
render(MealActionSheet, { props: baseProps });
|
||||||
|
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Jetzt kochen links to the cook route', () => {
|
||||||
|
render(MealActionSheet, { props: baseProps });
|
||||||
|
const link = screen.getByRole('link', { name: /Jetzt kochen/i });
|
||||||
|
expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Rezept ansehen links to the recipe detail route', () => {
|
||||||
|
render(MealActionSheet, { props: baseProps });
|
||||||
|
const link = screen.getByRole('link', { name: /Rezept ansehen/i });
|
||||||
|
expect(link.getAttribute('href')).toBe('/recipes/r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Gericht tauschen calls onswap', async () => {
|
||||||
|
const onswap = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(MealActionSheet, { props: { ...baseProps, onswap } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
|
||||||
|
expect(onswap).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Abbrechen calls oncancel', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking backdrop calls oncancel', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||||
|
await user.click(screen.getByTestId('sheet-backdrop'));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when open is false', () => {
|
||||||
|
render(MealActionSheet, { props: { ...baseProps, open: false } });
|
||||||
|
expect(screen.queryByText('Tomato pasta')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
126
frontend/src/lib/planner/SwapSuggestionList.svelte
Normal file
126
frontend/src/lib/planner/SwapSuggestionList.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Recipe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
effort?: string | null;
|
||||||
|
cookTimeMin?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
replacingName,
|
||||||
|
replacingMeta,
|
||||||
|
recipes,
|
||||||
|
currentWeekRecipeIds,
|
||||||
|
excludeRecipeId,
|
||||||
|
isLoading = false,
|
||||||
|
onpick,
|
||||||
|
oncancel
|
||||||
|
}: {
|
||||||
|
replacingName: string;
|
||||||
|
replacingMeta?: string;
|
||||||
|
recipes: Recipe[];
|
||||||
|
currentWeekRecipeIds: Set<string>;
|
||||||
|
excludeRecipeId?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onpick: (recipeId: string, recipeName: string) => void;
|
||||||
|
oncancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let visibleRecipes = $derived(
|
||||||
|
excludeRecipeId ? recipes.filter((r) => r.id !== excludeRecipeId) : recipes
|
||||||
|
);
|
||||||
|
|
||||||
|
function recipeMeta(recipe: Recipe): string {
|
||||||
|
return [
|
||||||
|
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} min` : null,
|
||||||
|
recipe.effort ?? null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Replacing banner -->
|
||||||
|
<div
|
||||||
|
style="background: var(--orange-tint); border: 1px solid #FBCDA4; border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 14px;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 4px 0; font-family: var(--font-sans);"
|
||||||
|
>
|
||||||
|
Wird ersetzt
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
data-testid="replacing-name"
|
||||||
|
title={replacingName}
|
||||||
|
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 14px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
|
||||||
|
>
|
||||||
|
{replacingName}{#if replacingMeta} · {replacingMeta}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Eyebrow label -->
|
||||||
|
<p
|
||||||
|
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); margin: 0 0 6px 0; font-family: var(--font-sans);"
|
||||||
|
>
|
||||||
|
Ersetzen durch (einfachste zuerst)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Recipe list -->
|
||||||
|
{#if visibleRecipes.length === 0}
|
||||||
|
<p
|
||||||
|
data-testid="swap-empty-state"
|
||||||
|
style="text-align: center; color: var(--color-text-muted); font-family: var(--font-sans); margin: 0;"
|
||||||
|
>
|
||||||
|
Keine Rezepte verfügbar.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each visibleRecipes as recipe (recipe.id)}
|
||||||
|
{@const meta = recipeMeta(recipe)}
|
||||||
|
{@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
|
||||||
|
<div
|
||||||
|
style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 6px; display: flex; align-items: center; gap: 8px;"
|
||||||
|
>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<p
|
||||||
|
style="font-family: var(--font-display); font-size: 13px; color: var(--color-text); margin: 0;"
|
||||||
|
>
|
||||||
|
{recipe.name}
|
||||||
|
</p>
|
||||||
|
{#if meta}
|
||||||
|
<p
|
||||||
|
style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans); margin: 1px 0 0;"
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if alreadyPlanned}
|
||||||
|
<p
|
||||||
|
data-testid="already-planned-{recipe.id}"
|
||||||
|
style="font-size: 9px; color: var(--yellow-text); font-family: var(--font-sans); margin: 1px 0 0;"
|
||||||
|
>
|
||||||
|
⚠ Bereits diese Woche
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onpick(recipe.id, recipe.name)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style="background: none; border: none; cursor: {isLoading ? 'default' : 'pointer'}; font-size: 11px; font-weight: 500; color: var(--green); font-family: var(--font-sans); flex-shrink: 0; opacity: {isLoading ? '0.4' : '1'};"
|
||||||
|
>
|
||||||
|
Wählen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cancel button (optional) -->
|
||||||
|
{#if oncancel}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="width: 100%; background: none; border: none; cursor: pointer; color: var(--color-text-muted); font-size: 13px; text-align: center; padding: 8px 0; font-family: var(--font-sans);"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
120
frontend/src/lib/planner/SwapSuggestionList.test.ts
Normal file
120
frontend/src/lib/planner/SwapSuggestionList.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import SwapSuggestionList from './SwapSuggestionList.svelte';
|
||||||
|
|
||||||
|
const recipes = [
|
||||||
|
{ id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 },
|
||||||
|
{ id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 },
|
||||||
|
{ id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
replacingName: 'Tomato pasta',
|
||||||
|
replacingMeta: '45 min · Easy',
|
||||||
|
recipes,
|
||||||
|
currentWeekRecipeIds: new Set<string>(),
|
||||||
|
onpick: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SwapSuggestionList', () => {
|
||||||
|
it('renders the Replacing banner', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders old meal name with strikethrough', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
const struck = screen.getByTestId('replacing-name');
|
||||||
|
expect(struck.textContent).toContain('Tomato pasta');
|
||||||
|
expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replacing-name span has title attribute for full name', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
const struck = screen.getByTestId('replacing-name');
|
||||||
|
expect(struck.getAttribute('title')).toBe('Tomato pasta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the easiest-first eyebrow label', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all recipe names', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Wählen calls onpick with recipeId and name', async () => {
|
||||||
|
const onpick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, onpick } });
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
await user.click(buttons[0]);
|
||||||
|
expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows already-planned warning for recipes in currentWeekRecipeIds', () => {
|
||||||
|
render(SwapSuggestionList, {
|
||||||
|
props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) }
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('already-planned-r2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.queryByTestId('already-planned-r1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no recipes', () => {
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } });
|
||||||
|
expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the recipe being replaced when excludeRecipeId is provided', () => {
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } });
|
||||||
|
expect(screen.queryByText('Chicken stir-fry')).toBeNull();
|
||||||
|
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all recipes when excludeRecipeId is not provided', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.getByText('Quick carbonara')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables all Wählen buttons when isLoading is true', () => {
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } });
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wählen buttons are enabled when isLoading is false', () => {
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } });
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders optional Abbrechen button when oncancel provided', () => {
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Abbrechen button when oncancel not provided', () => {
|
||||||
|
render(SwapSuggestionList, { props: baseProps });
|
||||||
|
expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Abbrechen calls oncancel', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
weekDays,
|
weekDays,
|
||||||
isToday,
|
isToday,
|
||||||
formatWeekRange,
|
formatWeekRange,
|
||||||
formatDayLabel
|
formatDayLabel,
|
||||||
|
sortEasiestFirst
|
||||||
} from './week';
|
} from './week';
|
||||||
|
|
||||||
describe('getWeekStart', () => {
|
describe('getWeekStart', () => {
|
||||||
@@ -144,3 +145,52 @@ describe('formatDayLabel', () => {
|
|||||||
expect(formatDayLabel('2026-03-30')).toContain(',');
|
expect(formatDayLabel('2026-03-30')).toContain(',');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sortEasiestFirst', () => {
|
||||||
|
it('sorts easy before medium before hard', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
|
||||||
|
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 },
|
||||||
|
{ id: '3', name: 'Medium', effort: 'medium', cookTimeMin: 10 }
|
||||||
|
];
|
||||||
|
const sorted = sortEasiestFirst(recipes);
|
||||||
|
expect(sorted.map((r) => r.effort)).toEqual(['easy', 'medium', 'hard']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by cookTimeMin ascending within same effort', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ id: '1', name: 'Slow Easy', effort: 'easy', cookTimeMin: 60 },
|
||||||
|
{ id: '2', name: 'Fast Easy', effort: 'easy', cookTimeMin: 15 }
|
||||||
|
];
|
||||||
|
const sorted = sortEasiestFirst(recipes);
|
||||||
|
expect(sorted[0].name).toBe('Fast Easy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing effort as after hard', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ id: '1', name: 'No effort', effort: undefined, cookTimeMin: 5 },
|
||||||
|
{ id: '2', name: 'Hard', effort: 'hard', cookTimeMin: 5 }
|
||||||
|
];
|
||||||
|
const sorted = sortEasiestFirst(recipes);
|
||||||
|
expect(sorted[0].effort).toBe('hard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing cookTimeMin as after defined values', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ id: '1', name: 'No time', effort: 'easy', cookTimeMin: undefined },
|
||||||
|
{ id: '2', name: 'Has time', effort: 'easy', cookTimeMin: 30 }
|
||||||
|
];
|
||||||
|
const sorted = sortEasiestFirst(recipes);
|
||||||
|
expect(sorted[0].name).toBe('Has time');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the original array', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
|
||||||
|
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 }
|
||||||
|
];
|
||||||
|
const original = [...recipes];
|
||||||
|
sortEasiestFirst(recipes);
|
||||||
|
expect(recipes[0].effort).toBe(original[0].effort);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,6 +75,25 @@ export function isToday(dateStr: string): boolean {
|
|||||||
return dateStr === todayStr;
|
return dateStr === todayStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EFFORT_ORDER: Record<string, number> = { easy: 0, medium: 1, hard: 2 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new array of recipes sorted easiest first (effort ASC, cookTimeMin ASC).
|
||||||
|
* Used for the J4 mid-week swap context — different from variety-first sorting in J2.
|
||||||
|
*/
|
||||||
|
export function sortEasiestFirst<T extends { effort?: string | null; cookTimeMin?: number | null }>(
|
||||||
|
recipes: T[]
|
||||||
|
): T[] {
|
||||||
|
return [...recipes].sort((a, b) => {
|
||||||
|
const ea = a.effort != null ? (EFFORT_ORDER[a.effort] ?? 99) : 99;
|
||||||
|
const eb = b.effort != null ? (EFFORT_ORDER[b.effort] ?? 99) : 99;
|
||||||
|
if (ea !== eb) return ea - eb;
|
||||||
|
const ta = a.cookTimeMin ?? Infinity;
|
||||||
|
const tb = b.cookTimeMin ?? Infinity;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a week range: "30. Mär – 5. Apr 2026".
|
* Formats a week range: "30. Mär – 5. Apr 2026".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
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 RecipePicker from '$lib/planner/RecipePicker.svelte';
|
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||||
|
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
|
||||||
|
import SwapSuggestionList from '$lib/planner/SwapSuggestionList.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';
|
||||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } from '$lib/planner/week';
|
||||||
|
|
||||||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
||||||
|
|
||||||
@@ -55,8 +57,19 @@
|
|||||||
|
|
||||||
let panelState = $state<PanelState>({ kind: 'idle' });
|
let panelState = $state<PanelState>({ kind: 'idle' });
|
||||||
|
|
||||||
// Mobile bottom sheet for RecipePicker
|
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
|
let actionSheetOpen = $state(false);
|
||||||
|
let swapSheetOpen = $state(false);
|
||||||
|
let swapLoading = $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))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recipes sorted easiest-first for the swap suggestion list
|
||||||
|
let sortedRecipes = $derived(sortEasiestFirst(data.recipes));
|
||||||
|
|
||||||
// Hidden form field bindings
|
// Hidden form field bindings
|
||||||
let addPlanId = $state('');
|
let addPlanId = $state('');
|
||||||
@@ -126,6 +139,13 @@
|
|||||||
deleteSlotFormEl.requestSubmit();
|
deleteSlotFormEl.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSwapPick(recipeId: string, recipeName: string) {
|
||||||
|
swapLoading = true;
|
||||||
|
await handleRecipePick(recipeId, recipeName);
|
||||||
|
swapSheetOpen = false;
|
||||||
|
swapLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
function closePanelToIdle() {
|
function closePanelToIdle() {
|
||||||
panelState = { kind: 'idle' };
|
panelState = { kind: 'idle' };
|
||||||
}
|
}
|
||||||
@@ -205,7 +225,8 @@
|
|||||||
isToday={selectedDay === today}
|
isToday={selectedDay === today}
|
||||||
isSelected={true}
|
isSelected={true}
|
||||||
readonly={!isPlanner}
|
readonly={!isPlanner}
|
||||||
onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined}
|
onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
|
||||||
|
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,7 +276,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Mobile RecipePicker in BottomSheet -->
|
<!-- Mobile: empty slot → RecipePicker -->
|
||||||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
||||||
<RecipePicker
|
<RecipePicker
|
||||||
planId={weekPlan?.id ?? ''}
|
planId={weekPlan?.id ?? ''}
|
||||||
@@ -267,6 +288,34 @@
|
|||||||
onpick={handleRecipePick}
|
onpick={handleRecipePick}
|
||||||
/>
|
/>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
|
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
|
||||||
|
<MealActionSheet
|
||||||
|
open={actionSheetOpen}
|
||||||
|
slot={selectedSlot}
|
||||||
|
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
|
||||||
|
oncancel={() => (actionSheetOpen = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mobile: swap suggestions sheet -->
|
||||||
|
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
|
||||||
|
{@const replacingMeta = [
|
||||||
|
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
|
||||||
|
selectedSlot.recipe?.effort ?? null
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
<div style="padding: 16px;">
|
||||||
|
<SwapSuggestionList
|
||||||
|
replacingName={selectedSlot.recipe?.name ?? ''}
|
||||||
|
replacingMeta={replacingMeta || undefined}
|
||||||
|
recipes={sortedRecipes}
|
||||||
|
{currentWeekRecipeIds}
|
||||||
|
excludeRecipeId={selectedSlot.recipe?.id}
|
||||||
|
isLoading={swapLoading}
|
||||||
|
onpick={handleSwapPick}
|
||||||
|
oncancel={() => (swapSheetOpen = false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: 3-panel layout -->
|
<!-- Desktop: 3-panel layout -->
|
||||||
@@ -472,11 +521,13 @@
|
|||||||
|
|
||||||
{:else if panelState.kind === 'recipe-picker'}
|
{:else if panelState.kind === 'recipe-picker'}
|
||||||
{@const pickerDate = panelState.date}
|
{@const pickerDate = panelState.date}
|
||||||
|
{@const pickerSlot = slotMap[pickerDate] ?? null}
|
||||||
|
{@const isSwapContext = !!pickerSlot?.recipe}
|
||||||
|
|
||||||
<!-- Panel header with back/close button -->
|
<!-- Panel header with back/close button -->
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<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)]">
|
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
Rezept wählen
|
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -488,17 +539,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
{#if isSwapContext}
|
||||||
<RecipePicker
|
{@const replacingMeta = [
|
||||||
planId={weekPlan?.id ?? ''}
|
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
|
||||||
date={pickerDate}
|
pickerSlot.recipe.effort ?? null
|
||||||
dateLabel={formatDayLabel(pickerDate)}
|
].filter(Boolean).join(' · ')}
|
||||||
currentVarietyScore={varietyScore?.score ?? 0}
|
<div class="flex-1 overflow-y-auto -mx-4 -mb-4 px-4">
|
||||||
suggestions={[]}
|
<SwapSuggestionList
|
||||||
allRecipes={data.recipes}
|
replacingName={pickerSlot.recipe.name}
|
||||||
onpick={handleRecipePick}
|
replacingMeta={replacingMeta || undefined}
|
||||||
/>
|
recipes={sortedRecipes}
|
||||||
</div>
|
{currentWeekRecipeIds}
|
||||||
|
excludeRecipeId={pickerSlot.recipe.id}
|
||||||
|
onpick={handleRecipePick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||||
|
<RecipePicker
|
||||||
|
planId={weekPlan?.id ?? ''}
|
||||||
|
date={pickerDate}
|
||||||
|
dateLabel={formatDayLabel(pickerDate)}
|
||||||
|
currentVarietyScore={varietyScore?.score ?? 0}
|
||||||
|
suggestions={[]}
|
||||||
|
allRecipes={data.recipes}
|
||||||
|
onpick={handleRecipePick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user