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);
|
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;
|
||||||
|
|||||||
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(
|
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
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("/", 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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) && (
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user