diff --git a/src/components/CalculateButton/CalculateButton.tsx b/src/components/CalculateButton/CalculateButton.tsx index 97cbc967..5df4602f 100644 --- a/src/components/CalculateButton/CalculateButton.tsx +++ b/src/components/CalculateButton/CalculateButton.tsx @@ -18,15 +18,15 @@ import { CgMathDivide, CgMathPlus } from "react-icons/cg"; import { useBudget } from "../../context/BudgetContext"; import { useConfig } from "../../context/ConfigContext"; import { useDB } from "../../hooks/useDB"; -import type { ItemForm } from "../ItemForm/ItemForm"; import "./CalculateButton.css"; import type { CalculationHistoryItem, ItemOperation, } from "../../guitos/domain/calculationHistoryItem"; +import type { BudgetItem } from "../../guitos/domain/budgetItem"; interface CalculateButtonProps { - itemForm: ItemForm; + itemForm: BudgetItem; label: string; onCalculate: (changeValue: number, operation: ItemOperation) => void; } diff --git a/src/components/CalculateButton/__snapshots__/CalculateButton.test.tsx.snap b/src/components/CalculateButton/__snapshots__/CalculateButton.test.tsx.snap index d007ac0d..c1730949 100644 --- a/src/components/CalculateButton/__snapshots__/CalculateButton.test.tsx.snap +++ b/src/components/CalculateButton/__snapshots__/CalculateButton.test.tsx.snap @@ -4,7 +4,7 @@ exports[`CalculateButton > matches snapshot 1`] = ` { - it("builds ItemForm", () => { - const newItemForm2 = new ItemForm({ - id: 1, - name: "a", - value: 2, - }); - expect(JSON.stringify(newItemForm2)).toBe('{"id":1,"name":"a","value":2}'); - }); -}); diff --git a/src/components/ItemForm/ItemForm.ts b/src/components/ItemForm/ItemForm.ts deleted file mode 100644 index 696cbe3e..00000000 --- a/src/components/ItemForm/ItemForm.ts +++ /dev/null @@ -1,12 +0,0 @@ -export class ItemForm { - id!: number; - name!: string; - value!: number; - - constructor(initializer?: ItemForm) { - if (!initializer) return; - if (initializer.id) this.id = initializer.id; - if (initializer.name) this.name = initializer.name; - if (initializer.value) this.value = initializer.value; - } -} diff --git a/src/components/ItemForm/ItemFormGroup.tsx b/src/components/ItemForm/ItemFormGroup.tsx index d8375c88..bbb53454 100644 --- a/src/components/ItemForm/ItemFormGroup.tsx +++ b/src/components/ItemForm/ItemFormGroup.tsx @@ -16,23 +16,15 @@ import { useConfig } from "../../context/ConfigContext"; import type { Expenses } from "../../guitos/domain/expenses"; import type { Incomes } from "../../guitos/domain/incomes"; import { useDB } from "../../hooks/useDB"; -import { - calc, - calcAvailable, - calcSaved, - calcTotal, - calcWithGoal, - parseLocaleNumber, - roundBig, -} from "../../utils"; +import { calc, parseLocaleNumber, roundBig } from "../../utils"; import { CalculateButton } from "../CalculateButton/CalculateButton"; -import type { ItemForm } from "./ItemForm"; import "./ItemFormGroup.css"; import type { BudgetItem } from "../../guitos/domain/budgetItem"; import type { ItemOperation } from "../../guitos/domain/calculationHistoryItem"; +import { Budget } from "../../guitos/domain/budget"; interface ItemFormProps { - itemForm: ItemForm; + itemForm: BudgetItem; costPercentage: number; label: string; inputRef: RefObject; @@ -116,19 +108,25 @@ export function ItemFormGroup({ } if (isExpense) { - draft.expenses.total = roundBig(calcTotal(draft.expenses.items), 2); + draft.expenses.total = roundBig( + Budget.itemsTotal(draft.expenses.items), + 2, + ); } else { - draft.incomes.total = roundBig(calcTotal(draft.incomes.items), 2); + draft.incomes.total = roundBig( + Budget.itemsTotal(draft.incomes.items), + 2, + ); } - draft.stats.available = roundBig(calcAvailable(draft), 2); - draft.stats.withGoal = calcWithGoal(draft); - draft.stats.saved = calcSaved(draft); + draft.stats.available = roundBig(Budget.available(draft as Budget), 2); + draft.stats.withGoal = Budget.availableWithGoal(draft as Budget); + draft.stats.saved = Budget.saved(draft as Budget); }, budget); setBudget(newState(), saveInHistory); } - function handleRemove(toBeDeleted: ItemForm) { + function handleRemove(toBeDeleted: BudgetItem) { if (!table?.items) return; if (!budget) return; @@ -142,10 +140,10 @@ export function ItemFormGroup({ newTable.items = table.items.filter( (item: BudgetItem) => item.id !== toBeDeleted.id, ); - newTable.total = roundBig(calcTotal(newTable.items), 2); - draft.stats.available = roundBig(calcAvailable(draft), 2); - draft.stats.withGoal = calcWithGoal(draft); - draft.stats.saved = calcSaved(draft); + newTable.total = roundBig(Budget.itemsTotal(newTable.items), 2); + draft.stats.available = roundBig(Budget.available(draft as Budget), 2); + draft.stats.withGoal = Budget.availableWithGoal(draft as Budget); + draft.stats.saved = Budget.saved(draft as Budget); }, budget); setBudget(newState(), true); diff --git a/src/components/ItemForm/__snapshots__/ItemFormGroup.test.tsx.snap b/src/components/ItemForm/__snapshots__/ItemFormGroup.test.tsx.snap index 0044c883..5b2f46ac 100644 --- a/src/components/ItemForm/__snapshots__/ItemFormGroup.test.tsx.snap +++ b/src/components/ItemForm/__snapshots__/ItemFormGroup.test.tsx.snap @@ -17,7 +17,7 @@ exports[`ItemFormGroup > matches snapshot 1`] = ` } } itemForm={ - ItemForm { + BudgetItem { "id": 1, "name": "name1", "value": 10, diff --git a/src/components/NavBar/NavBarImpExp.tsx b/src/components/NavBar/NavBarImpExp.tsx index 60712517..43c82323 100644 --- a/src/components/NavBar/NavBarImpExp.tsx +++ b/src/components/NavBar/NavBarImpExp.tsx @@ -14,7 +14,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { BsArrowDownUp, BsUpload } from "react-icons/bs"; import { useBudget } from "../../context/BudgetContext"; import { useDB } from "../../hooks/useDB"; -import { budgetToCsv } from "../../utils"; +import { Budget } from "../../guitos/domain/budget"; interface NavBarImpExpProps { expanded: boolean; @@ -61,7 +61,7 @@ export function NavBarImpExp({ expanded, setExpanded }: NavBarImpExpProps) { function handleExportCSV() { if (budget) { const filename = `${budget.name}.csv`; - const url = window.URL.createObjectURL(new Blob([budgetToCsv(budget)])); + const url = window.URL.createObjectURL(new Blob([Budget.toCsv(budget)])); const link = document.createElement("a"); link.href = url; link.setAttribute("download", filename); diff --git a/src/components/StatCard/StatCard.tsx b/src/components/StatCard/StatCard.tsx index 00eafc7d..df6e3443 100644 --- a/src/components/StatCard/StatCard.tsx +++ b/src/components/StatCard/StatCard.tsx @@ -23,16 +23,9 @@ import { } from "react-icons/bs"; import { useBudget } from "../../context/BudgetContext"; import { useConfig } from "../../context/ConfigContext"; -import { - calcAutoGoal, - calcAvailable, - calcSaved, - calcWithGoal, - focusRef, - parseLocaleNumber, - roundBig, -} from "../../utils"; +import { focusRef, parseLocaleNumber, roundBig } from "../../utils"; import "./StatCard.css"; +import { Budget } from "../../guitos/domain/budget"; interface StatCardProps { onShowGraphs: () => void; @@ -64,9 +57,9 @@ export function StatCard({ onShowGraphs }: StatCardProps) { setAutoGoal(false); const newState = produce((draft) => { draft.stats.goal = item.target.valueAsNumber; - draft.stats.available = roundBig(calcAvailable(draft), 2); - draft.stats.withGoal = calcWithGoal(draft); - draft.stats.saved = calcSaved(draft); + draft.stats.available = roundBig(Budget.available(draft as Budget), 2); + draft.stats.withGoal = Budget.availableWithGoal(draft as Budget); + draft.stats.saved = Budget.saved(draft as Budget); }, budget); setBudget(newState(), false); } @@ -84,10 +77,10 @@ export function StatCard({ onShowGraphs }: StatCardProps) { function handleAutoGoal() { if (budget && stat) { const newState = produce((draft) => { - draft.stats.goal = calcAutoGoal(draft); - draft.stats.available = roundBig(calcAvailable(draft), 2); - draft.stats.withGoal = calcWithGoal(draft); - draft.stats.saved = calcSaved(draft); + draft.stats.goal = Budget.automaticGoal(draft as Budget); + draft.stats.available = roundBig(Budget.available(draft as Budget), 2); + draft.stats.withGoal = Budget.availableWithGoal(draft as Budget); + draft.stats.saved = Budget.saved(draft as Budget); }, budget); setBudget(newState(), true); } diff --git a/src/components/TableCard/TableCard.tsx b/src/components/TableCard/TableCard.tsx index 339eacc7..59c07be2 100644 --- a/src/components/TableCard/TableCard.tsx +++ b/src/components/TableCard/TableCard.tsx @@ -14,21 +14,13 @@ import { import { BsArrowsVertical, BsPlusLg } from "react-icons/bs"; import { useBudget } from "../../context/BudgetContext"; import { useConfig } from "../../context/ConfigContext"; -import { - calcAvailable, - calcPercentage, - calcSaved, - calcTotal, - calcWithGoal, - intlFormat, - roundBig, -} from "../../utils"; -import type { ItemForm } from "../ItemForm/ItemForm"; +import { intlFormat, roundBig } from "../../utils"; import { ItemFormGroup } from "../ItemForm/ItemFormGroup"; import "./TableCard.css"; -import type { BudgetItem } from "../../guitos/domain/budgetItem"; +import { BudgetItem } from "../../guitos/domain/budgetItem"; import type { Expenses } from "../../guitos/domain/expenses"; import type { Incomes } from "../../guitos/domain/incomes"; +import { Budget } from "../../guitos/domain/budget"; interface TableCardProps { header: "Revenue" | "Expenses"; @@ -46,7 +38,7 @@ export default function TableCard({ header: label }: TableCardProps) { const table = isExpense ? budget?.expenses : budget?.incomes; const total = table?.total ?? 0; - function reorderTable(newOrder: ItemForm[]) { + function reorderTable(newOrder: BudgetItem[]) { if (!budget) return; const newState = produce((draft) => { if (isExpense) { @@ -66,9 +58,9 @@ export default function TableCard({ header: label }: TableCardProps) { } else { draft.incomes = item; } - draft.stats.available = roundBig(calcAvailable(draft), 2); - draft.stats.withGoal = calcWithGoal(draft); - draft.stats.saved = calcSaved(draft); + draft.stats.available = roundBig(Budget.available(draft as Budget), 2); + draft.stats.withGoal = Budget.availableWithGoal(draft as Budget); + draft.stats.saved = Budget.saved(draft as Budget); }, budget); setBudget(newState(), true); } @@ -76,7 +68,7 @@ export default function TableCard({ header: label }: TableCardProps) { function addItemToTable(tableBeingEdited: Incomes | Expenses | undefined) { if (!tableBeingEdited) return; const tableHasItems = table && table.items.length !== 0; - const newItemForm = {} as ItemForm; + const newItemForm = BudgetItem.create(); const newTable = isRevenue ? ({} as Incomes) : ({} as Expenses); let maxId: number; @@ -95,7 +87,7 @@ export default function TableCard({ header: label }: TableCardProps) { newItemForm.value = 0; newTable.items = tableBeingEdited.items.concat(newItemForm); - newTable.total = roundBig(calcTotal(newTable.items), 2); + newTable.total = roundBig(Budget.itemsTotal(newTable.items), 2); handleTableChange(newTable); } @@ -140,7 +132,7 @@ export default function TableCard({ header: label }: TableCardProps) { onReorder={reorderTable} as="div" > - {table.items?.map((item: ItemForm) => ( + {table.items?.map((item: BudgetItem) => ( b?.id === budget?.id); const future = futureState.filter((b) => b?.id === budget?.id); - const revenuePercentage = calcPercentage( - budget?.expenses.total ?? 0, - budget?.incomes.total ?? 0, - ); + const revenuePercentage = Budget.revenuePercentage(budget); const canReallyUndo = undoPossible && past[past.length - 1] !== undefined; const canReallyRedo = redoPossible && future[0] !== undefined; diff --git a/src/guitos/domain/budget.ts b/src/guitos/domain/budget.ts index d3726c44..674db262 100644 --- a/src/guitos/domain/budget.ts +++ b/src/guitos/domain/budget.ts @@ -1,8 +1,12 @@ -import { immerable } from "immer"; import type { Expenses } from "./expenses"; import type { Incomes } from "./incomes"; import type { Stats } from "./stats"; import { Uuid } from "./uuid"; +import { BudgetItem } from "./budgetItem"; +import type { CsvItem } from "./csvItem"; +import { roundBig } from "../../utils"; +import Big from "big.js"; +import { immerable } from "immer"; export class Budget { id: Uuid; @@ -28,7 +32,7 @@ export class Budget { } static create(date?: string, goal?: number): Budget { - const newId = new Uuid(crypto.randomUUID()); + const newId = Uuid.random(); const year = new Date().getFullYear(); const newBudget = new Budget( newId, @@ -53,6 +57,171 @@ export class Budget { return newBudget; } + static createEmpty(name: string): Budget { + const emptyExpenses: BudgetItem[] = []; + const emptyIncomes: BudgetItem[] = []; + const emptyBudget: Budget = { + ...Budget.create(name, 0), + expenses: { + items: emptyExpenses, + total: 0, + }, + incomes: { + items: emptyIncomes, + total: 0, + }, + }; + return emptyBudget; + } + + static clone(budget: Budget): Budget { + const clonedBudget: Budget = { + ...budget, + id: Uuid.random(), + name: `${budget.name}-clone`, + }; + return clonedBudget; + } + + static fromCsv(csv: string[], date: string): Budget { + const newBudget = Budget.createEmpty(date); + + csv.forEach((value: string, key: number) => { + const item = value as unknown as CsvItem; + const newBudgetItem = new BudgetItem(key, item.name, Number(item.value)); + + switch (item.type) { + case "expense": + newBudget.expenses.items.push(newBudgetItem); + newBudget.expenses.total = roundBig( + Budget.itemsTotal(newBudget.expenses.items), + 2, + ); + break; + case "income": + newBudget.incomes.items.push(newBudgetItem); + newBudget.incomes.total = roundBig( + Budget.itemsTotal(newBudget.incomes.items), + 2, + ); + break; + case "goal": + newBudget.stats.goal = Number(item.value); + break; + case "reserves": + newBudget.stats.reserves = Number(item.value); + break; + } + }); + + newBudget.stats.available = roundBig(Budget.available(newBudget), 2); + newBudget.stats.withGoal = Budget.availableWithGoal(newBudget); + newBudget.stats.saved = Budget.saved(newBudget); + + return newBudget as unknown as Budget; + } + + static toCsv(budget: Budget): string { + const header = ["type", "name", "value"]; + + const expenses = budget.expenses.items.map((expense) => { + return ["expense", expense.name, expense.value].join(","); + }); + + const incomes = budget.incomes.items.map((income) => { + return ["income", income.name, income.value].join(","); + }); + + const stats = ["goal", "goal", budget.stats.goal].join(","); + const reserves = ["reserves", "reserves", budget.stats.reserves].join(","); + + return [header, ...expenses, ...incomes, stats, reserves].join("\n"); + } + + static itemsTotal(items: BudgetItem[]): Big { + let total = Big(0); + if (!items) { + return total; + } + + for (const item of items) { + if (!Number.isNaN(item.value)) { + total = total.add(Big(item.value)); + } + } + + return total; + } + + static available(budget: Budget | undefined): Big { + if (!budget) { + return Big(0); + } + const expenseTotal = Budget.itemsTotal(budget.expenses.items); + const incomeTotal = Budget.itemsTotal(budget.incomes.items); + return incomeTotal.sub(expenseTotal); + } + + static saved(budget: Budget): number { + const valueIsCalculable = + budget.stats.saved !== null && !Number.isNaN(budget.stats.goal); + + if (valueIsCalculable) { + const available = Budget.itemsTotal(budget.incomes.items); + const saved = Big(budget.stats.goal).mul(available).div(100); + return roundBig(saved, 2); + } + return 0; + } + + static availableWithGoal(value: Budget): number { + const goalIsCalculable = + value.stats.goal !== null && !Number.isNaN(value.stats.goal); + + if (goalIsCalculable) { + const available = Budget.available(value); + const availableWithGoal = Big(value.stats.goal) + .mul(Budget.itemsTotal(value.incomes.items)) + .div(100); + return roundBig(available.sub(availableWithGoal), 2); + } + return 0; + } + + static automaticGoal(value: Budget): number { + const valueIsCalculable = + value.stats.goal !== null && !Number.isNaN(value.stats.goal); + + if (valueIsCalculable) { + const incomeTotal = Budget.itemsTotal(value.incomes.items); + const available = Budget.available(value); + + if (incomeTotal.gt(0) && available.gt(0)) { + const autoGoal = available.mul(100).div(incomeTotal); + return roundBig(autoGoal, 5); + } + } + return 0; + } + + static revenuePercentage(budget: Budget | undefined): number { + if (!budget) { + return 0; + } + const areRoundNumbers = + !Number.isNaN(budget.incomes.total) && + budget.incomes.total > 0 && + !Number.isNaN(budget.expenses.total); + if (areRoundNumbers) { + const percentageOfTotal = Big(budget.expenses.total) + .mul(100) + .div(budget.incomes.total); + + return roundBig(percentageOfTotal, percentageOfTotal.gte(1) ? 0 : 1); + } + return 0; + } + static toSafeFormat(budget: Budget) { return { ...budget, diff --git a/src/guitos/domain/budgetItem.mother.ts b/src/guitos/domain/budgetItem.mother.ts new file mode 100644 index 00000000..9cdede5f --- /dev/null +++ b/src/guitos/domain/budgetItem.mother.ts @@ -0,0 +1,28 @@ +import { faker } from "@faker-js/faker"; +import { BudgetItem } from "./budgetItem"; +import { ObjectMother } from "./objectMother.mother"; + +// biome-ignore lint/complexity/noStaticOnlyClass: +export class BudgetItemsMother { + static budgetItems(): BudgetItem[] { + const list = [ + new BudgetItem( + ObjectMother.positiveNumber(), + faker.lorem.word(), + faker.number.int(), + ), + new BudgetItem( + ObjectMother.positiveNumber(), + faker.lorem.word(), + faker.number.int(), + ), + new BudgetItem( + ObjectMother.positiveNumber(), + faker.lorem.word(), + faker.number.int(), + ), + ]; + + return faker.helpers.arrayElements(list); + } +} diff --git a/src/guitos/domain/budgetItem.ts b/src/guitos/domain/budgetItem.ts index e4d65faf..10b8d771 100644 --- a/src/guitos/domain/budgetItem.ts +++ b/src/guitos/domain/budgetItem.ts @@ -1,3 +1,6 @@ +import Big from "big.js"; +import { roundBig } from "../../utils"; + export class BudgetItem { id: number; name: string; @@ -12,4 +15,18 @@ export class BudgetItem { static create(): BudgetItem { return new BudgetItem(1, "", 0); } + + static percentage(itemValue: number, revenueTotal: number): number { + if (!itemValue) return 0; + const canRoundNumbers = + !Number.isNaN(revenueTotal) && + revenueTotal > 0 && + !Number.isNaN(itemValue); + if (canRoundNumbers) { + const percentageOfTotal = Big(itemValue).mul(100).div(revenueTotal); + + return roundBig(percentageOfTotal, percentageOfTotal.gte(1) ? 0 : 1); + } + return 0; + } } diff --git a/src/guitos/domain/objectMother.mother.ts b/src/guitos/domain/objectMother.mother.ts index a874a3b2..46f1ac90 100644 --- a/src/guitos/domain/objectMother.mother.ts +++ b/src/guitos/domain/objectMother.mother.ts @@ -1,6 +1,4 @@ import { faker } from "@faker-js/faker"; -import { BudgetItem } from "./budgetItem"; -import { Stats } from "./stats"; import { Uuid } from "./uuid"; // biome-ignore lint/complexity/noStaticOnlyClass: @@ -44,36 +42,4 @@ export class ObjectMother { static recentDate(): Date { return faker.date.recent(); } - - static budgetItems(): BudgetItem[] { - const list = [ - new BudgetItem( - ObjectMother.positiveNumber(), - faker.lorem.word(), - faker.number.int(), - ), - new BudgetItem( - ObjectMother.positiveNumber(), - faker.lorem.word(), - faker.number.int(), - ), - new BudgetItem( - ObjectMother.positiveNumber(), - faker.lorem.word(), - faker.number.int(), - ), - ]; - - return faker.helpers.arrayElements(list); - } - - static budgetStats(): Stats { - return new Stats( - ObjectMother.randomNumber(), - ObjectMother.randomNumber(), - ObjectMother.randomNumber(), - ObjectMother.randomNumber(), - ObjectMother.randomNumber(), - ); - } } diff --git a/src/guitos/domain/stats.mother.ts b/src/guitos/domain/stats.mother.ts new file mode 100644 index 00000000..a439e679 --- /dev/null +++ b/src/guitos/domain/stats.mother.ts @@ -0,0 +1,15 @@ +import { Stats } from "./stats"; +import { ObjectMother } from "./objectMother.mother"; + +// biome-ignore lint/complexity/noStaticOnlyClass: +export class StatsMother { + static budgetStats(): Stats { + return new Stats( + ObjectMother.randomNumber(), + ObjectMother.randomNumber(), + ObjectMother.randomNumber(), + ObjectMother.randomNumber(), + ObjectMother.randomNumber(), + ); + } +} diff --git a/src/hooks/useDB.ts b/src/hooks/useDB.ts index 9704f770..c7d0d9dd 100644 --- a/src/hooks/useDB.ts +++ b/src/hooks/useDB.ts @@ -16,12 +16,7 @@ import { Uuid } from "../guitos/domain/uuid"; import { localForageBudgetRepository } from "../guitos/infrastructure/localForageBudgetRepository"; import { localForageCalcHistRepository } from "../guitos/infrastructure/localForageCalcHistRepository"; import { localForageOptionsRepository } from "../guitos/infrastructure/localForageOptionsRepository"; -import { - convertCsvToBudget, - createBudgetNameList, - saveLastOpenedBudget, - userLang, -} from "../utils"; +import { createBudgetNameList, saveLastOpenedBudget, userLang } from "../utils"; const budgetRepository = new localForageBudgetRepository(); const optionsRepository = new localForageOptionsRepository(); @@ -67,29 +62,26 @@ export function useDB() { ? budgetList.concat(newBudget) : newBudgetList.concat(newBudget); - budgetRepository.update(newBudget.id, newBudget); - setBudget(newBudget, true); - setBudgetList(newBudgetList); - setBudgetNameList(createBudgetNameList(newBudgetList)); - - setNotifications( - produce(notifications, (draft) => { - draft.push({ - show: true, - id: crypto.randomUUID(), - body: `created "${newBudget.name}" budget`, - }); - }), - ); + budgetRepository.update(newBudget.id, newBudget).then(() => { + setBudget(newBudget, true); + setBudgetList(newBudgetList); + setBudgetNameList(createBudgetNameList(newBudgetList)); + + setNotifications( + produce(notifications, (draft) => { + draft.push({ + show: true, + id: Uuid.random().toString(), + body: `created "${newBudget.name}" budget`, + }); + }), + ); + }); } function cloneBudget() { if (budget) { - const newBudget = { - ...budget, - id: Uuid.random(), - name: `${budget.name}-clone`, - }; + const newBudget = Budget.clone(budget); let newBudgetList: Budget[] = []; newBudgetList = budgetList @@ -100,52 +92,56 @@ export function useDB() { produce(notifications, (draft) => { draft.push({ show: true, - id: crypto.randomUUID(), + id: Uuid.random().toString(), body: `cloned "${newBudget.name}" budget`, }); }), ); - budgetRepository.update(newBudget.id, newBudget); - setBudget(newBudget, true); - setBudgetList(newBudgetList); - setBudgetNameList(createBudgetNameList(newBudgetList)); + budgetRepository.update(newBudget.id, newBudget).then(() => { + setBudget(newBudget, true); + setBudgetList(newBudgetList); + setBudgetNameList(createBudgetNameList(newBudgetList)); + }); } } function deleteBudget(toBeDeleted: Uuid) { - budgetList && - budgetRepository - .delete(toBeDeleted) - .then(() => { - const newBudgetList = budgetList - .filter((item: Budget) => item.id !== toBeDeleted) - .sort((a, b) => a.name.localeCompare(b.name)) - .reverse(); - - setBudgetList(newBudgetList); - setBudgetNameList( - createBudgetNameList(newBudgetList as unknown as Budget[]), - ); + if (!budgetList) return; + budgetRepository + .delete(toBeDeleted) + .then(() => { + const newBudgetList = budgetList + .filter( + (item: Budget) => item.id.toString() !== toBeDeleted.toString(), + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .reverse(); + + setBudgetList(newBudgetList); + setBudgetNameList( + createBudgetNameList(newBudgetList as unknown as Budget[]), + ); - setNotifications( - produce(notifications, (draft) => { - draft.push({ - show: true, - showUndo: true, - id: crypto.randomUUID(), - body: `deleted "${budget?.name}" budget`, - }); - }), - ); - if (newBudgetList.length >= 1) { - setBudget(newBudgetList[0], true); - } else { - setBudget(undefined, true); - } - }) - .catch((e: unknown) => { - handleError(e); - }); + setNotifications( + produce(notifications, (draft) => { + draft.push({ + show: true, + showUndo: true, + id: Uuid.random().value, + body: `deleted "${budget?.name}" budget`, + }); + }), + ); + if (newBudgetList.length >= 1) { + setBudget(newBudgetList[0], true); + } else { + setBudget(undefined, true); + localStorage.setItem("guitos_lastOpenedBudget", ""); + } + }) + .catch((e: unknown) => { + handleError(e); + }); } function importCSV(fileReader: FileReader, file: File) { @@ -169,14 +165,15 @@ export function useDB() { return; } - const newBudget = convertCsvToBudget( + const newBudget = Budget.fromCsv( csvObject.data as string[], file.name.slice(0, -4), ); newBudgetList.push(newBudget); - budgetRepository.update(newBudget.id, newBudget); - setBudgetList(newBudgetList); - setBudgetNameList(createBudgetNameList(newBudgetList)); + budgetRepository.update(newBudget.id, newBudget).then(() => { + setBudgetList(newBudgetList); + setBudgetNameList(createBudgetNameList(newBudgetList)); + }); } function importJSON(fileReader: FileReader, file: File) { @@ -403,9 +400,9 @@ export function useDB() { draft.name = event.target.value; }, budget); - // budgetRepository.update(budget.id, budget).then(() => { - setBudget(newState(), false); - // }); + budgetRepository.update(budget.id, budget).then(() => { + setBudget(newState(), false); + }); } } @@ -419,9 +416,7 @@ export function useDB() { async function saveCalcHist(id: string, item: CalculationHistoryItem) { const calcHist = await getCalcHist(id); const newCalcHist = calcHist ? [...calcHist, item] : [item]; - calcHistRepository.update(id, newCalcHist).catch((e: unknown) => { - throw e; - }); + calcHistRepository.update(id, newCalcHist); } async function deleteCalcHist(id: string) { diff --git a/src/setupTests.ts b/src/setupTests.ts index d8d0c2da..3c71ac69 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -6,14 +6,14 @@ import "@testing-library/jest-dom"; import { randomUUID } from "node:crypto"; import * as matchers from "@testing-library/jest-dom/matchers"; import { cleanup } from "@testing-library/react"; -import { immerable } from "immer"; import { createElement } from "react"; import { afterEach, beforeEach, expect, vi } from "vitest"; -import { ItemForm } from "./components/ItemForm/ItemForm"; import * as AppBudgetContext from "./context/BudgetContext"; import * as AppConfigContext from "./context/ConfigContext"; import * as AppGeneralContext from "./context/GeneralContext"; import { Uuid } from "./guitos/domain/uuid"; +import { Budget } from "./guitos/domain/budget"; +import { BudgetItem } from "./guitos/domain/budgetItem"; window.crypto.randomUUID = randomUUID; global.URL.createObjectURL = vi.fn(); @@ -68,7 +68,8 @@ Object.defineProperty(window, "matchMedia", { }); export const testBudget = { - id: Uuid.random().value, + ...Budget.create(), + id: Uuid.random().value as unknown as Uuid, name: "2023-03", expenses: { items: [{ id: 1, name: "expense1", value: 10 }], @@ -92,8 +93,9 @@ export const testBudgetClone = { name: "2023-03-clone", }; -export const testBudget2 = { - id: Uuid.random().value, +export const testBudget2: Budget = { + ...Budget.create(), + id: Uuid.random().value as unknown as Uuid, name: "2023-04", expenses: { items: [{ id: 1, name: "name", value: 50 }], @@ -112,8 +114,8 @@ export const testBudget2 = { }, }; -export const testBigBudget = { - id: Uuid.random(), +export const testBigBudget: Budget = { + ...Budget.create(), name: "2023-03", expenses: { items: [ @@ -173,22 +175,15 @@ goal,,goal,,, reservaes,reserves,0 `; -export const testBudgetCsv = { - [immerable]: true, - id: Uuid.random(), +export const testBudgetCsv: Budget = { + ...Budget.create(), name: "2023-03", expenses: { - items: [ - new ItemForm({ id: 0, name: "rent", value: 1000 }), - new ItemForm({ id: 1, name: "food", value: 200 }), - ], + items: [new BudgetItem(0, "rent", 1000), new BudgetItem(1, "food", 200)], total: 1200, }, incomes: { - items: [ - new ItemForm({ id: 2, name: "salary", value: 2000 }), - new ItemForm({ id: 3, name: "sale", value: 100 }), - ], + items: [new BudgetItem(2, "salary", 2000), new BudgetItem(3, "sale", 100)], total: 2100, }, stats: { @@ -215,17 +210,9 @@ export const budgetNameList = [ }, ]; -export const itemForm1 = new ItemForm({ - id: 1, - name: "name1", - value: 10, -}); +export const itemForm1 = new BudgetItem(1, "name1", 10); -export const itemForm2 = new ItemForm({ - id: 2, - name: "name2", - value: 100, -}); +export const itemForm2 = new BudgetItem(2, "name2", 100); export const testCalcHist = [ { diff --git a/src/utils.test.ts b/src/utils.test.ts index ed9d3387..81192a78 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,9 +1,8 @@ import Big from "big.js"; -import { immerable } from "immer"; import Papa from "papaparse"; import { expect, test } from "vitest"; import type { FilteredItem } from "./components/ChartsPage/ChartsPage"; -import type { Budget } from "./guitos/domain/budget"; +import { Budget } from "./guitos/domain/budget"; import type { ItemOperation } from "./guitos/domain/calculationHistoryItem"; import { Uuid } from "./guitos/domain/uuid"; import { chromeLocalesList } from "./lists/chromeLocalesList"; @@ -20,15 +19,7 @@ import { testCsv, } from "./setupTests"; import { - budgetToCsv, calc, - calcAutoGoal, - calcAvailable, - calcPercentage, - calcSaved, - calcTotal, - calcWithGoal, - convertCsvToBudget, createBudgetNameList, getCountryCode, getCurrencyCode, @@ -41,6 +32,7 @@ import { parseLocaleNumber, roundBig, } from "./utils"; +import { BudgetItem } from "./guitos/domain/budgetItem"; test("round", () => { expect(roundBig(Big(123.123123123), 5)).eq(123.12312); @@ -53,43 +45,51 @@ test("round", () => { }); test("calcTotal", () => { - expect(calcTotal([itemForm1, itemForm2])).toEqual(Big(110)); - expect(calcTotal([])).toEqual(Big(0)); + expect(Budget.itemsTotal([itemForm1, itemForm2])).toEqual(Big(110)); + expect(Budget.itemsTotal([])).toEqual(Big(0)); }); -test("calcPercentage", () => { - expect(calcPercentage(itemForm1.value, testBudget.incomes.total)).eq(10); - expect(calcPercentage(itemForm2.value, testBudget.incomes.total)).eq(100); - expect(calcPercentage(itemForm1.value, testBudget.expenses.total)).eq(100); - expect(calcPercentage(itemForm2.value, testBudget.expenses.total)).eq(1000); - expect(calcPercentage(0, 0)).eq(0); - expect(calcPercentage(0, testBudget.incomes.total)).eq(0); - expect(calcPercentage(0, testBudget.expenses.total)).eq(0); +test("BudgetItem.percentage", () => { + expect(BudgetItem.percentage(itemForm1.value, testBudget.incomes.total)).eq( + 10, + ); + expect(BudgetItem.percentage(itemForm2.value, testBudget.incomes.total)).eq( + 100, + ); + expect(BudgetItem.percentage(itemForm1.value, testBudget.expenses.total)).eq( + 100, + ); + expect(BudgetItem.percentage(itemForm2.value, testBudget.expenses.total)).eq( + 1000, + ); + expect(BudgetItem.percentage(0, 0)).eq(0); + expect(BudgetItem.percentage(0, testBudget.incomes.total)).eq(0); + expect(BudgetItem.percentage(0, testBudget.expenses.total)).eq(0); }); -test("calcAvailable", () => { - expect(calcAvailable(testBudget)).toEqual(Big(90)); - expect(calcAvailable(null)).toEqual(Big(0)); +test("Budget.available", () => { + expect(Budget.available(testBudget)).toEqual(Big(90)); + expect(Budget.available(undefined)).toEqual(Big(0)); }); -test("calcWithGoal", () => { - expect(calcWithGoal(testBudget)).eq(80); +test("Budget.availableWithGoal", () => { + expect(Budget.availableWithGoal(testBudget)).eq(80); }); -test("calcSaved", () => { - expect(calcSaved(testBudget)).eq(10); +test("Budget.saved", () => { + expect(Budget.saved(testBudget)).eq(10); }); -test("calcAutoGoal", () => { - expect(calcAutoGoal(testBigBudget)).eq(93.36298); +test("Budget.automaticGoal", () => { + expect(Budget.automaticGoal(testBigBudget)).eq(93.36298); }); -test("convertCsvToBudget", () => { +test("Budget.fromCsv", () => { const csvObject = Papa.parse(testCsv as string, { header: true, skipEmptyLines: "greedy", }); - expect(convertCsvToBudget(csvObject.data as string[], "2023-03")).toEqual( + expect(Budget.fromCsv(csvObject.data as string[], "2023-03")).toEqual( testBudgetCsv, ); }); @@ -148,8 +148,8 @@ test("parseLocaleNumber", () => { expect(parseLocaleNumber("1,20,54,100.55", "en-IN")).eq(12054100.55); }); -test("budgetToCsv", () => { - expect(budgetToCsv(testBigBudget)).eq(`type,name,value +test("Budget.toCsv", () => { + expect(Budget.toCsv(testBigBudget)).eq(`type,name,value expense,name,11378.64 expense,name2,11378.64 income,name,100.03 @@ -220,9 +220,9 @@ test("getLabelKeyFilteredItem", () => { name: "2023-03", item: "abcd", value: 1, - type: "efgh", + type: "abcd", }; expect(getLabelKeyFilteredItem(optionWithoutItem)).toEqual( - "abcd (2023-03 efgh)", + "abcd (2023-03 abcd)", ); }); diff --git a/src/utils.ts b/src/utils.ts index 2616b94e..72c54a02 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,11 @@ import Big from "big.js"; -import { immerable } from "immer"; import type { MutableRefObject } from "react"; import type { NavigateFunction } from "react-router-dom"; import type { FilteredItem } from "./components/ChartsPage/ChartsPage"; -import { ItemForm } from "./components/ItemForm/ItemForm"; import type { SearchOption } from "./components/NavBar/NavBar"; -import type { Budget } from "./guitos/domain/budget"; import type { ItemOperation } from "./guitos/domain/calculationHistoryItem"; -import type { CsvItem } from "./guitos/domain/csvItem"; -import { Uuid } from "./guitos/domain/uuid"; import { currenciesMap } from "./lists/currenciesMap"; +import type { Budget } from "./guitos/domain/budget"; export const userLang = navigator.language; @@ -38,35 +34,6 @@ export function roundBig(number: Big, precision: number): number { return Big(number).round(precision, 1).toNumber(); } -export function calcTotal(values: ItemForm[]): Big { - let total = Big(0); - if (!values) { - return total; - } - - for (const value of values) { - if (!Number.isNaN(value.value)) { - total = total.add(Big(value.value)); - } - } - - return total; -} - -export function calcPercentage( - itemValue: number, - revenueTotal: number, -): number { - const areRoundableNumbers = - !Number.isNaN(revenueTotal) && revenueTotal > 0 && !Number.isNaN(itemValue); - if (areRoundableNumbers) { - const percentage = Big(itemValue).mul(100).div(revenueTotal); - - return roundBig(percentage, percentage.gte(1) ? 0 : 1); - } - return 0; -} - export function calc( itemValue: number, change: number, @@ -103,120 +70,6 @@ export function calc( return 0; } -export function calcAvailable(value: Budget | null): Big { - if (value !== null) { - const expenseTotal = calcTotal(value.expenses.items); - const incomeTotal = calcTotal(value.incomes.items); - return incomeTotal.sub(expenseTotal); - } - return Big(0); -} - -export function calcWithGoal(value: Budget): number { - const goalIsCalculable = - value.stats.goal !== null && !Number.isNaN(value.stats.goal); - - if (goalIsCalculable) { - const available = calcAvailable(value); - const availableWithGoal = Big(value.stats.goal) - .mul(calcTotal(value.incomes.items)) - .div(100); - return roundBig(available.sub(availableWithGoal), 2); - } - return 0; -} - -export function calcSaved(value: Budget): number { - const valueIsCalculable = - value.stats.saved !== null && !Number.isNaN(value.stats.goal); - - if (valueIsCalculable) { - const available = calcTotal(value.incomes.items); - const saved = Big(value.stats.goal).mul(available).div(100); - return roundBig(saved, 2); - } - return 0; -} - -export function calcAutoGoal(value: Budget): number { - const valueIsCalculable = - value.stats.goal !== null && !Number.isNaN(value.stats.goal); - - if (valueIsCalculable) { - const incomeTotal = calcTotal(value.incomes.items); - const available = calcAvailable(value); - - if (incomeTotal.gt(0) && available.gt(0)) { - const autoGoal = available.mul(100).div(incomeTotal); - return roundBig(autoGoal, 5); - } - } - return 0; -} - -export function convertCsvToBudget(csv: string[], date: string): Budget { - const emptyExpenses: ItemForm[] = []; - const emptyIncomes: ItemForm[] = []; - const newBudget: Budget = { - [immerable]: true, - id: Uuid.random(), - name: date, - expenses: { - items: emptyExpenses, - total: 0, - }, - incomes: { - items: emptyIncomes, - total: 0, - }, - stats: { - available: 0, - withGoal: 0, - saved: 0, - goal: 0, - reserves: 0, - }, - }; - - csv.forEach((value: string, key: number) => { - const item = value as unknown as CsvItem; - const newItemForm = new ItemForm({ - id: key, - name: item.name, - value: Number(item.value), - }); - - switch (item.type) { - case "expense": - newBudget.expenses.items.push(newItemForm); - newBudget.expenses.total = roundBig( - calcTotal(newBudget.expenses.items), - 2, - ); - break; - case "income": - newBudget.incomes.items.push(newItemForm); - newBudget.incomes.total = roundBig( - calcTotal(newBudget.incomes.items), - 2, - ); - break; - case "goal": - newBudget.stats.goal = Number(item.value); - break; - case "reserves": - newBudget.stats.reserves = Number(item.value); - break; - } - }); - - newBudget.stats.available = roundBig(calcAvailable(newBudget), 2); - newBudget.stats.withGoal = calcWithGoal(newBudget); - newBudget.stats.saved = calcSaved(newBudget); - - return newBudget as unknown as Budget; -} - export function intlFormat(amount: number, currencyCode: string) { return new Intl.NumberFormat(userLang, { style: "currency", @@ -257,23 +110,6 @@ export function parseLocaleNumber( ); } -export function budgetToCsv(budget: Budget) { - const header = ["type", "name", "value"]; - - const expenses = budget.expenses.items.map((expense) => { - return ["expense", expense.name, expense.value].join(","); - }); - - const incomes = budget.incomes.items.map((income) => { - return ["income", income.name, income.value].join(","); - }); - - const stats = ["goal", "goal", budget.stats.goal].join(","); - const reserves = ["reserves", "reserves", budget.stats.reserves].join(","); - - return [header, ...expenses, ...incomes, stats, reserves].join("\n"); -} - export function median(arr: number[]): number { if (!arr.length) return 0;