From 29f64a13d598ffad7ceb6d97d8cfdfcf88fbb0e5 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 13:55:54 -0800 Subject: [PATCH] have backend automatically reload with the addition of nodemon.json and within it are watch settings add bought items within grocery list page --- backend/controllers/lists.controller.js | 5 +++ backend/migrations/add_modified_on_column.sql | 8 ++++ backend/models/list.model.js | 41 ++++++++++++++++--- backend/nodemon.json | 16 ++++++++ backend/routes/list.routes.js | 1 + frontend/src/api/list.js | 1 + frontend/src/components/GroceryListItem.jsx | 22 +++++++++- frontend/src/components/ImageModal.jsx | 5 +-- frontend/src/pages/GroceryList.jsx | 31 +++++++++++++- frontend/src/styles/GroceryList.css | 22 +++++++--- 10 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 backend/migrations/add_modified_on_column.sql create mode 100644 backend/nodemon.json diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index ad109f2..da6316b 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -42,6 +42,11 @@ exports.getSuggestions = async (req, res) => { res.json(suggestions); }; +exports.getRecentlyBought = async (req, res) => { + const items = await List.getRecentlyBoughtItems(); + res.json(items); +}; + exports.updateItemImage = async (req, res) => { const { id, itemName, quantity } = req.body; const userId = req.user.id; diff --git a/backend/migrations/add_modified_on_column.sql b/backend/migrations/add_modified_on_column.sql new file mode 100644 index 0000000..2034edc --- /dev/null +++ b/backend/migrations/add_modified_on_column.sql @@ -0,0 +1,8 @@ +-- Add modified_on column to grocery_list table +ALTER TABLE grocery_list +ADD COLUMN modified_on TIMESTAMP DEFAULT NOW(); + +-- Set modified_on to NOW() for existing records +UPDATE grocery_list +SET modified_on = NOW() +WHERE modified_on IS NULL; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index ebd4d71..effaf11 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -5,18 +5,19 @@ exports.getUnboughtItems = async () => { const result = await pool.query( `SELECT gl.id, - gl.item_name, + LOWER(gl.item_name) AS item_name, gl.quantity, gl.bought, 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 + 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 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 WHERE gl.bought = FALSE - GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type + GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on ORDER BY gl.id ASC` ); return result.rows; @@ -46,7 +47,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, SET quantity = $1, bought = FALSE, item_image = $3, - image_mime_type = $4 + image_mime_type = $4, + modified_on = NOW() WHERE id = $2`, [quantity, result.rows[0].id, imageBuffer, mimeType] ); @@ -54,7 +56,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, await pool.query( `UPDATE grocery_list SET quantity = $1, - bought = FALSE + bought = FALSE, + modified_on = NOW() WHERE id = $2`, [quantity, result.rows[0].id] ); @@ -74,7 +77,10 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, exports.setBought = async (id, userId) => { - await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]); + await pool.query( + "UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [id] + ); }; @@ -99,3 +105,26 @@ exports.getSuggestions = async (query) => { return result.rows; }; +exports.getRecentlyBoughtItems = async () => { + const result = await pool.query( + `SELECT + gl.id, + LOWER(gl.item_name) AS item_name, + gl.quantity, + gl.bought, + 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 + 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 + WHERE gl.bought = TRUE + AND gl.modified_on >= NOW() - INTERVAL '24 hours' + GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on + ORDER BY gl.modified_on DESC` + ); + return result.rows; +}; + diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..dfcea7e --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,16 @@ +{ + "watch": [ + "**/*.js", + ".env" + ], + "ext": "js,json", + "ignore": [ + "node_modules/**", + "dist/**" + ], + "legacyWatch": true, + "verbose": true, + "execMap": { + "js": "node" + } +} \ No newline at end of file diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index a9f65e1..37d336a 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -11,6 +11,7 @@ const { upload, processImage } = require("../middleware/image"); router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList); router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName); router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions); +router.get("/recently-bought", auth, requireRole(...Object.values(ROLES)), controller.getRecentlyBought); router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index e48e575..b247c75 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -21,6 +21,7 @@ export const addItem = (itemName, quantity, imageFile = null) => { export const markBought = (id) => api.post("/list/mark-bought", { id }); export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); +export const getRecentlyBought = () => api.get("/list/recently-bought"); export const updateItemImage = (id, itemName, quantity, imageFile) => { const formData = new FormData(); diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/GroceryListItem.jsx index 537217f..ed8caa3 100644 --- a/frontend/src/components/GroceryListItem.jsx +++ b/frontend/src/components/GroceryListItem.jsx @@ -43,6 +43,25 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) { ? `data:${item.image_mime_type};base64,${item.item_image}` : null; + const getTimeAgo = (dateString) => { + if (!dateString) return null; + + const addedDate = new Date(dateString); + const now = new Date(); + const diffMs = now - addedDate; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 7) { + return `${diffDays}d ago`; + } else if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `${weeks}w ago`; + } else { + const months = Math.floor(diffDays / 30); + return `${months}m ago`; + } + }; + return ( <>
  • @@ -65,7 +84,8 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) { {item.added_by_users && item.added_by_users.length > 0 && (
    - {item.added_by_users.join(", ")} + {item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `} + {item.added_by_users.join(" • ")}
    )} diff --git a/frontend/src/components/ImageModal.jsx b/frontend/src/components/ImageModal.jsx index 913dd42..2bb4464 100644 --- a/frontend/src/components/ImageModal.jsx +++ b/frontend/src/components/ImageModal.jsx @@ -15,10 +15,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) { return (
    -
    e.stopPropagation()}> - +
    {itemName}

    {itemName}

    diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 420c6b5..4ec597f 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,5 +1,5 @@ import { useContext, useEffect, useState } from "react"; -import { addItem, getItemByName, getList, getSuggestions, markBought, updateItemImage } from "../api/list"; +import { addItem, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage } from "../api/list"; import AddItemForm from "../components/AddItemForm"; import FloatingActionButton from "../components/FloatingActionButton"; import GroceryListItem from "../components/GroceryListItem"; @@ -15,6 +15,7 @@ export default function GroceryList() { const { role } = useContext(AuthContext); const [items, setItems] = useState([]); + const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [sortedItems, setSortedItems] = useState([]); const [sortMode, setSortMode] = useState("az"); const [suggestions, setSuggestions] = useState([]); @@ -34,8 +35,19 @@ export default function GroceryList() { setLoading(false); }; + const loadRecentlyBought = async () => { + try { + const res = await getRecentlyBought(); + setRecentlyBoughtItems(res.data); + } catch (error) { + console.error("Failed to load recently bought items:", error); + setRecentlyBoughtItems([]); + } + }; + useEffect(() => { loadItems(); + loadRecentlyBought(); }, []); useEffect(() => { @@ -190,6 +202,7 @@ export default function GroceryList() { const handleBought = async (id, quantity) => { await markBought(id); loadItems(); + loadRecentlyBought(); }; const handleImageAdded = async (id, itemName, quantity, imageFile) => { @@ -234,6 +247,22 @@ export default function GroceryList() { /> ))} + + {recentlyBoughtItems.length > 0 && ( + <> +

    Recently Bought (Last 24 Hours)

    +
      + {recentlyBoughtItems.map((item) => ( + + ))} +
    + + )}
    {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( diff --git a/frontend/src/styles/GroceryList.css b/frontend/src/styles/GroceryList.css index 59b9abc..803a784 100644 --- a/frontend/src/styles/GroceryList.css +++ b/frontend/src/styles/GroceryList.css @@ -21,6 +21,16 @@ margin-bottom: 0.4em; } +.glist-section-title { + text-align: center; + font-size: 1.2em; + margin-top: 2em; + margin-bottom: 0.5em; + color: #495057; + border-top: 2px solid #e0e0e0; + padding-top: 1em; +} + /* Inputs */ .glist-input { font-size: 1em; @@ -103,9 +113,9 @@ } .glist-item-image { - width: 80px; - height: 80px; - min-width: 80px; + width: 50px; + height: 50px; + min-width: 50px; background: #f5f5f5; border: 2px solid #e0e0e0; border-radius: 8px; @@ -150,8 +160,8 @@ } .glist-item-name { - font-weight: 600; - font-size: 1.1em; + font-weight: 800; + font-size: 0.8em; color: #333; } @@ -171,7 +181,7 @@ } .glist-item-users { - font-size: 0.9em; + font-size: 0.7em; color: #888; font-style: italic; }