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
-
- }
- >
-
-
-
-
+
+
+
+
+
+ go back to budgets
+
+ }
+ >
+
+
+
+
+
+
+
+
+ {
+ handleSelect(option);
+ }}
+ options={options}
+ placeholder="Filter..."
+ isLoading={false}
+ onSearch={(q) => handleSearch({ value: q, type: "" })}
+ />
+ setStrictFilter(!strictFilter)}
+ >
+ strict match
+
+
+
+
+
+
+
+ {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);
+ });
+}