diff --git a/.run/Emulator Deploy.run.xml b/.run/Emulator Deploy.run.xml
new file mode 100644
index 0000000..63dd083
--- /dev/null
+++ b/.run/Emulator Deploy.run.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Emulator Run Dev.run.xml b/.run/Emulator Run Dev.run.xml
new file mode 100644
index 0000000..b25fcbb
--- /dev/null
+++ b/.run/Emulator Run Dev.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/firebase.json b/firebase.json
index cac788e..5fc39b9 100644
--- a/firebase.json
+++ b/firebase.json
@@ -20,6 +20,12 @@
"ui": {
"enabled": true
},
- "singleProjectMode": true
+ "singleProjectMode": true,
+ "auth": {
+ "port": 9099
+ },
+ "firestore": {
+ "port": 8080
+ }
}
}
diff --git a/package-lock.json b/package-lock.json
index a5481bb..eae137a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"dependencies": {
"@types/papaparse": "^5.3.14",
"bootstrap": "^5.3.3",
- "firebase": "^10.6.0",
+ "firebase": "^10.11.0",
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
@@ -21,6 +21,7 @@
"sass": "^1.72.0"
},
"devDependencies": {
+ "@faker-js/faker": "^8.4.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
@@ -499,6 +500,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@faker-js/faker": {
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
+ "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fakerjs"
+ }
+ ],
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0",
+ "npm": ">=6.14.13"
+ }
+ },
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
diff --git a/package.json b/package.json
index 183c1fb..4801037 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"dependencies": {
"@types/papaparse": "^5.3.14",
"bootstrap": "^5.3.3",
- "firebase": "^10.6.0",
+ "firebase": "^10.11.0",
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
@@ -23,6 +23,7 @@
"sass": "^1.72.0"
},
"devDependencies": {
+ "@faker-js/faker": "^8.4.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 28b7cb5..1daa3e5 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,5 +1,6 @@
import useWindowDimensions from "../hooks/WindowDimensionsHook.tsx";
import "../assets/css/Header.css"
+import {Link} from "react-router-dom";
interface Props {
user?: string
@@ -18,15 +19,17 @@ export function Header({user} : Props) {
{/*Pages*/}
-
+
- {/*Links to dashboard with tiles*/}
- - Dashboard
+ {/*Links to dashboard with tiles*/}
+ - Dashboard
- {/*Links to transactions page with table of expenses*/}
- - Transactions
+ {/*Links to transactions page with table of expenses*/}
+ - Transactions
- - {user}
-
+ - Firestore Test
+
+ - {user}
+
;
}
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
index a300453..a1fb6fd 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './utils/firebase.ts'
+import './utils/firestore.ts'
import {RouterProvider} from "react-router-dom";
import {router} from "./router.tsx";
diff --git a/src/pages/test firestore/TestFirestore.tsx b/src/pages/test firestore/TestFirestore.tsx
new file mode 100644
index 0000000..ba10d12
--- /dev/null
+++ b/src/pages/test firestore/TestFirestore.tsx
@@ -0,0 +1,110 @@
+import {FullscreenCenter} from "../../components/FullscreenCenter.tsx";
+import {auth} from "../../utils/firebase.ts";
+import {useState} from "react";
+import {
+ deleteTransaction,
+ getTransactionsFilterOrderBy,
+ overwriteTransaction,
+ Transaction,
+ writeNewTransaction
+} from "../../utils/firestore.ts";
+import {faker, fakerEN_GB} from "@faker-js/faker";
+import {getCurrentBalance} from "../../utils/transaction_utils.ts";
+import {signInWithGoogle} from "../../utils/authentication.ts";
+import {Header} from "../../components/Header.tsx";
+import { orderBy } from "firebase/firestore";
+
+function writeSampleData() {
+ if (auth.currentUser === null) {
+ console.log("No User");
+ return;
+ }
+
+ console.log("Adding sample data");
+ const t = new Transaction(
+ fakerEN_GB.location.streetAddress() + ", " + fakerEN_GB.location.city() + ", " + fakerEN_GB.location.zipCode(),
+ parseFloat(faker.finance.amount({min: -1000, max: 1000})),
+ faker.word.noun(),
+ faker.finance.currency().code,
+ faker.date.past().valueOf(),
+ faker.lorem.sentence(),
+ faker.internet.emoji(),
+ faker.word.noun(),
+ faker.lorem.sentence(),
+ auth.currentUser.uid
+ );
+
+ writeNewTransaction(auth.currentUser, t).then((t) => {console.log("Added sample data:"); console.log(t);})
+}
+
+
+export function TestFirestorePage() {
+ const [authResolved, setAuthResolved] = useState(false);
+ const [transactions, setTransactions] = useState([]);
+ const [balance, setBalance] = useState(0);
+ const [update ,setUpdate] = useState(0);
+
+ if (!authResolved) {
+ auth.authStateReady().then(() => setAuthResolved(true));
+ return <>
+
+
+
Waiting for Auth
+
+
+ >;
+ }
+
+ if (auth.currentUser === null) {
+ return <>
+
+
+
+
Not Logged In
+
+
+
+ >;
+ }
+
+ getTransactionsFilterOrderBy(auth.currentUser, orderBy("dateTime", "desc")).then((t) => setTransactions(t));
+ getCurrentBalance(auth.currentUser).then((b) => setBalance(b));
+
+ return (
+ <>
+
+
+
Logged In - {auth.currentUser.uid} - {auth.currentUser.displayName}
+
+
+
+
+ Balance: {balance}
+
+
+ Transactions:
+
+ {
+ transactions.map((t) =>
+
{new Date(t.dateTime).toISOString()}
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/router.tsx b/src/router.tsx
index 522ceeb..a8c8d51 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -6,6 +6,7 @@ import {SampleSidebar} from "./pages/samples/sidebar/SampleSidebar.tsx";
import {SampleSidebarHeader} from "./pages/samples/sidebar_header/SampleSidebarHeader.tsx";
import {SampleModal} from "./pages/samples/modal/SampleModal.tsx";
import GraphDashboard from "./pages/Graphs/Graphs.tsx";
+import {TestFirestorePage} from "./pages/test firestore/TestFirestore.tsx";
// ? Routing - see https://reactrouter.com/en/main
@@ -143,4 +144,8 @@ export const router = createBrowserRouter([
path: "/sample_modal",
element: ,
},
+ {
+ path: "/test",
+ element: ,
+ },
]);
\ No newline at end of file
diff --git a/src/utils/firebase.ts b/src/utils/firebase.ts
index 21d8e6f..7538a05 100644
--- a/src/utils/firebase.ts
+++ b/src/utils/firebase.ts
@@ -1,7 +1,7 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
-import { getAnalytics } from "firebase/analytics";
-import { getAuth } from "firebase/auth";
+import {connectAuthEmulator, getAuth } from "firebase/auth";
+import {connectFirestoreEmulator, getFirestore } from "firebase/firestore";
// Your web app's Firebase configuration
const firebaseConfig = {
@@ -16,5 +16,12 @@ const firebaseConfig = {
// Initialize Firebase
export const app = initializeApp(firebaseConfig);
-export const analytics = getAnalytics(app);
-export const auth = getAuth(app); // Initialize Firebase Authentication
\ No newline at end of file
+// export const analytics = getAnalytics(app);
+export const auth = getAuth(app); // Initialize Firebase Authentication
+export const db = getFirestore(app);
+
+if (import.meta.env.DEV) {
+ connectAuthEmulator(auth, "http://localhost:9099");
+ connectFirestoreEmulator(db, "localhost", 8080);
+}
+
diff --git a/src/utils/firestore.ts b/src/utils/firestore.ts
new file mode 100644
index 0000000..5692c80
--- /dev/null
+++ b/src/utils/firestore.ts
@@ -0,0 +1,203 @@
+import {collection,
+ deleteDoc, doc, DocumentSnapshot, getDocs, limit, query,
+ QueryConstraint, setDoc, SnapshotOptions,
+ startAfter, where, writeBatch} from "firebase/firestore";
+import {User} from "firebase/auth";
+import {db} from "./firebase.ts";
+
+
+export class Transaction {
+ private docName?: string;
+ public address: string;
+ public amount: number;
+ public category: string;
+ public currency: string;
+ public dateTime: number;
+ public description: string;
+ public emoji: string;
+ public name: string;
+ public notes: string;
+ public readonly uid: string;
+
+ constructor (address: string, amount: number, category: string, currency: string, dateTime: number, description: string, emoji: string, name: string, notes: string, uid: string) {
+ this.address = address;
+ this.amount = amount;
+ this.category = category;
+ this.currency = currency;
+ this.dateTime = dateTime;
+ this.description = description;
+ this.emoji = emoji;
+ this.name = name;
+ this.notes = notes;
+ this.uid = uid;
+ }
+
+ setDocName(docName: string) {
+ this.docName = docName;
+ }
+
+ /*
+ Returns the Firestore document name if set. If not returns `undefined`.
+
+ Document name will be set if the transaction originated from Firebase.
+ */
+ getDocName(): string | undefined {
+ return this.docName;
+ }
+
+ /*
+ Returns the Firestore document name if set. Throws an error if not set!
+
+ Document name will be set if the transaction originated from Firebase.
+ */
+ forceGetDocName(): string {
+ if (!this.docName) {
+ throw Error("Doc name is not set!");
+ }
+ return this.docName;
+ }
+
+ // Utility method for creating `Transactions`
+ static fromFirestore(snapshot: DocumentSnapshot, options: SnapshotOptions): Transaction {
+ const data = snapshot.data(options);
+ if (!data) {
+ throw Error("No data returned for snapshot!");
+ }
+ const t = new Transaction(data.address, data.amount, data.category, data.currency, data.dateTime, data.description, data.emoji, data.name, data.notes, data.uid);
+ t.docName = snapshot.id;
+ return t;
+ }
+
+ toSendObject(): object {
+ const {...transObject} = this;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ delete transObject.docName;
+ return transObject;
+ }
+}
+
+
+const BATCH_SIZE = 500;
+
+// Returns all transactions for the given `user` with the `docName` attribute set
+export async function getTransactions(user: User): Promise {
+ const q = query(collection(db, "Transactions"), where("uid", "==", user.uid));
+ const ts: Transaction[] = [];
+ await getDocs(q).then((qs) =>
+ qs.forEach((q) => ts.push(Transaction.fromFirestore(q, {})))
+ );
+ return ts;
+}
+
+// Returns `pageSize` transactions for the given `user` with the `docName` attribute set
+export async function getTransactionsPage(user: User, pageSize: number, page: number): Promise {
+ const q = query(collection(db, "Transactions"), where("uid", "==", user.uid), startAfter(page * pageSize), limit(pageSize));
+ const ts: Transaction[] = [];
+ await getDocs(q).then((qs) =>
+ qs.forEach((q) => ts.push(Transaction.fromFirestore(q, {})))
+ );
+ return ts;
+}
+
+/*
+Returns the given document if it exists with the `docName` attribute set
+
+Note: Will not return document if it exists for a different user
+ */
+export async function getTransactionsByDocName(user: User, docName: string): Promise {
+ const q = query(collection(db, "Transactions", docName), where("uid", "==", user.uid));
+ const ts: Transaction[] = [];
+ await getDocs(q).then((qs) =>
+ qs.forEach((q) => ts.push(Transaction.fromFirestore(q, {})))
+ );
+
+ if (ts.length === 0) {
+ return undefined;
+ }
+ else {
+ if (ts.length > 1) {
+ console.warn(`Multiple docs found with name ${docName}!`)
+ }
+ return ts[0]
+ }
+}
+
+// Returns all transactions for the given `user` with the `filters` applied
+export async function getTransactionsFilterOrderBy(user: User, ...filters: QueryConstraint[]): Promise {
+ const q = query(collection(db, "Transactions"), where("uid", "==", user.uid), ...filters);
+ const ts: Transaction[] = [];
+ await getDocs(q).then((qs) =>
+ qs.forEach((q) => ts.push(Transaction.fromFirestore(q, {})))
+ );
+ return ts;
+}
+
+/*
+ Writes a new transaction to Firestore
+
+ Returns the same transaction with `docName` set.
+ */
+export async function writeNewTransaction(user: User, transaction: Transaction): Promise {
+ if (user.uid != transaction.uid) {
+ throw Error(`Current user is '${user.uid}' however transaction is '${transaction.uid}'`);
+ }
+ const newTransactionRef = doc(collection(db, "Transactions"));
+ await setDoc(newTransactionRef, transaction.toSendObject());
+ transaction.setDocName(newTransactionRef.id);
+ return transaction;
+}
+
+// Deletes a transaction
+export async function deleteTransaction(docName: string): Promise {
+ await deleteDoc(doc(collection(db, "Transactions"), docName));
+}
+
+/*
+ Overwrites an existing transaction in Firestore
+
+ Returns the same transaction with `docName` set.
+ */
+export async function overwriteTransaction(user: User, docName: string, transaction: Transaction): Promise {
+ if (user.uid != transaction.uid) {
+ throw Error(`Current user is '${user.uid}' however transaction is '${transaction.uid}'`);
+ }
+ const newTransactionRef = doc(collection(db, "Transactions"), docName);
+ await setDoc(newTransactionRef, transaction.toSendObject());
+ transaction.setDocName(newTransactionRef.id);
+ return transaction;
+}
+
+// Writes a batch of transactions to Firestore
+export async function writeNewTransactionsBatched(user: User, transactions: Transaction[]): Promise {
+ for (let i = 0; i < transactions.length; i+=500) {
+ const batch = writeBatch(db);
+ const chunk = transactions.slice(i, i + BATCH_SIZE);
+ chunk.forEach((transaction) => {
+ if (user.uid == transaction.uid) {
+ throw Error(`Current user is '${user.uid}' however transaction is '${transaction.uid}'`);
+ }
+ const newTransactionRef = doc(collection(db, "Transactions"));
+ batch.set(newTransactionRef, transaction.toSendObject());
+ });
+
+ await batch.commit();
+ }
+}
+
+// Overwrites a batch of existing transactions in Firestore
+export async function overwriteTransactionsBatched(user: User, docName: string[], transactions: Transaction[]): Promise {
+ for (let i = 0; i < transactions.length; i += 500) {
+ const batch = writeBatch(db);
+ const chunk = transactions.slice(i, i + BATCH_SIZE);
+ chunk.forEach((transaction) => {
+ if (user.uid == transaction.uid) {
+ throw Error(`Current user is '${user.uid}' however transaction is '${transaction.uid}'`);
+ }
+ const newTransactionRef = doc(collection(db, "Transactions"), docName[i]);
+ batch.set(newTransactionRef, transaction.toSendObject());
+ });
+
+ await batch.commit();
+ }
+}
diff --git a/src/utils/transaction_utils.ts b/src/utils/transaction_utils.ts
new file mode 100644
index 0000000..147056e
--- /dev/null
+++ b/src/utils/transaction_utils.ts
@@ -0,0 +1,27 @@
+import {User} from "firebase/auth";
+import {getTransactions, getTransactionsFilterOrderBy, Transaction} from "./firestore.ts";
+import {where} from "firebase/firestore";
+
+export async function getCurrentBalance(user: User): Promise {
+ const transactions = await getTransactions(user);
+ const balance = transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
+ return Math.round(balance * 100) / 100;
+}
+
+const MONTH_MILLIS = 2.628e+9;
+
+export async function getLastMonthTransaction(user: User): Promise {
+ return await getTransactionsFilterOrderBy(user, where("dateTime", ">", Date.now() - MONTH_MILLIS));
+}
+
+const WEEK_MILLIS = 6.048e+8;
+
+export async function getLastWeekTransaction(user: User): Promise {
+ return await getTransactionsFilterOrderBy(user, where("dateTime", ">", Date.now() - WEEK_MILLIS));
+}
+
+const DAY_MILLIS = 8.64e+7;
+
+export async function getLastDayTransaction(user: User): Promise {
+ return await getTransactionsFilterOrderBy(user, where("dateTime", ">", Date.now() - DAY_MILLIS));
+}
\ No newline at end of file