diff --git a/package-lock.json b/package-lock.json index af19e26..1472ad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,8 @@ "name": "smart-shopping-list-next", "dependencies": { "firebase": "^10.12.5", + "fp-ts": "^2.16.9", + "io-ts": "^2.2.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" @@ -6330,6 +6332,12 @@ "node": ">= 6" } }, + "node_modules/fp-ts": { + "version": "2.16.9", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", + "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -6883,6 +6891,15 @@ "node": ">= 0.4" } }, + "node_modules/io-ts": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.21.tgz", + "integrity": "sha512-zz2Z69v9ZIC3mMLYWIeoUcwWD6f+O7yP92FMVVaXEOSZH1jnVBmET/urd/uoarD1WGBY4rCj8TAyMPzsGNzMFQ==", + "license": "MIT", + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", diff --git a/package.json b/package.json index c0616b8..ae451a0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ }, "dependencies": { "firebase": "^10.12.5", + "fp-ts": "^2.16.9", + "io-ts": "^2.2.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/src/api/firebase.ts b/src/api/firebase.ts index 40c0265..73ee98a 100644 --- a/src/api/firebase.ts +++ b/src/api/firebase.ts @@ -6,27 +6,49 @@ import { doc, onSnapshot, updateDoc, + Timestamp, } from 'firebase/firestore'; import { useEffect, useState } from 'react'; import { db } from './config'; import { getFutureDate } from '../utils'; import { User } from 'firebase/auth'; -interface ListModel { - id: string; - path: string; -} +import * as t from 'io-ts'; +import { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/PathReporter'; + +const FirebaseTimestamp = new t.Type< + Timestamp, + { seconds: number; nanoseconds: number }, + unknown +>( + 'FirebaseTimestamp', + (input): input is Timestamp => input instanceof Timestamp, + (input, context) => { + if (input instanceof Timestamp) { + return t.success(input); + } + + return t.failure(input, context); + }, + (timestamp) => ({ + seconds: timestamp.seconds, + nanoseconds: timestamp.nanoseconds, + }), +); + +const ListModel = t.type({ + id: t.string, + path: t.string, +}); + +type ListModel = t.TypeOf; export interface List { name: string; path: string; } -export interface ListItem { - itemName: string; - daysUntilNextPurchase: number; -} - /** * A custom hook that subscribes to the user's shopping lists in our Firestore * database and returns new data whenever the lists change. @@ -48,12 +70,22 @@ export function useShoppingLists(userId: string, userEmail: string) { onSnapshot(userDocRef, (docSnap) => { if (docSnap.exists()) { - const listRefs = docSnap.data().sharedLists as ListModel[]; - const newData = listRefs.map((listRef) => { - // We keep the list's id and path so we can use them later. - return { name: listRef.id, path: listRef.path }; + // deserialize the list into a typed List + const data = docSnap.data().sharedLists.map((list: unknown) => { + const decoded = ListModel.decode(list); + if (isLeft(decoded)) { + throw Error( + `Could not validate data: ${PathReporter.report(decoded).join('\n')}`, + ); + } + + const model = decoded.right; + return { + name: model.id, + path: model.path, + }; }); - setData(newData); + setData(data); } }); }, [userId, userEmail]); @@ -61,6 +93,17 @@ export function useShoppingLists(userId: string, userEmail: string) { return data; } +const ListItemModel = t.type({ + id: t.string, + name: t.string, + dateLastPurchased: t.union([FirebaseTimestamp, t.null]), + dateNextPurchased: FirebaseTimestamp, + totalPurchases: t.number, + dateCreated: FirebaseTimestamp, +}); + +export type ListItem = t.TypeOf; + /** * A custom hook that subscribes to a shopping list in our Firestore database * and returns new data whenever the list changes. @@ -88,8 +131,14 @@ export function useShoppingListData(listPath: string | null) { // but it is very useful, so we add it to the data ourselves. item.id = docSnapshot.id; - // todo: validate - return item as ListItem; + const decoded = ListItemModel.decode(item); + if (isLeft(decoded)) { + throw Error( + `Could not validate data: ${PathReporter.report(decoded).join('\n')}`, + ); + } + + return decoded.right; }); // Update our React state with the new data. @@ -182,12 +231,13 @@ export async function shareList( * Add a new item to the user's list in Firestore. * @param {string} listPath The path of the list we're adding to. * @param {Object} itemData Information about the new item. - * @param {string} itemData.itemName The name of the item. + * @param {string} itemData.name The name of the item. * @param {number} itemData.daysUntilNextPurchase The number of days until the user thinks they'll need to buy the item again. */ export async function addItem( listPath: string, - { itemName, daysUntilNextPurchase }: ListItem, + name: string, + daysUntilNextPurchase: number, ) { const listCollectionRef = collection(db, listPath, 'items'); // TODO: Replace this call to console.log with the appropriate @@ -198,7 +248,7 @@ export async function addItem( // We'll use updateItem to put a Date here when the item is purchased! dateLastPurchased: null, dateNextPurchased: getFutureDate(daysUntilNextPurchase), - name: itemName, + name, totalPurchases: 0, }); } diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx index 558b919..d377d29 100644 --- a/src/components/ListItem.tsx +++ b/src/components/ListItem.tsx @@ -1,8 +1,8 @@ import * as api from '../api'; import './ListItem.css'; -type Props = Pick; +type Props = Pick; -export function ListItem({ itemName }: Props) { - return
  • {itemName}
  • ; +export function ListItem({ name }: Props) { + return
  • {name}
  • ; } diff --git a/src/views/List.tsx b/src/views/List.tsx index 02dede9..bfd5a8b 100644 --- a/src/views/List.tsx +++ b/src/views/List.tsx @@ -16,7 +16,7 @@ export function List({ data }: Props) {
      {hasItem && data.map((item) => ( - + ))}