have backend automatically reload with the addition of nodemon.json and within it are watch settings

add bought items within grocery list page
This commit is contained in:
Nico 2026-01-02 13:55:54 -08:00
parent 3073403f58
commit 29f64a13d5
10 changed files with 134 additions and 18 deletions

View File

@ -42,6 +42,11 @@ exports.getSuggestions = async (req, res) => {
res.json(suggestions); res.json(suggestions);
}; };
exports.getRecentlyBought = async (req, res) => {
const items = await List.getRecentlyBoughtItems();
res.json(items);
};
exports.updateItemImage = async (req, res) => { exports.updateItemImage = async (req, res) => {
const { id, itemName, quantity } = req.body; const { id, itemName, quantity } = req.body;
const userId = req.user.id; const userId = req.user.id;

View File

@ -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;

View File

@ -5,18 +5,19 @@ exports.getUnboughtItems = async () => {
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
gl.id, gl.id,
gl.item_name, LOWER(gl.item_name) AS item_name,
gl.quantity, gl.quantity,
gl.bought, gl.bought,
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
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
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 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` ORDER BY gl.id ASC`
); );
return result.rows; return result.rows;
@ -46,7 +47,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
SET quantity = $1, SET quantity = $1,
bought = FALSE, bought = FALSE,
item_image = $3, item_image = $3,
image_mime_type = $4 image_mime_type = $4,
modified_on = NOW()
WHERE id = $2`, WHERE id = $2`,
[quantity, result.rows[0].id, imageBuffer, mimeType] [quantity, result.rows[0].id, imageBuffer, mimeType]
); );
@ -54,7 +56,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
await pool.query( await pool.query(
`UPDATE grocery_list `UPDATE grocery_list
SET quantity = $1, SET quantity = $1,
bought = FALSE bought = FALSE,
modified_on = NOW()
WHERE id = $2`, WHERE id = $2`,
[quantity, result.rows[0].id] [quantity, result.rows[0].id]
); );
@ -74,7 +77,10 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
exports.setBought = async (id, userId) => { 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; 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;
};

16
backend/nodemon.json Normal file
View File

@ -0,0 +1,16 @@
{
"watch": [
"**/*.js",
".env"
],
"ext": "js,json",
"ignore": [
"node_modules/**",
"dist/**"
],
"legacyWatch": true,
"verbose": true,
"execMap": {
"js": "node"
}
}

View File

@ -11,6 +11,7 @@ const { upload, processImage } = require("../middleware/image");
router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList); router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList);
router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName); router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName);
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions); 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); router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem);

View File

@ -21,6 +21,7 @@ export const addItem = (itemName, quantity, imageFile = null) => {
export const markBought = (id) => api.post("/list/mark-bought", { id }); export const markBought = (id) => api.post("/list/mark-bought", { id });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); 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) => { export const updateItemImage = (id, itemName, quantity, imageFile) => {
const formData = new FormData(); const formData = new FormData();

View File

@ -43,6 +43,25 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
? `data:${item.image_mime_type};base64,${item.item_image}` ? `data:${item.image_mime_type};base64,${item.item_image}`
: null; : 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 ( return (
<> <>
<li className="glist-li" onClick={handleItemClick}> <li className="glist-li" onClick={handleItemClick}>
@ -65,7 +84,8 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
</div> </div>
{item.added_by_users && item.added_by_users.length > 0 && ( {item.added_by_users && item.added_by_users.length > 0 && (
<div className="glist-item-users"> <div className="glist-item-users">
{item.added_by_users.join(", ")} {item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `}
{item.added_by_users.join(" • ")}
</div> </div>
)} )}
</div> </div>

View File

@ -15,10 +15,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
return ( return (
<div className="image-modal-overlay" onClick={onClose}> <div className="image-modal-overlay" onClick={onClose}>
<div className="image-modal-content" onClick={(e) => e.stopPropagation()}> <div className="image-modal-content" onClick={onClose}>
<button className="image-modal-close" onClick={onClose}>
×
</button>
<img src={imageUrl} alt={itemName} className="image-modal-img" /> <img src={imageUrl} alt={itemName} className="image-modal-img" />
<p className="image-modal-caption">{itemName}</p> <p className="image-modal-caption">{itemName}</p>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useContext, useEffect, useState } from "react"; 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 AddItemForm from "../components/AddItemForm";
import FloatingActionButton from "../components/FloatingActionButton"; import FloatingActionButton from "../components/FloatingActionButton";
import GroceryListItem from "../components/GroceryListItem"; import GroceryListItem from "../components/GroceryListItem";
@ -15,6 +15,7 @@ export default function GroceryList() {
const { role } = useContext(AuthContext); const { role } = useContext(AuthContext);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [sortedItems, setSortedItems] = useState([]); const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("az"); const [sortMode, setSortMode] = useState("az");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
@ -34,8 +35,19 @@ export default function GroceryList() {
setLoading(false); 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(() => { useEffect(() => {
loadItems(); loadItems();
loadRecentlyBought();
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -190,6 +202,7 @@ export default function GroceryList() {
const handleBought = async (id, quantity) => { const handleBought = async (id, quantity) => {
await markBought(id); await markBought(id);
loadItems(); loadItems();
loadRecentlyBought();
}; };
const handleImageAdded = async (id, itemName, quantity, imageFile) => { const handleImageAdded = async (id, itemName, quantity, imageFile) => {
@ -234,6 +247,22 @@ export default function GroceryList() {
/> />
))} ))}
</ul> </ul>
{recentlyBoughtItems.length > 0 && (
<>
<h2 className="glist-section-title">Recently Bought (Last 24 Hours)</h2>
<ul className="glist-ul">
{recentlyBoughtItems.map((item) => (
<GroceryListItem
key={item.id}
item={item}
onClick={null}
onImageAdded={null}
/>
))}
</ul>
</>
)}
</div> </div>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (

View File

@ -21,6 +21,16 @@
margin-bottom: 0.4em; 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 */ /* Inputs */
.glist-input { .glist-input {
font-size: 1em; font-size: 1em;
@ -103,9 +113,9 @@
} }
.glist-item-image { .glist-item-image {
width: 80px; width: 50px;
height: 80px; height: 50px;
min-width: 80px; min-width: 50px;
background: #f5f5f5; background: #f5f5f5;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
@ -150,8 +160,8 @@
} }
.glist-item-name { .glist-item-name {
font-weight: 600; font-weight: 800;
font-size: 1.1em; font-size: 0.8em;
color: #333; color: #333;
} }
@ -171,7 +181,7 @@
} }
.glist-item-users { .glist-item-users {
font-size: 0.9em; font-size: 0.7em;
color: #888; color: #888;
font-style: italic; font-style: italic;
} }