further apply zoning and add ordering by zone feature

This commit is contained in:
Nico 2026-01-02 15:33:53 -08:00
parent 8894cf21ea
commit aee9cd3244
8 changed files with 232 additions and 49 deletions

View File

@ -100,17 +100,57 @@ const ITEM_GROUPS = {
], ],
}; };
const ZONES = [ // Store zones - path-oriented physical shopping areas
"Front Entry", // Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
"Fresh Foods Right", const ZONES = {
"Fresh Foods Left", ENTRANCE: "Entrance & Seasonal",
"Center Aisles", PRODUCE_SECTION: "Produce & Fresh Vegetables",
"Bakery", MEAT_SEAFOOD: "Meat & Seafood Counter",
"Meat Department", DELI_PREPARED: "Deli & Prepared Foods",
"Dairy Cooler", BAKERY_SECTION: "Bakery",
"Freezer Section", DAIRY_SECTION: "Dairy & Refrigerated",
"Back Wall", FROZEN_FOODS: "Frozen Foods",
"Checkout Area", 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 // Validation helpers
@ -121,13 +161,20 @@ const isValidItemGroup = (type, group) => {
return ITEM_GROUPS[type]?.includes(group) || false; 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 = { module.exports = {
ITEM_TYPES, ITEM_TYPES,
ITEM_GROUPS, ITEM_GROUPS,
ZONES, ZONES,
ITEM_TYPE_TO_ZONE,
ZONE_FLOW,
isValidItemType, isValidItemType,
isValidItemGroup, isValidItemGroup,
isValidZone, isValidZone,
getSuggestedZone,
}; };

View File

@ -11,13 +11,17 @@ exports.getUnboughtItems = async () => {
ENCODE(gl.item_image, 'base64') as item_image, ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type, 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, 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 FROM grocery_list gl
LEFT JOIN users creator ON gl.added_by = creator.id LEFT JOIN users creator ON gl.added_by = creator.id
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_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 users gh_user ON gh.added_by = gh_user.id
LEFT JOIN item_classification ic ON gl.id = ic.id
WHERE gl.bought = FALSE 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` ORDER BY gl.id ASC`
); );
return result.rows; return result.rows;

View File

@ -1,5 +1,5 @@
import { useRef, useState } from "react"; 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"; import "../styles/AddItemWithDetailsModal.css";
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { 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" className="add-item-details-select"
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{ZONES.map((z) => ( {getZoneValues().map((z) => (
<option key={z} value={z}> <option key={z} value={z}>
{z} {z}
</option> </option>

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; 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"; import "../styles/EditItemModal.css";
export default function EditItemModal({ item, onSave, onCancel }) { export default function EditItemModal({ item, onSave, onCancel }) {
@ -134,7 +134,7 @@ export default function EditItemModal({ item, onSave, onCancel }) {
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{ZONES.map((z) => ( {getZoneValues().map((z) => (
<option key={z} value={z}> <option key={z} value={z}>
{z} {z}
</option> </option>

View File

@ -5,6 +5,7 @@ export default function SortDropdown({ value, onChange }) {
<option value="za">Z A</option> <option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option> <option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option> <option value="qty-low">Quantity: Low High</option>
<option value="zone">By Zone</option>
</select> </select>
); );
} }

View File

@ -101,18 +101,57 @@ export const ITEM_GROUPS = {
], ],
}; };
// Store zones for Costco layout // Store zones - path-oriented physical shopping areas
export const ZONES = [ // Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
"Front Entry", export const ZONES = {
"Fresh Foods Right", ENTRANCE: "Entrance & Seasonal",
"Fresh Foods Left", PRODUCE_SECTION: "Produce & Fresh Vegetables",
"Center Aisles", MEAT_SEAFOOD: "Meat & Seafood Counter",
"Bakery", DELI_PREPARED: "Deli & Prepared Foods",
"Meat Department", BAKERY_SECTION: "Bakery",
"Dairy Cooler", DAIRY_SECTION: "Dairy & Refrigerated",
"Freezer Section", FROZEN_FOODS: "Frozen Foods",
"Back Wall", DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
"Checkout Area", 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 // Helper to get display label for item type
@ -132,3 +171,11 @@ export const getItemTypeLabel = (type) => {
}; };
return labels[type] || 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;
};

View File

@ -18,7 +18,7 @@ export default function GroceryList() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [sortedItems, setSortedItems] = useState([]); const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("az"); const [sortMode, setSortMode] = useState("zone");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [loading, setLoading] = useState(true); 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 === "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-high") sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.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); setSortedItems(sorted);
}, [items, sortMode]); }, [items, sortMode]);
@ -277,6 +300,19 @@ export default function GroceryList() {
setEditingItem(null); 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 <p>Loading...</p>; if (loading) return <p>Loading...</p>;
return ( return (
@ -295,6 +331,37 @@ export default function GroceryList() {
/> />
)} )}
{sortMode === "zone" ? (
// Grouped view by zone
(() => {
const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => (
<div key={zone} className="glist-classification-group">
<h3 className="glist-classification-header">
{zone === 'unclassified' ? 'Unclassified' : zone}
</h3>
<ul className="glist-ul">
{grouped[zone].map((item) => (
<GroceryListItem
key={item.id}
item={item}
onClick={(quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
</div>
));
})()
) : (
// Regular flat list view
<ul className="glist-ul"> <ul className="glist-ul">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<GroceryListItem <GroceryListItem
@ -312,10 +379,11 @@ export default function GroceryList() {
/> />
))} ))}
</ul> </ul>
)}
{recentlyBoughtItems.length > 0 && ( {recentlyBoughtItems.length > 0 && (
<> <>
<h2 className="glist-section-title">Recently Bought (Last 24 Hours)</h2> <h2 className="glist-section-title">Recently Bought (24HR)</h2>
<ul className="glist-ul"> <ul className="glist-ul">
{recentlyBoughtItems.map((item) => ( {recentlyBoughtItems.map((item) => (
<GroceryListItem <GroceryListItem

View File

@ -31,6 +31,22 @@
padding-top: 1em; padding-top: 1em;
} }
/* Classification Groups */
.glist-classification-group {
margin-bottom: 2em;
}
.glist-classification-header {
font-size: 1.1em;
font-weight: 600;
color: #007bff;
margin: 1em 0 0.5em 0;
padding: 0.5em 0.8em;
background: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
}
/* Inputs */ /* Inputs */
.glist-input { .glist-input {
font-size: 1em; font-size: 1em;