diff --git a/README.md b/README.md index e755b91..622d3e6 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,12 @@ However, if you'd like to use it offline, follow the instructions for your devic I receive a salary at the end of each month and around that time I create a new budget to plan the expenses/savings goals/etc. I frequently add a new expense before I purchase it so I can see the impact on the budget and prepare accordingly. -If everything went according to plan, once I receive a new salary, I transfer the value in the `savings estimate` field to a savings account. +If everything went according to plan, once I receive a new salary, I transfer the value in the `savings estimate` field to my bank savings account. Later, I can compare the budget with previous months to understand where the money is going by hovering the mouse/tapping on a single expense. A tooltip pops up and shows its value in percentage of revenue (see the tooltip screenshot above). +The charts page is useful for these kinds of insighs. + +There I can see an overview of the evolution of several metrics such as `savings`, `revenue`, etc. If I want to narrow down the chart to a specific type of item I can search for it in the filter searchbox. If I toggle the `strict match` button then only items that are named exactly as the search value are shown in the resulting chart. ### Starting from scratch: diff --git a/src/components/Chart/ChartTooltip.tsx b/src/components/Chart/ChartTooltip.tsx index 113e75c..6d2c843 100644 --- a/src/components/Chart/ChartTooltip.tsx +++ b/src/components/Chart/ChartTooltip.tsx @@ -5,10 +5,21 @@ import { intlFormat, roundBig } from "../../utils"; interface ChartTooltipProps { active?: boolean; - payload?: { name: string; value: number; unit: string }[]; + payload?: { + name: string; + payload?: { + id: string; + item: string; + name: string; + type: string; + value: number; + }; + value: number; + unit: string; + }[]; label?: string; - key1?: string; - key2?: string; + key1?: string | undefined; + key2?: string | undefined; } function ChartTooltip({ @@ -19,52 +30,45 @@ function ChartTooltip({ key2, }: ChartTooltipProps) { const { intlConfig } = useConfig(); + const currency = intlConfig?.currency; + const showTooltip = active && payload?.length && currency; + const filteredItemName = payload?.length && payload[0].payload?.item; + const showMultipleLegends = showTooltip && payload.length > 1; - if (active && payload?.length && intlConfig?.currency) { - return ( - - {label} - {payload.length > 1 ? ( - <> - - {`${key1 ?? ""}:`} - - {`${intlFormat( - roundBig(Big(payload[0].value), 2), - intlConfig.currency, - )}`} - - - - {`${key2 ?? ""}:`} - - {intlFormat( - roundBig(Big(payload[1].value), 2), - intlConfig.currency, - )} - - - - ) : ( + const item1Value = + showTooltip && intlFormat(roundBig(Big(payload[0].value), 2), currency); + const item2Value = + showMultipleLegends && + intlFormat(roundBig(Big(payload[1].value), 2), currency); + + return showTooltip ? ( + + {label} + {showMultipleLegends ? ( + <> {`${key1 ?? ""}:`} - {key1 === "goal" ? ( - {`${payload[0].value}%`} - ) : ( - - {`${intlFormat( - roundBig(Big(payload[0].value), 2), - intlConfig.currency, - )}`} - - )} + {item1Value} - )} - - ); - } - - return null; + + {`${key2 ?? ""}:`} + + {item2Value} + + + + ) : ( + + {`${key1 ?? filteredItemName}:`} + {key1 === "goal" ? ( + {`${payload[0].value}%`} + ) : ( + {item1Value} + )} + + )} + + ) : null; } export default ChartTooltip; diff --git a/src/components/ChartsPage/ChartsPage.css b/src/components/ChartsPage/ChartsPage.css new file mode 100644 index 0000000..570621c --- /dev/null +++ b/src/components/ChartsPage/ChartsPage.css @@ -0,0 +1,4 @@ +.filter-search { + border-radius: 0.375rem; + border-radius: 0.375rem; +} diff --git a/src/components/ChartsPage/ChartsPage.tsx b/src/components/ChartsPage/ChartsPage.tsx index 8c8f489..f8e57da 100644 --- a/src/components/ChartsPage/ChartsPage.tsx +++ b/src/components/ChartsPage/ChartsPage.tsx @@ -1,68 +1,270 @@ +import { useRef, useState } from "react"; import { Button, Col, Container, + InputGroup, + Nav, + Navbar, OverlayTrigger, Row, + ToggleButton, Tooltip, } from "react-bootstrap"; +import { AsyncTypeahead, TypeaheadRef } from "react-bootstrap-typeahead"; +import "react-bootstrap-typeahead/css/Typeahead.bs5.css"; +import "react-bootstrap-typeahead/css/Typeahead.css"; +import { Option } from "react-bootstrap-typeahead/types/types"; import { useHotkeys } from "react-hotkeys-hook"; import { BsArrowLeft } from "react-icons/bs"; import { useBudget } from "../../context/BudgetContext"; +import { budgetsDB } from "../../context/db"; +import { focusRef, getNestedValues } from "../../utils"; import { Budget } from "../Budget/Budget"; import Chart from "../Chart/Chart"; +import "./ChartsPage.css"; interface GraphProps { onShowGraphs: () => void; } +interface Filter { + value: string; + type: string; +} + +export interface FilteredItem { + id: string; + name: string; + item: string; + value: number; + type: string; +} + function ChartsPage({ onShowGraphs }: GraphProps) { const { budgetList } = useBudget(); + const filterRef = useRef(null); + + const [options, setOptions] = useState([]); + const [filter, setFilter] = useState({ value: "", type: "" }); + const [filteredData, setFilteredData] = useState([]); + const [strictFilter, setStrictFilter] = useState(false); + + const showFilterChart = + filter.value.length > 0 && filter.type && filteredData.length > 0; + const filterChartStroke = filter.type === "Expense" ? "yellow" : "highlight"; + const filterChartFill = filter.type === "Expense" ? "orange" : "highlight"; + + const incomeValues = getNestedValues(budgetList, "incomes", "total"); + const expenseValues = getNestedValues(budgetList, "expenses", "total"); + const savedValue = getNestedValues(budgetList, "stats", "saved"); + const reservesValue = getNestedValues(budgetList, "stats", "reserves"); + const availableValue = getNestedValues(budgetList, "stats", "available"); + const withGoalValue = getNestedValues(budgetList, "stats", "withGoal"); + const goalValue = getNestedValues(budgetList, "stats", "goal"); + useHotkeys("i", (e) => !e.repeat && onShowGraphs(), { preventDefault: true, }); + useHotkeys( + ["/", "f"], + (e) => + !e.repeat && + focusRef( + filterRef as unknown as React.MutableRefObject, + ), + { preventDefault: true }, + ); + function handleShowGraphs() { onShowGraphs(); } + function getLabelKey(option: unknown): string { + const label = option as FilteredItem; + return `${label.item} (${label.name} ${label.type.toLowerCase()})`; + } + + function handleSelect(option: Option[]) { + const newFilter = option[0] as FilteredItem; + setFilter({ value: filter.value, type: newFilter.type }); + + const filteredIncomes = budgetList + ?.map((b: Budget) => { + return b.incomes.items + .filter((i) => + i.value && strictFilter + ? i.name === filter.value + : i.name.toLowerCase().includes(filter.value.toLowerCase()), + ) + .map((i) => { + return { + id: b.id, + name: b.name, + item: i.name, + value: i.value, + type: "Income", + }; + }) + .filter((i) => i.type.includes(newFilter.type)); + }) + .flat(); + + const filteredExpenses = budgetList + ?.map((b: Budget) => { + return b.expenses.items + .filter((i) => + i.value && strictFilter + ? i.name === filter.value + : i.name.toLowerCase().includes(filter.value.toLowerCase()), + ) + .map((i) => { + return { + id: b.id, + name: b.name, + item: i.name, + value: i.value, + type: "Expense", + }; + }) + .filter((i) => i.type.includes(newFilter.type)); + }) + .flat(); + + filteredIncomes && + filteredExpenses && + setFilteredData([...filteredIncomes, ...filteredExpenses]); + + if (filterRef.current) { + filterRef.current.clear(); + } + } + + function handleSearch(filter: Filter) { + setFilter(filter); + + let options: FilteredItem[] = []; + budgetsDB + .iterate((budget: Budget) => { + options = options.concat( + budget.incomes.items.map((i) => { + return { + id: budget.id, + name: budget.name, + item: i.name, + value: i.value, + type: "Income", + }; + }), + budget.expenses.items.map((i) => { + return { + id: budget.id, + name: budget.name, + item: i.name, + value: i.value, + type: "Expense", + }; + }), + ); + }) + .then(() => { + setOptions( + options + .sort((a, b) => a.name.localeCompare(b.name)) + .filter((v, i, a) => a.indexOf(v) === i) + .filter((i) => i.value) + .reverse(), + ); + }) + .catch((e) => { + throw new Error(e as string); + }); + } + return ( - - go back to budgets - - } - > - - + + + + + + + {showFilterChart && ( + i.value)} + areaDataKey1={"value"} + areaStroke1={filterChartStroke} + areaFill1={filterChartFill} + legend1={"median value"} + /> + )} { - return b.incomes.total; - }) ?? [] - } + legendValues1={incomeValues} areaDataKey1={"incomes.total"} - legendValues2={ - budgetList?.map((b: Budget) => { - return b.expenses.total; - }) ?? [] - } + legendValues2={expenseValues} areaDataKey2={"expenses.total"} areaStroke1={"highlight"} areaFill1={"highlight"} @@ -77,11 +279,7 @@ function ChartsPage({ onShowGraphs }: GraphProps) { { - return b.stats.saved; - }) ?? [] - } + legendValues1={savedValue} areaDataKey1={"stats.saved"} areaStroke1={"highlight"} areaFill1={"highlight"} @@ -93,11 +291,7 @@ function ChartsPage({ onShowGraphs }: GraphProps) { { - return b.stats.reserves; - }) ?? [] - } + legendValues1={reservesValue} areaDataKey1={"stats.reserves"} areaStroke1={"purple"} areaFill1={"purple"} @@ -111,17 +305,9 @@ function ChartsPage({ onShowGraphs }: GraphProps) { header={"Available vs with goal"} tooltipKey1={"available"} tooltipKey2={"with goal"} - legendValues1={ - budgetList?.map((b: Budget) => { - return b.stats.available; - }) ?? [] - } + legendValues1={availableValue} areaDataKey1={"stats.available"} - legendValues2={ - budgetList?.map((b: Budget) => { - return b.stats.withGoal; - }) ?? [] - } + legendValues2={withGoalValue} areaDataKey2={"stats.withGoal"} areaStroke1={"highlight"} areaFill1={"highlight"} @@ -131,16 +317,11 @@ function ChartsPage({ onShowGraphs }: GraphProps) { legend2={"median with goal"} /> - -
+ { - return b.stats.goal; - }) ?? [] - } + legendValues1={goalValue} areaDataKey1={"stats.goal"} areaStroke1={"cyan"} areaFill1={"cyan"} diff --git a/src/components/NavBar/NavBar.css b/src/components/NavBar/NavBar.css index 5b5897e..291bab7 100644 --- a/src/components/NavBar/NavBar.css +++ b/src/components/NavBar/NavBar.css @@ -78,3 +78,16 @@ input[type="text"]::placeholder { monospace; font-size: 0.9em; } + +.rbt-menu > .dropdown-item { + text-align: start; + overflow: visible; +} + +.dropdown-item.active, +.dropdown-item:active { + background: var(--lightbgcolor); + border: 1px solid var(--pink); + box-shadow: 0 0 0; + color: var(--textcolor); +} diff --git a/src/utils.test.ts b/src/utils.test.ts index b74770a..41b6223 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -11,6 +11,7 @@ import { testBudget, testBudget2, testBudgetCsv, + testBudgetList, testCsv, } from "./setupTests"; import { @@ -26,6 +27,8 @@ import { createBudgetNameList, getCountryCode, getCurrencyCode, + getNestedProperty, + getNestedValues, intlFormat, median, parseLocaleNumber, @@ -168,3 +171,22 @@ test("median", () => { expect(median([])).eq(0); expect(median([-1, -2])).eq(-1.5); }); + +test("getNestedProperty", () => { + expect(getNestedProperty(testBudget, "expenses", "total")).eq(10); + expect(getNestedProperty(testBudget, "incomes", "items")).eq( + testBudget.incomes.items, + ); +}); + +test("getNestedValues", () => { + const expected = testBudgetList.map((i) => i.expenses.total); + const expected2 = testBudgetList.map((i) => i.incomes.items); + + expect(getNestedValues(testBudgetList, "expenses", "total")).toEqual( + expected, + ); + expect(getNestedValues(testBudgetList, "incomes", "items")).toEqual( + expected2, + ); +}); diff --git a/src/utils.ts b/src/utils.ts index 58d013a..ade3763 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -305,3 +305,21 @@ export function median(arr: number[]): number { .toNumber() : s[mid]; } + +export function getNestedProperty( + object: O, + firstProp: K, + secondProp: L, +): O[K][L] { + return object[firstProp][secondProp]; +} + +export function getNestedValues( + list: T[] | undefined, + prop1: K, + prop2: L, +): T[K][L][] { + return list!.map((o: T) => { + return getNestedProperty(o, prop1, prop2); + }); +}