+
-
+
+ {!isOnline && (
+
+ Offline. Server-backed list changes are paused; local skipped items can still be restored.
+
+ )}
+
{canEditList && (
{sortedItems.length === 0 ? (
{isListSearchActive
? `No list items match "${listSearchQuery.trim()}".`
- : "No items in this store yet."}
+ : skippedItems.length > 0
+ ? "No active items in this store. Check Skipped below."
+ : "No items in this store yet."}
) : (
(() => {
@@ -900,17 +1044,48 @@ export default function GroceryList() {
onLongPress={
canEditList ? handleLongPress : null
}
+ onSkip={
+ canEditList ? handleSkipItem : null
+ }
/>
- ))}
+ ))}
)}
);
});
- })()
+ })()
)}
-
- {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
+
+ {skippedItems.length > 0 && (
+
+
+
+
Skipped / Not Found ({skippedItems.length})
+
Local to this device and hidden from the active list.
+
+
+
+
+ )}
+
+ {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
<>
))}
diff --git a/frontend/src/styles/ConfirmBuyModal.css b/frontend/src/styles/ConfirmBuyModal.css
index 6a64977..c8fa95a 100644
--- a/frontend/src/styles/ConfirmBuyModal.css
+++ b/frontend/src/styles/ConfirmBuyModal.css
@@ -15,7 +15,7 @@
.confirm-buy-modal {
background: var(--modal-bg);
padding: var(--spacing-md);
- border-radius: var(--border-radius-xl);
+ border-radius: var(--border-radius-lg);
max-width: 450px;
width: 90%;
box-shadow: var(--shadow-xl);
@@ -93,6 +93,13 @@
background: var(--color-gray-100);
}
+.confirm-buy-image-container.no-image {
+ width: 100%;
+ max-width: 280px;
+ height: 76px;
+ border-style: dashed;
+}
+
.confirm-buy-image {
max-width: 100%;
max-height: 100%;
@@ -100,8 +107,9 @@
}
.confirm-buy-image-placeholder {
- font-size: 4em;
+ font-size: var(--font-size-sm);
color: var(--color-border-medium);
+ font-weight: var(--font-weight-semibold);
}
.confirm-buy-quantity-section {
@@ -222,8 +230,17 @@
/* Mobile optimizations */
@media (max-width: 480px) {
+ .confirm-buy-modal-overlay {
+ align-items: flex-end;
+ }
+
.confirm-buy-modal {
- padding: 0.8em;
+ width: 100%;
+ max-width: 100%;
+ padding: var(--spacing-md);
+ border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
+ max-height: 92vh;
+ overflow-y: auto;
}
.confirm-buy-header {
@@ -255,13 +272,18 @@
}
.confirm-buy-image-container {
- width: 220px;
- height: 220px;
+ width: min(220px, 62vw);
+ height: min(220px, 62vw);
+ }
+
+ .confirm-buy-image-container.no-image {
+ width: min(220px, 62vw);
+ height: 52px;
}
.confirm-buy-nav-btn {
- width: 30px;
- height: 30px;
+ width: 40px;
+ height: 40px;
font-size: 1.6em;
}
@@ -284,7 +306,7 @@
@media (max-width: 360px) {
.confirm-buy-modal {
- padding: 0.7em;
+ padding: var(--spacing-sm);
}
.confirm-buy-cancel,
@@ -294,13 +316,11 @@
}
.confirm-buy-image-container {
- width: 180px;
- height: 180px;
+ width: min(180px, 58vw);
+ height: min(180px, 58vw);
}
- .confirm-buy-nav-btn {
- width: 28px;
- height: 28px;
- font-size: 1.4em;
+ .confirm-buy-image-container.no-image {
+ height: 48px;
}
}
diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css
index 5c455e9..6896c22 100644
--- a/frontend/src/styles/components/AddItemForm.css
+++ b/frontend/src/styles/components/AddItemForm.css
@@ -3,8 +3,8 @@
background: var(--color-bg-surface);
padding: var(--spacing-md);
border-radius: var(--border-radius-lg);
- box-shadow: var(--shadow-md);
- margin-bottom: var(--spacing-xs);
+ box-shadow: var(--shadow-sm);
+ margin-bottom: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light);
}
@@ -51,6 +51,7 @@
font-family: var(--font-family-base);
transition: var(--transition-base);
width: 100%;
+ min-height: 44px;
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
@@ -73,6 +74,8 @@
right: 0;
margin-top: var(--spacing-xs);
z-index: var(--z-dropdown);
+ max-height: min(260px, 45vh);
+ box-shadow: var(--shadow-lg);
}
/* Actions Row */
@@ -81,7 +84,7 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
- min-height: 40px;
+ min-height: 44px;
}
/* Quantity Control */
@@ -89,11 +92,11 @@
display: flex;
align-items: center;
gap: var(--spacing-xs);
- height: 40px;
+ height: 44px;
}
.quantity-btn {
- width: 40px;
+ width: 44px;
height: 100%;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface);
@@ -128,8 +131,8 @@
}
.add-item-form-quantity-input {
- width: 40px;
- max-width: 40px;
+ width: 44px;
+ max-width: 44px;
height: 100%;
box-sizing: border-box;
padding: var(--input-padding-y) var(--input-padding-x);
@@ -159,7 +162,7 @@
/* Submit Button */
.add-item-form-submit {
- height: 40px;
+ min-height: 44px;
padding: 0 var(--spacing-lg);
background: var(--color-primary);
color: var(--color-text-inverse);
@@ -197,25 +200,39 @@
/* Responsive */
@media (max-width: 480px) {
.add-item-form-container {
- padding: var(--spacing-md);
+ padding: var(--spacing-sm);
+ }
+
+ .add-item-form-input-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
}
.add-item-form-assignee-toggle {
- width: 100px;
+ width: 104px;
}
.add-item-form-quantity-control {
- height: 36px;
+ height: 44px;
}
.quantity-btn {
- width: 36px;
+ width: 44px;
height: 100%;
font-size: var(--font-size-lg);
}
.add-item-form-quantity-input,
.add-item-form-submit {
- height: 36px;
+ min-height: 44px;
+ }
+
+ .add-item-form-actions {
+ gap: var(--spacing-xs);
+ }
+
+ .add-item-form-submit {
+ min-width: 0;
+ padding: 0 var(--spacing-sm);
}
}
diff --git a/frontend/src/styles/components/AddItemWithDetailsModal.css b/frontend/src/styles/components/AddItemWithDetailsModal.css
index 9bd4565..cd4f2ed 100644
--- a/frontend/src/styles/components/AddItemWithDetailsModal.css
+++ b/frontend/src/styles/components/AddItemWithDetailsModal.css
@@ -209,6 +209,10 @@
margin-bottom: var(--spacing-sm);
}
+.add-item-details-zone-field {
+ margin-bottom: 0;
+}
+
.add-item-details-field label {
display: block;
margin: 0;
@@ -328,9 +332,17 @@
}
@media (max-width: 480px) {
+ .add-item-details-overlay {
+ align-items: flex-end;
+ padding: 0;
+ }
+
.add-item-details-modal {
padding: var(--spacing-md);
- border-radius: var(--border-radius-lg);
+ border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
+ width: 100%;
+ max-width: 100%;
+ max-height: 92vh;
}
.add-item-details-title {
@@ -357,4 +369,8 @@
.add-item-details-field label {
font-size: var(--font-size-sm);
}
+
+ .add-item-details-field {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/frontend/src/styles/components/EditItemModal.css b/frontend/src/styles/components/EditItemModal.css
index 1e3973c..35cb099 100644
--- a/frontend/src/styles/components/EditItemModal.css
+++ b/frontend/src/styles/components/EditItemModal.css
@@ -118,6 +118,36 @@
margin-bottom: 0;
}
+.edit-modal-advanced {
+ margin-top: var(--spacing-sm);
+ border: var(--border-width-thin) solid var(--color-border-light);
+ border-radius: var(--border-radius-md);
+ background: var(--color-bg-surface);
+}
+
+.edit-modal-advanced summary {
+ min-height: 40px;
+ padding: var(--spacing-sm) var(--spacing-md);
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ display: flex;
+ align-items: center;
+}
+
+.edit-modal-advanced[open] {
+ padding-bottom: var(--spacing-sm);
+}
+
+.edit-modal-advanced[open] summary {
+ margin-bottom: var(--spacing-xs);
+}
+
+.edit-modal-advanced .edit-modal-inline-field {
+ padding: 0 var(--spacing-md);
+}
+
.edit-modal-divider {
height: 1px;
background: var(--color-border-light);
@@ -235,6 +265,10 @@
width: 100%;
}
+ .edit-modal-advanced .edit-modal-inline-field {
+ padding: 0 var(--spacing-sm);
+ }
+
.edit-modal-actions {
flex-direction: column;
gap: var(--spacing-xs);
@@ -247,10 +281,16 @@
}
@media (max-width: 480px) {
+ .edit-modal-overlay {
+ align-items: flex-end;
+ padding: 0;
+ }
+
.edit-modal-content {
width: 100%;
padding: 1rem;
- border-radius: 8px;
+ border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
+ max-height: 92vh;
}
.edit-modal-title {
diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css
index 7dffa2a..dc2e3dc 100644
--- a/frontend/src/styles/pages/GroceryList.css
+++ b/frontend/src/styles/pages/GroceryList.css
@@ -3,6 +3,7 @@
font-family: var(--font-family-base);
padding: var(--spacing-sm);
background: var(--color-bg-body);
+ min-height: 100vh;
}
.glist-container {
@@ -14,6 +15,17 @@
box-shadow: var(--shadow-card);
}
+.glist-offline-banner {
+ margin: var(--spacing-sm) 0;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: var(--border-width-thin) solid var(--color-warning, #b7791f);
+ border-radius: var(--border-radius-sm);
+ background: var(--color-warning-light, #fff8dc);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+}
+
.glist-section-title {
text-align: center;
font-size: var(--font-size-xl);
@@ -239,7 +251,7 @@
.glist-li {
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light);
- border-radius: var(--border-radius-lg);
+ border-radius: var(--border-radius-md);
margin-bottom: var(--spacing-sm);
cursor: pointer;
transition: box-shadow var(--transition-base), transform var(--transition-base);
@@ -251,6 +263,17 @@
transform: translateY(-2px);
}
+.glist-li:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 2px var(--color-primary-light);
+}
+
+.glist-li.actions-open {
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-md);
+}
+
.glist-classification-group .glist-li {
margin-bottom: var(--spacing-xs);
}
@@ -261,9 +284,10 @@
.glist-item-layout {
display: flex;
- gap: 1em;
- padding: 0em;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-xs);
align-items: center;
+ min-height: 56px;
}
.glist-item-image {
@@ -282,6 +306,13 @@
position: relative;
}
+.glist-item-image-placeholder {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+}
+
.glist-item-image.has-image {
border-color: var(--color-primary);
background: var(--color-bg-surface);
@@ -310,8 +341,9 @@
.glist-item-name {
font-weight: 800;
- font-size: 0.8em;
+ font-size: var(--font-size-base);
color: var(--color-text-primary);
+ overflow-wrap: anywhere;
}
.glist-item-quantity {
@@ -333,6 +365,140 @@
font-size: 0.7em;
color: var(--color-text-secondary);
font-style: italic;
+ overflow-wrap: anywhere;
+}
+
+.glist-item-action-toggle {
+ flex: 0 0 auto;
+ width: 40px;
+ min-width: 40px;
+ height: 40px;
+ border: var(--border-width-thin) solid var(--color-border-light);
+ border-radius: var(--border-radius-sm);
+ background: var(--color-bg-hover);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-bold);
+ line-height: 1;
+ cursor: pointer;
+}
+
+.glist-item-action-toggle:hover,
+.glist-item-action-toggle:focus-visible,
+.glist-li.actions-open .glist-item-action-toggle {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+ outline: none;
+}
+
+.glist-item-action-tray {
+ display: flex;
+ gap: var(--spacing-xs);
+ padding: 0 var(--spacing-xs) var(--spacing-xs);
+ flex-wrap: wrap;
+}
+
+.glist-item-action {
+ flex: 1 1 5rem;
+ min-height: 40px;
+ border: var(--border-width-thin) solid var(--color-border-medium);
+ border-radius: var(--border-radius-sm);
+ background: var(--color-bg-surface);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--button-font-weight);
+ cursor: pointer;
+}
+
+.glist-item-action:hover,
+.glist-item-action:focus-visible {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+ outline: none;
+}
+
+.glist-item-action.secondary:hover,
+.glist-item-action.secondary:focus-visible {
+ border-color: var(--color-warning, #b7791f);
+ color: var(--color-warning, #b7791f);
+}
+
+.glist-skipped-section {
+ margin-top: var(--spacing-lg);
+ padding-top: var(--spacing-md);
+ border-top: var(--border-width-medium) solid var(--color-border-light);
+}
+
+.glist-skipped-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.glist-skipped-title {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: var(--font-size-lg);
+}
+
+.glist-skipped-note {
+ margin: 0.2rem 0 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-xs);
+}
+
+.glist-skipped-ul {
+ margin-top: var(--spacing-xs);
+}
+
+.glist-skipped-li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ cursor: default;
+ background: var(--color-bg-hover);
+ border-style: dashed;
+}
+
+.glist-skipped-li:hover {
+ transform: none;
+}
+
+.glist-skipped-item-main {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
+ min-width: 0;
+}
+
+.glist-skipped-quantity {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+}
+
+.glist-restore-btn {
+ flex: 0 0 auto;
+ min-height: 40px;
+ padding: 0 var(--spacing-md);
+ border: var(--border-width-thin) solid var(--color-primary);
+ border-radius: var(--border-radius-sm);
+ background: var(--color-bg-surface);
+ color: var(--color-primary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--button-font-weight);
+ cursor: pointer;
+}
+
+.glist-restore-btn:hover,
+.glist-restore-btn:focus-visible {
+ background: var(--color-primary);
+ color: var(--color-text-inverse);
+ outline: none;
}
/* Compact View */
@@ -514,8 +680,42 @@
/* Mobile tweaks */
@media (max-width: 480px) {
+ .glist-body {
+ padding: var(--spacing-xs);
+ }
+
.glist-container {
- padding: 1em 0.8em;
+ padding: var(--spacing-sm);
+ border-radius: var(--border-radius-md);
+ }
+
+ .glist-classification-header,
+ .glist-section-title.clickable {
+ min-height: 44px;
+ }
+
+ .glist-item-layout {
+ min-height: 60px;
+ gap: var(--spacing-xs);
+ }
+
+ .glist-item-image {
+ width: 48px;
+ height: 48px;
+ min-width: 48px;
+ }
+
+ .glist-item-name {
+ font-size: var(--font-size-sm);
+ }
+
+ .glist-item-action-tray {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .glist-skipped-li {
+ align-items: stretch;
}
.glist-fab {
diff --git a/frontend/tests/classification-details.spec.ts b/frontend/tests/classification-details.spec.ts
index 3f49a7c..e45b7e2 100644
--- a/frontend/tests/classification-details.spec.ts
+++ b/frontend/tests/classification-details.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, test, type Route } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
@@ -55,13 +55,32 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/stores/household/1", async (route) => {
+ const stores = [
+ { id: 10, name: "Costco", location: "Warehouse", is_default: true },
+ ];
+
+ const fulfillStores = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
- body: JSON.stringify([
- { id: 10, name: "Costco", location: "Warehouse", is_default: true },
- ]),
+ body: JSON.stringify(stores),
+ });
+ };
+
+ await page.route("**/stores/household/1", fulfillStores);
+ await page.route("**/households/1/stores", fulfillStores);
+
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ zones: [
+ { id: 1, name: "Dairy & Refrigerated" },
+ { id: 2, name: "Checkout Area" },
+ { id: 3, name: "Bakery" },
+ ],
+ }),
});
});
@@ -75,7 +94,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list/recent", async (route) => {
+ await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -83,7 +102,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
+ await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -91,7 +110,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list/classification**", async (route) => {
+ await page.route("**/households/1/locations/10/list/classification**", async (route) => {
const request = route.request();
if (request.method() === "GET") {
@@ -146,7 +165,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list/item**", async (route) => {
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
const request = route.request();
if (request.method() === "PUT") {
@@ -185,7 +204,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list/add", async (route) => {
+ await page.route("**/households/1/locations/10/list/add", async (route) => {
currentItem = {
id: 201,
item_id: 501,
@@ -216,7 +235,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
});
});
- await page.route("**/households/1/stores/10/list", async (route) => {
+ await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -240,17 +259,11 @@ async function openEditModal(itemRow: ReturnType
{
+test("add-details modal persists zone-first classification details", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupGroceryListRoutes(page);
- let dialogSeen = false;
- page.on("dialog", async (dialog) => {
- dialogSeen = true;
- await dialog.dismiss();
- });
-
await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt");
@@ -259,14 +272,7 @@ test("add-details modal validates with toasts and persists classification detail
const addDetailsModal = page.locator(".add-item-details-modal");
await expect(addDetailsModal).toBeVisible();
- await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy");
- await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
-
- await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
- expect(dialogSeen).toBe(false);
-
- await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
- await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
+ await addDetailsModal.locator(".add-item-details-select").selectOption("Dairy & Refrigerated");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
@@ -278,9 +284,10 @@ test("add-details modal validates with toasts and persists classification detail
await openEditModal(yogurtRow, page);
const editModal = page.locator(".edit-modal-content");
- await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy");
- await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk");
- await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated");
+ await expect(
+ editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
+ ).toHaveValue("Dairy & Refrigerated");
+ await expect(editModal.locator(".edit-modal-advanced")).toBeVisible();
});
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
@@ -292,7 +299,7 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click();
- await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click();
+ await page.locator(".add-item-details-modal").getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible();
@@ -300,8 +307,8 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await openEditModal(yogurtRow, page);
let editModal = page.locator(".edit-modal-content");
- await editModal.locator(".edit-modal-select").nth(0).selectOption("");
- await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area");
+ const zoneSelect = editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select");
+ await zoneSelect.selectOption("Checkout Area");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(
@@ -311,11 +318,12 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await openEditModal(yogurtRow, page);
editModal = page.locator(".edit-modal-content");
- await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("");
- await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area");
+ await expect(
+ editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
+ ).toHaveValue("Checkout Area");
routes.setClassificationRequestMode("error");
- await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
+ await editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select").selectOption("Bakery");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
diff --git a/frontend/tests/grocery-list-actions.spec.ts b/frontend/tests/grocery-list-actions.spec.ts
new file mode 100644
index 0000000..c7e27da
--- /dev/null
+++ b/frontend/tests/grocery-list-actions.spec.ts
@@ -0,0 +1,174 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ mockConfig,
+ mockHouseholdAndStoreShell,
+ seedAuthStorage,
+} from "./helpers/e2e";
+
+type MockItem = {
+ id: number;
+ item_id: number;
+ item_name: string;
+ quantity: number;
+ bought: boolean;
+ item_image: string | null;
+ image_mime_type: string | null;
+ added_by_users: string[];
+ last_added_on: string;
+ item_type: string | null;
+ item_group: string | null;
+ zone: string | null;
+};
+
+function makeItem(id: number, itemName: string, quantity = 1): MockItem {
+ return {
+ id,
+ item_id: id + 500,
+ item_name: itemName,
+ quantity,
+ bought: false,
+ item_image: null,
+ image_mime_type: null,
+ added_by_users: ["Owner User"],
+ last_added_on: "2026-03-28T12:00:00.000Z",
+ item_type: null,
+ item_group: null,
+ zone: "Produce",
+ };
+}
+
+async function setupGroceryRoutes(
+ page: Page,
+ options: {
+ householdRole?: string;
+ items?: MockItem[];
+ } = {}
+) {
+ const items = options.items || [
+ makeItem(1, "milk", 2),
+ makeItem(2, "bread", 1),
+ ];
+
+ await mockConfig(page);
+ await mockHouseholdAndStoreShell(page, {
+ household: {
+ name: "Grocery Actions House",
+ role: options.householdRole || "admin",
+ },
+ stores: [
+ {
+ id: 10,
+ name: "Costco",
+ location: "Warehouse",
+ is_default: true,
+ },
+ ],
+ });
+
+ await page.route("**/households/1/members", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([
+ { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
+ ]),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zones: [{ id: 1, name: "Produce" }] }),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/recent", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([]),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([]),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
+ const url = new URL(route.request().url());
+ const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
+ const item = items.find((candidate) => candidate.item_name === itemName);
+
+ await route.fulfill({
+ status: item ? 200 : 404,
+ contentType: "application/json",
+ body: JSON.stringify(item || { message: "Item not found" }),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ items }),
+ });
+ });
+}
+
+test("item actions expose bought flow and local skipped restore", async ({ page }) => {
+ await seedAuthStorage(page, { username: "grocery-actions-user" });
+ await setupGroceryRoutes(page);
+
+ await page.goto("/");
+
+ const milkRow = page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" });
+ await expect(milkRow).toBeVisible();
+
+ await milkRow.getByRole("button", { name: "Actions for milk" }).click();
+ await milkRow.getByRole("button", { name: "Bought" }).click();
+ await expect(page.locator(".confirm-buy-modal")).toBeVisible();
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ await milkRow.getByRole("button", { name: "Actions for milk" }).click();
+ await milkRow.getByRole("button", { name: "Skip" }).click();
+
+ await expect(milkRow).toHaveCount(0);
+ await expect(page.locator(".glist-skipped-section")).toContainText("milk");
+
+ await page.reload();
+ await expect(page.locator(".glist-skipped-section")).toContainText("milk");
+
+ await page.locator(".glist-skipped-section").getByRole("button", { name: "Restore" }).click();
+ await expect(page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" })).toBeVisible();
+});
+
+test("viewer role cannot see mutation controls", async ({ page }) => {
+ await seedAuthStorage(page, { username: "grocery-viewer-user" });
+ await setupGroceryRoutes(page, { householdRole: "viewer" });
+
+ await page.goto("/");
+
+ await expect(page.getByPlaceholder("Enter item name")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Actions for milk" })).toHaveCount(0);
+ await expect(page.getByLabel("Search list")).toBeVisible();
+});
+
+test("offline banner blocks server-backed add flow with a clear toast", async ({ page }) => {
+ await seedAuthStorage(page, { username: "grocery-offline-user" });
+ await setupGroceryRoutes(page);
+
+ await page.goto("/");
+ await page.evaluate(() => window.dispatchEvent(new Event("offline")));
+
+ await expect(page.locator(".glist-offline-banner")).toContainText("Offline");
+ await page.getByPlaceholder("Enter item name").fill("bananas");
+ await page.getByRole("button", { name: "Create + Add" }).click();
+
+ await expect(page.locator(".add-item-details-modal")).toHaveCount(0);
+ await expect(page.locator(".action-toast.action-toast-error")).toContainText("Adding items needs a connection");
+});
diff --git a/frontend/tests/helpers/e2e.ts b/frontend/tests/helpers/e2e.ts
index 1f4cbc2..fb3833f 100644
--- a/frontend/tests/helpers/e2e.ts
+++ b/frontend/tests/helpers/e2e.ts
@@ -1,4 +1,4 @@
-import { expect, type Page } from "@playwright/test";
+import { expect, type Page, type Route } from "@playwright/test";
type AuthSeed = {
token?: string;
@@ -69,13 +69,16 @@ export async function mockHouseholdAndStoreShell(
});
});
- await page.route(`**/stores/household/${household.id}`, async (route) => {
+ const fulfillStores = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(stores),
});
- });
+ };
+
+ await page.route(`**/stores/household/${household.id}`, fulfillStores);
+ await page.route(`**/households/${household.id}/stores`, fulfillStores);
}
export async function confirmSlide(page: Page) {