Skip to content

Commit e73e0e6

Browse files
Merge pull request #45 from the-collab-lab/nk-estimate-next-purchase-date
[ISSUE 11] Nk estimate next purchase date
2 parents dd8b855 + 136efe2 commit e73e0e6

File tree

13 files changed

+204
-56
lines changed

13 files changed

+204
-56
lines changed

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"npm": ">=8.19.0"
88
},
99
"dependencies": {
10+
"@the-collab-lab/shopping-list-utils": "^2.2.0",
1011
"firebase": "^10.12.5",
1112
"react": "^18.3.1",
1213
"react-dom": "^18.3.1",

src/App.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function App() {
5959
path="/list"
6060
element={<List data={data} listPath={listPath} />}
6161
/>
62-
<Route path="/manage-list" element={<ManageList />} />
62+
<Route path="/manage-list" element={<ManageList items={data} />} />
6363
</Route>
6464
</Routes>
6565
</Router>

src/api/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ This function takes user-provided data and uses it to create a new item in the F
4646

4747
#### Note
4848

49-
**`daysUntilNextPurchase` is not added to the item directly**. It is used alomngside the `getFutureDate` utility function to create a new _JavaScript Date_ that represents when we think the user will buy the item again.
49+
**`daysUntilNextPurchase` is not added to the item directly**. It is used alomngside the `addDaysFromToday` utility function to create a new _JavaScript Date_ that represents when we think the user will buy the item again.
5050

5151
### `updateItem`
5252

src/api/firebase.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from 'firebase/firestore';
1111
import { useEffect, useState } from 'react';
1212
import { db } from './config';
13-
import { getFutureDate } from '../utils';
13+
import { addDaysFromToday } from '../utils';
1414

1515
/**
1616
* A custom hook that subscribes to the user's shopping lists in our Firestore
@@ -173,28 +173,40 @@ export async function addItem(listPath, { itemName, daysUntilNextPurchase }) {
173173
return addDoc(listCollectionRef, {
174174
dateCreated: new Date(),
175175
dateLastPurchased: null,
176-
dateNextPurchased: getFutureDate(daysUntilNextPurchase),
176+
dateNextPurchased: addDaysFromToday(daysUntilNextPurchase),
177177
name: itemName,
178178
totalPurchases: 0,
179179
});
180180
}
181181

182+
/**
183+
* Update an item in the user's list in Firestore with new purchase information.
184+
* @param {string} listPath The path of the list the item belongs to.
185+
* @param {string} itemId The ID of the item being updated.
186+
* @param {Object} updatedData Object containing the updated item data.
187+
* @param {Date} updatedData.dateLastPurchased The date the item was last purchased.
188+
* @param {Date} updatedData.dateNextPurchased The estimated date for the next purchase.
189+
* @param {number} updatedData.totalPurchases The total number of times the item has been purchased.
190+
* @returns {Promise<string>} A message confirming the item was successfully updated.
191+
* @throws {Error} If the item update fails.
192+
*/
182193
export async function updateItem(
183194
listPath,
184195
itemId,
185-
{ dateLastPurchased, totalPurchases },
196+
{ dateLastPurchased, dateNextPurchased, totalPurchases },
186197
) {
187198
// reference the item path
188199
const itemDocRef = doc(db, listPath, 'items', itemId);
189200
// update the item with the purchase date and increment the total purchases made
190201
try {
191202
await updateDoc(itemDocRef, {
192203
dateLastPurchased,
204+
dateNextPurchased,
193205
totalPurchases,
194206
});
195207
return 'item purchased';
196-
} catch {
197-
return;
208+
} catch (error) {
209+
throw new Error(`Failed updating item: ${error.message}`);
198210
}
199211
}
200212

src/components/AddItems.jsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,32 @@ const daysUntilPurchaseOptions = {
1010
'Not soon': 30,
1111
};
1212

13-
export function AddItems() {
13+
export function AddItems({ items }) {
1414
const [listPath] = useStateWithStorage('tcl-shopping-list-path', null);
1515

1616
const handleSubmit = useCallback(
1717
async (event) => {
1818
event.preventDefault();
1919

2020
const itemName = event.target.elements['item-name'].value;
21+
const normalizedItemName = itemName
22+
.trim()
23+
.toLowerCase()
24+
.replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, '');
25+
if (items) {
26+
const currentItems = items.map((item) =>
27+
item.name
28+
.trim()
29+
.toLowerCase()
30+
.replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, ''),
31+
);
32+
if (currentItems.includes(normalizedItemName)) {
33+
alert('This item already exists in the list');
34+
event.target.reset();
35+
return;
36+
}
37+
}
38+
2139
const daysUntilNextPurchase =
2240
event.target.elements['purchase-date'].value;
2341

src/components/ListItem.jsx

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,45 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22
import './ListItem.css';
33
import { updateItem } from '../api';
4-
import { useStateWithStorage } from '../utils';
5-
import { increment } from 'firebase/firestore';
4+
import { calculateDateNextPurchased, ONE_DAY_IN_MILLISECONDS } from '../utils';
65

7-
export function ListItem({ name, itemId, purchaseTimestamp }) {
8-
const [isPurchased, setIsPurchased] = useState(false);
9-
const [listPath] = useStateWithStorage('tcl-shopping-list-path', null);
6+
const currentDate = new Date();
107

11-
useEffect(() => {
12-
if (!purchaseTimestamp) {
13-
setIsPurchased(false);
14-
return;
15-
}
16-
const purchaseDate = purchaseTimestamp.toDate();
17-
const oneDayLater = new Date(purchaseDate.getTime() + 24 * 60 * 60 * 1000);
18-
const currentDate = new Date();
19-
if (purchaseTimestamp) {
20-
if (currentDate < oneDayLater) {
21-
setIsPurchased(true);
22-
} else {
23-
setIsPurchased(false);
24-
}
25-
} else {
26-
return;
27-
}
28-
}, []);
8+
const calculateIsPurchased = (dateLastPurchased) => {
9+
if (!dateLastPurchased) {
10+
return false;
11+
}
12+
const purchaseDate = dateLastPurchased.toDate();
13+
const oneDayLater = new Date(
14+
purchaseDate.getTime() + ONE_DAY_IN_MILLISECONDS,
15+
);
16+
17+
return currentDate < oneDayLater;
18+
};
19+
20+
export function ListItem({ item, listPath }) {
21+
const [isPurchased, setIsPurchased] = useState(() =>
22+
calculateIsPurchased(item.dateLastPurchased),
23+
);
24+
const { name, id } = item;
25+
26+
const updateItemOnPurchase = () => {
27+
return {
28+
dateLastPurchased: currentDate,
29+
dateNextPurchased: calculateDateNextPurchased(currentDate, item),
30+
totalPurchases: item.totalPurchases + 1,
31+
};
32+
};
2933

3034
const handleChange = async () => {
3135
setIsPurchased(!isPurchased);
3236
if (!isPurchased) {
3337
try {
34-
await updateItem(listPath, itemId, {
35-
dateLastPurchased: new Date(),
36-
totalPurchases: increment(1),
37-
});
38+
const updatedItem = updateItemOnPurchase();
39+
40+
await updateItem(listPath, id, { ...updatedItem });
3841
} catch (error) {
39-
alert(`Item was not marked as purchased`, error);
42+
alert(`Item was not marked as purchased`, error.message);
4043
}
4144
}
4245
};
@@ -45,11 +48,11 @@ export function ListItem({ name, itemId, purchaseTimestamp }) {
4548
<li className="ListItem">
4649
<input
4750
type="checkbox"
48-
id={`checkbox-${itemId}`}
51+
id={`checkbox-${id}`}
4952
checked={isPurchased}
5053
onChange={handleChange}
5154
/>
52-
<label htmlFor={`checkbox-${itemId}`}>{name}</label>
55+
<label htmlFor={`checkbox-${id}`}>{name}</label>
5356
</li>
5457
);
5558
}

src/utils/dates.js

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,116 @@
1-
const ONE_DAY_IN_MILLISECONDS = 86400000;
1+
import { calculateEstimate } from '@the-collab-lab/shopping-list-utils';
2+
3+
export const ONE_DAY_IN_MILLISECONDS = 86400000;
24

35
/**
46
* Get a new JavaScript Date that is `offset` days in the future.
57
* @example
68
* // Returns a Date 3 days in the future
7-
* getFutureDate(3)
8-
* @param {number} offset
9+
* addDaysFromToday(3)
10+
* @param {number} daysOffset
11+
*/
12+
export function addDaysFromToday(daysOffset) {
13+
return new Date(Date.now() + daysOffset * ONE_DAY_IN_MILLISECONDS);
14+
}
15+
16+
/**
17+
* Calculates the estimated date for the next purchase based on current date, purchase history,
18+
* and total purchases.
19+
* @param {Date} currentDate - The current date to calculate against.
20+
* @param {Object} item - The item object containing purchase data.
21+
* @param {Date} item.dateCreated - The date the item was created.
22+
* @param {Date} item.dateNextPurchased - The previously estimated next purchase date.
23+
* @param {Date|null} item.dateLastPurchased - The last date the item was actually purchased, or null if not purchased yet.
24+
* @param {number} item.totalPurchases - The total number of purchases made for the item.
25+
* @returns {Date} - The estimated date of the next purchase.
26+
* @throws {Error} - Throws an error if the next purchase date cannot be calculated.
27+
*/
28+
export const calculateDateNextPurchased = (currentDate, item) => {
29+
try {
30+
// get purchase intervals and get new estimation for next purchase date
31+
const purchaseIntervals = calculatePurchaseIntervals(
32+
currentDate,
33+
item.dateCreated,
34+
item.dateNextPurchased,
35+
item.dateLastPurchased,
36+
);
37+
const estimatedNextPurchaseDate = getNextPurchaseEstimate(
38+
purchaseIntervals,
39+
item.totalPurchases + 1,
40+
);
41+
42+
return estimatedNextPurchaseDate;
43+
} catch (error) {
44+
throw new Error(`Failed getting next purchase date: ${error}`);
45+
}
46+
};
47+
48+
/**
49+
* Calculate the number of days between two dates.
50+
* @param {Date} earlierDate The starting date.
51+
* @param {Date} laterDate The ending date.
52+
* @returns {number} The number of days between the two dates.
53+
*/
54+
function getDaysBetweenDates(earlierDate, laterDate) {
55+
return Math.floor(
56+
(laterDate.getTime() - earlierDate.getTime()) / ONE_DAY_IN_MILLISECONDS,
57+
);
58+
}
59+
60+
/**
61+
* Calculate the purchase intervals between current, next, and last purchase dates.
62+
* @param {Date} currentDate The current date.
63+
* @param {Date} dateNextPurchased The previously estimated next purchase date.
64+
* @param {Date|null} dateLastPurchased The date the item was last purchased (can be null).
65+
* @returns {Object} An object containing the last estimated interval and days since last purchase.
66+
*/
67+
function calculatePurchaseIntervals(
68+
currentDate,
69+
dateCreated,
70+
dateNextPurchased,
71+
dateLastPurchased,
72+
) {
73+
const lastPurchaseDate = dateLastPurchased?.toDate();
74+
75+
const lastEstimatedIntervalStartDate =
76+
lastPurchaseDate ?? dateCreated.toDate();
77+
78+
const lastEstimatedInterval = getDaysBetweenDates(
79+
lastEstimatedIntervalStartDate,
80+
dateNextPurchased.toDate(),
81+
);
82+
83+
const daysSinceLastPurchase =
84+
lastPurchaseDate === undefined
85+
? 0
86+
: getDaysBetweenDates(lastPurchaseDate, currentDate);
87+
88+
return { lastEstimatedInterval, daysSinceLastPurchase };
89+
}
90+
91+
/**
92+
* Calculate the next purchase estimate based on purchase intervals and total purchases.
93+
* @param {Object} purchaseIntervals The intervals between the previous and current purchases.
94+
* @param {number} purchaseIntervals.lastEstimatedInterval The previously estimated number of days between purchases.
95+
* @param {number} purchaseIntervals.daysSinceLastPurchase The number of days since the last purchase.
96+
* @param {number} totalPurchases The total number of purchases made.
97+
* @returns {Date} The estimated next purchase date.
98+
* @throws {Error} If an error occurs during the next purchase estimation process.
999
*/
10-
export function getFutureDate(offset) {
11-
return new Date(Date.now() + offset * ONE_DAY_IN_MILLISECONDS);
100+
function getNextPurchaseEstimate(purchaseIntervals, totalPurchases) {
101+
const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals;
102+
103+
try {
104+
const estimatedDaysUntilPurchase = calculateEstimate(
105+
lastEstimatedInterval,
106+
daysSinceLastPurchase,
107+
totalPurchases,
108+
);
109+
110+
const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase);
111+
112+
return nextPurchaseEstimate;
113+
} catch (error) {
114+
throw new Error(`Failed updaing date next purchased: ${error}`);
115+
}
12116
}

src/views/List.jsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,7 @@ export function List({ data }) {
4343
</form>
4444
<ul>
4545
{filteredItems.map((item) => {
46-
return (
47-
<ListItem
48-
key={item.id}
49-
name={item.name}
50-
itemId={item.id}
51-
purchaseTimestamp={item.dateLastPurchased}
52-
/>
53-
);
46+
return <ListItem key={item.id} item={item} listPath={listPath} />;
5447
})}
5548
</ul>
5649
</>

src/views/ManageList.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { AddItems } from '../components/AddItems';
22
import { ShareList } from '../components/ShareList';
33

4-
export function ManageList() {
4+
export function ManageList({ items }) {
55
return (
66
<div>
7-
<AddItems />
7+
<AddItems items={items} />
88
<ShareList />
99
</div>
1010
);

0 commit comments

Comments
 (0)