From aee9cd3244426158be41b952168c9b4d2cc19744 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 15:33:53 -0800 Subject: [PATCH] further apply zoning and add ordering by zone feature --- backend/constants/classifications.js | 71 ++++++++++-- backend/models/list.model.js | 8 +- .../components/AddItemWithDetailsModal.jsx | 4 +- frontend/src/components/EditItemModal.jsx | 4 +- frontend/src/components/SortDropdown.jsx | 1 + frontend/src/constants/classifications.js | 71 ++++++++++-- frontend/src/pages/GroceryList.jsx | 106 ++++++++++++++---- frontend/src/styles/GroceryList.css | 16 +++ 8 files changed, 232 insertions(+), 49 deletions(-) diff --git a/backend/constants/classifications.js b/backend/constants/classifications.js index eacac75..f659d76 100644 --- a/backend/constants/classifications.js +++ b/backend/constants/classifications.js @@ -100,17 +100,57 @@ const ITEM_GROUPS = { ], }; -const ZONES = [ - "Front Entry", - "Fresh Foods Right", - "Fresh Foods Left", - "Center Aisles", - "Bakery", - "Meat Department", - "Dairy Cooler", - "Freezer Section", - "Back Wall", - "Checkout Area", +// Store zones - path-oriented physical shopping areas +// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores +const ZONES = { + ENTRANCE: "Entrance & Seasonal", + PRODUCE_SECTION: "Produce & Fresh Vegetables", + MEAT_SEAFOOD: "Meat & Seafood Counter", + DELI_PREPARED: "Deli & Prepared Foods", + BAKERY_SECTION: "Bakery", + DAIRY_SECTION: "Dairy & Refrigerated", + FROZEN_FOODS: "Frozen Foods", + DRY_GOODS_CENTER: "Center Aisles (Dry Goods)", + BEVERAGES: "Beverages & Water", + SNACKS_CANDY: "Snacks & Candy", + HOUSEHOLD_CLEANING: "Household & Cleaning", + HEALTH_BEAUTY: "Health & Beauty", + CHECKOUT_AREA: "Checkout Area", +}; + +// Default zone mapping for each item type +// This determines where items are typically found in the store +const ITEM_TYPE_TO_ZONE = { + [ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION, + [ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD, + [ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION, + [ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION, + [ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS, + [ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER, + [ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES, + [ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY, + [ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING, + [ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY, + [ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER, +}; + +// Optimal walking flow through the store +// Represents a typical shopping path that minimizes backtracking +// Start with perimeter (fresh items), then move to center aisles, end at checkout +const ZONE_FLOW = [ + ZONES.ENTRANCE, + ZONES.PRODUCE_SECTION, + ZONES.MEAT_SEAFOOD, + ZONES.DELI_PREPARED, + ZONES.BAKERY_SECTION, + ZONES.DAIRY_SECTION, + ZONES.FROZEN_FOODS, + ZONES.DRY_GOODS_CENTER, + ZONES.BEVERAGES, + ZONES.SNACKS_CANDY, + ZONES.HOUSEHOLD_CLEANING, + ZONES.HEALTH_BEAUTY, + ZONES.CHECKOUT_AREA, ]; // Validation helpers @@ -121,13 +161,20 @@ const isValidItemGroup = (type, group) => { return ITEM_GROUPS[type]?.includes(group) || false; }; -const isValidZone = (zone) => ZONES.includes(zone); +const isValidZone = (zone) => Object.values(ZONES).includes(zone); + +const getSuggestedZone = (itemType) => { + return ITEM_TYPE_TO_ZONE[itemType] || null; +}; module.exports = { ITEM_TYPES, ITEM_GROUPS, ZONES, + ITEM_TYPE_TO_ZONE, + ZONE_FLOW, isValidItemType, isValidItemGroup, isValidZone, + getSuggestedZone, }; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index 64a06f6..d3b1f27 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -11,13 +11,17 @@ exports.getUnboughtItems = async () => { ENCODE(gl.item_image, 'base64') as item_image, gl.image_mime_type, ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users, - gl.modified_on as last_added_on + gl.modified_on as last_added_on, + ic.item_type, + ic.item_group, + ic.zone FROM grocery_list gl LEFT JOIN users creator ON gl.added_by = creator.id LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id LEFT JOIN users gh_user ON gh.added_by = gh_user.id + LEFT JOIN item_classification ic ON gl.id = ic.id WHERE gl.bought = FALSE - GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on + GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone ORDER BY gl.id ASC` ); return result.rows; diff --git a/frontend/src/components/AddItemWithDetailsModal.jsx b/frontend/src/components/AddItemWithDetailsModal.jsx index d078651..dc42b30 100644 --- a/frontend/src/components/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/AddItemWithDetailsModal.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; import "../styles/AddItemWithDetailsModal.css"; export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { @@ -159,7 +159,7 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o className="add-item-details-select" > - {ZONES.map((z) => ( + {getZoneValues().map((z) => ( diff --git a/frontend/src/components/EditItemModal.jsx b/frontend/src/components/EditItemModal.jsx index 62a8dcd..1f6c113 100644 --- a/frontend/src/components/EditItemModal.jsx +++ b/frontend/src/components/EditItemModal.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; import "../styles/EditItemModal.css"; export default function EditItemModal({ item, onSave, onCancel }) { @@ -134,7 +134,7 @@ export default function EditItemModal({ item, onSave, onCancel }) { className="edit-modal-select" > - {ZONES.map((z) => ( + {getZoneValues().map((z) => ( diff --git a/frontend/src/components/SortDropdown.jsx b/frontend/src/components/SortDropdown.jsx index 65f171e..21e6ad4 100644 --- a/frontend/src/components/SortDropdown.jsx +++ b/frontend/src/components/SortDropdown.jsx @@ -5,6 +5,7 @@ export default function SortDropdown({ value, onChange }) { + ); } diff --git a/frontend/src/constants/classifications.js b/frontend/src/constants/classifications.js index 60e50a8..de63521 100644 --- a/frontend/src/constants/classifications.js +++ b/frontend/src/constants/classifications.js @@ -101,18 +101,57 @@ export const ITEM_GROUPS = { ], }; -// Store zones for Costco layout -export const ZONES = [ - "Front Entry", - "Fresh Foods Right", - "Fresh Foods Left", - "Center Aisles", - "Bakery", - "Meat Department", - "Dairy Cooler", - "Freezer Section", - "Back Wall", - "Checkout Area", +// Store zones - path-oriented physical shopping areas +// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores +export const ZONES = { + ENTRANCE: "Entrance & Seasonal", + PRODUCE_SECTION: "Produce & Fresh Vegetables", + MEAT_SEAFOOD: "Meat & Seafood Counter", + DELI_PREPARED: "Deli & Prepared Foods", + BAKERY_SECTION: "Bakery", + DAIRY_SECTION: "Dairy & Refrigerated", + FROZEN_FOODS: "Frozen Foods", + DRY_GOODS_CENTER: "Center Aisles (Dry Goods)", + BEVERAGES: "Beverages & Water", + SNACKS_CANDY: "Snacks & Candy", + HOUSEHOLD_CLEANING: "Household & Cleaning", + HEALTH_BEAUTY: "Health & Beauty", + CHECKOUT_AREA: "Checkout Area", +}; + +// Default zone mapping for each item type +// This determines where items are typically found in the store +export const ITEM_TYPE_TO_ZONE = { + [ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION, + [ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD, + [ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION, + [ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION, + [ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS, + [ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER, + [ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES, + [ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY, + [ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING, + [ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY, + [ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER, +}; + +// Optimal walking flow through the store +// Represents a typical shopping path that minimizes backtracking +// Start with perimeter (fresh items), then move to center aisles, end at checkout +export const ZONE_FLOW = [ + ZONES.ENTRANCE, + ZONES.PRODUCE_SECTION, + ZONES.MEAT_SEAFOOD, + ZONES.DELI_PREPARED, + ZONES.BAKERY_SECTION, + ZONES.DAIRY_SECTION, + ZONES.FROZEN_FOODS, + ZONES.DRY_GOODS_CENTER, + ZONES.BEVERAGES, + ZONES.SNACKS_CANDY, + ZONES.HOUSEHOLD_CLEANING, + ZONES.HEALTH_BEAUTY, + ZONES.CHECKOUT_AREA, ]; // Helper to get display label for item type @@ -132,3 +171,11 @@ export const getItemTypeLabel = (type) => { }; return labels[type] || type; }; + +// Helper to get all zone values as array (for dropdowns) +export const getZoneValues = () => Object.values(ZONES); + +// Helper to get suggested zone for an item type +export const getSuggestedZone = (itemType) => { + return ITEM_TYPE_TO_ZONE[itemType] || null; +}; diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index cfe72ba..cd7a947 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -18,7 +18,7 @@ export default function GroceryList() { const [items, setItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [sortedItems, setSortedItems] = useState([]); - const [sortMode, setSortMode] = useState("az"); + const [sortMode, setSortMode] = useState("zone"); const [suggestions, setSuggestions] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [loading, setLoading] = useState(true); @@ -60,6 +60,29 @@ export default function GroceryList() { if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); + if (sortMode === "zone") { + sorted.sort((a, b) => { + // Items without classification go to the end + if (!a.item_type && b.item_type) return 1; + if (a.item_type && !b.item_type) return -1; + if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name); + + // Sort by item_type + const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); + if (typeCompare !== 0) return typeCompare; + + // Then by item_group + const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); + if (groupCompare !== 0) return groupCompare; + + // Then by zone + const zoneCompare = (a.zone || "").localeCompare(b.zone || ""); + if (zoneCompare !== 0) return zoneCompare; + + // Finally by name + return a.item_name.localeCompare(b.item_name); + }); + } setSortedItems(sorted); }, [items, sortMode]); @@ -277,6 +300,19 @@ export default function GroceryList() { setEditingItem(null); }; + // Group items by zone for classification view + const groupItemsByZone = (items) => { + const groups = {}; + items.forEach(item => { + const zone = item.zone || 'unclassified'; + if (!groups[zone]) { + groups[zone] = []; + } + groups[zone].push(item); + }); + return groups; + }; + if (loading) return

Loading...

; return ( @@ -295,27 +331,59 @@ export default function GroceryList() { /> )} - + {sortMode === "zone" ? ( + // Grouped view by zone + (() => { + const grouped = groupItemsByZone(sortedItems); + return Object.keys(grouped).map(zone => ( +
+

+ {zone === 'unclassified' ? 'Unclassified' : zone} +

+ +
+ )); + })() + ) : ( + // Regular flat list view + + )} {recentlyBoughtItems.length > 0 && ( <> -

Recently Bought (Last 24 Hours)

+

Recently Bought (24HR)