feat(planner): add remove meal with undo; fix RecipePicker badge for neutral delta
- MealActionSheet: new onremove prop + Entfernen button (guarded by #if) - +page.svelte: handleRemoveMeal submits delete form, shows undo bar; undo re-adds via addSlot form; refactored handleUndo to undoCallback pattern; desktop day-detail panel also gets Entfernen button - RecipePicker: only show green +delta badge when scoreDelta > 0; neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt - Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,7 @@
|
|||||||
{meta}
|
{meta}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !suggestion.hasConflict}
|
{#if (suggestion.scoreDelta ?? 0) > 0}
|
||||||
<span
|
<span
|
||||||
data-testid="badge-{suggestion.recipe.id}"
|
data-testid="badge-{suggestion.recipe.id}"
|
||||||
data-type="good"
|
data-type="good"
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
>
|
>
|
||||||
↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
|
↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else if suggestion.hasConflict}
|
||||||
<span
|
<span
|
||||||
data-testid="badge-{suggestion.recipe.id}"
|
data-testid="badge-{suggestion.recipe.id}"
|
||||||
data-type="warning"
|
data-type="warning"
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ describe('RecipePicker', () => {
|
|||||||
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
|
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows no badge when scoreDelta is zero (neutral, no improvement)', () => {
|
||||||
|
const neutralSuggestions = [
|
||||||
|
{ recipe: { id: 'sn', name: 'Neutrales Rezept', effort: 'easy', cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false }
|
||||||
|
];
|
||||||
|
render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } });
|
||||||
|
expect(screen.queryByTestId('badge-sn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => {
|
it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => {
|
||||||
render(RecipePicker, { props: { ...baseProps, isLoading: true } });
|
render(RecipePicker, { props: { ...baseProps, isLoading: true } });
|
||||||
expect(screen.getByTestId('suggestions-loading')).toBeTruthy();
|
expect(screen.getByTestId('suggestions-loading')).toBeTruthy();
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
// UndoBar
|
// UndoBar
|
||||||
let undoVisible = $state(false);
|
let undoVisible = $state(false);
|
||||||
let undoMessage = $state('');
|
let undoMessage = $state('');
|
||||||
|
let undoCallback = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!activePickerDate || !weekPlan?.id) {
|
if (!activePickerDate || !weekPlan?.id) {
|
||||||
@@ -162,7 +163,33 @@
|
|||||||
|
|
||||||
function handleUndo() {
|
function handleUndo() {
|
||||||
undoVisible = false;
|
undoVisible = false;
|
||||||
|
undoCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
|
||||||
|
// Capture primitive values immediately — slot may be a reactive proxy that
|
||||||
|
// becomes stale after the first await (tick flushes state + re-render).
|
||||||
|
const slotId = slot.id;
|
||||||
|
const slotDate = slot.slotDate;
|
||||||
|
const recipeName = slot.recipe?.name ?? '';
|
||||||
|
const recipeId = slot.recipe?.id ?? '';
|
||||||
|
if (!slotId || !recipeId) return;
|
||||||
|
|
||||||
|
actionSheetOpen = false;
|
||||||
|
undoCallback = async () => {
|
||||||
|
addPlanId = weekPlan!.id;
|
||||||
|
addSlotDate = slotDate;
|
||||||
|
addRecipeId = recipeId;
|
||||||
|
addRecipeName = recipeName;
|
||||||
|
await tick();
|
||||||
|
addSlotFormEl.requestSubmit();
|
||||||
|
};
|
||||||
|
delPlanId = weekPlan!.id;
|
||||||
|
delSlotId = slotId;
|
||||||
|
await tick();
|
||||||
deleteSlotFormEl.requestSubmit();
|
deleteSlotFormEl.requestSubmit();
|
||||||
|
undoMessage = `${recipeName} entfernt`;
|
||||||
|
undoVisible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSwapPick(recipeId: string, recipeName: string) {
|
async function handleSwapPick(recipeId: string, recipeName: string) {
|
||||||
@@ -315,12 +342,13 @@
|
|||||||
/>
|
/>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
|
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
|
||||||
<MealActionSheet
|
<MealActionSheet
|
||||||
open={actionSheetOpen}
|
open={actionSheetOpen}
|
||||||
slot={selectedSlot}
|
slot={selectedSlot}
|
||||||
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
|
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
|
||||||
oncancel={() => (actionSheetOpen = false)}
|
oncancel={() => (actionSheetOpen = false)}
|
||||||
|
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Mobile: swap suggestions sheet -->
|
<!-- Mobile: swap suggestions sheet -->
|
||||||
@@ -530,6 +558,15 @@
|
|||||||
>
|
>
|
||||||
Gericht tauschen
|
Gericht tauschen
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -611,8 +648,10 @@
|
|||||||
formData.set('recipeId', addRecipeId);
|
formData.set('recipeId', addRecipeId);
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
if (result.type === 'success' && result.data?.success) {
|
if (result.type === 'success' && result.data?.success) {
|
||||||
|
const slotId = (result.data as any)?.slot?.id ?? '';
|
||||||
delPlanId = addPlanId;
|
delPlanId = addPlanId;
|
||||||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
delSlotId = slotId;
|
||||||
|
undoCallback = () => deleteSlotFormEl.requestSubmit();
|
||||||
undoMessage = `${addRecipeName} hinzugefügt`;
|
undoMessage = `${addRecipeName} hinzugefügt`;
|
||||||
undoVisible = true;
|
undoVisible = true;
|
||||||
}
|
}
|
||||||
@@ -639,6 +678,7 @@
|
|||||||
if (result.type === 'success' && result.data?.success) {
|
if (result.type === 'success' && result.data?.success) {
|
||||||
delPlanId = updPlanId;
|
delPlanId = updPlanId;
|
||||||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||||||
|
undoCallback = () => deleteSlotFormEl.requestSubmit();
|
||||||
undoMessage = `${updRecipeName} eingetragen`;
|
undoMessage = `${updRecipeName} eingetragen`;
|
||||||
undoVisible = true;
|
undoVisible = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
import { render, screen, waitFor, within } from '@testing-library/svelte';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
@@ -13,13 +13,23 @@ const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001';
|
|||||||
const DATE = '2025-01-06'; // Monday, January 6 2025
|
const DATE = '2025-01-06'; // Monday, January 6 2025
|
||||||
|
|
||||||
const mockData = {
|
const mockData = {
|
||||||
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] },
|
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] },
|
||||||
varietyScore: null,
|
varietyScore: null,
|
||||||
weekStart: DATE,
|
weekStart: DATE,
|
||||||
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
|
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
|
||||||
benutzer: { rolle: 'planer' }
|
benutzer: { rolle: 'planer' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockDataWithSlot = {
|
||||||
|
...mockData,
|
||||||
|
weekPlan: {
|
||||||
|
id: PLAN_ID,
|
||||||
|
weekStart: DATE,
|
||||||
|
status: 'draft',
|
||||||
|
slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const mockSuggestions = [
|
const mockSuggestions = [
|
||||||
{
|
{
|
||||||
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
|
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
|
||||||
@@ -82,3 +92,35 @@ describe('+page.svelte — $effect suggestion fetch', () => {
|
|||||||
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
|
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('+page.svelte — remove meal', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
|
||||||
|
|
||||||
|
render(Page, { props: { data: mockDataWithSlot } });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('day-meal-card'));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
|
||||||
|
|
||||||
|
const undoBar = screen.getByTestId('undo-bar');
|
||||||
|
expect(undoBar).toBeTruthy();
|
||||||
|
expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Rückgängig after remove hides the undo bar', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
|
||||||
|
|
||||||
|
render(Page, { props: { data: mockDataWithSlot } });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('day-meal-card'));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('undo-bar')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user