diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 835398c..07ef74f 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -20,12 +20,23 @@ const schema = a.schema({ storeId: a.id().required(), isDiscount: a.boolean(), discountedPrice: a.float(), + priceHistory: a.hasMany("PriceHistory", "itemId"), }) .secondaryIndexes((index) => [ index("barcode"), index("itemName"), ]) .authorization((allow) => [allow.guest()]), + PriceHistory: a. + model({ + itemId: a.id().required(), + itemName: a.string().required(), + price: a.float().required(), + discountedPrice: a.float(), + changedAt: a.string().required(), + item: a.belongsTo("Item", "itemId"), + }) + .authorization((allow) => [allow.guest()]), StoreLocation: a .customType({ streetName: a.string().required(), diff --git a/package-lock.json b/package-lock.json index f954127..6564cb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,11 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "aws-amplify": "^6.15.3", + "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", "dompurify": "^3.2.6", "react": "^19.1.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", "react-dropdown-select": "^4.12.2", "react-qr-barcode-scanner": "^2.1.8", @@ -47330,6 +47333,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -51213,6 +51222,28 @@ "node": "*" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -51828,6 +51859,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debounce-promise": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz", @@ -56485,6 +56527,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/package.json b/package.json index 529de57..02e9a10 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,11 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "aws-amplify": "^6.15.3", + "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", "dompurify": "^3.2.6", "react": "^19.1.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", "react-dropdown-select": "^4.12.2", "react-qr-barcode-scanner": "^2.1.8", diff --git a/src/components/ItemCard.tsx b/src/components/ItemCard.tsx index 4ff2488..1ccb96b 100644 --- a/src/components/ItemCard.tsx +++ b/src/components/ItemCard.tsx @@ -1,6 +1,23 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { useNavigate } from "react-router-dom"; +import type { PriceHistory } from "../types/priceHistory"; +import 'chartjs-adapter-date-fns'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + LineElement, + PointElement, + CategoryScale, + LinearScale, + Tooltip, + Legend, + TimeScale, + type ChartOptions, +} from 'chart.js'; + +ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, TimeScale); + export interface ItemCardProps { id: string; @@ -13,6 +30,7 @@ export interface ItemCardProps { isDiscount: boolean; itemPrice: number; discountedPrice?: number; + priceHistory?: PriceHistory[]; } const ItemCard: React.FC = ({ @@ -26,6 +44,7 @@ const ItemCard: React.FC = ({ isDiscount, itemPrice, discountedPrice, + priceHistory }) => { const navigate = useNavigate(); @@ -33,6 +52,98 @@ const ItemCard: React.FC = ({ navigate(`/edit-item/${id}`); }; + const formatPrice = (price: number) => { + return price % 1 === 0 ? `${price}` : `${price.toFixed(2)}` + } + + let sortedHistory = Array.isArray(priceHistory) + ? [...priceHistory] + .filter(h => h && h.changedAt && h.price !== undefined) + .sort((a, b) => new Date(a.changedAt).getTime() - new Date(b.changedAt).getTime()) + : []; + + if (sortedHistory.length > 0) { + const last = sortedHistory[sortedHistory.length - 1]; + const lastDate = new Date(last.changedAt); + const today = new Date(); + lastDate.setHours(0,0,0,0); + today.setHours(0,0,0,0); + + if (lastDate.getTime() < today.getTime()) { + sortedHistory = [ + ...sortedHistory, + { + ...last, + id: last.id + '-virtual', + changedAt: today.toISOString(), + } + ]; + } + } + + const chartData = { + datasets: [ + { + label: 'Price', + data: sortedHistory.map(h => ({ + x: h.changedAt, + y: h.discountedPrice !== undefined && h.discountedPrice !== null + ? h.discountedPrice + : h.price + })), + fill: false, + borderColor: '#c8ff00', + backgroundColor: '#c8ff00', + tension: 0.2, + stepped: 'before' as const, + }, + ], + }; + + const chartOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + callbacks: { + label: function (context) { + const yValue = (context.raw as { x: any; y: number }).y; + return `$${formatPrice(yValue)}`; + }, + }, + }, + }, + scales: { + x: { + type: 'time', + time: { + unit: 'day', + tooltipFormat: 'MM/dd/yy hh:mm:ss a', + displayFormats: { + day: 'MM/dd/yy', + }, + }, + title: { display: false }, + grid: { display: false }, + border: { display: false }, + ticks: { color: "#FFF", maxTicksLimit: 4} + }, + y: { + title: { display: true, text: 'Price', color: "#FFF" }, + grid: { display: false }, + border: { display: false }, + ticks: { + callback: function(value) { + return `${formatPrice(Number(value))}`; + }, + color: "#FFF" + } + }, + }, + }; + return (
{itemName}/ @@ -42,18 +153,60 @@ const ItemCard: React.FC = ({
{category}
{storeName}
- {isDiscount && discountedPrice !== undefined ? ( -
-

${discountedPrice}

- ${itemPrice} -
- ) : ( -

${itemPrice}

- )} + {isDiscount && discountedPrice !== undefined ? ( +
+

${formatPrice(discountedPrice)}

+ ${formatPrice(itemPrice)} +
+ ) : ( +

${formatPrice(itemPrice)}

+ )}
+ + {Array.isArray(priceHistory) && priceHistory.length > 0 && ( +
+

Price History

+ {sortedHistory.length > 0 && ( +
+ +
+ )} + + + + + + + + + {[...priceHistory] + .sort((a, b) => new Date(a.changedAt).getTime() - new Date(b.changedAt).getTime()) + .map((h) => ( + + + + + ))} + +
DatePrice
+ {new Date(h.changedAt).toLocaleDateString(undefined, { + year: '2-digit', + month: '2-digit', + day: '2-digit' + })} + + {h.discountedPrice !== undefined && h.discountedPrice !== null + ? ( + ${formatPrice(h.discountedPrice)} + ) : ( + ${formatPrice(h.price)} + )} +
+
+ )}
) } diff --git a/src/index.css b/src/index.css index bde42e5..6d935b8 100644 --- a/src/index.css +++ b/src/index.css @@ -628,6 +628,20 @@ h1 { margin-left: auto; } +.price-history h4 { + margin-top: 0; +} + +.price-history-graph { + min-width: 100%; + margin-bottom: 1rem; +} + +.price-history-table td { + padding: 0.25rem 0.75rem; +} + + @media (max-width: 1059.98px) { /* smaller desktop layout */ .nav-bar { display: grid; diff --git a/src/pages/CreateNewItem.tsx b/src/pages/CreateNewItem.tsx index fb387c7..f7aaa05 100644 --- a/src/pages/CreateNewItem.tsx +++ b/src/pages/CreateNewItem.tsx @@ -125,7 +125,21 @@ const CreateNewItem: React.FC = () => { } try { - await client.models.Item.create(sanitizedItemInputs); + const result = await client.models.Item.create(sanitizedItemInputs); + if (!result?.data) { + throw new Error("no data returned on create new item"); + } + const newItem = result.data; + await client.models.PriceHistory.create({ + itemId: newItem.id, + itemName: sanitizedItemInputs.itemName, + price: sanitizedItemInputs.itemPrice, + discountedPrice: sanitizedItemInputs.isDiscount + ? sanitizedItemInputs.discountedPrice + : undefined, + changedAt: new Date().toISOString(), + }); + const successMessage = `Item: ${sanitizedItemInputs.itemName} created successfully for $${sanitizedItemInputs.itemPrice} per ${sanitizedItemInputs.units}`; Swal.fire({ title: 'New Item Successfully Created', diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1b4a0b7..b08190e 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -21,12 +21,15 @@ const Dashboard: React.FC = () => { try { setLoading(true); const { data: items } = await client.models.Item.list(); - if (items && items.length > 0) { - setDashboardItems(items as ItemInputs[]); - } else { - setDashboardItems([]); - console.log("Issue: No items available"); - } + const itemsWithHistory = await Promise.all( + (items ?? []).map(async (item) => { + const { data: history } = await client.models.PriceHistory.list({ + filter: { itemId: { eq: item.id } } + }); + return { ...item, priceHistory: history ?? [] }; + }) + ); + setDashboardItems(itemsWithHistory as ItemInputs[]); } catch (error) { console.error('Error fetching stores:', error); setDashboardItems([]); @@ -133,7 +136,8 @@ const Dashboard: React.FC = () => { storeName={item.storeName} isDiscount={item.isDiscount} itemPrice={item.itemPrice} - discountedPrice={item.discountedPrice} /> + discountedPrice={item.discountedPrice} + priceHistory={item.priceHistory}/> )) )} diff --git a/src/pages/EditItem.tsx b/src/pages/EditItem.tsx index d670b11..9819647 100644 --- a/src/pages/EditItem.tsx +++ b/src/pages/EditItem.tsx @@ -176,6 +176,15 @@ const EditItem: React.FC = () => { id: itemId!, ...sanitizedItemInputs, }); + + const newHistory = await client.models.PriceHistory.create({ + itemId: itemId!, + itemName: sanitizedItemInputs.itemName, + price: sanitizedItemInputs.itemPrice, + discountedPrice: sanitizedItemInputs.isDiscount ? sanitizedItemInputs.discountedPrice : undefined, + changedAt: new Date().toISOString(), + }); + console.log("created pricehistory:", newHistory); const successMessage = `Item: ${sanitizedItemInputs.itemName} updated successfully`; Swal.fire({ title: 'Item Successfully Updated', diff --git a/src/types/item.ts b/src/types/item.ts index 3227549..290ada8 100644 --- a/src/types/item.ts +++ b/src/types/item.ts @@ -1,3 +1,5 @@ +import type { PriceHistory } from "./priceHistory"; + export interface ItemInputs { id: string; barcode: string; @@ -11,4 +13,5 @@ export interface ItemInputs { storeId: string; isDiscount: boolean; discountedPrice: number; + priceHistory?: PriceHistory[]; } \ No newline at end of file diff --git a/src/types/priceHistory.ts b/src/types/priceHistory.ts new file mode 100644 index 0000000..083bb79 --- /dev/null +++ b/src/types/priceHistory.ts @@ -0,0 +1,8 @@ +export interface PriceHistory { + id: string; + itemId: string; + itemName: string; + price: number; + discountedPrice?: number; + changedAt: string; +} \ No newline at end of file