diff --git a/README.md b/README.md index 8e2693d..430c656 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Start Firebase emulator(s): `firebase emulators:start` ### Test Deployment -Creating a test deployment to Firebase: +Creating a tiles deployment to Firebase: ``` npm run build firebase hosting:channel:deploy [Test Deployment Name] @@ -51,7 +51,7 @@ firebase hosting:channel:deploy [Test Deployment Name] ### Production Deployment Making a [pull request](https://github.com/Robert-M-Lucas/budget-19/compare) -will automatically create a test deployment to Firebase (i.e. not +will automatically create a tiles deployment to Firebase (i.e. not overwrite the production app). You should see a message below your new pull request notifying you that your changes are being deployed. Click on the link shown when this is complete to view your changes. The pull diff --git a/package-lock.json b/package-lock.json index c65656a..270b0cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "bootstrap": "^5.3.3", "firebase": "^10.11.0", "lodash": "^4.17.21", + "multi-range-slider-react": "^2.0.7", "papaparse": "^5.4.1", "react": "^18.2.0", "react-bootstrap": "^2.10.2", @@ -5544,6 +5545,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/multi-range-slider-react": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/multi-range-slider-react/-/multi-range-slider-react-2.0.7.tgz", + "integrity": "sha512-KRYUkatXxxYceL5ZT8xvetIN+4yTCdWszxRC6Y6Jkua+oRrWVkmBR6v3R03kosYg/QtcETBf2L1Jt+4U66DFbg==" + }, "node_modules/nano-css": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.1.tgz", diff --git a/package.json b/package.json index 4dce34f..548026a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "bootstrap": "^5.3.3", "firebase": "^10.11.0", "lodash": "^4.17.21", + "multi-range-slider-react": "^2.0.7", "papaparse": "^5.4.1", "react": "^18.2.0", "react-bootstrap": "^2.10.2", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fd668e9..ec298ff 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -42,7 +42,7 @@ export function Header() { {/* Links to transactions page with table of expenses */}
  • Transactions
  • -
  • Firestore Test
  • +
  • Firestore Test
  • {displayName &&
  • {displayName}
  • } diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index f8b4c78..dce11e9 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -1,151 +1,108 @@ import "./styles.css"; import "react-tiles-dnd/esm/index.css"; -import { TilesContainer, RenderTileFunction } from "react-tiles-dnd"; import useWindowDimensions from "../../hooks/WindowDimensionsHook.tsx"; import {Header} from "../../components/Header.tsx"; -import {ReactNode, useEffect, useState} from "react"; -import { getTransactionsFilterOrderBy, Transaction } from "../../utils/transaction.ts" +import {useEffect, useState} from "react"; +import {Transaction, getTransactionsFilterOrderBy } from "../../utils/transaction.ts" import {auth} from "../../utils/firebase.ts"; import {orderBy} from "firebase/firestore"; -import {getCurrentBalance} from "../../utils/transaction_utils.ts"; -import { User } from "firebase/auth"; import {FullscreenCenter} from "../../components/FullscreenCenter.tsx"; -import {Button} from "react-bootstrap"; -import {CSVUpload} from "../../components/transactions/CSVUpload.tsx"; -import {InputTransaction} from "../../components/transactions/InputTransaction.tsx"; -import Graphs from "./Graphs.tsx" -import test from "./test.tsx" - -type transactionPoint = { date: string; amount: number } -type tsxContents = ReactNode; - -class TileElement { - private graph?: transactionPoint[]; - private TSX?: () => tsxContents; - - constructor(graph: transactionPoint[] | undefined, TSX: (() => tsxContents) | undefined) { - this.graph = graph; - this.TSX = TSX; - } - - static newGraph(graph: transactionPoint[]): TileElement { - return new TileElement(graph, undefined); - } - static newTSX(TSX: () => tsxContents): TileElement { - return new TileElement(undefined, TSX); - } - - isGraph(): boolean { - return typeof this.graph !== "undefined"; - } - - forceGetGraph(): transactionPoint[] { - return this.graph!; - } - forceGetTSX(): () => tsxContents { - return this.TSX!; - } -} +import Graphs from "./graphs/Graphs.tsx" +import {getTileSize, TileElement} from "./TileUtils.ts"; +import {finalGraphData, readTransactions} from "./graphs/GraphUtils.ts"; +import {signInWithGoogle} from "../../utils/authentication.ts"; +import totalTile from "./total tile/TotalTile.tsx"; +import {getUserPrefs, UserPrefs} from "../../utils/user_prefs.ts"; +import {User} from "firebase/auth"; +import goalsTile from "./goals tile/GoalsTile.tsx"; +import {RenderTileFunction, TilesContainer} from "react-tiles-dnd"; export default function Dashboard() { - const [balance, setBalance] = useState(0); - const [transactionPoints, setPoints] = useState([[]]); - const [authResolved, setAuthResolved] = useState(false); - const [fetchResolved, setFetchResolved] = useState(false); - const [showCSVModal, setShowCSVModal] = useState(false); - const [showTransactionModal, setShowTransactionModal] = useState(false); + // const [balance, setBalance] = useState(0); + const [transactionPoints, setPoints] = useState(null); + const [transactions, setTransactions] = useState([]); + const [authResolved, setAuthResolved] = useState(false); + const [userPrefs, setUserPrefs] = useState(null); + // const draggable = useRef(true); + // const [showCSVModal, setShowCSVModal] = useState(false); + // const [showTransactionModal, setShowTransactionModal] = useState(false); + const [update, setUpdate] = useState(0) + + const forceUpdate = () => { + setUpdate(update + 1); + setUserPrefs(null); + }; - const tileSize = (tile: typeof transactionTiles[0]) => ({ - colSpan: tile.cols, - rowSpan: tile.rows - }); - const cumulateTransactions = (points: transactionPoint[]): transactionPoint[] => { - let total = 0; - return points.map(value => { - total += value.amount; - return {date: value.date, amount: total}; - }) - } - const getDateString = (timestamp: number): string => { - const date = new Date(timestamp) - const day = date.getDate().toString().padStart(2, '0'); // Ensures two digits - const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Month is 0-indexed, add 1 - const year = date.getFullYear(); - return `${day}/${month}/${year}`; - } - const splitTransactions = (data: transactionPoint[]): void => { - const moneyIn: transactionPoint[] = [] - const moneyOut: transactionPoint[] = [] - data.forEach(t => { - if (t.amount > 0) { - moneyIn.push(t) - } else { - moneyOut.push(t) - } - }) - setPoints([cumulateTransactions(data), cumulateTransactions(moneyIn), cumulateTransactions(moneyOut)]) - console.log(transactionPoints) - } - const readTransactions = (data: Transaction[]): void => { - const result: transactionPoint[] = [] - data.forEach(t => { - result.push({amount: t.amount, date: getDateString(t.dateTime)}) - }) - splitTransactions(result) - } const fetchTransactions = async (user: User) => { - try { - const transactions = await getTransactionsFilterOrderBy(user, orderBy("dateTime", "desc")) - readTransactions(transactions) - setFetchResolved(true); - } catch (error) {} + const transactions = await getTransactionsFilterOrderBy(user, orderBy("dateTime", "desc")); + setTransactions(transactions); + setPoints(readTransactions(transactions)); } const {width} = useWindowDimensions(); - const columns = Math.max(Math.floor(width / 200), 1); + const columns = Math.max(Math.floor(width / 180), 1); // Transaction Loading and Handling useEffect(() => { if (auth.currentUser !== null) { - getCurrentBalance(auth.currentUser).then((b) => setBalance(b)); - fetchTransactions(auth.currentUser).then(() => console.log("Fetched Transactions", transactionPoints)); + fetchTransactions(auth.currentUser).then(); + getUserPrefs(auth.currentUser).then((prefs) => setUserPrefs(prefs)); } - },[auth.currentUser]) - + },[auth.currentUser, update]); if (!authResolved) { auth.authStateReady().then(() => setAuthResolved(true)); return <> +
    -

    Waiting for Auth

    +

    Waiting for Authentication

    ; } - if (!fetchResolved) { - auth.authStateReady().then(() => setAuthResolved(true)); + + if (auth.currentUser === null) { + auth.onAuthStateChanged(() => { + setUpdate(update + 1); + }); + return <> +
    + +
    +

    Not Logged In

    + +
    +
    + ; + } + + if (!transactionPoints || !userPrefs) { return <> +
    -

    Fetching

    +

    Fetching {transactionPoints ? "" : "transactions"}{!transactionPoints && !userPrefs ? "," : ""} {userPrefs ? "" : "goals"}

    ; } - const transactionTiles = [ - {d: TileElement.newGraph(transactionPoints[0]), cols:5, rows:2}, - {d: TileElement.newGraph(transactionPoints[1]), cols:5, rows:2}, - {d: TileElement.newGraph(transactionPoints[2]), cols:5, rows:2}, - {d: TileElement.newTSX(test), cols:1, rows:1} + const transactionTiles: TileElement[] = [ + TileElement.newTSX(() => totalTile(transactions), 2, 1, columns), + TileElement.newTSX(() => (goalsTile(userPrefs, forceUpdate)), 2, 2, columns), + TileElement.newGraph(transactionPoints.raw, 3, 2, columns), + TileElement.newGraph(transactionPoints.in, 3, 2, columns), + TileElement.newGraph(transactionPoints.out, 3, 2, columns), ]; - const renderFirebase: RenderTileFunction = ({ data, isDragging }) => ( + const renderTile: RenderTileFunction = ({ data, isDragging }) => (
    - {data.d.isGraph() ? : data.d.forceGetTSX()()} + {data.isGraph() ? : data.forceGetTSX()()}
    ); @@ -153,23 +110,11 @@ export default function Dashboard() { return (
    -
    - - - - - -
    - - {balance} diff --git a/src/pages/dashboard/GraphUtils.ts b/src/pages/dashboard/GraphUtils.ts deleted file mode 100644 index 266777c..0000000 --- a/src/pages/dashboard/GraphUtils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {Transaction} from "../../utils/transaction.ts"; - -type transactionPoint = { date: string; amount: number } - -// const cumulateTransactions = (points: transactionPoint[]): transactionPoint[] => { -// let total = 0; -// return points.map(value => { -// total += value.amount; -// return {date: value.date, amount: total}; -// }) -// } - -const getDateString = (timestamp: number): string => { - const date = new Date(timestamp) - const day = date.getDate().toString().padStart(2, '0'); // Ensures two digits - const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Month is 0-indexed, add 1 - const year = date.getFullYear(); - return `${day}/${month}/${year}`; -} -export const splitTransactions = (data: transactionPoint[]): void => { - const moneyIn: transactionPoint[] = [] - const moneyOut: transactionPoint[] = [] - data.forEach(t => { - if (t.amount > 0) { - moneyIn.push(t) - } else { - moneyOut.push(t) - } - }) -} -export const readTransactions = (data: Transaction[]): void => { - const result: transactionPoint[] = [] - data.forEach(t => { - result.push({amount: t.amount, date: getDateString(t.dateTime)}) - }) - splitTransactions(result) -} \ No newline at end of file diff --git a/src/pages/dashboard/TileUtils.ts b/src/pages/dashboard/TileUtils.ts new file mode 100644 index 0000000..a18e05f --- /dev/null +++ b/src/pages/dashboard/TileUtils.ts @@ -0,0 +1,43 @@ +import {ReactNode} from "react"; +import {transactionPoint} from "./graphs/GraphUtils.ts"; +import {min} from "lodash"; + +type tsxContents = ReactNode; + +export class TileElement { + private readonly graph?: transactionPoint[]; + private readonly TSX?: () => tsxContents; + public readonly cols: number; + public readonly rows: number; + + private constructor(graph: transactionPoint[] | undefined, TSX: (() => tsxContents) | undefined, cols: number, rows: number) { + this.graph = graph; + this.TSX = TSX; + this.cols = cols; + this.rows = rows; + } + + static newGraph(graph: transactionPoint[], cols: number, rows: number, maxCol: number): TileElement { + return new TileElement(graph, undefined, min([cols, maxCol])!, rows); + } + static newTSX(TSX: () => tsxContents, cols: number, rows: number, maxCol: number): TileElement { + return new TileElement(undefined, TSX, min([cols, maxCol])!, rows); + } + + isGraph(): boolean { + return typeof this.graph !== "undefined"; + } + + forceGetGraph(): transactionPoint[] { + return this.graph!; + } + forceGetTSX(): () => tsxContents { + return this.TSX!; + } +} + +export type tileData = { d: TileElement; rows: number; cols: number }; + +export function getTileSize (tile: TileElement) { + return {colSpan: tile.cols, rowSpan: tile.rows}; +} \ No newline at end of file diff --git a/src/pages/dashboard/goals tile/GoalsTile.css b/src/pages/dashboard/goals tile/GoalsTile.css new file mode 100644 index 0000000..27ca49e --- /dev/null +++ b/src/pages/dashboard/goals tile/GoalsTile.css @@ -0,0 +1,227 @@ +.modified-multi-range-slider * { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +.modified-multi-range-slider { + display: flex; + position: relative; + padding: 20px 10px; + flex-direction: column; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge,*/ +} + +.modified-multi-range-slider .bar { + display: flex; +} + +.modified-multi-range-slider .bar-left { + width: 25%; + background-color: rgb(202, 15, 15); + border-radius: 10px 0 0 10px; + padding: 4px 0; +} + +.modified-multi-range-slider .bar-right { + width: 25%; + background-color: green; + border-radius: 0 10px 10px 0; +} + +.modified-multi-range-slider .bar-inner { + background-color: #0000bf; + display: flex; + flex-grow: 1; + flex-shrink: 1; + justify-content: space-between; + position: relative; +} + +.modified-multi-range-slider .bar-inner-left { + width: 50%; +} + +.modified-multi-range-slider .bar-inner-right { + width: 50%; +} + +.modified-multi-range-slider .thumb { + background-color: #d6cfcf; + position: relative; + z-index: 1; + cursor: pointer; +} + +.modified-multi-range-slider .thumb::before { + content: ""; + background-color: inherit; + position: absolute; + top: 3px; + width: 17px; + height: 17px; + border: solid 1px #979797; + border-radius: 50%; + z-index: 1; + margin: -8px -12px; + cursor: pointer; +} + +.modified-multi-range-slider .input-type-range:focus + .thumb::after { + content: ""; + position: absolute; + top: -4px; + left: -7px; + width: 11px; + height: 11px; + z-index: 2; + border-radius: 50%; + border: dotted 1px black; + box-shadow: 0 0 5px white, inset 0 0 10px black; +} + +.modified-multi-range-slider .caption { + display: none; +} + +.modified-multi-range-slider .thumb .caption * { + display: none; +} + +.modified-multi-range-slider .thumb:active .caption { + display: none; +} + +.modified-multi-range-slider .input-type-range:focus + .thumb .caption { + display: flex; +} + +.modified-multi-range-slider .input-type-range { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + pointer-events: none; +} + +.modified-multi-range-slider .ruler { + margin: 10px 0 -5px 0; + display: flex; + /* display: none; */ + overflow: hidden; +} + +.modified-multi-range-slider .ruler .ruler-rule { + border-left: solid 1px; + border-bottom: solid 1px; + display: flex; + flex-grow: 1; + flex-shrink: 1; + padding: 5px 0; +} + +.modified-multi-range-slider .ruler .ruler-rule:last-child { + border-right: solid 1px; +} + +.modified-multi-range-slider .ruler .ruler-sub-rule { + border-left: solid 1px; + /* border-bottom: solid 1px; */ + display: flex; + flex-grow: 1; + flex-shrink: 1; + padding: 3px 0; + bottom: 0; + margin-bottom: -5px; +} + +.modified-multi-range-slider .ruler .ruler-sub-rule:first-child { + border-left: none; +} + +.modified-multi-range-slider .labels { + display: flex; + justify-content: space-between; + padding: 0; + margin-top: 10px; + margin-bottom: -20px; + /* display: none; */ +} + +.modified-multi-range-slider .label { + font-size: 80%; + display: flex; + width: 1px; + justify-content: center; +} + +.modified-multi-range-slider .label:first-child { + justify-content: start; +} + +.modified-multi-range-slider .label:last-child { + justify-content: end; +} + +.modified-multi-range-slider.zero-ranage-margin .thumb-left { + right: 12px; +} + +.modified-multi-range-slider.zero-ranage-margin .thumb-right { + left: 8px; +} + +/* Disabled */ +.modified-multi-range-slider.disabled { + border: solid 1px rgb(200, 200, 200); + box-shadow: 1px 1px 4px rgb(180, 180, 180); + color: rgb(180, 180, 180); +} + +.modified-multi-range-slider .bar { + display: flex; +} + +.modified-multi-range-slider.disabled .bar-left { + background-color: #c9c9c9; + box-shadow: inset 0 0 5px rgb(160, 160, 160); +} + +.modified-multi-range-slider.disabled .bar-right { + background-color: #c9c9c9; + box-shadow: inset 0 0 5px rgb(160, 160, 160); +} + +.modified-multi-range-slider.disabled .bar-inner { + background-color: rgb(130, 243, 130); + border: solid 1px rgb(149, 149, 149); + box-shadow: inset 0 0 5px rgb(103, 103, 103); +} + +.modified-multi-range-slider.disabled .thumb { + background-color: white; +} + +.modified-multi-range-slider.disabled .thumb::before { + border: solid 1px rgb(200, 200, 200); + box-shadow: 0 0 3px rgb(35, 35, 35), inset 0 0 5px gray; +} + +.modified-multi-range-slider.disabled .input-type-range:focus + .thumb::after { + border: dotted 1px rgb(35, 35, 35); + box-shadow: 0 0 5px white, inset 0 0 10px rgb(35, 35, 35); +} + +.modified-multi-range-slider.disabled .thumb .caption * { + background-color: rgb(84, 84, 137); + color: rgb(199, 199, 199); + box-shadow: 0 0 5px rgb(35, 35, 35); +} + +/*# sourceMappingURL=GoalsTile.css.map */ diff --git a/src/pages/dashboard/goals tile/GoalsTile.css.map b/src/pages/dashboard/goals tile/GoalsTile.css.map new file mode 100644 index 0000000..203b8eb --- /dev/null +++ b/src/pages/dashboard/goals tile/GoalsTile.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["GoalsTile.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;AACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;AACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;AACA;;;AAEF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAIF;AACA;EACE;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;EACA;;;AAEF;EACE;EACA;EACA","file":"GoalsTile.css"} \ No newline at end of file diff --git a/src/pages/dashboard/goals tile/GoalsTile.scss b/src/pages/dashboard/goals tile/GoalsTile.scss new file mode 100644 index 0000000..b05c5a5 --- /dev/null +++ b/src/pages/dashboard/goals tile/GoalsTile.scss @@ -0,0 +1,199 @@ +.modified-multi-range-slider * { + box-sizing: border-box; + padding: 0; + margin: 0; +} +.modified-multi-range-slider { + display: flex; + position: relative; + padding: 20px 10px; + flex-direction: column; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge,*/ +} +.modified-multi-range-slider .bar { + display: flex; +} +.modified-multi-range-slider .bar-left { + width: 25%; + background-color: rgb(202, 15, 15); + border-radius: 10px 0 0 10px; + padding: 4px 0; +} +.modified-multi-range-slider .bar-right { + width: 25%; + background-color: green; + border-radius: 0 10px 10px 0; +} +.modified-multi-range-slider .bar-inner { + background-color: #0000bf; + display: flex; + flex-grow: 1; + flex-shrink: 1; + justify-content: space-between; + position: relative; +} +.modified-multi-range-slider .bar-inner-left { + width: 50%; +} +.modified-multi-range-slider .bar-inner-right { + width: 50%; +} +.modified-multi-range-slider .thumb { + background-color: #d6cfcf; + position: relative; + z-index: 1; + cursor: pointer; +} + +.modified-multi-range-slider .thumb::before { + content: ''; + background-color: inherit; + position: absolute; + top: 3px; + width: 17px; + height: 17px; + border: solid 1px #979797; + border-radius: 50%; + z-index: 1; + margin: -8px -12px; + cursor: pointer; +} +.modified-multi-range-slider .input-type-range:focus + .thumb::after { + content: ''; + position: absolute; + top: -4px; + left: -7px; + width: 11px; + height: 11px; + z-index: 2; + border-radius: 50%; + border: dotted 1px black; + box-shadow: 0 0 5px white, inset 0 0 10px black; +} +.modified-multi-range-slider .caption { + display: none; +} +.modified-multi-range-slider .thumb .caption * { + display: none; +} +.modified-multi-range-slider .thumb:active .caption { + display: none; +} +.modified-multi-range-slider .input-type-range:focus + .thumb .caption { + display: flex; +} + +.modified-multi-range-slider .input-type-range { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + pointer-events: none; +} + +.modified-multi-range-slider .ruler { + margin: 10px 0 -5px 0; + display: flex; + /* display: none; */ + overflow: hidden; +} +.modified-multi-range-slider .ruler .ruler-rule { + border-left: solid 1px; + border-bottom: solid 1px; + display: flex; + flex-grow: 1; + flex-shrink: 1; + padding: 5px 0; +} +.modified-multi-range-slider .ruler .ruler-rule:last-child { + border-right: solid 1px; +} + +.modified-multi-range-slider .ruler .ruler-sub-rule { + border-left: solid 1px; + /* border-bottom: solid 1px; */ + display: flex; + flex-grow: 1; + flex-shrink: 1; + padding: 3px 0; + bottom: 0; + margin-bottom: -5px; +} +.modified-multi-range-slider .ruler .ruler-sub-rule:first-child { + border-left: none; +} + +.modified-multi-range-slider .labels { + display: flex; + justify-content: space-between; + padding: 0; + margin-top: 10px; + margin-bottom: -20px; + /* display: none; */ +} +.modified-multi-range-slider .label { + font-size: 80%; + display: flex; + width: 1px; + justify-content: center; +} +.modified-multi-range-slider .label:first-child { + justify-content: start; +} +.modified-multi-range-slider .label:last-child { + justify-content: end; +} +.modified-multi-range-slider.zero-ranage-margin .thumb-left { + right: 12px; +} +.modified-multi-range-slider.zero-ranage-margin .thumb-right { + left: 8px; +} + + +/* Disabled */ +.modified-multi-range-slider.disabled { + border: solid 1px rgb(200, 200, 200); + box-shadow: 1px 1px 4px rgb(180, 180, 180); + color:rgb(180, 180, 180); +} +.modified-multi-range-slider .bar { + display: flex; +} +.modified-multi-range-slider.disabled .bar-left { + background-color: #c9c9c9; + box-shadow: inset 0 0 5px rgb(160, 160, 160); +} +.modified-multi-range-slider.disabled .bar-right { + background-color: #c9c9c9; + box-shadow: inset 0 0 5px rgb(160, 160, 160); +} +.modified-multi-range-slider.disabled .bar-inner { + background-color: rgb(130 243 130); + border: solid 1px rgb(149, 149, 149); + box-shadow: inset 0 0 5px rgb(103, 103, 103); +} +.modified-multi-range-slider.disabled .thumb { + background-color: white; +} +.modified-multi-range-slider.disabled .thumb::before { + border: solid 1px rgb(200, 200, 200); + box-shadow: 0 0 3px rgb(35, 35, 35), inset 0 0 5px gray; +} +.modified-multi-range-slider.disabled .input-type-range:focus + .thumb::after { + border: dotted 1px rgb(35, 35, 35); + box-shadow: 0 0 5px white, inset 0 0 10px rgb(35, 35, 35); +} +.modified-multi-range-slider.disabled .thumb .caption * { + background-color: rgb(84, 84, 137); + color: rgb(199, 199, 199); + box-shadow: 0 0 5px rgb(35, 35, 35); +} + + diff --git a/src/pages/dashboard/goals tile/GoalsTile.tsx b/src/pages/dashboard/goals tile/GoalsTile.tsx new file mode 100644 index 0000000..313b5b9 --- /dev/null +++ b/src/pages/dashboard/goals tile/GoalsTile.tsx @@ -0,0 +1,107 @@ +import React, {ReactNode, useState} from "react"; +import {setUserPrefs, UserPrefs} from "../../../utils/user_prefs.ts"; +import MultiRangeSlider, {ChangeResult} from "multi-range-slider-react"; +import "./GoalsTile.scss"; +import {auth} from "../../../utils/firebase.ts"; + +export default function goalsTile(userPrefs: UserPrefs, forceUpdate: () => void): ReactNode { + const [minValue, setMinValue] = useState( + Math.round(userPrefs.getNeedsBudget() * 100) + ); + const [maxValue, setMaxValue] = useState( + Math.round((userPrefs.getNeedsBudget() + userPrefs.getWantsBudget()) * 100) + ); + + const needs = minValue; + const wants = maxValue - minValue; + const savings = 100 - maxValue; + + const disableSet = needs === Math.round(userPrefs.getNeedsBudget() * 100) && wants === Math.round(userPrefs.getWantsBudget() * 100); + const disableRecommended = needs === 50 && wants === 30; + + const handleInput = (e: ChangeResult) => { + const min = e.minValue; + const max = e.maxValue; + + setMinValue(min); + setMaxValue(max); + }; + + const stop = (e: React.PointerEvent) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + } + + return <> +
    + Goals +
    +
      +
    • +
      +
      Needs:
      +
      {needs}% +
      +
      +
    • +
    • +
      +
      Wants:
      +
      {wants}% +
      +
      +
    • +
    • +
      +
      Savings:
      +
      {savings}% +
      +
      +
    • +
    • +
      {/* THIS LINE TOOK 1.5H */} + +
      +
    • +
    +
    +
    + + +
    +
    + ; +} \ No newline at end of file diff --git a/src/pages/dashboard/graphs/GraphUtils.ts b/src/pages/dashboard/graphs/GraphUtils.ts new file mode 100644 index 0000000..dab63e1 --- /dev/null +++ b/src/pages/dashboard/graphs/GraphUtils.ts @@ -0,0 +1,39 @@ +import {Transaction} from "../../../utils/transaction.ts"; +import strftime from "strftime"; + +export type transactionPoint = { date: string; amount: number; goal: number } +export type finalGraphData = {raw: transactionPoint[], in: transactionPoint[], out: transactionPoint[]}; + +function cumulateTransactions(points: transactionPoint[]): transactionPoint[] { + let total = 0; + return points.map(value => { + total += value.amount; + value.amount = total; + return value; + }) +} + +function getDateString(timestamp: number): string { + return strftime("%d/%m/%y", new Date(timestamp)) +} + +function splitTransactions (data: transactionPoint[]): finalGraphData { + const moneyIn: transactionPoint[] = [] + const moneyOut: transactionPoint[] = [] + data.forEach(t => { + if (t.amount > 0) { + moneyIn.push(t) + } else { + moneyOut.push(t) + } + }) + return {raw: cumulateTransactions(data), in: cumulateTransactions(moneyIn), out: cumulateTransactions(moneyOut)}; +} + +export function readTransactions(data: Transaction[]): finalGraphData { + return splitTransactions( + data.map((t) => { + return {date: getDateString(t.dateTime), amount: t.amount, goal: 800}; + }) + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Graphs.tsx b/src/pages/dashboard/graphs/Graphs.tsx similarity index 81% rename from src/pages/dashboard/Graphs.tsx rename to src/pages/dashboard/graphs/Graphs.tsx index 8bf93e7..098d6c8 100644 --- a/src/pages/dashboard/Graphs.tsx +++ b/src/pages/dashboard/graphs/Graphs.tsx @@ -1,6 +1,5 @@ import {Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts"; - -type transactionPoint = { date: string; amount: number } +import {transactionPoint} from "./GraphUtils.ts"; interface Props { data: transactionPoint[]; @@ -14,6 +13,7 @@ export default function Graphs({data}: Props) { + ); diff --git a/src/pages/dashboard/test.tsx b/src/pages/dashboard/test.tsx deleted file mode 100644 index 13cba03..0000000 --- a/src/pages/dashboard/test.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function test() { - return ( - <> - Hi - - ); -} \ No newline at end of file diff --git a/src/pages/dashboard/total tile/TotalTile.tsx b/src/pages/dashboard/total tile/TotalTile.tsx new file mode 100644 index 0000000..3507508 --- /dev/null +++ b/src/pages/dashboard/total tile/TotalTile.tsx @@ -0,0 +1,38 @@ +import {ReactNode} from "react"; +import { Transaction } from "../../../utils/transaction.ts"; +import {max, min} from "lodash"; + +export default function totalTile(transactions: Transaction[]): ReactNode { + const balance = transactions.reduce((prev, curr): number => prev + curr.amount, 0); + const income = transactions.reduce((prev, curr): number => prev + max([curr.amount, 0])!, 0); + const expenses = transactions.reduce((prev, curr): number => prev + min([curr.amount, 0])!, 0); + + return
      +
    • +
      +
      Balance:
      + {balance > 0 ? <> +
      £
      +
      {balance.toFixed(2)}
      + : <> +
      £
      +
      {balance.toFixed(2)}
      + } +
      +
    • +
    • +
      +
      Income:
      +
      £
      +
      {income.toFixed(2)}
      +
      +
    • +
    • +
      +
      Expenses:
      +
      £
      +
      {expenses.toFixed(2)}
      +
      +
    • +
    ; +} \ No newline at end of file diff --git a/src/pages/test firestore/TestFirestore.tsx b/src/pages/test firestore/TestFirestore.tsx index 2655ca2..647c59d 100644 --- a/src/pages/test firestore/TestFirestore.tsx +++ b/src/pages/test firestore/TestFirestore.tsx @@ -15,6 +15,7 @@ import {Header} from "../../components/Header.tsx"; import { orderBy } from "firebase/firestore"; import {User} from "firebase/auth"; import {getUserPrefs, setUserPrefs, UserPrefs} from "../../utils/user_prefs.ts"; +import {round} from "lodash"; function writeSampleData() { if (auth.currentUser === null) { @@ -95,11 +96,12 @@ export function TestFirestorePage() {

    UserPrefs

    { userPrefs ? <> -

    Goal: {userPrefs.goal}

    +

    Goal: {userPrefs.getNeedsBudget()} | {userPrefs.getWantsBudget()} | {userPrefs.getSavingsBudget()}

    :

    Loading

    } diff --git a/src/router.tsx b/src/router.tsx index 9b24d6f..f5b2c09 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -26,7 +26,7 @@ export const router = createBrowserRouter([ element: , }, { - path: "/user-test", + path: "/user-tiles", element: , errorElement:<_404Page/> }, @@ -47,7 +47,7 @@ export const router = createBrowserRouter([ element: , }, { - path: "/test", + path: "/tiles", element: , }, ]); \ No newline at end of file diff --git a/src/utils/user_prefs.test.ts b/src/utils/user_prefs.test.ts index 6ab94d1..5651651 100644 --- a/src/utils/user_prefs.test.ts +++ b/src/utils/user_prefs.test.ts @@ -14,7 +14,7 @@ describe("Firestore UserPrefs Tests", () => { expect(_.isEqual(prefs, UserPrefs.default()), "Non-existent UserPrefs should return default").toBeTruthy(); - prefs.goal = 213; + const new_prefs = UserPrefs.newChecked(0.4, 0.2); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -24,7 +24,7 @@ describe("Firestore UserPrefs Tests", () => { // @ts-expect-error const read_prefs = await getUserPrefs(user); - console.log(prefs); + console.log(new_prefs); console.log(read_prefs); expect(_.isEqual(read_prefs, prefs), "UserPref changes to be read").toBeTruthy(); diff --git a/src/utils/user_prefs.ts b/src/utils/user_prefs.ts index 2aa65b5..fc822c4 100644 --- a/src/utils/user_prefs.ts +++ b/src/utils/user_prefs.ts @@ -1,17 +1,52 @@ import {collection, doc, DocumentSnapshot, getDoc, setDoc, SnapshotOptions} from "firebase/firestore"; import {User} from "firebase/auth"; import {db} from "./firebase.ts"; +import {round} from "lodash"; export class UserPrefs { - public goal: number; + private readonly needsBudget: number; + private readonly wantsBudget: number; - constructor(goal: number) { - this.goal = goal; + private constructor(needsBudget: number, wantsBudget: number) { + if (needsBudget > 1) { + needsBudget = 1; + } + + if (needsBudget + wantsBudget > 1) { + wantsBudget = 1 - needsBudget; + } + + this.needsBudget = round(needsBudget, 2); + this.wantsBudget = round(wantsBudget, 2); + } + + static newChecked(needsBudget: number, wantsBudget: number): UserPrefs { + if (needsBudget > 1) { + throw new Error("needsBudget > 1!"); + } + + if (needsBudget + wantsBudget > 1) { + throw new Error("needsBudget + wantsBudget > 1!"); + } + + return new UserPrefs(needsBudget, wantsBudget); } static default(): UserPrefs { - return new UserPrefs(100); + return new UserPrefs(0.5, 0.3); + } + + getNeedsBudget(): number { + return this.needsBudget; + } + + getWantsBudget(): number { + return this.wantsBudget; + } + + getSavingsBudget(): number { + return round(1 - this.wantsBudget - this.needsBudget, 2); } // Utility method for creating `Transactions` @@ -20,7 +55,8 @@ export class UserPrefs { if (!data) { throw Error("No data returned for snapshot!"); } - return new UserPrefs(data.goal); + + return new UserPrefs(round(data.needsBudget, 2), round(data.wantsBudget, 2)); } toSendObject(): object {