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:
parent
3073403f58
commit
29f64a13d5
@ -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;
|
||||
|
||||
8
backend/migrations/add_modified_on_column.sql
Normal file
8
backend/migrations/add_modified_on_column.sql
Normal 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;
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
16
backend/nodemon.json
Normal file
16
backend/nodemon.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"watch": [
|
||||
"**/*.js",
|
||||
".env"
|
||||
],
|
||||
"ext": "js,json",
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
],
|
||||
"legacyWatch": true,
|
||||
"verbose": true,
|
||||
"execMap": {
|
||||
"js": "node"
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<li className="glist-li" onClick={handleItemClick}>
|
||||
@ -65,7 +84,8 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
|
||||
</div>
|
||||
{item.added_by_users && item.added_by_users.length > 0 && (
|
||||
<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>
|
||||
|
||||
@ -15,10 +15,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
|
||||
|
||||
return (
|
||||
<div className="image-modal-overlay" onClick={onClose}>
|
||||
<div className="image-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="image-modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="image-modal-content" onClick={onClose}>
|
||||
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
||||
<p className="image-modal-caption">{itemName}</p>
|
||||
</div>
|
||||
|
||||
@ -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() {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user