chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
6 changed files with 326 additions and 181 deletions
Showing only changes of commit f6a66a37ea - Show all commits

View File

@ -155,6 +155,7 @@ For `app/api/**/[param]/route.ts`:
- Tap targets remain >= 40px on mobile.
- Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
- Add Playwright UI tests for new UI features and critical flows.

View File

@ -92,7 +92,7 @@ export default function ManageStores() {
<section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2>
<p className="manage-stores-help">
Item management lives inside each store card below for items already used in that household/store.
Use each store card's Manage Items button to edit or delete the household/store item list.
</p>
{!isAdmin && (
<p className="manage-stores-note">

View File

@ -7,6 +7,7 @@ import {
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function itemImageSource(item) {
if (!item?.item_image) {
@ -19,7 +20,7 @@ function itemImageSource(item) {
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
const toast = useActionToast();
const [expanded, setExpanded] = useState(true);
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState("");
@ -27,6 +28,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) {
@ -41,10 +43,10 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setCatalogReady(response.data.catalog_ready !== false);
setCatalogMessage(response.data.message || "");
} catch (error) {
console.error("Failed to load available items:", error);
console.error("Failed to load store items:", error);
setCatalogReady(false);
setCatalogMessage("Store item catalog is unavailable right now.");
const message = getApiErrorMessage(error, "Failed to load available items");
setCatalogMessage("Store item management is unavailable right now.");
const message = getApiErrorMessage(error, "Failed to load store items");
toast.error("Load store items failed", `Load store items failed: ${message}`);
} finally {
setLoading(false);
@ -52,18 +54,23 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}, [householdId, query, store?.id, toast]);
useEffect(() => {
if (!expanded) {
if (!isOpen) {
return;
}
loadItems(query);
}, [expanded, query, loadItems]);
}, [isOpen, query, loadItems]);
const closeManager = () => {
setIsOpen(false);
setPendingDeleteItem(null);
};
const handleUpdate = async (payload) => {
if (!catalogReady) {
toast.info(
"Store item catalog unavailable",
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
"Store item management unavailable",
catalogMessage || "Store item management is unavailable until the latest database migration is applied."
);
return;
}
@ -75,32 +82,25 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update available item");
const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`);
throw error;
}
};
const handleDelete = async (item) => {
if (!catalogReady) {
toast.info(
"Store item catalog unavailable",
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
);
return;
}
if (!confirm(`Remove ${item.item_name} from ${store.name}'s available items?`)) {
const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) {
return;
}
try {
await deleteAvailableItem(householdId, store.id, item.item_id);
toast.success("Cleared store item settings", `Cleared settings for ${item.item_name} in ${store.name}`);
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`);
setPendingDeleteItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to clear store item settings");
toast.error("Clear store item settings failed", `Clear store item settings failed: ${message}`);
const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
}
};
@ -109,29 +109,40 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}
return (
<div className="store-available-items">
<div className="store-available-items-header">
<>
<button
type="button"
className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)}
>
Manage Items
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h4>Store Item Catalog</h4>
<p>Manage settings for items already used in {store.name} for this household.</p>
<h3>{store.name} Items</h3>
<p>Manage the household/store items used for suggestions and store defaults.</p>
</div>
<button
type="button"
className="btn-secondary btn-small"
onClick={() => setExpanded((value) => !value)}
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage items modal"
>
{expanded ? "Hide Items" : "Manage Items"}
x
</button>
</div>
{expanded ? (
<div className="store-available-items-panel">
{!catalogReady ? (
<p className="store-available-items-notice">
{catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."}
{catalogMessage || "Store item management is unavailable until the latest database migration is applied."}
</p>
) : null}
<div className="store-available-items-toolbar">
<div className="store-items-modal-toolbar">
<input
className="store-available-items-search"
value={query}
@ -141,19 +152,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
/>
</div>
<div className="store-items-modal-body">
{!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable this catalog.</p>
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
) : loading ? (
<p className="empty-message">Loading store items...</p>
) : items.length === 0 ? (
<p className="empty-message">No household items found for this store yet.</p>
) : (
<div className="store-available-items-list">
<div className="store-items-table">
<div className="store-items-table-head" aria-hidden="true">
<span>Item</span>
<span>Store Defaults</span>
<span>Actions</span>
</div>
<div className="store-items-table-body">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
return (
<div key={item.item_id} className="store-available-items-card">
<div key={item.item_id} className="store-items-table-row">
<div className="store-items-table-cell store-items-table-item">
<span className="store-items-mobile-label">Item</span>
<div className="store-available-items-summary">
{imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" />
@ -164,9 +185,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
)}
<div className="store-available-items-copy">
<strong>{item.item_name}</strong>
<span>{details.join(" | ") || "No store defaults set"}</span>
</div>
</div>
</div>
<div className="store-items-table-cell">
<span className="store-items-mobile-label">Store Defaults</span>
<span className="store-items-defaults-text">
{details.join(" | ") || "No store defaults set"}
</span>
</div>
<div className="store-items-table-cell store-items-table-actions">
<span className="store-items-mobile-label">Actions</span>
<div className="store-available-items-actions">
<button
type="button"
@ -176,24 +207,26 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setShowEditor(true);
}}
>
Edit
Edit Settings
</button>
{item.has_managed_settings ? (
<button
type="button"
className="btn-danger btn-small"
onClick={() => handleDelete(item)}
onClick={() => setPendingDeleteItem(item)}
>
Clear Settings
Delete Item
</button>
) : null}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
) : null}
<AvailableItemEditorModal
@ -205,6 +238,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}}
onSave={handleUpdate}
/>
</div>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
description={
pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.`
: ""
}
confirmLabel="Delete Item"
onClose={() => setPendingDeleteItem(null)}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -101,10 +101,10 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
<div className="available-item-editor-overlay" onClick={onCancel}>
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="available-item-editor-title">
{item ? `Edit ${item.item_name}` : "Add Available Item"}
{item ? `Edit ${item.item_name}` : "Edit Store Item"}
</h2>
<p className="available-item-editor-subtitle">
Save store-specific item defaults for this household.
Save store-specific defaults for this household/store item.
</p>
<div className="available-item-editor-field">

View File

@ -1,32 +1,75 @@
.store-available-items {
border-top: var(--border-width-thin) solid var(--color-border-light);
padding-top: var(--spacing-md);
.store-available-items-trigger {
width: 100%;
}
.store-available-items-header {
.store-items-modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
background: var(--modal-backdrop-bg);
}
.store-items-modal {
width: min(960px, 100%);
max-height: min(80vh, 760px);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-xl);
background: var(--modal-bg);
box-shadow: var(--shadow-xl);
}
.store-items-modal-header {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
align-items: flex-start;
}
.store-available-items-header h4 {
.store-items-modal-header h3 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-size: var(--font-size-xl);
}
.store-available-items-header p {
.store-items-modal-header p {
margin: var(--spacing-xs) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-available-items-panel {
margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
.store-items-modal-close {
width: 40px;
height: 40px;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: 50%;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
line-height: 1;
}
.store-items-modal-toolbar {
position: sticky;
top: 0;
z-index: 1;
background: var(--modal-bg);
}
.store-available-items-search {
width: 100%;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.store-available-items-notice {
@ -38,45 +81,58 @@
color: var(--color-text-secondary);
}
.store-available-items-toolbar {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex-wrap: wrap;
.store-items-modal-body {
min-height: 0;
overflow-y: auto;
}
.store-available-items-search {
flex: 1 1 240px;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.store-available-items-toolbar-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.store-available-items-list {
.store-items-table {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.store-available-items-card {
display: flex;
justify-content: space-between;
gap: var(--spacing-sm);
.store-items-table-head,
.store-items-table-row {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
gap: var(--spacing-md);
align-items: center;
}
.store-items-table-head {
position: sticky;
top: 0;
padding: 0 var(--spacing-sm) var(--spacing-xs);
background: var(--modal-bg);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.store-items-table-body {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.store-items-table-row {
padding: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
}
.store-items-table-cell {
min-width: 0;
}
.store-items-table-item {
min-width: 0;
}
.store-available-items-summary {
display: flex;
align-items: center;
@ -110,34 +166,68 @@
.store-available-items-copy strong {
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-available-items-copy span {
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions {
justify-self: end;
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 640px) {
.store-available-items-header,
.store-available-items-card,
.store-available-items-toolbar {
.store-items-mobile-label {
display: none;
}
@media (max-width: 720px) {
.store-items-modal {
max-height: min(88vh, 900px);
padding: var(--spacing-md);
}
.store-items-table-head {
display: none;
}
.store-items-table-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm);
}
.store-available-items-actions,
.store-available-items-toolbar-actions {
.store-items-mobile-label {
display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.store-items-table-actions {
justify-self: stretch;
}
.store-available-items-actions {
width: 100%;
justify-content: stretch;
}
.store-available-items-actions button,
.store-available-items-toolbar-actions button {
flex: 1;
.store-available-items-actions button {
flex: 1 1 0;
}
}

View File

@ -45,7 +45,7 @@ async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page)
});
}
test("manage stores lets admins edit settings for existing household/store items", async ({ page }) => {
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await mockHouseholdAndStoreShell(page);
@ -128,21 +128,11 @@ test("manage stores lets admins edit settings for existing household/store items
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") {
availableItems = availableItems.map((item) =>
item.item_id === 501
? {
...item,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
}
: item
);
availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Store item settings cleared" }),
body: JSON.stringify({ message: "Store item deleted" }),
});
return;
}
@ -154,14 +144,16 @@ test("manage stores lets admins edit settings for existing household/store items
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByText("Store Item Catalog")).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
await expect(storeCard.getByText("milk")).toBeVisible();
await expect(storeCard.getByText("apples")).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Add Item" })).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Import Current List" })).toHaveCount(0);
await storeCard.getByRole("button", { name: "Manage Items" }).click();
await storeCard.locator(".store-available-items-card").filter({ hasText: "apples" }).getByRole("button", { name: "Edit" }).click();
const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk")).toBeVisible();
await expect(managerModal.getByText("apples")).toBeVisible();
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
@ -171,13 +163,29 @@ test("manage stores lets admins edit settings for existing household/store items
await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(storeCard.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Cleared store item settings");
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByText("No store defaults set")).toBeVisible();
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" })).toHaveCount(0);
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
const slider = confirmModal.locator(".confirm-slide-handle");
const track = confirmModal.locator(".confirm-slide-track");
const sliderBox = await slider.boundingBox();
const trackBox = await track.boundingBox();
if (!sliderBox || !trackBox) {
throw new Error("Confirm slide control was not measurable");
}
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
await page.mouse.down();
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
await page.mouse.up();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item");
await expect(managerModal.getByText("milk")).toHaveCount(0);
});
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {