Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions components/modules/meals-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ export function MealsModule() {
() => days.map((d) => planMap.get(cellKey(d, 'dinner'))).filter((p): p is Plan => !!p?.meal),
[days, planMap],
);
// A removal or realtime refresh can shorten this week's dinners without
// changing weeks. Keep every part of the card on the same remaining dish.
const selectedDinnerIndex = Math.min(dinnerIdx, Math.max(0, dinners.length - 1));
const selectedDinner = dinners[selectedDinnerIndex];
useEffect(() => {
setDinnerIdx(index => Math.min(index, Math.max(0, dinners.length - 1)));
}, [dinners.length]);
useEffect(() => { setDinnerIdx(0); setLastAdd(null); }, [weekOffset]);

/**
Expand Down Expand Up @@ -267,6 +274,7 @@ export function MealsModule() {
const q = recipeSearch.trim().toLowerCase();
return q ? recipes.filter((r) => r.name.toLowerCase().includes(q)) : recipes;
}, [recipes, recipeSearch]);
const noRecipeMatches = recipes.length > 0 && filteredRecipes.length === 0;

async function removePlan(id: string) {
if (!isCurrentScope() || removeIntent.current) return;
Expand Down Expand Up @@ -489,7 +497,8 @@ export function MealsModule() {
{recipeSearch && <button onClick={() => setRecipeSearch('')} aria-label={tr('meals.clear')}><XIcon className="h-4 w-4 text-muted" /></button>}
</div>
{recipesLoading ? <SkeletonList count={3} /> : recipesError ? <ErrorState message={recipesError} onRetry={refreshRecipes} /> : filteredRecipes.length === 0 ? (
<EmptyState icon={Utensils} title={tr('meals.noRecipesYet')} description={tr('mealsModule.savedRecipesWillAppearHere')} />
<EmptyState icon={Utensils} title={tr(noRecipeMatches ? 'mealsPlanner.noMatches' : 'meals.noRecipesYet')}
description={noRecipeMatches ? undefined : tr('mealsModule.savedRecipesWillAppearHere')} />
) : (
<RecipeGrid recipes={filteredRecipes} onToggleFavorite={toggleFavorite} />
)}
Expand Down Expand Up @@ -580,28 +589,28 @@ export function MealsModule() {
) : (
<div className="relative">
<div className="overflow-hidden rounded-xl border border-border">
<MealImg src={dinners[dinnerIdx]?.meal?.image_url ?? null} emoji="🍽️" className="h-36 w-full" />
<MealImg src={selectedDinner?.meal?.image_url ?? null} emoji="🍽️" className="h-36 w-full" />
</div>
<div className="mt-2">
<p className="text-sm font-bold leading-snug">{dinners[dinnerIdx]?.meal?.name}</p>
<p className="text-sm font-bold leading-snug">{selectedDinner?.meal?.name}</p>
<p className="mt-0.5 text-[11px] text-muted">
{dayLabel(dinners[Math.min(dinnerIdx, dinners.length - 1)].plan_date)}
{dayLabel(dinners[selectedDinnerIndex].plan_date)}
</p>
</div>
{dinners[dinnerIdx]?.meal?.recipe_url && (
<a href={dinners[dinnerIdx]!.meal!.recipe_url!} target="_blank" rel="noreferrer"
{selectedDinner?.meal?.recipe_url && (
<a href={selectedDinner.meal.recipe_url} target="_blank" rel="noreferrer"
className="mt-2 block rounded-lg bg-brand py-2 text-center text-xs font-semibold text-brand-fg transition hover:opacity-90">
{tr('meals.viewRecipe')}
</a>
)}
{dinners.length > 1 && (
<>
<button onClick={() => setDinnerIdx((i) => (i - 1 + dinners.length) % dinners.length)} aria-label={tr('meals.previousDinner')}
<button onClick={() => setDinnerIdx((i) => (Math.min(i, dinners.length - 1) - 1 + dinners.length) % dinners.length)} aria-label={tr('meals.previousDinner')}
className="absolute left-1 top-[68px] grid h-7 w-7 -translate-y-1/2 place-items-center rounded-full bg-bg/70 text-fg backdrop-blur hover:bg-bg"><ChevronLeft className="h-4 w-4" /></button>
<button onClick={() => setDinnerIdx((i) => (i + 1) % dinners.length)} aria-label={tr('meals.nextDinner')}
<button onClick={() => setDinnerIdx((i) => (Math.min(i, dinners.length - 1) + 1) % dinners.length)} aria-label={tr('meals.nextDinner')}
className="absolute right-1 top-[68px] grid h-7 w-7 -translate-y-1/2 place-items-center rounded-full bg-bg/70 text-fg backdrop-blur hover:bg-bg"><ChevronRight className="h-4 w-4" /></button>
<div className="mt-2 flex justify-center gap-1">
{dinners.map((_, i) => <span key={i} className={cn('h-1.5 w-1.5 rounded-full', i === dinnerIdx ? 'bg-brand' : 'bg-border')} />)}
{dinners.map((_, i) => <span key={i} className={cn('h-1.5 w-1.5 rounded-full', i === selectedDinnerIndex ? 'bg-brand' : 'bg-border')} />)}
</div>
</>
)}
Expand Down
121 changes: 118 additions & 3 deletions tests/e2e/weekly-meal-planner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ async function fixture(page: Page, options: { familyId?: string; timezone?: stri
const translate = (key, vars = {}) => Object.entries(vars).reduce((text, [name,value]) => text.split('{' + name + '}').join(String(value)), messages[key] || key);
function from(table) {
const filters = [];
let limit = Infinity;
const builder = { select() { return this; }, order() { return this; }, limit(value) { limit = value; return this; },
let limit = Infinity; const orders = [];
const builder = { select() { return this; }, order(column, options = {}) { orders.push({column, ...options}); return this; }, limit(value) { limit = value; return this; },
eq(column,value) { filters.push(['eq',column,value]); return this; },
gte(column,value) { filters.push(['gte',column,value]); return this; },
lte(column,value) { filters.push(['lte',column,value]); return this; },
Expand All @@ -124,7 +124,15 @@ async function fixture(page: Page, options: { familyId?: string; timezone?: stri
const isJoin = table === 'meals' && filters.some(([op]) => op === 'in');
const fail = p.readErrors[isJoin ? 'meal-join' : table];
const rows = (p.tables[table] || []).filter(row => filters.every(([op,column,value]) => op === 'eq' ? row[column] === value
: op === 'gte' ? row[column] >= value : op === 'lte' ? row[column] <= value : value.includes(row[column]))).slice(0,limit);
: op === 'gte' ? row[column] >= value : op === 'lte' ? row[column] <= value : value.includes(row[column]))).sort((a,b) => {
for (const ordering of orders) {
const x=a[ordering.column], y=b[ordering.column];
if (x === y) continue;
if (x == null || y == null) return x == null ? (ordering.nullsFirst === true ? -1 : 1) : (ordering.nullsFirst === true ? 1 : -1);
const cmp=String(x).localeCompare(String(y)); if (cmp) return ordering.ascending === false ? -cmp : cmp;
}
return 0;
}).slice(0,limit);
return Promise.resolve(fail ? {data:null,error:{message:'Fixture read unavailable',code:'XX000'}} : {data:structuredClone(rows),error:null}).then(resolve,reject);
},
};
Expand Down Expand Up @@ -770,3 +778,110 @@ test('dismisses a hovered save notification after reopening a dinner and replace
expect(plans.find(row=>row.id==='existing-lunch')?.meal_id).toBe('meal-tacos');
expect(await calls(page)).toHaveLength(3);
});
const carouselCaseNames = new Set([
'unmatched search does not report the saved recipe library is empty',
'truly empty recipe library has the ordinary empty state',
'clearing an unmatched search restores the saved recipe unchanged',
'ordered read keeps recently cooked first and sorts uncooked recipes by name',
'saved meal picker follows alphabetical library order',
'deleting selected last dinner keeps remaining carousel name and date consistent',
'same-week refresh removing selected dinner keeps remaining carousel usable',
'empty selected week keeps seven choices and truthful sidebar',
'remaining first selection survives removal without hiding its dish',
'keeps dinner navigation and indicators consistent after shrinking three dinners to two',
]);
const carouselBrowserEvents = new WeakMap<Page, string[]>();
test.beforeEach(async ({page}, testInfo) => {
if (!carouselCaseNames.has(testInfo.title)) return;
const events: string[] = [];
carouselBrowserEvents.set(page, events);
page.on('console', message => {
if (message.type() === 'error' || message.type() === 'warning') events.push(`${message.type()}: ${message.text()}`);
});
page.on('pageerror', error => events.push(`pageerror: ${error.message}`));
page.on('requestfailed', request => events.push(`requestfailed: ${request.url()} ${request.failure()?.errorText}`));
});
test.afterEach(async ({page}) => {
const events = carouselBrowserEvents.get(page);
if (events) expect(events).toEqual([]);
});

const carouselMeals = [
{id:'meal-tacos',family_id:'family-A',name:'Lime tacos',ingredients:[],meal_type:'dinner',image_url:null,recipe_url:'https://recipe.invalid/tacos'},
{id:'meal-curry',family_id:'family-A',name:'Coconut curry',ingredients:[],meal_type:'dinner',image_url:null,recipe_url:'https://recipe.invalid/curry'},
];
const carouselPlans = [
{id:'first-dinner',family_id:'family-A',meal_id:'meal-tacos',plan_date:'2026-09-07',meal_type:'dinner'},
{id:'last-dinner',family_id:'family-A',meal_id:'meal-curry',plan_date:'2026-09-08',meal_type:'dinner'},
];
const dinnerCard=(page:Page)=>page.locator('.module-sidebar .sidebar-card').first();
test('deleting selected last dinner keeps remaining carousel name and date consistent',async({page})=>{
await page.setViewportSize({width:1280,height:900});await fixture(page,{plans:carouselPlans,meals:carouselMeals});
const card=dinnerCard(page);await expect(card).toContainText('Lime tacos');await card.getByRole('button',{name:'Next dinner'}).click();await expect(card).toContainText('Coconut curry');
await page.getByRole('button',{name:/Remove meal: Dinner, Tuesday/}).click();
await expect(slot(page,'Tuesday')).toContainText('Choose a meal');await expect(slot(page,'Monday')).toContainText('Lime tacos');
await expect(card).toContainText('Lime tacos');await expect(card).toContainText('Monday');await expect(card.getByRole('link',{name:'View recipe'})).toHaveAttribute('href','https://recipe.invalid/tacos');
});
test('same-week refresh removing selected dinner keeps remaining carousel usable',async({page})=>{
await page.setViewportSize({width:1280,height:900});await fixture(page,{plans:carouselPlans,meals:carouselMeals});const card=dinnerCard(page);
await card.getByRole('button',{name:'Next dinner'}).click();await expect(card).toContainText('Coconut curry');
await page.evaluate(()=>{window.__weeklyMeals.tables.meal_plans=window.__weeklyMeals.tables.meal_plans.filter(row=>row.id!=='last-dinner');window.dispatchEvent(new Event('online'));});
await expect(slot(page,'Tuesday')).toContainText('Choose a meal');await expect(card).toContainText('Lime tacos');
});
test('empty selected week keeps seven choices and truthful sidebar',async({page})=>{
await fixture(page);await expect(page.getByRole('button',{name:/Dinner for /})).toHaveCount(7);await expect(dinnerCard(page)).toContainText('No dinners planned this week yet');
await page.getByRole('button',{name:'Next week',exact:true}).click();await expect(page.getByRole('button',{name:/Dinner for /})).toHaveCount(7);
await page.getByRole('button',{name:'This Week',exact:true}).click();await expect(slot(page,'Monday')).toContainText('Choose a meal');
});
test('remaining first selection survives removal without hiding its dish',async({page})=>{
await page.setViewportSize({width:1280,height:900});await fixture(page,{plans:carouselPlans,meals:carouselMeals});
await page.getByRole('button',{name:/Remove meal: Dinner, Tuesday/}).click();await expect(dinnerCard(page)).toContainText('Lime tacos');await expect(dinnerCard(page)).toContainText('Monday');
});
test('keeps dinner navigation and indicators consistent after shrinking three dinners to two',async({page})=>{
await page.setViewportSize({width:1280,height:900});
await fixture(page,{meals:carouselMeals,plans:[...carouselPlans,{id:'third-dinner',family_id:'family-A',meal_id:'meal-tacos',plan_date:'2026-09-09',meal_type:'dinner'}]});
const card=dinnerCard(page);
await card.getByRole('button',{name:'Next dinner'}).click();await card.getByRole('button',{name:'Next dinner'}).click();
await expect(card).toContainText('Wednesday');
await page.getByRole('button',{name:/Remove meal: Dinner, Wednesday/}).click();
await expect(card).toContainText('Coconut curry');await expect(card).toContainText('Tuesday');
await expect(card.getByRole('link',{name:'View recipe'})).toHaveAttribute('href','https://recipe.invalid/curry');
await expect(card.locator('span.bg-brand')).toHaveCount(1);
await card.getByRole('button',{name:'Next dinner'}).click();await expect(card).toContainText('Lime tacos');await expect(card).toContainText('Monday');
await expect(card.getByRole('link',{name:'View recipe'})).toHaveAttribute('href','https://recipe.invalid/tacos');
await card.getByRole('button',{name:'Previous dinner'}).click();await expect(card).toContainText('Coconut curry');await expect(card).toContainText('Tuesday');
await expect(card.locator('span.bg-brand')).toHaveCount(1);
});
const libraryRecipes=(page:Page)=>page.locator('.module-main .group .truncate.text-sm.font-semibold');
async function recipesTab(page:Page){await page.getByRole('button',{name:'Recipes',exact:true}).click();}
test('unmatched search does not report the saved recipe library is empty',async({page})=>{
await fixture(page);await recipesTab(page);await expect(page.getByText('Tomato soup',{exact:true})).toBeVisible();
await page.getByPlaceholder('Search recipes…').fill('zzzz-not-a-recipe');
const tables=await page.evaluate(()=>window.__weeklyMeals.tables.family_recipes.filter(r=>r.family_id==='family-A'));
expect(tables).toHaveLength(1);await expect(page.getByRole('heading',{name:'No meals match your search.',exact:true})).toBeVisible();await expect(page.getByRole('heading',{name:'No recipes yet',exact:true})).toHaveCount(0);await expect(page.getByText('Saved recipes will appear here.',{exact:true})).toHaveCount(0);
});
test('truly empty recipe library has the ordinary empty state',async({page})=>{
await fixture(page);await page.evaluate(()=>{window.__weeklyMeals.tables.family_recipes=[];window.dispatchEvent(new Event('online'));});
await recipesTab(page);await expect(page.getByRole('heading',{name:'No recipes yet',exact:true})).toBeVisible();
});
test('clearing an unmatched search restores the saved recipe unchanged',async({page})=>{
await fixture(page);await recipesTab(page);const search=page.getByPlaceholder('Search recipes…');await search.fill('zzzz-not-a-recipe');await search.fill(' tomato ');
await expect(page.getByText('Tomato soup',{exact:true})).toBeVisible();await search.fill('');await expect(page.getByText('Tomato soup',{exact:true})).toBeVisible();
expect((await page.evaluate(()=>window.__weeklyMeals.tables.family_recipes.filter(r=>r.family_id==='family-A')))[0].id).toBe('recipe-soup');
});
test('ordered read keeps recently cooked first and sorts uncooked recipes by name',async({page})=>{
await fixture(page);await page.evaluate(()=>{
const base=window.__weeklyMeals.tables.family_recipes[0];window.__weeklyMeals.tables.family_recipes=[
{...base,id:'older',name:'Older cooked',last_made_at:'2026-09-01T12:00:00Z'},
{...base,id:'zebra',name:'Zebra uncooked',last_made_at:null},
{...base,id:'recent',name:'Recent cooked',last_made_at:'2026-09-11T12:00:00Z'},
{...base,id:'apple',name:'Apple uncooked',last_made_at:null},
];window.dispatchEvent(new Event('online'));});await recipesTab(page);
await expect(libraryRecipes(page)).toHaveText(['Recent cooked','Older cooked','Apple uncooked','Zebra uncooked']);
});
test('saved meal picker follows alphabetical library order',async({page})=>{
await fixture(page,{meals:[
{id:'z',family_id:'family-A',name:'Zucchini dinner',ingredients:[],meal_type:'dinner',image_url:null,recipe_url:null},
{id:'a',family_id:'family-A',name:'Apple dinner',ingredients:[],meal_type:'dinner',image_url:null,recipe_url:null},
]});await slot(page).click();await expect(dialog(page).getByRole('button',{name:/Apple dinner|Zucchini dinner/})).toHaveText(['🍽️Apple dinner','🍽️Zucchini dinner']);
});
Loading