diff --git a/CLAUDE.md b/CLAUDE.md
index 12b5a1f3..e77eda32 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -176,4 +176,5 @@ The CI workflow uses a fresh local database; the deploy workflow runs against th
- **Read `env.apiUrl` for direct API calls in `page.evaluate`**, not a hard-coded `localhost` URL. In the deploy environment the API is proxied at the same origin as the frontend (`E2E_API_URL=E2E_BASE_URL`), so direct fetch calls inside tests must use `env.apiUrl` from `e2e/env.ts`.
- **Shared `apiRequest` helper:** `e2e/apiRequest.ts` exports the authenticated `page.evaluate`-based fetch helper (reads the bearer token + household ID from `localStorage`). Import it instead of redefining a local copy per spec file.
- **Uncleaned test recipes silently corrupt food-plan suggestion rankings.** `GET /api/food-plan/suggestions` scores every household recipe and returns only the top `count` (max 200, see `GetFoodPlanSuggestions.MaxCount`). A recipe with real planning history (rotation/favorites/seasonality) scores far higher than a brand-new "not planned yet" recipe (a fixed, low score). Any e2e test that creates a recipe (and especially one that also schedules it on the food plan) without deleting it in a `finally` block leaves a permanently-scoring competitor in the persistent deploy household — over many runs this pushes freshly-created test recipes out of the ranked suggestions window, causing unrelated suggestion tests in `food-plans.spec.ts` to fail. Always wrap recipe/food-plan-entry/shopping-list creation in `try {} finally { /* delete via apiRequest */ }`, even in tests that aren't primarily about food plans (e.g. `flows.spec.ts`, `recipes.spec.ts`).
+ - **Raising `MaxCount` and cleaning up leaks is not sufficient once the shared household is already crowded.** This was tried (`MaxCount` 50→200, cleanup added to `flows.spec.ts`/`recipes.spec.ts`) and the very next deploy run failed with the same errors — the shared `anything-e2e` household had *already* accumulated 200+ recipes that permanently outscore a fresh candidate, and a code fix can't undo data that already leaked in past runs. Any test asserting a specific recipe's rank/membership in `/api/food-plan/suggestions` is fundamentally unreliable against a real, ever-growing shared household — no `count` value fixes it. The actual fix (see `food-plans.spec.ts`'s `useEphemeralHousehold` helper): create a throwaway household via `POST /api/households`, point the page at it via `localStorage.setItem("householdId", ...)`, run the ranking-sensitive assertions there (guaranteed empty, so ranking is deterministic), then `DELETE /api/households/{id}` in `finally` — which also makes any recipes/entries created in it permanently invisible to every other household's suggestions, so no per-recipe cleanup is needed for those specific tests either.
- **Don't let a dropdown auto-open over content it doesn't own.** The food-plan day dialog's ranked-suggestions `
` used to be `position: absolute`, which is fine while the user is actively typing (it overlays the space below the input without shifting layout) but breaks when the dropdown can auto-open on mount (`showSuggestionsOnOpen`, for empty upcoming days) and stay open indefinitely because the input never blurs — the floating box then visually covers unrelated controls further down the dialog (e.g. the note's "Clear note" button), permanently intercepting clicks meant for them. The fix was to render that specific list in normal document flow (no `absolute`/`z-10`) so it pushes later content down instead of covering it. If you re-introduce an auto-opening overlay anywhere, make sure it can't render on top of controls it doesn't logically own.
diff --git a/anything-frontend/e2e/food-plans.spec.ts b/anything-frontend/e2e/food-plans.spec.ts
index 541cd93b..a37c05a1 100644
--- a/anything-frontend/e2e/food-plans.spec.ts
+++ b/anything-frontend/e2e/food-plans.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
import { FoodPlanPage } from "./pages/FoodPlanPage";
import { apiRequest } from "./apiRequest";
@@ -28,6 +28,32 @@ function tomorrowDateString(): string {
return toDateString(tomorrow);
}
+/**
+ * Creates a throwaway household and switches the page's client to it, then
+ * returns its id so the caller can delete it in a `finally` block.
+ *
+ * Suggestion ranking scores every recipe in the household, so tests that
+ * assert on ranking need a household with no pre-existing recipes to reliably
+ * surface a brand-new "Not planned yet" candidate. The shared deploy
+ * household accumulates real recipes over time and will eventually outrank a
+ * fresh candidate no matter how high the API's result count is set (see
+ * GetFoodPlanSuggestions.MaxCount) — that crowding is what made these tests
+ * flaky against the persistent deploy environment. Deleting the household
+ * afterwards leaves its recipes/entries orphaned but invisible to every
+ * other household's suggestions, so no per-recipe/per-entry cleanup is
+ * needed either.
+ */
+async function useEphemeralHousehold(page: Page): Promise<{ id: number }> {
+ const household = await apiRequest<{ id: number }>(page, "POST", "/api/households", {
+ name: `E2E Ephemeral ${Date.now()}`,
+ });
+ await page.evaluate(
+ (id) => localStorage.setItem("householdId", String(id)),
+ household.id
+ );
+ return household;
+}
+
test("add and remove a meal entry from the food plan", async ({ page }) => {
const mealName = `E2E Meal ${Date.now()}`;
const foodPlan = new FoodPlanPage(page);
@@ -164,23 +190,21 @@ test("suggests recipes and adds a meal with one tap from the dropdown", async ({
page.getByRole("heading", { name: "Food Plan", level: 1 })
).toBeVisible();
- // All 7 days active so today's row is visible on weekends too.
- await apiRequest(page, "PUT", "/api/food-plan/settings", { activeDays: 127 });
- const recipe = await apiRequest<{ id: number }>(page, "POST", "/api/recipes", {
- name: recipeName,
- });
- let suggestedName: string | null = null;
+ const household = await useEphemeralHousehold(page);
try {
- // API-level membership check: the dropdown shows only the top 5, and in the
- // deploy environment a brand-new "Not planned yet" recipe can legitimately be
- // outranked by rested favorites — so assert membership via the API instead.
- // count=200 (the API's max) gives headroom against the deploy household's
- // accumulated recipe history; see GetFoodPlanSuggestions.MaxCount.
+ // All 7 days active so today's row is visible on weekends too.
+ await apiRequest(page, "PUT", "/api/food-plan/settings", { activeDays: 127 });
+ const recipe = await apiRequest<{ id: number }>(page, "POST", "/api/recipes", {
+ name: recipeName,
+ });
+
+ // The ephemeral household has no other recipes, so ours is guaranteed to
+ // rank first regardless of result count.
const suggestions = await apiRequest(
page,
"GET",
- `/api/food-plan/suggestions?date=${tomorrowDateString()}&count=200`
+ `/api/food-plan/suggestions?date=${tomorrowDateString()}&count=10`
);
const mine = suggestions.find((s) => s.recipeId === recipe.id);
expect(mine, "newly created recipe should be suggested").toBeTruthy();
@@ -200,7 +224,7 @@ test("suggests recipes and adds a meal with one tap from the dropdown", async ({
const addButton = page.locator('ul button[aria-label^="Add "]').first();
await expect(addButton).toBeVisible();
const label = await addButton.getAttribute("aria-label");
- suggestedName = label?.replace(/^Add /, "") ?? null;
+ const suggestedName = label?.replace(/^Add /, "") ?? null;
await addButton.click();
// The entry appears in the dialog's meal list.
@@ -216,22 +240,10 @@ test("suggests recipes and adds a meal with one tap from the dropdown", async ({
// Clean up the added entry through the UI.
await entryInList.getByRole("button", { name: "Remove entry" }).click();
await expect(entryInList).not.toBeVisible();
- suggestedName = null;
} finally {
- // If the UI cleanup did not run, remove any entry added for today via the API.
- if (suggestedName) {
- const today = toDateString(new Date());
- const entries = await apiRequest<{ id: number; name: string }[]>(
- page,
- "GET",
- `/api/food-plan/entries?startDate=${today}T00:00:00Z&endDate=${today}T23:59:59Z`
- );
- const leftover = entries.find((e) => e.name === suggestedName);
- if (leftover) {
- await apiRequest(page, "DELETE", `/api/food-plan/entries/${leftover.id}`);
- }
- }
- await apiRequest(page, "DELETE", `/api/recipes/${recipe.id}`);
+ // Deleting the household takes the recipe/entry/settings created above
+ // with it — no per-record cleanup needed.
+ await apiRequest(page, "DELETE", `/api/households/${household.id}`);
}
});
@@ -244,27 +256,30 @@ test("recently planned recipes are excluded from suggestions", async ({ page })
page.getByRole("heading", { name: "Food Plan", level: 1 })
).toBeVisible();
- // All 7 days active so tomorrow's row is visible on weekends too.
- await apiRequest(page, "PUT", "/api/food-plan/settings", { activeDays: 127 });
- const recipe = await apiRequest<{ id: number }>(page, "POST", "/api/recipes", {
- name: recipeName,
- });
- let entryId: number | null = null;
+ const household = await useEphemeralHousehold(page);
try {
- const suggestionsPath = `/api/food-plan/suggestions?date=${tomorrowDateString()}&count=50`;
+ // All 7 days active so tomorrow's row is visible on weekends too.
+ await apiRequest(page, "PUT", "/api/food-plan/settings", { activeDays: 127 });
+ const recipe = await apiRequest<{ id: number }>(page, "POST", "/api/recipes", {
+ name: recipeName,
+ });
+
+ // The ephemeral household has no other recipes, so this membership check
+ // is a real assertion of the exclusion logic rather than a coin flip
+ // against whatever else the household happens to contain.
+ const suggestionsPath = `/api/food-plan/suggestions?date=${tomorrowDateString()}&count=10`;
// Before planning, the recipe is suggested for tomorrow.
const before = await apiRequest(page, "GET", suggestionsPath);
expect(before.some((s) => s.recipeId === recipe.id)).toBe(true);
// Plan it for today — inside the variety exclusion window around tomorrow.
- const entry = await apiRequest<{ id: number }>(page, "POST", "/api/food-plan/entries", {
+ await apiRequest(page, "POST", "/api/food-plan/entries", {
name: recipeName,
recipeId: recipe.id,
date: `${toDateString(new Date())}T00:00:00Z`,
});
- entryId = entry.id;
// The recipe is no longer suggested for tomorrow.
const after = await apiRequest(page, "GET", suggestionsPath);
@@ -281,9 +296,8 @@ test("recently planned recipes are excluded from suggestions", async ({ page })
await suggestionsLoaded;
await expect(page.locator(`ul button[aria-label="Add ${recipeName}"]`)).toHaveCount(0);
} finally {
- if (entryId != null) {
- await apiRequest(page, "DELETE", `/api/food-plan/entries/${entryId}`);
- }
- await apiRequest(page, "DELETE", `/api/recipes/${recipe.id}`);
+ // Deleting the household takes the recipe/entry/settings created above
+ // with it — no per-record cleanup needed.
+ await apiRequest(page, "DELETE", `/api/households/${household.id}`);
}
});