diff --git a/src/api/firebase.js b/src/api/firebase.js index f0584dd..7c5bfaf 100644 --- a/src/api/firebase.js +++ b/src/api/firebase.js @@ -167,6 +167,7 @@ export async function addItem(listPath, { itemName, daysUntilNextPurchase }) { dateNextPurchased: addDaysFromToday(daysUntilNextPurchase), name: itemName, totalPurchases: 0, + averagePurchaseInterval: 0, }); } @@ -178,13 +179,19 @@ export async function addItem(listPath, { itemName, daysUntilNextPurchase }) { * @param {Date} updatedData.dateLastPurchased The date the item was last purchased. * @param {Date} updatedData.dateNextPurchased The estimated date for the next purchase. * @param {number} updatedData.totalPurchases The total number of times the item has been purchased. + * @param {number} updatedData.averagePurchaseInterval The average purchase interval of the item that has been purchased. * @returns {Promise} A message confirming the item was successfully updated. * @throws {Error} If the item update fails. */ export async function updateItem( listPath, itemId, - { dateLastPurchased, dateNextPurchased, totalPurchases }, + { + dateLastPurchased, + dateNextPurchased, + totalPurchases, + averagePurchaseInterval, + }, ) { // reference the item path const itemDocRef = doc(db, listPath, 'items', itemId); @@ -194,6 +201,7 @@ export async function updateItem( dateLastPurchased, dateNextPurchased, totalPurchases, + averagePurchaseInterval, }); return 'item purchased'; } catch (error) { diff --git a/src/components/AddItems.jsx b/src/components/AddItems.jsx index 7b0d01a..89f73cf 100644 --- a/src/components/AddItems.jsx +++ b/src/components/AddItems.jsx @@ -69,13 +69,13 @@ export function AddItems({ items }) { required={true} /> - {Object.entries(daysUntilPurchaseOptions).map(([key, value]) => ( + {Object.entries(daysUntilPurchaseOptions).map(([status, days]) => ( ))} diff --git a/src/components/DeleteIconWithTooltip.jsx b/src/components/DeleteIconWithTooltip.jsx deleted file mode 100644 index 1d46c02..0000000 --- a/src/components/DeleteIconWithTooltip.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DeleteOutlineOutlined } from '@mui/icons-material'; -import { Tooltip, IconButton } from '@mui/material'; - -export const tooltipStyle = { - fontSize: '1.5rem', - marginBlockStart: '0', - marginBlockEnd: '0', -}; - -export const DeleteIconWithTooltip = ({ ariaLabel, toggleDialog }) => { - return ( - Delete

} placement="right" arrow> - - - -
- ); -}; diff --git a/src/components/IconWithTooltip.jsx b/src/components/IconWithTooltip.jsx new file mode 100644 index 0000000..125057d --- /dev/null +++ b/src/components/IconWithTooltip.jsx @@ -0,0 +1,27 @@ +import { Tooltip, IconButton, Box } from '@mui/material'; + +export const tooltipStyle = { + fontSize: '1.5rem', + marginBlockStart: '0', + marginBlockEnd: '0', +}; + +export function IconWithTooltip({ + icon, + onClick, + ariaLabel, + title, + placement, +}) { + return ( + {title}} + placement={placement} + arrow + > + + {icon} + + + ); +} diff --git a/src/components/InfoCard.jsx b/src/components/InfoCard.jsx new file mode 100644 index 0000000..e0cd921 --- /dev/null +++ b/src/components/InfoCard.jsx @@ -0,0 +1,63 @@ +import { describeAveragePurchaseInterval } from '../utils'; +import { + Box, + Card, + CardContent, + CardHeader, + Typography, + IconButton, + Collapse, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; + +export const InfoCard = ({ item, toggleCard, show }) => { + const typographyOptions = { + totalPurchases: `You've purchased this item ${item.totalPurchases} times`, + averagePurchaseInterval: describeAveragePurchaseInterval( + item.averagePurchaseInterval, + ), + dateCreated: `Item added on: ${item.dateCreated?.toDate().toLocaleString()}`, + dateLastPurchased: item.dateLastPurchased + ? `Last bought on: ${item.dateLastPurchased?.toDate().toLocaleString()}` + : 'Not purchased yet', + dateNextPurchased: `Expected to buy again by: ${item.dateNextPurchased?.toDate().toLocaleString() ?? 'No estimate yet'}`, + }; + + return ( + + + + {item?.name}} + action={ + ({ + position: 'absolute', + right: 20, + top: 15, + color: theme.palette.grey[700], + })} + > + + + } + /> + + {Object.entries(typographyOptions).map(([key, option]) => ( + + {option} + + ))} + + + + + ); +}; diff --git a/src/components/ListItem.jsx b/src/components/ListItem.jsx index c1220a0..01ff1a8 100644 --- a/src/components/ListItem.jsx +++ b/src/components/ListItem.jsx @@ -1,14 +1,17 @@ import { useState } from 'react'; import { updateItem, deleteItem } from '../api'; -import { calculateDateNextPurchased, ONE_DAY_IN_MILLISECONDS } from '../utils'; +import { calculateDateNextPurchased, calculateIsPurchased } from '../utils'; import { toast } from 'react-toastify'; import { useConfirmDialog } from '../hooks/useConfirmDialog'; -import { ConfirmDialog } from './ConfirmDialog'; -import { DeleteIconWithTooltip, tooltipStyle } from './DeleteIconWithTooltip'; +import { + IconWithTooltip, + tooltipStyle, + InfoCard, + ConfirmDialog, +} from './index'; import { ListItem as MaterialListItem, Tooltip, - IconButton, ListItemButton, ListItemIcon, ListItemText, @@ -20,55 +23,43 @@ import { RadioButtonUnchecked as KindOfSoonIcon, RemoveCircle as NotSoonIcon, RadioButtonChecked as InactiveIcon, + MoreHoriz, + DeleteOutlineOutlined, } from '@mui/icons-material'; import './ListItem.css'; -const currentDate = new Date(); - const urgencyStatusIcons = { overdue: OverdueIcon, soon: SoonIcon, - kindOfSoon: KindOfSoonIcon, - notSoon: NotSoonIcon, + 'kind of soon': KindOfSoonIcon, + 'not soon': NotSoonIcon, inactive: InactiveIcon, }; -const urgencyStatusStyle = { +const largeWhiteFontStyle = { fontSize: '2.5rem', color: 'white', }; -const toolTipStyle = { - fontSize: '1.5rem', - marginBlockStart: '0', - marginBlockEnd: '0', -}; - -const calculateIsPurchased = (dateLastPurchased) => { - if (!dateLastPurchased) { - return false; - } - const purchaseDate = dateLastPurchased.toDate(); - const oneDayLater = new Date( - purchaseDate.getTime() + ONE_DAY_IN_MILLISECONDS, - ); - - return currentDate < oneDayLater; -}; - export function ListItem({ item, listPath, itemUrgencyStatus }) { const { open, isOpen, toggleDialog } = useConfirmDialog(); + const [showCard, setShowCard] = useState(false); + + const currentDate = new Date(); const [isPurchased, setIsPurchased] = useState(() => - calculateIsPurchased(item.dateLastPurchased), + calculateIsPurchased(item.dateLastPurchased, currentDate), ); const { name, id } = item; const updateItemOnPurchase = () => { + const { nextPurchaseEstimate, averagePurchaseInterval } = + calculateDateNextPurchased(currentDate, item); return { dateLastPurchased: currentDate, - dateNextPurchased: calculateDateNextPurchased(currentDate, item), + dateNextPurchased: nextPurchaseEstimate, totalPurchases: item.totalPurchases + 1, + averagePurchaseInterval, }; }; @@ -86,7 +77,6 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { }; const handleDeleteItem = async () => { - console.log('attempting item deletion'); try { await deleteItem(listPath, id); toast.success('Item deleted'); @@ -96,13 +86,33 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { return; }; + const toggleMoreInformation = () => { + setShowCard((prev) => !prev); + }; + const UrgencyStatusIcon = urgencyStatusIcons[itemUrgencyStatus]; - const props = { + const deleteIconProps = { + icon: , + onClick: toggleDialog, + ariaLabel: 'Delete item', + title: 'Delete', + placement: 'left', + }; + + const moreInformationProps = { + icon: , + onClick: toggleMoreInformation, + ariaLabel: 'More information', + title: 'More information', + placement: 'right', + }; + + const confirmDialogProps = { handleDelete: handleDeleteItem, title: `Are you sure you want to delete ${name}?`, setOpen: isOpen, - open: open, + open, }; const tooltipTitle = isPurchased @@ -111,46 +121,61 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { return ( <> - {open && } + {open && } - {UrgencyStatusIcon && ( - {itemUrgencyStatus}

} - placement="left" - arrow - > - - - -
- )} - - - {tooltipTitle}

} - placement="left" - arrow - > - -
-
- -
+ ) : ( + <> + {UrgencyStatusIcon && ( + {itemUrgencyStatus}

} + placement="left" + arrow + > + +
+ )} + + + + {tooltipTitle}

} + placement="left" + arrow + > + +
+
+ + +
- + {/* delete icon */} + + + {/* more information icon */} + + + )}
); diff --git a/src/components/SingleList.jsx b/src/components/SingleList.jsx index f8e8ae7..dc3771d 100644 --- a/src/components/SingleList.jsx +++ b/src/components/SingleList.jsx @@ -1,13 +1,17 @@ import { useNavigate } from 'react-router-dom'; import { useState } from 'react'; import { toast } from 'react-toastify'; -import { PushPin, PushPinOutlined } from '@mui/icons-material'; +import { + PushPin, + PushPinOutlined, + DeleteOutlineOutlined, +} from '@mui/icons-material'; import { Tooltip, IconButton, Button } from '@mui/material'; import { deleteList } from '../api'; import { useAuth } from '../hooks'; import { useConfirmDialog } from '../hooks/useConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog'; -import { tooltipStyle, DeleteIconWithTooltip } from './DeleteIconWithTooltip'; +import { tooltipStyle, IconWithTooltip } from './IconWithTooltip'; import './SingleList.css'; const deletionResponse = { @@ -69,7 +73,7 @@ export function SingleList({ handleDelete, title: `Are you sure you want to delete ${name}?`, setOpen: isOpen, - open: open, + open, }; const importantStatusLabel = isImportant ? 'Unpin list' : 'Pin list'; @@ -104,9 +108,14 @@ export function SingleList({ {name} - + } ariaLabel="Delete list" - toggleDialog={toggleDialog} + onClick={toggleDialog} + title="Delete" + placement="right" /> diff --git a/src/components/index.js b/src/components/index.js index b1b83c6..5b48ccf 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -4,4 +4,5 @@ export * from './AddItems'; export * from './TextInputElement'; export * from './RadioInputElement'; export * from './ConfirmDialog'; -export * from './DeleteIconWithTooltip'; +export * from './IconWithTooltip'; +export * from './InfoCard'; diff --git a/src/hooks/useUrgency.js b/src/hooks/useUrgency.js index aedfaea..7fa1942 100644 --- a/src/hooks/useUrgency.js +++ b/src/hooks/useUrgency.js @@ -5,8 +5,8 @@ export function useUrgency(items) { const [urgencyObject, setUrgencyObject] = useState({ overdue: new Set(), soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), inactive: new Set(), }); @@ -16,8 +16,8 @@ export function useUrgency(items) { let initialUrgencyState = { overdue: new Set(), soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), inactive: new Set(), }; diff --git a/src/utils/calculateIsPurchased.js b/src/utils/calculateIsPurchased.js new file mode 100644 index 0000000..22ca23d --- /dev/null +++ b/src/utils/calculateIsPurchased.js @@ -0,0 +1,13 @@ +import { ONE_DAY_IN_MILLISECONDS } from './dates'; + +export const calculateIsPurchased = (dateLastPurchased, currentDate) => { + if (!dateLastPurchased) { + return false; + } + const purchaseDate = dateLastPurchased.toDate(); + const oneDayLater = new Date( + purchaseDate.getTime() + ONE_DAY_IN_MILLISECONDS, + ); + + return currentDate < oneDayLater; +}; diff --git a/src/utils/dates.js b/src/utils/dates.js index 6f78a6f..daaaa56 100644 --- a/src/utils/dates.js +++ b/src/utils/dates.js @@ -34,12 +34,15 @@ export const calculateDateNextPurchased = (currentDate, item) => { item.dateNextPurchased, item.dateLastPurchased, ); - const estimatedNextPurchaseDate = getNextPurchaseEstimate( + const { estimatedDaysUntilPurchase, nextPurchaseEstimate } = + getNextPurchaseEstimate(purchaseIntervals, item.totalPurchases + 1); + + const averagePurchaseInterval = getAveragePurchaseInterval( purchaseIntervals, - item.totalPurchases + 1, - ); + estimatedDaysUntilPurchase, + ).toFixed(1); - return estimatedNextPurchaseDate; + return { nextPurchaseEstimate, averagePurchaseInterval }; } catch (error) { throw new Error(`Failed getting next purchase date: ${error}`); } @@ -120,8 +123,38 @@ function getNextPurchaseEstimate(purchaseIntervals, totalPurchases) { const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase); - return nextPurchaseEstimate; + return { estimatedDaysUntilPurchase, nextPurchaseEstimate }; } catch (error) { throw new Error(`Failed updaing date next purchased: ${error}`); } } + +/** + * Calculates the average purchase interval based on known purchase intervals. + * + * This function takes into account the last estimated purchase interval, + * the number of days since the last purchase, and the estimated days until the next purchase. + * It computes the average of these intervals by summing them up and dividing by the total count. + * + * @param {Object} purchaseIntervals - An object containing the purchase interval data. + * @param {number} purchaseIntervals.lastEstimatedInterval - The last estimated interval in days. + * @param {number} purchaseIntervals.daysSinceLastPurchase - The number of days since the last purchase. + * @param {number} estimatedDaysUntilPurchase - The estimated number of days until the next purchase. + * @returns {number} The average purchase interval calculated from the provided intervals. + */ +export function getAveragePurchaseInterval( + purchaseIntervals, + estimatedDaysUntilPurchase, +) { + const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals; + const knownIntervals = [ + lastEstimatedInterval, + daysSinceLastPurchase, + estimatedDaysUntilPurchase, + ]; + + return ( + knownIntervals.reduce((sum, interval) => sum + interval, 0) / + knownIntervals.length + ); +} diff --git a/src/utils/index.js b/src/utils/index.js index bf31387..9ebdc1e 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,3 +2,5 @@ export * from './dates'; export * from './normalize'; export * from './urgencyUtils'; export * from './importanceUtils'; +export * from './calculateIsPurchased'; +export * from './infoCardUtils'; diff --git a/src/utils/infoCardUtils.js b/src/utils/infoCardUtils.js new file mode 100644 index 0000000..38143da --- /dev/null +++ b/src/utils/infoCardUtils.js @@ -0,0 +1,9 @@ +export const describeAveragePurchaseInterval = (averageInterval) => { + if (averageInterval > 1) { + return `On average, this item is purchased every ${averageInterval} days`; + } else if (1 >= averageInterval) { + return 'On average, this item is purchased every day'; + } else { + return 'No average purchase interval available yet'; + } +}; diff --git a/src/utils/urgencyUtils.js b/src/utils/urgencyUtils.js index d1aede2..a198fb9 100644 --- a/src/utils/urgencyUtils.js +++ b/src/utils/urgencyUtils.js @@ -10,9 +10,9 @@ export const sortByUrgency = (item, daysUntilNextPurchase) => { } else if (daysUntilNextPurchase < 7) { return 'soon'; } else if (daysUntilNextPurchase >= 7 && daysUntilNextPurchase < 30) { - return 'kindOfSoon'; + return 'kind of soon'; } else if (daysUntilNextPurchase >= 30) { - return 'notSoon'; + return 'not soon'; } else { throw new Error(`Failed to place [${item.name}]`); } diff --git a/src/views/List.jsx b/src/views/List.jsx index 4f5727a..ac9c8fa 100644 --- a/src/views/List.jsx +++ b/src/views/List.jsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { useEnsureListPath, useUrgency } from '../hooks'; import { getUrgency } from '../utils/urgencyUtils'; -import { List as UnorderedList, Box, Grid } from '@mui/material'; +import { List as UnorderedList, Box } from '@mui/material'; +import Grid from '@mui/material/Grid2'; import { ListItem, AddItems, TextInputElement } from '../components'; // React.memo is needed to prevent unnecessary re-renders of the List component @@ -47,10 +48,10 @@ export const List = React.memo(function List({ data, listPath }) { columns={16} justifyContent="space-between" > - + - +
event.preventDefault()}> ({ useEnsureListPath: vi.fn(), useStateWithStorage: vi.fn(), + useEnsureListPath: vi.fn(), useUrgency: vi.fn(() => ({ getUrgency: vi.fn((name) => { if (name === 'nutella') return 'soon'; @@ -33,6 +34,7 @@ vi.mock('../src/utils', () => ({ getDateLastPurchasedOrDateCreated: vi.fn(), getDaysFromDate: vi.fn(), getDaysBetweenDates: vi.fn(), + calculateIsPurchased: vi.fn(), })); beforeEach(() => { @@ -73,6 +75,20 @@ describe('List Component', () => { }); }); + test('shows AddItems component with existing items', () => { + render( + + + , + ); + + expect(screen.getByLabelText('Item Name:')).toBeInTheDocument(); + expect(screen.getByLabelText('Soon')).toBeInTheDocument(); + expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); + expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + test('shows welcome message and AddItems component when no items are present', () => { render( @@ -108,18 +124,4 @@ describe('List Component', () => { 'It seems like you landed here without first creating a list or selecting an existing one. Please select or create a new list first. Redirecting to Home.', ); }); - - test('shows AddItems component with existing items', () => { - render( - - - , - ); - - expect(screen.getByLabelText('Item Name:')).toBeInTheDocument(); - expect(screen.getByLabelText('Soon')).toBeInTheDocument(); - expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); - expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); - expect(screen.getByText('Submit')).toBeInTheDocument(); - }); }); diff --git a/tests/ListItem.test.jsx b/tests/ListItem.test.jsx new file mode 100644 index 0000000..7b6ff62 --- /dev/null +++ b/tests/ListItem.test.jsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { userEvent } from '@testing-library/user-event'; +import { ListItem } from '../src/components/ListItem'; +import { mockShoppingListData } from '../src/mocks/__fixtures__/shoppingListData'; + +describe('ListItem Component', () => { + test('displays additional item information if More Information button is clicked', async () => { + render( + + + , + ); + + const moreInformationIcon = screen.getByTestId('MoreHorizIcon'); + await userEvent.click(moreInformationIcon); + + expect(screen.getByText(mockShoppingListData[1].name)).toBeInTheDocument(); + expect( + screen.getByText( + `Item added on: ${mockShoppingListData[1].dateCreated.toDate().toLocaleString()}`, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + `Last bought on: ${mockShoppingListData[1].dateLastPurchased.toDate().toLocaleString()}`, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + `Expected to buy again by: ${mockShoppingListData[1].dateNextPurchased.toDate().toLocaleString()}`, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/getAveragePurchaseInterval.test.js b/tests/getAveragePurchaseInterval.test.js new file mode 100644 index 0000000..5c91b03 --- /dev/null +++ b/tests/getAveragePurchaseInterval.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { getAveragePurchaseInterval } from '../src/utils/dates'; + +describe('getAveragePurchaseInterval function', () => { + it('correctly calculates average purchase intervals', () => { + const purchaseIntervals = { + lastEstimatedInterval: 4, + daysSinceLastPurchase: 6, + }; + const estimatedDaysUntilPurchase = 5; + const result = getAveragePurchaseInterval( + purchaseIntervals, + estimatedDaysUntilPurchase, + ); + expect(result).toBe(5); + }); + + it('handles zero values in the intervals', () => { + const purchaseIntervals = { + lastEstimatedInterval: 0, + daysSinceLastPurchase: 6, + }; + const estimatedDaysUntilPurchase = 5; + + const result = getAveragePurchaseInterval( + purchaseIntervals, + estimatedDaysUntilPurchase, + ); + + expect(result).toBeCloseTo(3.67, 2); + }); + + it('returns 0 when all intervals are zero', () => { + const purchaseIntervals = { + lastEstimatedInterval: 0, + daysSinceLastPurchase: 0, + }; + const estimatedDaysUntilPurchase = 0; + + const result = getAveragePurchaseInterval( + purchaseIntervals, + estimatedDaysUntilPurchase, + ); + + expect(result).toBe(0); + }); +}); diff --git a/tests/sortByUrgency.test.js b/tests/sortByUrgency.test.js index ef18092..d064857 100644 --- a/tests/sortByUrgency.test.js +++ b/tests/sortByUrgency.test.js @@ -16,18 +16,18 @@ describe('sortByUrgency', () => { expect(result).toBe('soon'); }); - it('should return "kindOfSoon" if daysUntilNextPurchase is between 7 and 30', () => { + it('should return "kind of soon" if daysUntilNextPurchase is between 7 and 30', () => { const item = { name: 'Jam' }; const daysUntilNextPurchase = 15; const result = sortByUrgency(item, daysUntilNextPurchase); - expect(result).toBe('kindOfSoon'); + expect(result).toBe('kind of soon'); }); - it('should return "notSoon" if daysUntilNextPurchase is 30 or more', () => { + it('should return "not soon" if daysUntilNextPurchase is 30 or more', () => { const item = { name: 'Nutella' }; const daysUntilNextPurchase = 30; const result = sortByUrgency(item, daysUntilNextPurchase); - expect(result).toBe('notSoon'); + expect(result).toBe('not soon'); }); it('should throw an error if daysUntilNextPurchase cannot be classified', () => {